সরাসরি পর্বে যান
পর্ব ৬৫ · ৯৩-এর মধ্যে · মডিউল ৯

Payment integration — gateway flow, webhook আর idempotency

Payment Integration — Hosted Checkout, Webhooks, Idempotency and Reconciliation

Taught in Bangla, this lesson maps a hosted checkout flow with a mock gateway in a lab, models orders and payments as a state machine in PostgreSQL and Prisma, sets prices on the server with an Idempotency-Key, treats the return URL as display only while webhooks and the validation API decide, handles duplicate, late and out-of-order webhooks, reconciles stuck payments, covers refunds and cash on delivery, and keeps card data out of logs.

এই পর্বে যা শিখবেন

  • Hosted checkout-এর পুরো পথ: session, redirect, return URL আর webhook, কোনটির কী দায়িত্ব
  • PostgreSQL আর Prisma 7-এ order ও payment-এর state machine, আর অবৈধ ধাপ আটকানো
  • Session তৈরিতে আর webhook প্রক্রিয়ায় idempotency, duplicate, late ও উল্টো ক্রমের ঘটনা সামলানো
  • খবর না আসা payment-এর জন্য reconciliation job, আর refund, partial refund, মেয়াদ শেষ ও cash on delivery-র পথ
  • কার্ডের তথ্য না রাখা, test mode-এর অভ্যাস, secret ছাড়া log, আর Edge-এ একটি পূর্ণ checkout চালানো

১ · টাকা নেওয়ার পথ — hosted checkout-এর মানচিত্র

“বাজারঘর”-এ এখন cart আছে, order আছে, stock-এর transaction-ও আছে (পর্ব ৪৩)। বাকি একটি কাজ, আর সেটিই সবচেয়ে স্পর্শকাতর: টাকা নেওয়া। এখানে ভুলের দাম সরাসরি টাকায়। ক্রেতার টাকা কাটা গেল কিন্তু order নিশ্চিত হলো না, অথবা টাকা আসেনি অথচ ইলিশ পাঠিয়ে দেওয়া হলো। দুটিই বাস্তবে ঘটে, আর দুটিই এড়ানো যায় যদি পথটি ঠিকভাবে বোঝেন।

কাঁচাবাজারে বড় দোকানের কথা ভাবুন। দোকানি নিজে ক্রেতার মানিব্যাগ ধরেন না। তিনি একটি রসিদ কেটে দেন: “৳২৫০০, রসিদ নং BG-2041”. ক্রেতা পাশের ব্যাংক বুথে গিয়ে টাকা জমা দেন। ফিরে এসে ক্রেতা বলেন, “দিয়েছি”। ভালো দোকানি শুধু মুখের কথায় মাল দেন না। তিনি দুটি জিনিসের অপেক্ষা করেন: ব্যাংক থেকে সরাসরি আসা খবর, আর দরকার হলে নিজে ব্যাংকে ফোন করে জিজ্ঞেস করা, “BG-2041-এ কত টাকা জমা হয়েছে?” এখানে রসিদ হলো payment session, বুথ হলো gateway-র পাতা, ক্রেতার ফিরে আসা হলো return URL, আর ব্যাংকের সরাসরি খবর হলো webhook.

এই ধরনকে বলে hosted checkout. দোকানের পাতায় কার্ড নম্বর বা ওয়ালেটের PIN লেখার ঘর থাকে না। Payment gateway নিজের পাতা দেখায়, OTP বা PIN নেয়, ব্যাংক বা ওয়ালেটের সাথে কথা বলে। দোকানের server শুধু তিনটি কাজ করে: (১) টাকার পরিমাণ ঠিক করে session বানায়, (২) ক্রেতাকে সেই পাতায় পাঠায়, (৩) ফলাফলের খবর যাচাই করে order-এর অবস্থা বদলায়। নিচের ছবিতে পুরো পথ।

একটি hosted checkout: ক্লিক থেকে order নিশ্চিত হওয়া পর্যন্ত

Browser, shop server and gateway — who tells the truth

Hosted checkout-এর ক্রম ক্রেতা Edge browser বাজারঘর server + DB Gateway নকল BazarPay ১ POST /checkout ২ session + key ৩ sess_0001, payUrl ৪ 303 → payUrl ৫ gateway-র পাতায় টাকা দেওয়া ৬ 303 → return URL (status=success) ৭ দেখায়: PENDING ৮ webhook, HMAC ৯ validation API ১০ succeeded 250000 ১১ paisa মিলল PAID + outbox ১২ আবার দেখা PAID, CONFIRMED
ছবি ৬৫.১ — লাল ড্যাশ দেওয়া পথটি (৬) browser দিয়ে আসে, তাই সেটি শুধু দেখানোর। সবুজ মোটা পথটি (৮) server থেকে server, স্বাক্ষরসহ; তারপর নিজে জিজ্ঞেস করা (৯–১০)। Order বদলায় শুধু ১১ নম্বরে।

বাংলাদেশে অনেক gateway আর MFS এই ছাঁচের কোনো না কোনো রূপ ব্যবহার করে। উদাহরণ হিসেবে, লেখার সময়ে SSLCommerz-এর প্রকাশ্য developer docs (v4) বর্ণনা করে: server থেকে একটি session API ডাকা, উত্তরে একটি GatewayPageURL, success, fail আর cancel URL, একটি ঐচ্ছিক কিন্তু প্রস্তাবিত IPN listener, আর একটি order validation API, যার মাধ্যমে পরিমাণ আর অবস্থা নিশ্চিত করার পরামর্শ দেওয়া হয়। bKash, Nagad আর অন্য MFS-এর নিজস্ব developer portal আছে; সেখানে ক্রেতা সাধারণত সেবাদাতার নিজের পাতায় বা অ্যাপে অনুমোদন দেন। প্রতিটির API-র নাম, ধাপ, স্বাক্ষরের ধরন, মেয়াদ আর খরচ আলাদা আর সময়ের সাথে বদলায়। এই পর্বে সেগুলোর কোনো API নকল করা হয়নি, আর কোনো খরচের হিসাবও দেওয়া হচ্ছে না। কাজ শুরুর আগে আপনার বাছাই করা সেবার সর্বশেষ অফিসিয়াল docs আর চুক্তি পড়বেন।

মূল ধারণা

Payment-এ তিনটি পক্ষ তিন রকম কথা বলে। Browser যা বলে, ক্রেতা বা অন্য কেউ তা বদলাতে পারেন, তাই তা শুধু ইঙ্গিত। Webhook স্বাক্ষর মিললে gateway-র কথা, কিন্তু দেরিতে, দুবার বা উল্টো ক্রমে আসতে পারে, আবার না-ও আসতে পারে। Validation API-র উত্তর আপনি নিজে চেয়ে আনেন, তাই সেটিই সবচেয়ে নির্ভরযোগ্য। ভালো নকশা তিনটিকেই ব্যবহার করে, কিন্তু order বদলায় শুধু শেষেরটির ভিত্তিতে।

২ · ল্যাবের নকল gateway — BazarPay আর test mode-এর অভ্যাস

আসল gateway-তে পরীক্ষা মানে আসল account, আসল key আর নিয়মের বাধ্যবাধকতা। শেখার জন্য আমরা নিজেই একটি ছোট gateway বানিয়েছি: BazarPay. এটি একটি শিক্ষার জন্য বানানো নকল সেবা, কোনো বাস্তব প্রতিষ্ঠানের API নয়, আর এর URL, header বা field-এর নাম কোনো বাস্তব gateway-তে কাজ করবে না। কাজটি কিন্তু বাস্তবের মতো: Express 5 দিয়ে আলাদা port-এ চলে, session বানায়, hosted পাতা দেখায়, HMAC স্বাক্ষরসহ webhook পাঠায়, ব্যর্থ হলে retry করে, আর একটি validation API দেয়।

BazarPay (নকল) endpointকাজকে ডাকে
POST /v1/sessionsSession তৈরি; Idempotency-Key বাধ্যতামূলকবাজারঘর server
GET /pay/:idHosted পাতা, “TEST MODE” লেখাক্রেতার browser
GET /v1/sessions/:idValidation API: আসল অবস্থা, paisa, মুদ্রাবাজারঘর server
POST /v1/refundsপুরো বা আংশিক refundবাজারঘর admin
GET /v1/reports/transactionsদিনের লেনদেনের তালিকাReconciliation job
Webhook → notifyUrlHeader x-bazarpay-signature, retry: 0, 0.5, 1, 2, 4 সেকেন্ড পরেGateway নিজে

ল্যাবের retry-র সময় ইচ্ছা করে ছোট রাখা হয়েছে, যাতে কয়েক সেকেন্ডে সব দেখা যায়। বাস্তব gateway সাধারণত মিনিট থেকে ঘণ্টা, এমনকি দিনের পর দিন ধরে retry করে, ক্রমশ বাড়ানো বিরতিতে (exponential backoff); সঠিক সূচি তাদের docs-এ থাকে। ল্যাবে খারাপ দিন বানানোর জন্য কয়েকটি শুধু-ল্যাবের সুইচও আছে: webhook ফেলে দেওয়া, দুবার পাঠানো, উল্টো ক্রমে পাঠানো, আর session-এর মেয়াদ ছোট করা।

এই পর্বের প্রতিটি আউটপুট একটি সত্যিকারের ল্যাব থেকে। লেখার সময়ের version: Node 24.15.0, Express 5.3.0, Prisma 7.10.1 আর @prisma/adapter-pg 7.10.1 (npm-এ prisma-র latest তখন 8.0.0-এর release candidate ছিল, তাই সর্বশেষ stable 7.10.1 নেওয়া হয়েছে), PostgreSQL চলেছে portable embedded-postgres 18.6.0-beta.19 দিয়ে, log-এর জন্য pino 10.4.0, আর browser চালাতে Playwright 1.64.1, Microsoft Edge-এ। তিনটি server-ই শুধু 127.0.0.1-এ: gateway 47651, দোকান 47652, database 47653. Key আর secret সব নকল: sk_test_example, whsec_test_example. Version বদলায়, তাই নিজের package.json মিলিয়ে নেবেন।

Sandbox-এর অভ্যাস: (১) test আর live-এর key আলাদা environment variable-এ, আর নামেই পার্থক্য থাকুক (sk_test_… বনাম sk_live_…); production server test key পেলে চালু হতেই থেমে যাক (পর্ব ৩১-এর শুরুতেই config যাচাই)। (২) Test mode-এর পাতায় আর admin panel-এ একটি স্পষ্ট “TEST” চিহ্ন। (৩) সফল, ব্যর্থ, বাতিল, মেয়াদ শেষ, duplicate আর দেরির webhook, প্রতিটি পথ sandbox-এ একবার হলেও চালানো। (৪) Live-এ যাওয়ার দিন খুব ছোট অঙ্কের একটি আসল লেনদেন আর তার refund নিজে করে দেখা, যদি gateway-র নিয়ম তা অনুমতি দেয়।

৩ · অবস্থার যন্ত্র — order আর payment, PostgreSQL আর Prisma-য়

প্রথম নকশার সিদ্ধান্ত: order আর payment আলাদা টেবিল। একটি order-এর একাধিক payment চেষ্টা হতে পারে (প্রথমটি ব্যর্থ, দ্বিতীয়টি সফল), আর cash on delivery-র order-এ online payment থাকেই না। প্রতিটির নিজের অবস্থা, আর অবস্থা বদলের নিয়ম একটি state machine. নিয়মটি কাগজে আঁকুন আগে, কোড পরে।

Payment-এর অবস্থা আর বৈধ পথ

Payment state machine (double border = final state)

Payment state machine PENDING FAILED AUTHORISED EXPIRED PAID একসাথে capture PARTIALLY_REFUNDED REFUNDED
ছবি ৬৫.২ — দুই দাগের বাক্স শেষ অবস্থা। PAID থেকে কখনো PENDING বা FAILED-এ ফেরা যায় না; ফেরত মানে নতুন অবস্থা REFUNDED, ইতিহাস মোছা নয়।

এখন Prisma schema. দাম রাখা হয়েছে minor unit-এ, অর্থাৎ পয়সায়, পূর্ণসংখ্যা হিসেবে: ৳২৫০০ মানে 250000. কেন floating point নয়, সে আলোচনা আর প্রমাণ আছে পর্ব ৩২-এর টাকার representation অংশে। অবস্থাগুলো PostgreSQL-এর enum, তাই database নিজেই অচেনা মান আটকায়।

enum PaymentStatus {
  PENDING
  AUTHORISED
  PAID
  FAILED
  EXPIRED
  PARTIALLY_REFUNDED
  REFUNDED
}

model Payment {
  id             Int           @id @default(autoincrement())
  order          Order         @relation(fields: [orderId], references: [id])
  orderId        Int
  provider       String        // "bazarpay" বা "cod"
  sessionId      String?       @unique
  amountPaisa    Int
  currency       String
  status         PaymentStatus @default(PENDING)
  refundedPaisa  Int           @default(0)
  idempotencyKey String        @unique   // session তৈরির key
  lastEventAt    DateTime?
  createdAt      DateTime      @default(now())
  updatedAt      DateTime      @updatedAt
  refunds        Refund[]
}

model WebhookEvent {
  id         String   @id          // gateway-র event id: duplicate আটকানোর দেয়াল
  type       String
  sessionId  String
  outcome    String
  receivedAt DateTime @default(now())
}

Order-এ আছে ref (unique), totalPaisa, method (ONLINE বা COD) আর নিজের অবস্থা AWAITING_PAYMENT, CONFIRMED, CANCELLED; Refund আর OutboxEvent টেবিলও আছে। prisma migrate dev-এর তৈরি SQL থেকে দুটি অংশ, সত্যিকারের run:

$ npx prisma migrate dev --name payments
Datasource "db": PostgreSQL database "bazarghar_pay", schema "public" at "127.0.0.1:47653"
Applying migration `20261102181004_payments`
Your database is now in sync with your schema.

-- migration.sql থেকে
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'AUTHORISED', 'PAID', 'FAILED', 'EXPIRED', 'PARTIALLY_REFUNDED', 'REFUNDED');
CREATE UNIQUE INDEX "Payment_sessionId_key" ON "Payment"("sessionId");
CREATE UNIQUE INDEX "Payment_idempotencyKey_key" ON "Payment"("idempotencyKey");
CREATE UNIQUE INDEX "Refund_idempotencyKey_key" ON "Refund"("idempotencyKey");

Enum শুধু বলে কোন মানগুলো বৈধ। কোন অবস্থা থেকে কোথায় যাওয়া যায়, সেই নিয়ম কোডে। নিচের ব্লকে ল্যাবের একই নিয়ম, একটি ছোট রূপে। কয়েকটি বৈধ আর কয়েকটি অবৈধ ধাপ চেষ্টা করা হয়েছে।

// পেমেন্টের অবস্থার যন্ত্র: কোন অবস্থা থেকে কোথায় যাওয়া বৈধ
const NEXT = {
  PENDING: ["AUTHORISED", "PAID", "FAILED", "EXPIRED"],
  AUTHORISED: ["PAID", "FAILED", "EXPIRED"],
  PAID: ["PARTIALLY_REFUNDED", "REFUNDED"],
  PARTIALLY_REFUNDED: ["PARTIALLY_REFUNDED", "REFUNDED"],
  FAILED: [], EXPIRED: [], REFUNDED: [],          // শেষ অবস্থা: আর কোথাও নয়
};

function move(p, to, why) {
  if (!NEXT[p.status].includes(to)) {
    console.log(`  REFUSED ${p.status} -> ${to} (${why})`);
    return p;                                      // অবস্থা বদলায় না
  }
  console.log(`  ok      ${p.status} -> ${to} (${why})`);
  return { ...p, status: to };
}

let p = { ref: "BG-2045", status: "PENDING", amountPaisa: 125000 };
p = move(p, "AUTHORISED", "bank approved");
p = move(p, "PAID", "captured");
p = move(p, "PENDING", "old event replayed");
p = move(p, "FAILED", "late failure event");
p = move(p, "PARTIALLY_REFUNDED", "refund 300 taka");
p = move(p, "PAID", "someone clicked undo");
p = move(p, "REFUNDED", "refund the rest");
p = move(p, "PARTIALLY_REFUNDED", "refund again");
console.log("final:", p.status);

let q = { ref: "BG-2046", status: "PENDING" };
q = move(q, "EXPIRED", "customer left");
q = move(q, "PAID", "payment after expiry");   // টাকা কাটা গেলে মানুষের হাতে দিন

প্রথম দুটি লাইন ok PENDING -> AUTHORISED আর ok AUTHORISED -> PAID: স্বাভাবিক পথ। তারপর REFUSED PAID -> PENDING (old event replayed) আর REFUSED PAID -> FAILED (late failure event): একটি পুরোনো বা দেরিতে আসা ঘটনা টাকা পাওয়া order-কে পিছিয়ে দিতে পারল না। REFUSED PARTIALLY_REFUNDED -> PAID: ফেরত দেওয়া টাকা “undo” করে আবার PAID বানানো যায় না। final: REFUNDED, আর তারপর আরেকটি refund-ও প্রত্যাখ্যাত। শেষ লাইন REFUSED EXPIRED -> PAID (payment after expiry) সবচেয়ে শিক্ষণীয়: যন্ত্র ঠিকই আটকেছে, কিন্তু gateway যদি সত্যিই বলে টাকা কাটা হয়েছে, তাহলে আটকানোই শেষ কথা নয়। ল্যাবের কোডে এই ক্ষেত্রে outbox-এ একটি payment.review কাজ লেখা হয়, যাতে একজন মানুষ refund বা order আবার চালুর সিদ্ধান্ত নেন।

৪ · Session তৈরি — দাম server-এ, আর একই key-তে একবারই

Checkout-এর form-এ শুধু পণ্যের sku, পরিমাণ আর পদ্ধতি আসে। দাম আসে বাজারঘরের নিজের catalogue থেকে। Browser থেকে amount পাঠালেও তা উপেক্ষিত। ল্যাবে আমরা ইচ্ছা করে amount=100 পাঠিয়েছিলাম; log বলছে "ignoredBrowserAmount":"100" আর "total":42000. এটি log-এ রাখা শুধু দেখানোর জন্য; বাস্তবে এমন field এলে সন্দেহজনক অনুরোধ হিসেবে গোনা যায়।

app.post('/checkout', async (req, res) => {
  const item = CATALOGUE[req.body.sku];
  const qty = Number(req.body.qty);
  if (!item || !Number.isInteger(qty) || qty < 1 || qty > 10) return res.status(422).send('bad item');
  const total = item.pricePaisa * qty;          // দাম আমাদের catalogue থেকে, browser থেকে নয়
  const ref = `BG-${++nOrder}`;
  const order = await prisma.order.create({ data: { ref, totalPaisa: total, method: 'ONLINE',
    payments: { create: { provider: 'bazarpay', amountPaisa: total, currency: 'BDT',
      idempotencyKey: `pay_${ref}_1` } } }, include: { payments: true } });
  const pay = order.payments[0];
  // ... body: merchantRef, amountPaisa, currency, successUrl, failUrl, notifyUrl
  for (let attempt = 1; attempt <= 2 && !s; attempt++) {   // retry-তে একই key
    const r = await fetch(`${GW}/v1/sessions`, { method: 'POST', body,
      signal: AbortSignal.timeout(5000),
      headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.GATEWAY_KEY}`,
        'idempotency-key': pay.idempotencyKey } });
    if (r.ok) s = await r.json();
  }
  await prisma.payment.update({ where: { id: pay.id }, data: { sessionId: s.id } });
  res.redirect(303, s.payUrl);
});

কেন key? ধরুন বাজারঘর session চাইল, gateway session বানাল, কিন্তু উত্তর আসার আগেই network-এর সময়সীমা পার হলো। বাজারঘর জানে না session হয়েছে কি না, তাই আবার চায়। Key না থাকলে দুটি session, আর ক্রেতা দুটি আলাদা ট্যাবে দুবার টাকা দিতে পারেন। Key থাকলে gateway চিনে ফেলে, “এটি আগের অনুরোধই”, আর একই উত্তর ফেরত দেয়। এটি পর্ব ৩৩-এর idempotency key-এর হুবহু একই নীতি, শুধু এবার আমরা client, gateway server. BazarPay-কে সরাসরি curl দিয়ে চারবার জিজ্ঞেস করা হলো, সত্যিকারের run:

$ curl … /v1/sessions -H 'idempotency-key: pay_BG-2099_1' -d "$BODY"          # প্রথমবার
{"id":"sess_0008","payUrl":"http://127.0.0.1:47651/pay/sess_0008","expiresAt":"2026-11-02T18:26:45.580Z"} [201]
$ curl … একই key, একই body                                                    # retry
{"id":"sess_0008","payUrl":"http://127.0.0.1:47651/pay/sess_0008","expiresAt":"2026-11-02T18:26:45.580Z"} [200]
$ curl … একই key, কিন্তু amountPaisa 4200
{"error":"key reused with different body"} [422]
$ curl … নতুন key, amountPaisa 420.5
{"error":"amountPaisa must be a positive integer, currency BDT"} [422]
$ curl … ভুল key দিয়ে (Authorization: Bearer wrong)
{"error":"bad key"} [401]

দ্বিতীয় উত্তর 201 নয়, 200, কিন্তু একই sess_0008: নতুন কিছু তৈরি হয়নি। তৃতীয়টি বিপজ্জনক ভুল ধরেছে: একই key-তে ভিন্ন body মানে client-এর বাগ, তাই 422. চতুর্থটি দেখায় gateway ভগ্নাংশ পয়সা নেয় না। Order-এর key-টি payment-এর সারিতে unique column-এ জমা, তাই বাজারঘরের server restart হলেও একই order-এর জন্য একই key যায়।

৫ · Return URL দেখায়, webhook আর validation API সিদ্ধান্ত নেয়

Gateway-র পাতায় টাকা দেওয়ার পর ক্রেতা ফেরেন এই ঠিকানায়: /payment/return?ref=BG-2041&session=sess_0001&status=success. এই return URL browser-এর address bar দিয়ে আসে। যে কেউ নিজে status=success লিখে খুলতে পারেন। তাই বাজারঘরের return handler শুধু database-এর বর্তমান অবস্থা দেখায়, কিছু লেখে না। অপেক্ষার সময় পাতাটি প্রতি সেকেন্ডে নিজে refresh হয়।

// Return URL শুধু দেখায়। এটি কখনো কিছু paid করে না।
app.get('/payment/return', async (req, res) => {
  const ref = String(req.query.ref ?? '');
  const order = await prisma.order.findUnique({ where: { ref }, include: { payments: true } });
  if (!order) return res.status(404).send('no order');
  const p = order.payments[0];
  log.info({ ref, claimed: req.query.status ?? null, actual: p.status }, 'customer returned');
  // ... PENDING হলে <meta http-equiv="refresh" content="1">, নইলে ফলাফল
});

সিদ্ধান্ত হয় webhook-এ। Express-এ webhook route-টি express.json()-এর আগে বসানো, আর নিজে express.raw() নেয়, কারণ HMAC মেলাতে হয় হুবহু byte-এর ওপর (raw body). স্বাক্ষর, সময়সীমা আর timingSafeEqual-এর বিস্তারিত যুক্তি পর্ব ৫৭-এ; এখানে সেই একই কাজ, তারপর তিনটি নতুন ধাপ। নিচের কোড ল্যাবের ফাইল থেকে, log-এর লাইন বাদ দিয়ে আর unique violation-এর পরীক্ষা একটি ছোট helper-এ সরিয়ে সংক্ষেপ করা।

export async function applyGatewayTruth(sessionId: string, source: string, eventId?: string, eventType?: string) {
  const g = await gatewayGet(`/v1/sessions/${sessionId}`);          // ১. নিজে জিজ্ঞেস করা
  const pay = await prisma.payment.findUnique({ where: { sessionId }, include: { order: true } });
  if (!pay) return 'unknown';
  if (g.amountPaisa !== pay.amountPaisa || g.currency !== pay.currency || g.merchantRef !== pay.order.ref) {
    log.error({ ref: pay.order.ref }, 'AMOUNT/CURRENCY MISMATCH - not fulfilling, needs review');
    return 'mismatch';                                                // ২. paisa আর মুদ্রা
  }
  const to = targetFrom(g, pay.amountPaisa);
  return prisma.$transaction(async (tx) => {
    if (eventId) {                                                    // ৩. event id একবারই
      try { await tx.webhookEvent.create({ data: { id: eventId, type: eventType!, sessionId, outcome: 'applied' } }); }
      catch (e) { if (isUniqueViolation(e)) return 'duplicate'; throw e; }
    }
    if (!to || (to === pay.status && g.refundedPaisa === pay.refundedPaisa)) return 'no-change';
    if (!canMove(pay.status, to)) return 'refused';                   // state machine
    const n = await tx.payment.updateMany({ where: { id: pay.id, status: pay.status },
      data: { status: to, refundedPaisa: g.refundedPaisa, lastEventAt: new Date() } });
    if (n.count === 0) throw new Error('concurrent change, retry');
    if (to === 'PAID') {
      await tx.order.update({ where: { id: pay.orderId }, data: { status: 'CONFIRMED' } });
      await tx.outboxEvent.create({ data: { type: 'order.paid', payload: { ref: pay.order.ref } } });
    }
    return to;
  });
}

কয়েকটি সিদ্ধান্ত খেয়াল করুন। Event-এর type শুধু একটি ইঙ্গিত। আসল লক্ষ্য অবস্থা আসে validation API-র উত্তর থেকে। তাই একটি জাল কিন্তু বৈধ-স্বাক্ষরের ঘটনা থাকলেও (ধরুন secret ফাঁস হয়েছে), gateway নিজে যা বলে তার বাইরে কিছু হয় না। Event id-র জন্য unique constraint, আর order বদল, outbox, সব একই transaction-এ। updateMany-এর where-এ পুরোনো status: মাঝখানে অন্য কেউ বদলালে count হবে 0, আর পুরো transaction বাতিল হয়ে gateway-র retry-তে আবার চেষ্টা হবে। Order নিশ্চিত হলে email বা গুদামের কাজ সরাসরি নয়, outbox-এ লেখা হয় (পর্ব ৪৩-এর transactional outbox), যা পরে একটি worker পাঠায় (পর্ব ৬২)।

নিচের ব্লকে স্বাক্ষর যাচাই, duplicate বাদ আর paisa-মুদ্রা মেলানো, এক জায়গায়। একটি নির্দিষ্ট সময় ব্যবহার করা হয়েছে, যাতে ফল সবসময় এক থাকে।

const { createHmac, timingSafeEqual } = require("node:crypto");
const SECRET = "whsec_test_example";                 // নকল মান
const orders = { "BG-2042": { amountPaisa: 42000, currency: "BDT", status: "PENDING" } };
const seen = new Set();                              // বাস্তবে: unique column

const sign = (t, raw) => createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");

function handle(header, raw, now) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const exp = Buffer.from(sign(t, raw), "hex"), got = Buffer.from(v1 ?? "", "hex");
  if (Math.abs(now - Number(t)) > 300) return "401 too old";
  if (got.length !== exp.length || !timingSafeEqual(got, exp)) return "401 bad signature";
  const ev = JSON.parse(raw);
  if (seen.has(ev.id)) return `200 duplicate ${ev.id}`;
  seen.add(ev.id);
  const o = orders[ev.ref];
  if (!o) return "200 unknown order, flagged";
  if (ev.amountPaisa !== o.amountPaisa || ev.currency !== o.currency)
    return `200 MISMATCH ours ${o.amountPaisa} ${o.currency}, theirs ${ev.amountPaisa} ${ev.currency}: review`;
  o.status = "PAID";
  return `200 ${ev.ref} PAID (${(o.amountPaisa / 100).toFixed(2)} taka)`;
}

const now = 1793640000;
const send = (obj, at = now) => { const raw = JSON.stringify(obj);
  return [`t=${now},v1=${sign(now, raw)}`, raw, at]; };
const ok = send({ id: "evt_0004", ref: "BG-2042", amountPaisa: 42000, currency: "BDT" });
console.log("1", handle(...send({ id: "evt_0015", ref: "BG-2042", amountPaisa: 420, currency: "BDT" })));
console.log("2", handle(...send({ id: "evt_0016", ref: "BG-2042", amountPaisa: 42000, currency: "USD" })));
console.log("3", handle(...ok));
console.log("4", handle(ok[0], ok[1], now + 30));                  // retry
console.log("5", handle(ok[0], ok[1].replace("42000", "4200"), now)); // বদলানো
console.log("6", handle(ok[0], ok[1], now + 900));
console.log("status:", orders["BG-2042"].status);

1 200 MISMATCH ours 42000 BDT, theirs 420 BDT: review: স্বাক্ষর ঠিক, কিন্তু পরিমাণ ৪২০ পয়সা, অর্থাৎ ৪ টাকা ২০ পয়সা। টাকা আর পয়সা গুলিয়ে ফেলার এই বাগ বাস্তবে সাধারণ; order PAID হলো না। 2 … theirs 42000 USD: সংখ্যা মেলে, মুদ্রা মেলে না, তাও review. 3 200 BG-2042 PAID (420.00 taka): সব মিলেছে। 4 200 duplicate evt_0004: ৩০ সেকেন্ড পরে একই ঘটনা, কিছু বদলায়নি, কিন্তু 200 দেওয়া হলো যাতে gateway থামে। 5 401 bad signature আর 6 401 too old: বদলানো body আর পুরোনো বার্তা। লক্ষ করুন, mismatch-এও উত্তর 200: বার্তাটি ঠিকমতো পেয়েছি, আবার পাঠানোর দরকার নেই; সমস্যাটি আমাদের দিকের তদন্তের বিষয়, gateway-র retry-তে তা মিটবে না।

Edge-এ একটি পূর্ণ checkout, সত্যিকারের run

এবার পুরো পথ আসল browser-এ। Playwright-এর একটি ছোট end-to-end script Microsoft Edge খুলে (chromium.launch({ channel: 'msedge' })) বাজারঘরের পাতায় দুই কেজি ইলিশ বেছে “Place order” চাপে, BazarPay-এর পাতায় “Pay (test)” চাপে, তারপর ফিরে আসা পাতায় অবস্থা পড়ে। Playwright-এর পূর্ণ আলোচনা পর্ব ৭৬-এ (End-to-end test — Playwright আর নির্ভরযোগ্য selector).

$ node e2e/checkout.mjs
[edge] at http://127.0.0.1:47652/
[edge] at http://127.0.0.1:47651/pay/sess_0001
[edge] hosted page says: Pay 2500.00 BDT to BG-2041
[edge] at http://127.0.0.1:47652/payment/return?ref=BG-2041&session=sess_0001&status=success
[edge] first view : payment: PENDING, order: AWAITING_PAYMENT
[edge] at http://127.0.0.1:47652/payment/return?ref=BG-2041&session=sess_0001&status=success
[edge] final view : payment: PAID, order: CONFIRMED

একই সময়ে দুই server-এর log. pino প্রতিটি লাইন JSON-এ লেখে; একটি লাইন হুবহু এমন:

{"level":"info","time":"2026-11-02T18:10:20.111Z","svc":"shop","evt":"evt_0001","type":"payment.authorised","ref":"BG-2041","from":"PENDING","to":"PAID","source":"webhook","msg":"payment moved"}

পড়ার সুবিধায় বাকিগুলো সময়, service, বার্তা আর মূল field রেখে এক লাইনে সাজানো হলো; মান কিছু বদলানো হয়নি।

18:10:19.956 shop     order created                    ref=BG-2041 total=250000 method=ONLINE
18:10:19.981 gateway  session created                  key=pay_BG-2041_1 session=sess_0001 amountPaisa=250000
18:10:19.990 shop     redirecting customer to hosted page  ref=BG-2041 session=sess_0001
18:10:20.066 gateway  customer finished on hosted page session=sess_0001 outcome=success
18:10:20.070 shop     customer returned                ref=BG-2041 claimed=success actual=PENDING
18:10:20.096 shop     webhook verified                 evt=evt_0001 type=payment.authorised
18:10:20.097 gateway  validation API called            session=sess_0001 status=succeeded
18:10:20.111 shop     payment moved                    evt=evt_0001 from=PENDING to=PAID source=webhook
18:10:20.115 gateway  webhook delivered                evt=evt_0001 attempt=1 status=200
18:10:20.380 shop     webhook verified                 evt=evt_0002 type=payment.succeeded
18:10:20.384 shop     no change                        evt=evt_0002 status=PAID source=webhook
18:10:21.086 shop     customer returned                ref=BG-2041 claimed=success actual=PAID

সবচেয়ে গুরুত্বপূর্ণ লাইন claimed=success actual=PENDING. ক্রেতা ফেরার মুহূর্তে browser “success” বলছিল, কিন্তু server তখনো কিছু জানত না, আর বিশ্বাসও করেনি। ২৬ মিলিসেকেন্ড পরে webhook এলো, যাচাই হলো, তারপর PAID. আরেকটি মজার জিনিস: প্রথম ঘটনার type ছিল payment.authorised, তবু অবস্থা সরাসরি PAID, কারণ validation API তখনই বলছিল succeeded. দ্বিতীয় ঘটনা payment.succeeded এসে দেখল কিছু করার নেই।

  1. OrderBG-2041দাম server-এ
  2. Sessionsess_0001key সহ
  3. Hosted পাতাPaygateway-তে
  4. ReturnPENDINGশুধু দেখা
  5. WebhookPAIDযাচাই করে
  6. Fulfilmentoutboxপরে worker
DatabasePOST /checkout-এর পরে
Order   BG-2041  total=250000  AWAITING_PAYMENT
Payment PENDING  amountPaisa=250000  BDT
        idempotencyKey=pay_BG-2041_1
        sessionId=null
Gateway-র উত্তর201
{"id":"sess_0001",
 "payUrl":"…/pay/sess_0001"}
Payment.sessionId = sess_0001
303 → payUrl
Edge-এ যা দেখা গেলgateway-র পাতা
TEST MODE - fake gateway, no real money
Pay 2500.00 BDT to BG-2041
[Pay (test)]  [Fail (test)]
Return URLbrowser-এর দাবি
?ref=BG-2041&status=success
log: claimed=success actual=PENDING
পাতা: payment: PENDING, order: AWAITING_PAYMENT
Webhook evt_0001৩ ধাপের যাচাই
HMAC + সময়সীমা → ঠিক
validation API → succeeded, 250000, BDT
আমাদের সারি   → 250000, BDT  ✓
PENDING → PAID, Order → CONFIRMED
একই transaction-এoutbox
OutboxEvent order.paid {ref: BG-2041}
evt_0002 payment.succeeded → no change
পাতা refresh: payment: PAID, order: CONFIRMED
  1. Order তৈরি। দুই কেজি ইলিশ, catalogue-এর দাম 125000 × 2 = 250000 পয়সা। Payment সারি PENDING, key pay_BG-2041_1, session এখনো নেই।
  2. Session চাওয়া। Server key দিয়ে gateway-তে session চায়, পায় sess_0001, সারিতে লেখে, আর browser-কে 303 দিয়ে payUrl-এ পাঠায়।
  3. Gateway-র পাতা। ক্রেতা দেখেন ৳২৫০০.০০, “Pay (test)” চাপেন। কার্ড বা PIN-এর তথ্য এখানে, বাজারঘরের কাছে নয়।
  4. ফিরে আসা। URL-এ status=success, কিন্তু server database দেখে বলে PENDING. পাতাটি অপেক্ষা করে, কিছু লেখে না।
  5. Webhook এবং যাচাই। evt_0001-এর স্বাক্ষর ঠিক। Server নিজে validation API ডাকে: succeeded, 250000, BDT; নিজের সারির সাথে মেলে। Payment PAID, order CONFIRMED.
  6. Fulfilment পরে। একই transaction-এ outbox-এ order.paid; email আর গুদামের কাজ worker করবে। দ্বিতীয় ঘটনা evt_0002 কিছু বদলায় না, আর পাতা refresh হয়ে দেখায় PAID.

৬ · Duplicate, দেরিতে আর উল্টো ক্রমে আসা webhook

Webhook পাঠানো হয় at-least-once নীতিতে: gateway 2xx না পেলে আবার পাঠায়। ফলে তিন রকম বিশৃঙ্খলা স্বাভাবিক। একই ঘটনা দুবার। একটি ঘটনা অনেক দেরিতে, যখন অবস্থা অন্য পথে বদলে গেছে। আর উল্টো ক্রম: পরে ঘটা ঘটনার খবর আগে। ল্যাবের সুইচ দিয়ে তিনটিই ঘটানো হলো।

(ক) একই ঘটনা একসাথে দুবার। BazarPay-কে বলা হলো প্রতিটি webhook দুই কপি পাঠাতে, প্রায় একই মিলিসেকেন্ডে। Order BG-2042, সত্যিকারের run-এর log, সাজানো:

18:10:36.396 shop  webhook verified   evt=evt_0003 type=payment.authorised
18:10:36.396 shop  webhook verified   evt=evt_0003 type=payment.authorised
18:10:36.402 shop  payment moved      evt=evt_0003 from=PENDING to=PAID source=webhook
18:10:36.434 shop  duplicate event, nothing changed   evt=evt_0003
18:10:36.698 shop  webhook verified   evt=evt_0004 type=payment.succeeded
18:10:36.699 shop  webhook verified   evt=evt_0004 type=payment.succeeded
18:10:36.703 shop  no change          evt=evt_0004 status=PAID
18:10:36.704 shop  duplicate event, nothing changed   evt=evt_0004

দুটি কপি একই মিলিসেকেন্ডে যাচাই পেরিয়েছে। একটি WebhookEvent-এ evt_0003 লিখে order বদলেছে। অন্যটি একই id লিখতে গিয়ে PostgreSQL-এর unique index-এ অপেক্ষা করেছে, প্রথমটি commit হতেই P2002 (unique violation) পেয়ে থেমেছে। Memory-র একটি Set এখানে কাজ করত না, কারণ দুটি অনুরোধ একসাথে চলছিল, আর বাস্তবে server-এর একাধিক কপি থাকে।

(খ) উল্টো ক্রম, আর বাজারঘরের endpoint দুবার বন্ধ। এবার gateway succeeded আগে, authorised পরে পাঠাল, আর বাজারঘর প্রথম দুটি অনুরোধে ইচ্ছা করে 503 দিল:

18:10:39.155 gateway webhook delivered  evt=evt_0006 type=payment.succeeded  attempt=1 status=503
18:10:39.458 gateway webhook delivered  evt=evt_0005 type=payment.authorised attempt=1 status=503
18:10:39.674 shop    payment moved      evt=evt_0006 from=PENDING to=PAID source=webhook
18:10:39.675 gateway webhook delivered  evt=evt_0006 type=payment.succeeded  attempt=2 status=200
18:10:39.975 shop    no change          evt=evt_0005 type=payment.authorised status=PAID
18:10:39.976 gateway webhook delivered  evt=evt_0005 type=payment.authorised attempt=2 status=200

(Gateway-র বার্তার লেখা “webhook delivered” প্রতিটি চেষ্টার পরে লেখা হয়; আসল ফল status-এ। 503 মানে চেষ্টা ব্যর্থ, তাই retry.) প্রথম চেষ্টা ব্যর্থ, আধা সেকেন্ড পরে দ্বিতীয় চেষ্টায় সফল। আর দেরিতে আসা payment.authorised PAID order-কে পিছিয়ে দেয়নি, কারণ PAID থেকে AUTHORISED-এ যাওয়ার কোনো পথ যন্ত্রে নেই।

আমাদের ল্যাব প্রতিবার validation API জিজ্ঞেস করে, তাই উল্টো ক্রম নিজে থেকেই মিটে যায়। কিন্তু সব gateway প্রতিটি ঘটনার পরে API ডাকার সুযোগ দেয় না, বা প্রতিবার ডাকা খরচের হতে পারে। তখন ঘটনার নিজের তথ্য দিয়েই সিদ্ধান্ত: অবস্থার rank, আর একই rank-এ ঘটার সময় (occurredAt, পৌঁছানোর সময় নয়)। নিচের ব্লকে একটি payment-এর ছয়টি ঘটনা এলোমেলো ক্রমে আসে।

// উল্টো ক্রমে আসা ঘটনা: rank আর ঘটার সময় (occurredAt) দিয়ে সিদ্ধান্ত
const RANK = { PENDING: 0, AUTHORISED: 1, FAILED: 2, PAID: 3, PARTIALLY_REFUNDED: 4, REFUNDED: 5 };

function apply(p, ev) {
  const newer = ev.at > p.lastAt;
  const up = RANK[ev.status] > RANK[p.status];
  const sameButNewer = ev.status === p.status && newer;     // যেমন দুটি partial refund
  if (!up && !sameButNewer) return { ...p, note: `ignored ${ev.id} ${ev.status}@${ev.at}` };
  return { status: ev.status, refunded: ev.refunded ?? p.refunded,
    lastAt: Math.max(p.lastAt, ev.at), note: `applied ${ev.id} ${ev.status}@${ev.at}` };
}

// ঘটেছে এই ক্রমে (at = সেকেন্ড), কিন্তু পৌঁছেছে নিচের ক্রমে
const arrivals = [
  { id: "e2", status: "PAID", at: 102 },
  { id: "e5", status: "PARTIALLY_REFUNDED", refunded: 80000, at: 260 },
  { id: "e1", status: "AUTHORISED", at: 101 },
  { id: "e4", status: "PARTIALLY_REFUNDED", refunded: 30000, at: 200 },
  { id: "e0", status: "FAILED", at: 95 },            // আগের একটি ব্যর্থ চেষ্টা, দেরিতে
  { id: "e6", status: "REFUNDED", refunded: 125000, at: 300 },
];

let p = { status: "PENDING", refunded: 0, lastAt: 0 };
for (const ev of arrivals) {
  p = apply(p, ev);
  console.log(p.note.padEnd(36), "=>", p.status, p.refunded);
}

// সময়ের ক্রম ধরে চালালে একই শেষ অবস্থা আসে কি না
let q = { status: "PENDING", refunded: 0, lastAt: 0 };
for (const ev of [...arrivals].sort((a, b) => a.at - b.at)) q = apply(q, ev);
console.log("in time order  =>", q.status, q.refunded, "| same?", q.status === p.status && q.refunded === p.refunded);

প্রথমে applied e2 PAID@102. তারপর applied e5 PARTIALLY_REFUNDED@260 => … 80000: মোট ফেরত ৮০০ টাকা। এরপর ignored e1 AUTHORISED@101 (rank কম), আর মূল শিক্ষা ignored e4 PARTIALLY_REFUNDED@200: rank সমান, কিন্তু ঘটার সময় পুরোনো। অন্ধভাবে বসালে মোট ফেরত ৮০০ থেকে কমে ৩০০ টাকা হয়ে যেত, আর হিসাব ভুল হতো। ignored e0 FAILED@95: আগের একটি ব্যর্থ চেষ্টার খবর, দেরিতে। শেষে applied e6 REFUNDED@300 => REFUNDED 125000. শেষ লাইন in time order => REFUNDED 125000 | same? true প্রমাণ করে, পৌঁছানোর ক্রম যা-ই হোক, শেষ অবস্থা একই। ঘটনাগুলোতে refund-এর অঙ্ক “এ পর্যন্ত মোট” হিসেবে আছে, “এবারের অংশ” হিসেবে নয়; এ কারণেই নতুনটি বসালেই চলে, যোগ করতে হয় না।

  1. কপি ১evt_0003BG-2042
  2. কপি ২P2002একই ms
  3. 503BG-2045৬ চেষ্টা
  4. ReconcilePAIDআগেই
  5. দেরির খবরno change৪র্থ চেষ্টা
18:10:36.402প্রথম কপি
INSERT WebhookEvent id=evt_0003  ✓
Payment PENDING → PAID
Order → CONFIRMED, outbox order.paid
উত্তর 200
18:10:36.434দ্বিতীয় কপি
INSERT WebhookEvent id=evt_0003
→ unique index-এ অপেক্ষা, তারপর P2002
"duplicate event, nothing changed"
উত্তর 200
BG-2045endpoint ইচ্ছা করে বন্ধ
07.675 evt_0009 attempt=1 → 503
07.964 evt_0010 attempt=1 → 503
08.176 / 08.479   attempt=2 → 503
09.192 / 09.495   attempt=3 → 503
Payment তখনো PENDING
18:11:09.578reconcile job
stuck payments found: 1
validation API → succeeded
PENDING → PAID source=reconcile
18:11:11.208attempt=4 → 200
evt_0009 authorised → no change (PAID)
evt_0010 succeeded  → no change (PAID)
outbox-এ order.paid মোট ১টি
  1. প্রথম কপি জেতে। evt_0003 টেবিলে বসে, payment PAID, order CONFIRMED, outbox-এ একটি কাজ। সব একটি transaction-এ।
  2. দ্বিতীয় কপি থামে। একই id বসাতে গিয়ে unique index-এ আটকায়, প্রথমটি commit হতেই P2002. কিছু বদলায় না, তবু 200, যাতে gateway আর না পাঠায়।
  3. অন্য দিনে endpoint বন্ধ। BG-2045-এর দুটি ঘটনা তিনবার করে 503 পায়। Gateway 0.5, 1, 2 সেকেন্ড বিরতিতে চেষ্টা করছে; payment এখনো PENDING.
  4. Reconcile আগে পৌঁছায়। Job আটকে থাকা payment দেখে validation API জিজ্ঞেস করে, আর PAID করে। Webhook-এর অপেক্ষা করেনি।
  5. দেরির খবর নিরীহ। চতুর্থ চেষ্টায় দুটি ঘটনাই পৌঁছায়, কিন্তু অবস্থা আগেই PAID, তাই no change. Order নিশ্চিতের কাজ outbox-এ একবারই আছে।

একটি হিসাব, retry কতক্ষণ ধরে চলে: ধরুন (অনুমান) একটি gateway প্রথম চেষ্টার পরে 1, 2, 4, 8, 16, 32 মিনিট বিরতিতে আরও ৬বার পাঠায়। শেষ চেষ্টা হয় 1 + 2 + 4 + 8 + 16 + 32 = ৬৩ মিনিট পরে। আপনার server এক ঘণ্টার বেশি বন্ধ থাকলে ঘটনাটি আর আসবে না। n-তম বিরতি 2n−1 হলে n বিরতির যোগফল 2n − 1. আট ঘণ্টা (৪৮০ মিনিট) ঢাকতে 2n − 1 ≥ 480, অর্থাৎ n = ৯ (৫১১ মিনিট)। এই কারণেই শুধু retry-র ওপর ভরসা না করে reconciliation job লাগে।

৭ · টাকা কাটা গেল, খবর এলো না — timeout আর reconciliation

সবচেয়ে কষ্টের অভিযোগ: “আমার টাকা কেটেছে, কিন্তু order দেখাচ্ছে বাকি”। কারণ অনেক রকম হতে পারে: webhook হারিয়েছে, আপনার server তখন deploy হচ্ছিল, firewall আটকেছে, বা secret বদলের পরে স্বাক্ষর মিলছিল না। ক্রেতার কাছে সব একই। সমাধান হলো reconciliation: নিজে থেকে নিয়মিত gateway-কে জিজ্ঞেস করা।

চায়ের দোকানের মালিক দিনের শেষে খাতা আর ক্যাশবাক্স মেলান। খাতায় লেখা ৪২টি চা বিক্রি, বাক্সে ৪১টির টাকা। তিনি অনুমান করে খাতা বদলান না; খুঁজে দেখেন কোনটি বাকিতে গেছে। Reconciliation হলো ঠিক এই কাজ: আপনার payment টেবিল একটি খাতা, gateway-র report আরেকটি, আর অমিল মানে তদন্ত।

কাজটির দুটি অংশ। এক, দ্রুত সারানো: যেসব payment কয়েক মিনিটের বেশি PENDING বা AUTHORISED, তাদের প্রতিটির জন্য validation API ডেকে applyGatewayTruth চালানো, webhook-এর একই ফাংশন। দুই, দিনের শেষে মেলানো: gateway-র পুরো settlement report নিয়ে প্রতিটি সারির অবস্থা আর পরিমাণ মিলিয়ে দেখা। Job কীভাবে নির্দিষ্ট সময়ে চালাবেন, queue বা cron দিয়ে, তা পর্ব ৬২ (Background job — queue, retry আর cron)-এর বিষয়। এখানে job-এর ভেতরের যুক্তি।

ল্যাবে BazarPay-কে বলা হলো সব webhook ফেলে দিতে। ক্রেতা টাকা দিলেন, তিন সেকেন্ড পরেও order বাকি। তারপর reconcile job চালানো হলো, সত্যিকারের run:

== C paid, webhooks dropped
checkout -> http://127.0.0.1:47651/pay/sess_0004
hosted page -> http://127.0.0.1:47652/payment/return?ref=BG-2044&session=sess_0004&status=success
after 3 s:
{"ref":"BG-2044","order":"AWAITING_PAYMENT","payment":"PENDING","amountPaisa":84000,"refundedPaisa":0}

$ node --import tsx src/reconcile.ts 2        # ২ সেকেন্ডের বেশি পুরোনো PENDING
{"level":"info",…,"svc":"reconcile","count":1,"olderThanSec":2,"msg":"reconcile: stuck payments found"}
{"level":"info",…,"svc":"reconcile","ref":"BG-2044","from":"PENDING","to":"PAID","source":"reconcile","msg":"payment moved"}
BG-2041  sess_0001  gateway=PAID               ours=PAID               OK
BG-2042  sess_0002  gateway=PAID               ours=PAID               OK
BG-2043  sess_0003  gateway=PAID               ours=PAID               OK
BG-2044  sess_0004  gateway=PAID               ours=PAID               OK
report rows=4 mismatches=0

সব পরীক্ষার শেষে আরেকবার পুরো report মেলানো হলো। এবার একটি অমিল ধরা পড়ল, আর সেটি সত্যিই একটি তদন্তের বিষয়:

BG-2045  sess_0005  gateway=REFUNDED           ours=REFUNDED           OK
BG-2046  sess_0006  gateway=EXPIRED            ours=EXPIRED            OK
BG-2047  sess_0007  gateway=FAILED             ours=FAILED             OK
BG-2099  sess_0008  gateway=PENDING            ours=MISSING            MISMATCH
report rows=8 mismatches=1

sess_0008 আমরা সেকশন ৪-এ curl দিয়ে সরাসরি gateway-তে বানিয়েছিলাম, বাজারঘরের database ছাড়াই। Gateway-তে আছে, আমাদের কাছে নেই। বাস্তবে এমন অমিল মানে হতে পারে: session বানানোর পরে database-এ লেখার আগেই server পড়ে গেছে, বা কেউ অন্য পথে API key ব্যবহার করছেন। দুটিই জানা দরকার।

1// ক্রেতা টাকা দিয়েছেন, webhook হারিয়েছে2const stuck = await prisma.payment.findMany({3  where: { status: { in: ['PENDING','AUTHORISED'] },4           createdAt: { lt: cutoff } } });5for (const p of stuck) {6  await applyGatewayTruth(p.sessionId, 'reconcile');7}8const report = await gatewayGet('/v1/reports/…');9// প্রতিটি সারি মেলানো, অমিল গোনা
BG-2044-এর অবস্থা
gateway
আমাদের payment
order
stuck
  1. খবর হারাল। ক্রেতা ৳৮৪০ দিলেন, gateway-তে লেনদেন succeeded. কিন্তু দুটি webhook (evt_0007, evt_0008) পৌঁছায়নি, তাই আমাদের কাছে এখনো PENDING.
  2. আটকে থাকা খোঁজা। Job নির্দিষ্ট সময়ের বেশি পুরোনো PENDING বা AUTHORISED payment খোঁজে, পায় ১টি: BG-2044.
  3. নিজে জিজ্ঞেস করা। Validation API বলে succeeded, 84000 পয়সা, BDT. আমাদের সারির সাথে মেলে।
  4. Webhook-এর একই পথ। একই applyGatewayTruth, একই state machine আর transaction: PENDING থেকে PAID, order CONFIRMED, outbox-এ কাজ। শুধু source=reconcile.
  5. পুরো খাতা মেলানো। Report-এর ৪টি সারি, অমিল ০। পরে হারানো webhook এলেও কিছু বদলাবে না, যেমন আগের stepper-এ দেখলেন।

পরের ব্লকে দিনের শেষের মেলানোটি, একটু বেশি অমিলসহ। প্রতিটি অমিলের জন্য আলাদা পদক্ষেপ।

// দিনের শেষে মিলানো: আমাদের payment টেবিল বনাম gateway-র report
const ours = [
  { ref: "BG-2041", session: "sess_0001", status: "PAID", paisa: 250000 },
  { ref: "BG-2044", session: "sess_0004", status: "PENDING", paisa: 84000 },
  { ref: "BG-2050", session: "sess_0010", status: "PAID", paisa: 42000 },
  { ref: "BG-2051", session: "sess_0011", status: "PAID", paisa: 125000 },
  { ref: "BG-2052", session: "sess_0012", status: "EXPIRED", paisa: 42000 },
];
const report = [
  { session: "sess_0001", status: "succeeded", paisa: 250000 },
  { session: "sess_0004", status: "succeeded", paisa: 84000 },
  { session: "sess_0011", status: "succeeded", paisa: 12500 },
  { session: "sess_0012", status: "expired", paisa: 42000 },
  { session: "sess_0013", status: "succeeded", paisa: 42000 },
];
const MAP = { succeeded: "PAID", failed: "FAILED", expired: "EXPIRED" };
const bySession = new Map(report.map((r) => [r.session, r]));
const action = [];

for (const o of ours) {
  const g = bySession.get(o.session);
  bySession.delete(o.session);
  if (!g) action.push(`${o.ref}: we say ${o.status}, gateway has nothing -> hold delivery, investigate`);
  else if (g.paisa !== o.paisa) action.push(`${o.ref}: amount ours ${o.paisa}, theirs ${g.paisa} -> review`);
  else if (MAP[g.status] !== o.status) action.push(`${o.ref}: ours ${o.status}, gateway ${MAP[g.status]} -> re-check via validation API`);
}
for (const g of bySession.values()) action.push(`${g.session}: gateway ${g.status} ${g.paisa}, no order here -> investigate`);

const sum = (xs) => xs.reduce((s, x) => s + x.paisa, 0);
const paidOurs = sum(ours.filter((o) => o.status === "PAID"));
const paidTheirs = sum(report.filter((r) => r.status === "succeeded"));
action.forEach((a) => console.log("-", a));
console.log(`paid total ours ${paidOurs} paisa, gateway ${paidTheirs} paisa, gap ${paidTheirs - paidOurs}`);

চার রকম অমিল, চার রকম পদক্ষেপ। BG-2044: ours PENDING, gateway PAID -> re-check via validation API: হারানো webhook, স্বয়ংক্রিয়ভাবে সারানো যায়। BG-2050: we say PAID, gateway has nothing -> hold delivery: সবচেয়ে বিপজ্জনক, কারণ আমরা টাকা না পেয়েই পণ্য পাঠাতে যাচ্ছি; হয়তো কোনো বাগ বা জালিয়াতি। BG-2051: amount ours 125000, theirs 12500: দশ গুণ কম; মানুষের হাতে। sess_0013 … no order here: আমাদের অজানা লেনদেন। শেষ লাইন paid total ours 417000 paisa, gateway 388500 paisa, gap -28500: 250000 + 42000 + 125000 = 417000, আর 250000 + 84000 + 12500 + 42000 = 388500. মোট মিললেও ভেতরের সারি না মিলতে পারে, তাই সারি ধরে মেলানো জরুরি; শুধু মোট দেখলে চলে না।

৮ · Refund, মেয়াদ শেষ, ব্যর্থ পেমেন্ট আর cash on delivery

Refund আর partial refund. BG-2045-এর ৳১২৫০ থেকে প্রথমে ৳৩০০ ফেরত, তারপর বাকিটা। Admin-এর প্রতিটি refund অনুরোধে নিজের Idempotency-Key, কারণ “Refund” বোতামে দুবার ক্লিক মানে দুবার টাকা ফেরত হওয়া উচিত নয়। আর refund-এর ফলও আসে webhook আর validation API থেকে, অনুরোধের উত্তর থেকে নয়। সত্যিকারের run:

$ curl -X POST …/admin/orders/BG-2045/refunds -H "idempotency-key: rf-BG-2045-a" -d '{"amountPaisa":30000}'
{"refund":"re_0001"}
$ curl … একই key, একই অনুরোধ (দুবার ক্লিক)
{"replay":true,"refund":"re_0001"}
$ curl …/orders/BG-2045
{"ref":"BG-2045","order":"CONFIRMED","payment":"PARTIALLY_REFUNDED","amountPaisa":125000,"refundedPaisa":30000}
$ curl … key rf-BG-2045-b, amountPaisa 100000
{"error":"max refundable 95000 paisa"}
$ curl … key rf-BG-2045-c, amountPaisa 95000
{"refund":"re_0002"}
$ curl …/orders/BG-2045
{"ref":"BG-2045","order":"CONFIRMED","payment":"REFUNDED","amountPaisa":125000,"refundedPaisa":125000}

shop log: evt_0011 refund.succeeded  from=PAID to=PARTIALLY_REFUNDED
shop log: evt_0012 refund.succeeded  from=PARTIALLY_REFUNDED to=REFUNDED

৳১০০০ চাওয়া হয়েছিল, কিন্তু বাকি ছিল মাত্র ৳৯৫০, তাই 422. সীমাটি বাজারঘর আর gateway দুই জায়গাতেই যাচাই হয়। লক্ষ করুন, order-এর অবস্থা CONFIRMED-ই থাকল; টাকা ফেরত মানে order মুছে ফেলা নয়। পণ্য ফেরত এসেছে কি না, stock বাড়বে কি না, তা আলাদা ব্যবসায়িক সিদ্ধান্ত। আর ক্রেতা নিজের ব্যাংকের কাছে লেনদেন নিয়ে আপত্তি তুললে (chargeback বা dispute) সেটি আরেকটি পথ, যার নিয়ম gateway আর ব্যাংক ঠিক করে; এই পর্বে তা আলোচনা করা হয়নি।

মেয়াদ শেষ আর ব্যর্থতা। Session-এর মেয়াদ ল্যাবে ৩ সেকেন্ড করে একজন ক্রেতাকে পাতায় গিয়ে কিছু না করে চলে যেতে দেওয়া হলো। আরেকজন “Fail (test)” চাপলেন:

18:11:31.597 gateway session expired         session=sess_0006
18:11:31.621 shop    payment moved           evt=evt_0013 type=payment.expired from=PENDING to=EXPIRED
{"ref":"BG-2046","order":"CANCELLED","payment":"EXPIRED","amountPaisa":42000,"refundedPaisa":0}
late visit to hosted page: 410

18:11:33.674 shop    payment moved           evt=evt_0014 type=payment.failed from=PENDING to=FAILED
{"ref":"BG-2047","order":"AWAITING_PAYMENT","payment":"FAILED","amountPaisa":125000,"refundedPaisa":0}

দুটির ফল আলাদা রাখা হয়েছে। মেয়াদ শেষ হলে order বাতিল, আর বাস্তবে আটকে রাখা stock ছেড়ে দেওয়া (পর্ব ৪৩-এর reservation-এর মেয়াদ)। ব্যর্থ হলে order বাকিই থাকে, কারণ ক্রেতা হয়তো অন্য কার্ড দিয়ে আবার চেষ্টা করবেন; তখন নতুন payment সারি, নতুন key (pay_BG-2047_2). মেয়াদ পেরোনো পাতায় দেরিতে গেলে gateway 410 Gone দেয়।

Cash on delivery. বাংলাদেশের অনলাইন কেনাকাটায় COD বহুল প্রচলিত বলে ব্যাপকভাবে জানা যায়, যদিও এর সঠিক অনুপাতের নির্ভরযোগ্য সংখ্যা আমরা এখানে দিচ্ছি না। নকশার দিক থেকে COD একই state machine-এর আরেকটি পথ, শুধু খবর দেয় gateway নয়, delivery-র লোক বা courier-এর system. Order শুরুতেই CONFIRMED (পণ্য পাঠানো যাবে), payment PENDING; টাকা হাতে পেলে PAID, ক্রেতা না নিলে FAILED আর order CANCELLED. সত্যিকারের run:

$ curl -X POST …/checkout -d 'sku=ilish-1kg&qty=1&method=COD'
checkout -> http://localhost:47652/orders/BG-2048/view
{"ref":"BG-2048","order":"CONFIRMED","payment":"PENDING","amountPaisa":125000,"refundedPaisa":0}
$ curl -X POST …/admin/orders/BG-2048/cod -d '{"collected":true}'
{"ok":true}
{"ref":"BG-2048","order":"CONFIRMED","payment":"PAID","amountPaisa":125000,"refundedPaisa":0}
$ curl -X POST …/admin/orders/BG-2048/cod -d '{"collected":true}'      # দ্বিতীয়বার
{"error":"not a pending COD"}

COD-এরও reconciliation লাগে: rider-এর হাতে তোলা টাকা আর courier-এর জমা দেওয়া হিসাব মেলানো। বাস্তবে এখানেও অমিল হয়, তাই PAID লেখার সাথে কে, কখন, কোন delivery থেকে লিখলেন, তা audit log-এ রাখা ভালো।

ল্যাবের শেষে database-এর অবস্থা, সত্যিকারের query:

ref      method  order_status      pay_status  amountPaisa  refundedPaisa
BG-2041  ONLINE  CONFIRMED         PAID        250000       0
BG-2042  ONLINE  CONFIRMED         PAID        42000        0
BG-2043  ONLINE  CONFIRMED         PAID        125000       0
BG-2044  ONLINE  CONFIRMED         PAID        84000        0
BG-2045  ONLINE  CONFIRMED         REFUNDED    125000       125000
BG-2046  ONLINE  CANCELLED         EXPIRED     42000        0
BG-2047  ONLINE  AWAITING_PAYMENT  FAILED      125000       0
BG-2048  COD     CONFIRMED         PAID        125000       0

WebhookEvent: events=12 distinct_ids=12      OutboxEvent order.paid: ৫টি (BG-2041 থেকে BG-2045)

Gateway মোট ১৪টি ঘটনা তৈরি করেছিল, দুটি ইচ্ছা করে ফেলা হয়েছে, বাকি ১২টি; duplicate আর retry মিলিয়ে অনুরোধ এসেছিল তার অনেক বেশি, তবু টেবিলে প্রতিটি id একবারই। পাঁচটি online order-এ টাকা এসেছিল, আর outbox-এ order.paid ঠিক পাঁচটি।

৯ · কার্ডের তথ্য, PCI DSS আর secret ছাড়া log

কার্ডের তথ্য রাখবেন না। কার্ড নম্বর, মেয়াদ আর CVV-র মতো তথ্য নিয়ে যে প্রতিষ্ঠান কাজ করে, তাদের জন্য কার্ড কোম্পানিগুলোর গড়া নিরাপত্তা মান PCI DSS, যা PCI Security Standards Council দেখাশোনা করে। মোটা দাগে নিয়ম হলো, আপনার system কার্ডের তথ্য যত কম ছোঁবে, মানার দায়ের পরিধি তত ছোট হয়। Hosted checkout-এ কার্ডের তথ্য দোকানের server-এ আসেই না, তাই অনেক ক্ষেত্রে দায় অনেক হালকা হয় বলে ধরা হয়। তবে ঠিক কোন প্রশ্নপত্র বা যাচাই আপনার জন্য প্রযোজ্য, তা gateway, acquiring ব্যাংক আর মানের সর্বশেষ version ঠিক করে; এটি আইনি বা compliance পরামর্শ নয়। বিস্তারিত তত্ত্ব Cybersecurity কোর্সের standards আর compliance পর্বে। আর মনে রাখুন, hosted পাতা থাকলেও আপনার নিজের পাতা যদি আক্রান্ত হয়ে ক্রেতাকে জাল একটি “gateway” পাতায় পাঠায়, ক্ষতি আপনারই; তাই নিজের পাতার নিরাপত্তা (পর্ব ৫০) এখানেও প্রাসঙ্গিক।

Log-এ secret নয়। Payment-এর log খুব দরকারি, কারণ অভিযোগ এলে প্রথমে log খোঁজা হয়। কিন্তু সেখানে gateway-র key, webhook secret, স্বাক্ষরের header বা ক্রেতার কার্ড-ফোনের পূর্ণ তথ্য থাকা চলবে না। ল্যাবে pino-র redact ব্যবহার করা হয়েছে (redaction, পর্ব ৩১):

export const log = pino({ base: { svc: process.env.SVC ?? 'shop' },
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: { level: (label) => ({ level: label }) },
  redact: ['headers.authorization', 'headers["x-bazarpay-signature"]'] });

ভালো payment log-এ থাকে: order ref, session id, event id, event type, আগের ও পরের অবস্থা, পরিমাণ (পয়সায়), উৎস (webhook, reconcile, admin), আর একটি request id. এগুলো দিয়ে একটি অভিযোগের পুরো গল্প জোড়া যায়, যেমন ওপরের log-গুলোতে দেখলেন। ব্যর্থ স্বাক্ষরের log-এ শুধু body-র দৈর্ঘ্য রাখা হয়েছে ("len":68), body নয়। সেকশন ৮-এর শেষে একটি জাল webhook পাঠানো হয়েছিল, সত্যিকারের run:

$ curl …/webhooks/bazarpay -H "x-bazarpay-signature: t=$NOW,v1=00ff00ff…" --data-binary '{"id":"evt_9999","type":"payment.succeeded","sessionId":"sess_0006"}'
{"error":"bad signature"} [401]
shop log: {"level":"warn",…,"svc":"shop","len":68,"msg":"webhook signature rejected"}
$ curl …/orders/BG-2046
{"ref":"BG-2046","order":"CANCELLED","payment":"EXPIRED",…}

Live-এ যাওয়ার আগে নিজে যাচাই করুন: (১) দাম কোথাও browser থেকে নেওয়া হচ্ছে না। (২) Return URL handler database-এ কিছু লেখে না। (৩) Webhook route raw body নেয়, স্বাক্ষর আর সময়সীমা যাচাই করে, তারপর validation API, তারপর paisa ও মুদ্রা মেলায়। (৪) Event id আর idempotency key-তে unique index আছে। (৫) Reconciliation job নির্দিষ্ট সময় পরপর চলে আর তার ব্যর্থতায় alert যায়। (৬) Log-এ কোনো key, secret বা স্বাক্ষর নেই। (৭) Test key production-এ ঢুকলে server চালু হয় না। (৮) Webhook endpoint-টি যেন CSRF সুরক্ষা বা login-এর পেছনে আটকে না যায়; তার নিরাপত্তা আসে স্বাক্ষর থেকে (API security).

আরও গভীরে: HMAC-এর সংজ্ঞা RFC 2104 (১৯৯৭)। Idempotency key-কে HTTP header হিসেবে মান করার কাজ IETF-এর HTTPAPI working group-এ “The Idempotency-Key HTTP Header Field” নামের খসড়ায় চলছে; লেখার সময় এটি চূড়ান্ত RFC নয় বলেই আমাদের জানা, তাই আচরণ gateway-ভেদে আলাদা। দুই সেনাপতির সমস্যা (Two Generals’ Problem) দেখায় অনির্ভরযোগ্য network-এ দুই পক্ষ নিশ্চিত ঐকমত্যে পৌঁছাতে পারে না; at-least-once পাঠানো আর idempotent গ্রহণ এর ব্যবহারিক জবাব। Service-জুড়ে লম্বা লেনদেনের ধারণা (saga) পড়তে পারেন System Design-এর distributed transactions-এ।

১০ · AI-এর যুগে payment কোড

Payment-এর কোড AI খুব আত্মবিশ্বাসের সাথে লেখে, আর প্রায়ই এমনভাবে, যা demo-তে কাজ করে কিন্তু টাকা হারায়। কারণ সহজ: হাজার tutorial-এ “success URL-এ order paid করুন” লেখা আছে, কারণ সেটিই সবচেয়ে ছোট উদাহরণ। নিচের ভুলগুলো খুঁজুন।

AI-এর ভুলকীভাবে ধরবেন
Success বা return URL-এ status=success দেখে order paidনিজে URL-টি হাতে লিখে খুলুন; order বদলালে কোড ভুল
দাম browser-এর form বা JSON থেকেamount=1 পাঠিয়ে দেখুন session-এ কত গেল
দাম Float বা Decimal-এর মিশ্রণে, টাকা আর পয়সা গুলিয়েSchema-তে Int আর নামে Paisa; ৪২০ বনাম ৪২০০০-এর test
Webhook-এ স্বাক্ষর নেই বা req.body parse-এর পরে স্বাক্ষরবদলানো body আর space বদলানো body দিয়ে 401 আসে কি না দেখুন
Duplicate ঠেকাতে memory-র Setএকই ঘটনা দুটি সমান্তরাল অনুরোধে পাঠান; database-এ সারি গুনুন
নেই এমন SDK বা method, বা কোনো gateway-র API-র বানানো endpointnpm view আর gateway-র অফিসিয়াল docs; না মিললে ব্যবহার নয়
Secret কোডে বা log-এ, test আর live key মেশানোDiff-এ sk_, secret খুঁজুন; log-এ redact আছে কি না দেখুন
Error ধরে চুপ করে 200 দেওয়াDatabase বন্ধ রেখে webhook পাঠান; 500 আর retry আসা উচিত

AI-কে version আর শর্ত স্পষ্ট বলুন। আসল key, secret বা ক্রেতার তথ্য prompt-এ দেবেন না; gateway-র নিয়ম নিজে docs থেকে পড়ে প্রয়োজনীয় অংশটুকু দিন।

আমি Node 24, Express 5.3, Prisma 7.10 (adapter-pg) আর PostgreSQL ব্যবহার করছি। একটি payment gateway-র webhook handler লিখে দাও। শর্ত: ১. Route-এ express.raw({ type: 'application/json' }) দিয়ে raw body নাও। HMAC-SHA256 node:crypto দিয়ে, timingSafeEqual দিয়ে তুলনা, ৩০০ সেকেন্ডের সময়সীমা। Header-এর নাম আর ধরন আমি নিচে দিচ্ছি; তুমি কোনো gateway-র নাম বা API বানাবে না। ২. যাচাইয়ের পরে gateway-র validation API (GET /v1/sessions/:id) ডেকে amountPaisa আর currency আমার Payment সারির সাথে মেলাও। না মিললে order বদলাবে না, error log করবে। ৩. Event id WebhookEvent টেবিলের primary key-তে বসাও, order বদল আর outbox একই prisma.$transaction-এ। Unique violation (P2002) হলে কিছু না বদলে 200 দাও। ৪. Payment-এর অবস্থা বদলাবে শুধু একটি canMove(from, to) নিয়ম মেনে; PAID থেকে PENDING বা FAILED-এ কখনো নয়। ৫. টাকা সবসময় Int, পয়সায়। কোনো নতুন npm package যোগ করবে না। Secret শুধু process.env থেকে। ৬. সাথে পাঁচটি test-এর বর্ণনা দাও: বৈধ, একই ঘটনা দুবার একসাথে, বদলানো body, ভুল amount, database বন্ধ। প্রত্যাশিত status লেখো, কিন্তু "পাস করেছে" বলবে না, কারণ তুমি চালাওনি। Header: x-pay-signature: t=<unix সময়>,v1=<hex>, স্বাক্ষর = HMAC(secret, t + "." + rawBody)

AI-এর লেখা payment কোড মেলানোর চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১ক্রেতা তো সত্যিই টাকা দিয়ে ফিরেছেন। তবু return URL-এর “success” বিশ্বাস না করে webhook আর validation API-র অপেক্ষা কেন?

প্রশ্নটি আসলে বিশ্বাসের সীমানা নিয়ে। Return URL-এর প্রতিটি অক্ষর ক্রেতার browser দিয়ে আসে। সৎ ক্রেতার ক্ষেত্রেও সেটি নিয়ন্ত্রণ করেন ক্রেতা, তাঁর browser extension, বা মাঝখানের যেকোনো কিছু। কেউ যদি দেখেন URL-এ status=success লিখলেই order নিশ্চিত হয়, তাহলে টাকা না দিয়েই পণ্য পাওয়ার রাস্তা খুলে গেল। এটি কোনো জটিল আক্রমণ নয়, address bar-এ লেখা মাত্র।

উল্টো দিকটিও আছে। সৎ ক্রেতা টাকা দিলেন, কিন্তু ফেরার আগেই ফোনের নেট চলে গেল, ব্যাটারি শেষ, বা ট্যাব বন্ধ করে দিলেন। Return URL কখনো খোলাই হলো না। যদি order শুধু return URL-এ নিশ্চিত হতো, তাহলে টাকা কাটা গেল অথচ order বাকি থাকল। SSLCommerz-এর প্রকাশ্য docs-ও IPN-এর প্রয়োজনীয়তার কারণ হিসেবে প্রায় এই কথাই বলে: ক্রেতার session টিকে নাও থাকতে পারে, আর লেনদেন pending অবস্থায় যেতে পারে।

Server-to-server notification-এর ধারণা নতুন নয়। অনলাইন পেমেন্টের প্রথম দিকের সেবাগুলোতেই “instant payment notification” ধরনের ব্যবস্থা দেখা যায়, আর IPN নামটি এখনো অনেক gateway ব্যবহার করে। কারণ একই: browser-এর পথ ক্রেতার, server-এর পথ ব্যবসার। তবে webhook-ও একা যথেষ্ট নয়। স্বাক্ষর বলে বার্তাটি gateway-র, কিন্তু secret ফাঁস হলে সেই নিশ্চয়তাও যায়, আর webhook দেরিতে, দুবার বা উল্টো ক্রমে আসতে পারে। তাই তৃতীয় স্তর: আপনি নিজে validation API ডেকে জিজ্ঞেস করেন, আর নিজের সারির paisa ও মুদ্রার সাথে মেলান। নিজে চেয়ে আনা উত্তর সবচেয়ে বিশ্বাসযোগ্য, কারণ প্রশ্নটি আপনার, ঠিকানাও আপনার জানা।

ব্যবহারকারীর অভিজ্ঞতা কি তাতে খারাপ হয়? ল্যাবে return থেকে PAID পর্যন্ত লেগেছে প্রায় এক সেকেন্ড, পাতাটি নিজে refresh হয়েছে। চাইলে return handler নিজেও validation API ডেকে দ্রুত দেখাতে পারে, কারণ সেটিও server-to-server; শুধু browser-এর দাবিটি বিশ্বাস করা চলবে না।

মূল উপলব্ধি: Browser যা বলে, তা ইঙ্গিত; gateway নিজে থেকে যা জানায়, তা খবর; আর আপনি নিজে জিজ্ঞেস করে যা জানেন, তা প্রমাণ। টাকার সিদ্ধান্ত হয় প্রমাণে।

প্র ০২“ঠিক একবার” পৌঁছানো নিশ্চিত করা যায় না কেন? তাহলে payment system “একবারই কাটা” নিশ্চিত করে কীভাবে?

Distributed system-এর একটি পুরোনো চিন্তা-পরীক্ষা, দুই সেনাপতির সমস্যা (Two Generals’ Problem), ১৯৭০-এর দশকের network গবেষণায় আলোচিত হয়। দুই পাহাড়ে দুই সেনাপতি, মাঝে শত্রুর উপত্যকা, দূত ধরা পড়তে পারে। “কাল ভোরে আক্রমণ” পাঠানোর পর প্রথম জন জানেন না বার্তা পৌঁছেছে কি না। দ্বিতীয় জন উত্তর পাঠালেন, এবার তিনি জানেন না উত্তর পৌঁছেছে কি না। যত বার্তাই পাঠান, শেষ বার্তাটির ব্যাপারে অনিশ্চয়তা থাকে। প্রমাণ করা যায়, অনির্ভরযোগ্য পথে নিশ্চিত ঐকমত্য অসম্ভব।

Webhook-এ এর মানে: gateway পাঠাল, আপনার server কাজ করল, কিন্তু 200 পথে হারাল। Gateway-র কাছে এটি “পৌঁছায়নি” আর “পৌঁছেছে কিন্তু উত্তর হারিয়েছে”-এর মধ্যে পার্থক্য করার উপায় নেই। নিরাপদ পথ একটিই: আবার পাঠানো। তাই প্রায় সব webhook ব্যবস্থা at-least-once. একইভাবে আপনার server যখন session চায় আর timeout হয়, তখনো জানে না session হয়েছে কি না।

সমাধান হলো লক্ষ্য বদলানো: “একবার পাঠানো” নয়, “একবার প্রভাব”। গ্রহণকারী idempotent হয়। Session-এর ক্ষেত্রে client একটি key পাঠায়, server key দেখে আগের উত্তর ফেরত দেয়। এই কৌশল জনপ্রিয় হয় বড় payment API-গুলোর হাত ধরে; ২০১০-এর দশকে কয়েকটি সুপরিচিত engineering লেখায় idempotency key-এর নকশা বিস্তারিত বর্ণনা করা হয়, আর এখন IETF-এ একটি খসড়া এটিকে মান হিসেবে লেখার চেষ্টা করছে। Webhook-এর ক্ষেত্রে key দেয় gateway: event id. গ্রহণকারী তা database-এর unique column-এ রাখে, আর order বদল একই transaction-এ।

আমাদের ল্যাবের duplicate পরীক্ষায় দুটি কপি একই মিলিসেকেন্ডে এসেছিল। এখানে বোঝা যায় কেন “আগে দেখে নিই আছে কি না, তারপর লিখি” যথেষ্ট নয়: দুজনেই দেখবেন নেই, দুজনেই লিখবেন। Unique index এই check-then-act ফাঁক বন্ধ করে, কারণ database নিজে একটিকে আটকায়। এটি পর্ব ৪৩-এর race condition-এর আলোচনার সরাসরি প্রয়োগ।

মূল উপলব্ধি: Network-কে একবার পাঠাতে বাধ্য করা যায় না, কিন্তু নিজের system-কে একবার প্রভাব নিতে বাধ্য করা যায়। Key, unique constraint আর state machine, তিনটি মিলে সেই নিশ্চয়তা।

প্র ০৩নিজের পাতায় কার্ডের ঘর রাখলে ডিজাইন সুন্দর হয়, conversion-ও হয়তো বাড়ে। তবু অনেক ছোট দল hosted checkout বেছে নেয় কেন? PCI DSS এখানে কী ভূমিকা রাখে?

২০০০-এর দশকের শুরুতে বড় কার্ড কোম্পানিগুলোর নিজস্ব নিরাপত্তা কর্মসূচি ছিল, আর ব্যবসাগুলোকে আলাদা আলাদা নিয়ম মানতে হতো। পরে তারা একটি সাধারণ মান তৈরি করে, PCI DSS, আর ২০০৬ সালে এর রক্ষণাবেক্ষণের জন্য PCI Security Standards Council গড়ে ওঠে। মানটি কয়েক বছর পরপর হালনাগাদ হয়; লেখার সময়ে সাম্প্রতিক বড় সংস্করণ 4.0, যার একটি ছোট হালনাগাদ 4.0.1. এর শর্তগুলো network বিভাজন, encryption, access control, log, নিয়মিত scan আর penetration test পর্যন্ত বিস্তৃত।

মূল কৌশল হলো scope কমানো। যে system কার্ডের তথ্য দেখে, রাখে বা পাঠায়, আর যা তার সাথে যুক্ত, সবই মানের আওতায়। আপনার server যদি কার্ড নম্বর একবারও পায়, এমনকি শুধু gateway-তে পাঠিয়ে দেওয়ার জন্য, তাহলে server, তার database, log, backup, যারা সেখানে ঢুকতে পারেন, সবাই আওতায়। Hosted checkout-এ কার্ডের তথ্য gateway-র পাতায় লেখা হয়, আপনার server শুধু একটি session id আর ফলাফল দেখে। অনেক ক্ষেত্রে এতে প্রযোজ্য যাচাই অনেক হালকা হয় বলে ধরা হয়, তবে ঠিক কোনটি প্রযোজ্য, তা আপনার acquiring ব্যাংক আর gateway-র নির্দেশনা ঠিক করবে।

মাঝামাঝি পথও আছে: gateway-র দেওয়া embedded field বা iframe, যেখানে ঘরটি আপনার পাতায় দেখায় কিন্তু তথ্য সরাসরি gateway-তে যায়, আপনার server-এ নয়। এতে ডিজাইনের স্বাধীনতা কিছুটা ফেরে, কিন্তু আপনার পাতার JavaScript-এর নিরাপত্তা আরও গুরুত্বপূর্ণ হয়ে ওঠে; কারণ আক্রান্ত পাতা জাল ঘর বসাতে পারে। এ কারণে সাম্প্রতিক মানে পেমেন্ট পাতার script-এর তালিকা আর অখণ্ডতা যাচাইয়ের ওপর জোর বেড়েছে বলে বিভিন্ন সূত্রে আলোচিত।

বাংলাদেশের প্রেক্ষাপটে আরেকটি দিক আছে: অনেক ক্রেতা কার্ড নয়, MFS ওয়ালেট ব্যবহার করেন, যার অনুমোদন সাধারণত সেবাদাতার নিজের পাতা বা অ্যাপে হয়। সেখানে PIN বা OTP দোকানের হাতে আসার প্রশ্নই ওঠে না, আর আসা উচিতও নয়।

মূল উপলব্ধি: নিরাপত্তার সবচেয়ে সস্তা কৌশল হলো বিপজ্জনক তথ্য না ছোঁয়া। যে তথ্য আপনার কাছে নেই, তা আপনার কাছ থেকে চুরি হতে পারে না।

প্র ০৪Cash on delivery-কে আলাদা ব্যবস্থা না বানিয়ে একই state machine-এর একটি পথ হিসেবে রাখার সুবিধা কী? COD-এর ঝুঁকিগুলো নকশায় কীভাবে আসে?

অনেক দোকান শুরুতে COD-কে “payment নেই” হিসেবে ধরে: order তৈরি, পণ্য পাঠানো, ব্যস। সমস্যা দেখা দেয় হিসাব মেলাতে গিয়ে। কোন order-এর টাকা এসেছে, কোনটি ক্রেতা নেননি, কোনটির টাকা rider-এর হাতে আছে কিন্তু জমা হয়নি, এসব প্রশ্নের উত্তর তখন spreadsheet আর ফোনকলে খুঁজতে হয়।

একই state machine-এ রাখলে প্রতিটি order-এর একটি payment সারি থাকে, provider cod. অবস্থা PENDING থেকে PAID (টাকা পাওয়া গেছে) বা FAILED (ক্রেতা নেননি)। এতে কয়েকটি জিনিস বিনামূল্যে পাওয়া যায়। Report-এ online আর COD একসাথে আসে। “PAID থেকে আবার PENDING নয়” নিয়মটি COD-তেও খাটে, তাই কেউ ভুল করে দুবার “collected” চাপলে দ্বিতীয়বার প্রত্যাখ্যাত, যেমন ল্যাবে not a pending COD. Refund-এর পথও একই, যদিও COD-এর টাকা ফেরত হয়তো MFS বা নগদে যায়।

COD-এর ঝুঁকিগুলো সাধারণভাবে পরিচিত: ক্রেতা পণ্য নেন না, ফলে দুই দিকের delivery খরচ; rider-এর হাতে নগদ; courier-এর জমা দিতে দেরি। নকশায় এগুলো আসে আলাদা অবস্থা বা field হিসেবে, যেমন courier-এর কাছে জমা হয়েছে কি না, আর তার নিজের reconciliation: courier-এর settlement report বনাম আপনার PAID সারি। কিছু দোকান COD-তে ছোট অগ্রিম নেয়, যা আসলে একটি online partial payment; একই সারণিতে দুটি payment সারি দিয়ে তা প্রকাশ করা যায়।

কোন পথ কত সাধারণ বা কত ক্ষতির, সে বিষয়ে আমরা এখানে কোনো পরিসংখ্যান দিচ্ছি না; নিজের দোকানের ডেটা থেকে মাপাই নির্ভরযোগ্য। নকশার শিক্ষাটি পরিসংখ্যান-নিরপেক্ষ।

মূল উপলব্ধি: টাকা কোন পথে আসে, তা একটি detail; টাকা এসেছে কি না, তা একটি অবস্থা। অবস্থার নিয়ম এক জায়গায় থাকলে নতুন পথ যোগ করা সহজ, আর হিসাব সবসময় এক খাতায়।

প্র ০৫Webhook আর retry থাকলে reconciliation কেন লাগে? এটি কি শুধু দুর্বল কোডের জোড়াতালি?

Reconciliation সফটওয়্যারের চেয়ে অনেক পুরোনো। দুই খাতার হিসাব (double-entry bookkeeping) পদ্ধতির প্রথম দিকের বিস্তারিত ছাপা বর্ণনা হিসেবে Luca Pacioli-র ১৪৯৪ সালের বইটি প্রায়ই উল্লেখ করা হয়। মূল ধারণা: প্রতিটি লেনদেন দুই জায়গায় লেখা, আর নিয়মিত মিলিয়ে দেখা। ব্যাংকের statement আর নিজের খাতা মেলানো হিসাবরক্ষকের নিয়মিত কাজ। কারণটি দুর্বলতা নয়, স্বাধীনতা: দুটি আলাদা ব্যবস্থা একই ভুল একসাথে করার সম্ভাবনা কম।

Payment-এ ভুলের উৎস অনেক, আর অনেকগুলো আপনার কোডের বাইরে। Gateway-র retry-র সময়সীমা পেরিয়ে গেছে (ধরে নেওয়া সূচিতে ৬৩ মিনিট পরে আর চেষ্টা হয় না, যেমন আগের হিসাবে দেখলেন)। আপনার firewall বা নতুন deploy কিছুক্ষণ webhook আটকেছে। Secret বদলানোর সময় কয়েক মিনিট স্বাক্ষর মেলেনি। Gateway নিজে কোনো লেনদেন পরে বদলেছে, যেমন ব্যাংকের দিক থেকে উল্টানো। এসবের কোনোটিই আপনার webhook handler-এর বাগ নয়, তবু ফল হলো অমিল।

Reconciliation দুই স্তরে কাজ করে। দ্রুত স্তর আটকে থাকা payment কয়েক মিনিট পরপর দেখে ঠিক করে, যাতে ক্রেতার অভিযোগের আগেই সমাধান হয়। ধীর স্তর দিনের report সারি ধরে মেলায় আর অমিল মানুষের হাতে দেয়। আমাদের ল্যাবের শেষ run-এ এই দ্বিতীয় স্তরই sess_0008 ধরেছে, যা কোনো webhook-এ কখনো আসত না, কারণ সেটি আমাদের checkout দিয়ে তৈরিই হয়নি। আর ব্লকের উদাহরণে দেখলেন, মোট অঙ্ক কাছাকাছি হলেও ভেতরে চারটি আলাদা সমস্যা লুকিয়ে থাকতে পারে।

একটি নকশাগত শর্ত আছে: reconciliation-কে webhook-এর একই ফাংশন ব্যবহার করতে হবে। নইলে দুটি পথে দুটি আলাদা নিয়ম তৈরি হয়, আর একদিন সেগুলো অসঙ্গত ফল দেবে। ল্যাবে applyGatewayTruth তিন জায়গা থেকে ডাকা হয়: webhook, reconcile আর সম্ভব হলে return পাতা।

মূল উপলব্ধি: Webhook হলো দ্রুত পথ, reconciliation হলো নিরাপত্তা জাল। ভালো payment system ধরে নেয় দ্রুত পথ কখনো না কখনো ব্যর্থ হবে, আর সেই দিনের জন্য আগে থেকেই খাতা মেলানোর ব্যবস্থা রাখে।

অনুশীলন

হিসাব, কমান্ড, সমালোচনা আর ফল অনুমান, চারটিই আছে।

  1. পয়সার হিসাব আর retry (numerical): একটি cart-এ ৩ কেজি মসুর ডাল, প্রতি কেজি ৳১৩৪.৯০, আর ২টি সাবান, প্রতিটি ৳৪৫.৫০. ১০% ছাড় পুরো cart-এ, ভগ্নাংশ পয়সা নিচের দিকে। (ক) পয়সায় মোট কত, gateway-তে কোন সংখ্যা যাবে? (খ) এক gateway প্রথম চেষ্টার পরে 1, 2, 4, … মিনিট বিরতিতে মোট ৭টি retry দেয় (অনুমান)। শেষ চেষ্টা প্রথমটির কত পরে? (গ) আপনার server ৩ ঘণ্টা বন্ধ থাকলে ঘটনাটি কি আসবে?

    (ক) ডাল 3 × 13490 = 40470 পয়সা, সাবান 2 × 4550 = 9100, মোট 49570. ছাড় 49570 × 10 / 100 = 4957, তাই 49570 − 4957 = 44613 পয়সা (৳৪৪৬.১৩)। Gateway-তে amountPaisa: 44613, পূর্ণসংখ্যা। ভগ্নাংশ এখানে হয়নি; হলে Math.floor নিয়মমতো, আর নিয়মটি লিখে রাখা। (খ) 1 + 2 + 4 + 8 + 16 + 32 + 64 = 27 − 1 = ১২৭ মিনিট, প্রায় ২ ঘণ্টা ৭ মিনিট। (গ) না, ৩ ঘণ্টা = ১৮০ মিনিট > ১২৭. শেষ চেষ্টাও ব্যর্থ, ঘটনা আর আসবে না। Reconciliation job ছাড়া এই payment PENDING থেকে যাবে।

  2. কমান্ড দিয়ে যাচাই: আপনার দোকানের webhook /webhooks/bazarpay. তিনটি curl কমান্ডের পরিকল্পনা লিখুন, যা প্রমাণ করবে: (ক) স্বাক্ষর ছাড়া অনুরোধ বাতিল হয়, (খ) একই বৈধ ঘটনা দুবার পাঠালে database-এ একটিই সারি, (গ) return URL হাতে খুললে order বদলায় না। প্রতিটির পরে কী দেখে পাস-ফেল ঠিক করবেন?

    (ক) curl -s -w ' [%{http_code}]' URL -H 'content-type: application/json' --data-binary @evt.json, header ছাড়া; পাস: 401, আর order-এর অবস্থা আগের মতো। (খ) একটি ছোট script একই secret দিয়ে header বানায়, তারপর একই কমান্ড দুবার, বা & দিয়ে একসাথে দুবার; পাস: দুটিই 200, দ্বিতীয়টির log-এ “duplicate”, আর SELECT count(*) FROM "WebhookEvent" WHERE id='evt_…' = 1, outbox-এ একটিই কাজ। (গ) curl -s '…/payment/return?ref=BG-2050&status=success', তারপর curl …/orders/BG-2050; পাস: payment এখনো PENDING. --data-binary জরুরি, কারণ -d newline বাদ দিতে পারে আর byte বদলায়।

  3. কোড সমালোচনা (নকশা): অন্তত ছয়টি সমস্যা ধরুন আর প্রতিটির সমাধান বলুন: app.get('/pay/success', async (req, res) => { const { orderId, amount } = req.query; await db.query(`UPDATE orders SET status='paid', paid_amount=${amount} WHERE id=${orderId}`); await sendSms(orderId); console.log('paid', req.query, process.env.GATEWAY_SECRET); res.send('ধন্যবাদ!'); });
    • Return URL-এ order paid: browser-এর দাবি বিশ্বাস। সমাধান: শুধু অবস্থা দেখানো; webhook আর validation API-তে সিদ্ধান্ত।
    • amount browser থেকে, আর তা দিয়ে কিছু মেলানো হয়নি। সমাধান: gateway-র উত্তরের paisa আর মুদ্রা নিজের সারির সাথে মেলানো।
    • String জুড়ে SQL: SQL injection. সমাধান: parameterized query বা Prisma.
    • GET-এ অবস্থা বদলানো: link preview বা prefetch-ও ডাকতে পারে।
    • State machine নেই: FAILED বা REFUNDED order-ও আবার paid হতে পারে।
    • Idempotency নেই: পাতা refresh করলে SMS আবার যাবে। সমাধান: অবস্থা বদলালেই শুধু outbox-এ কাজ।
    • Log-এ secret আর পুরো query: process.env.GATEWAY_SECRET কখনো log নয়; redact.
    • SMS সরাসরি request-এর ভেতরে: ধীর বা ব্যর্থ হলে পুরো অনুরোধ আটকায়; outbox আর worker.
  4. ফল অনুমান: একটি payment-এর state machine ছবি ৬৫.২-এর মতো (PENDING → AUTHORISED/PAID/FAILED/EXPIRED, AUTHORISED → PAID/FAILED/EXPIRED, PAID → PARTIALLY_REFUNDED/REFUNDED, PARTIALLY_REFUNDED → PARTIALLY_REFUNDED/REFUNDED; বাকিরা শেষ)। শুরু PENDING. এই ক্রমে চেষ্টা হলো: AUTHORISED, FAILED, PAID, PARTIALLY_REFUNDED, PAID, REFUNDED, EXPIRED. প্রতিটি চেষ্টার পরে অবস্থা কী, আর কোনগুলো প্রত্যাখ্যাত?

    AUTHORISED (বৈধ) → FAILED (বৈধ, AUTHORISED থেকে FAILED যাওয়া যায়) → PAID চেষ্টা প্রত্যাখ্যাত, কারণ FAILED শেষ অবস্থা; অবস্থা FAILED থাকে → PARTIALLY_REFUNDED প্রত্যাখ্যাত → PAID প্রত্যাখ্যাত → REFUNDED প্রত্যাখ্যাত → EXPIRED প্রত্যাখ্যাত. শেষ অবস্থা FAILED; বৈধ ধাপ ২টি, প্রত্যাখ্যাত ৫টি। লক্ষ করুন, তৃতীয় চেষ্টার PAID যদি gateway-র সত্যিকারের তথ্য হয়, তাহলে শুধু প্রত্যাখ্যান যথেষ্ট নয়; টাকা কাটা গেছে, তাই review-এর জন্য মানুষের কাছে পাঠাতে হবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি hosted checkout-এ ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Server নিজের catalogue থেকে দাম হিসাব করে order আর PENDING payment তৈরি করে
  2. Server idempotency key দিয়ে gateway-তে session চায়
  3. ক্রেতা gateway-র hosted পাতায় টাকা দেন
  4. ক্রেতা return URL-এ ফেরেন, পাতা বর্তমান অবস্থা দেখায়
  5. Server স্বাক্ষরসহ webhook পায় আর validation API দিয়ে paisa ও মুদ্রা মেলায়
  6. একই transaction-এ payment PAID, order CONFIRMED, outbox-এ fulfilment-এর কাজ

একটি অনলাইন দোকানে পেমেন্টের পর ক্রেতা /payment/return?ref=BG-7&status=success-এ ফেরেন। এই অনুরোধ পেয়ে server-এর সঠিক আচরণ কোনটি?

  • Order-কে সাথে সাথে paid করা, কারণ gateway-ই পাঠিয়েছে
  • Database-এর বর্তমান অবস্থা দেখানো, আর paid করা শুধু webhook বা validation API-র ভিত্তিতে
  • যেহেতু URL বিশ্বাসযোগ্য নয়, order-কে failed করা
  • URL-এর amount দাম-এর সাথে মিললে paid করা

Payment-এর state machine-এ অবস্থা PAID. দেরিতে একটি বৈধ-স্বাক্ষরের payment.failed ঘটনা এলো, যা আসলে আগের একটি ব্যর্থ চেষ্টার। সঠিক আচরণ কী?

  • Payment-কে FAILED করা, কারণ সর্বশেষ খবর সেটিই
  • 500 দিয়ে ঘটনাটি প্রত্যাখ্যান করা
  • 401 দেওয়া, কারণ ঘটনাটি নিশ্চয়ই জাল
  • অবস্থা PAID রেখে ঘটনা উপেক্ষা করা আর 200 দেওয়া

একটি webhook handler একই event id-র দুটি কপি একই মিলিসেকেন্ডে পায়, দুটি আলাদা server process-এ। Duplicate আটকানোর সবচেয়ে নির্ভরযোগ্য উপায় কোনটি?

  • Event id-তে database-এর unique constraint, আর order বদল একই transaction-এ
  • প্রতিটি process-এর memory-তে একটি Set
  • আগে SELECT দিয়ে দেখা আছে কি না, না থাকলে লেখা
  • দ্বিতীয় কপির আগে ১ সেকেন্ড অপেক্ষা

একটি order-এর দাম ৳৪২০.০০. Gateway-র validation API উত্তর দিল {"amountPaisa": 420, "currency": "BDT", "status": "succeeded"}. দোকানের database-এ amountPaisa = 42000. কী করা উচিত?

  • Status succeeded, তাই order নিশ্চিত করা
  • ধরে নেওয়া যে gateway টাকায় পাঠিয়েছে, 420 × 100 করে মিলিয়ে নেওয়া
  • Order নিশ্চিত না করে অমিল log করা আর review-এর জন্য পাঠানো
  • Order বাতিল করে কিছু না জানানো

একটি দোকানের server session তৈরির অনুরোধ পাঠাল, কিন্তু উত্তরের আগে timeout হলো। একই Idempotency-Key আর একই body দিয়ে আবার পাঠালে একটি সঠিকভাবে বানানো gateway কী করবে?

  • আরেকটি নতুন session বানাবে
  • আগের তৈরি session-এর একই উত্তর ফেরত দেবে
  • 422 দেবে, কারণ key আগে ব্যবহৃত
  • আগের session বাতিল করে নতুন বানাবে

দিনের শেষে মিলিয়ে দেখা গেল: দোকানের database-এ একটি order PAID, কিন্তু gateway-র report-এ ওই session-এর কোনো সফল লেনদেন নেই। সবচেয়ে উপযুক্ত পদক্ষেপ কোনটি?

  • কিছু না করা, কারণ database-ই আমাদের সত্যের উৎস
  • সাথে সাথে order মুছে ফেলা
  • ক্রেতাকে আবার টাকা দিতে বলা
  • Delivery আটকে রেখে validation API আর log দিয়ে তদন্ত করা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. দাম server-এ, টাকা gateway-র পাতায়। Session তৈরিতে Idempotency-Key, কার্ডের তথ্য কখনো নিজের server-এ নয়।
  2. Return URL শুধু দেখায়। সিদ্ধান্ত: স্বাক্ষরসহ webhook → validation API → paisa আর মুদ্রা মেলানো।
  3. Payment একটি state machine, PostgreSQL enum আর canMove-এ; PAID কখনো পিছিয়ে যায় না, ফেরত মানে REFUNDED.
  4. Event id-তে unique constraint, order বদল আর outbox একই transaction-এ; duplicate, late আর উল্টো ক্রম তখন নিরীহ।
  5. Reconciliation job আটকে থাকা payment সারায় আর দিনের report সারি ধরে মেলায়; refund, মেয়াদ আর COD-ও একই নিয়মে, log-এ কোনো secret নয়।

পরের পর্বে: মডিউল ১০ শুরু। React ছাড়াও জনপ্রিয় framework আছে। Vue আর Nuxt-এর reactivity কীভাবে কাজ করে, আর একই বাজারঘরের একটি পাতা React-এর সাথে পাশাপাশি রাখলে কী পার্থক্য চোখে পড়ে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান