IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime32/69-dars18 daqiqa
Mundarija (17)

Stripe: PaymentIntent, Checkout va imzoli webhook

Qisqacha: Stripe — xalqaro to'lovlar standarti va O'zbekiston provayderlaridan teskari: bu yerda siz Stripe API'ini chaqirasiz (PaymentIntent yoki Checkout Session yaratasiz), natijani esa Stripe webhook bilan xabar qiladi. Ikki qoida hammasidan muhim: har yaratuvchi so'rovga Idempotency-Key (rasmiy kutubxona tarmoq xatosida qayta urinadi) va webhook imzosini xom tana ustida tekshirish (express.raw, express.json emas). Kanon rasmiy stripe-mock va lokal webhook testlari bilan ishladi; haqiqiy Stripe hisobi, test kartalar va stripe listen — sinalmagan.

Bu darsda

  • PaymentIntent, Checkout Session va Customer nima ekanini bilasiz.
  • Idempotency-Key nega majburiy ekanini tushunasiz.
  • Stripe-Signature imzosini Web Crypto bilan qo'lda tekshirasiz.
  • Xom tana, vaqt oynasi (tolerance) va takror hodisalarni kanon testi bilan ko'rasiz.
  • stripe-mock nimani tekshiradi va nimani tekshirmaydi — bilasiz.
  • stripe/stripe.ts modulini yozasiz.

Oldin bilishingiz kerak: Uzum va boshqa mahalliy to'lov tizimlari, Idempotency-Key, crypto 1: tasodif, hash va HMAC, Request body o'qish va hajm cheklovi.

Oldingi uch darsda provayder bizni chaqirdi. Stripe'da yo'nalish aralash: biz so'raymiz, Stripe esa keyin xabar beradi. Shu sababli bu dars keyingi ikki darsga — kiruvchi va chiquvchi webhook'larga — ko'prik.

Diqqat: Kanon haqiqiy Stripe hisobini ochmadi. PaymentIntent va Checkout Session Stripe'ning rasmiy stripe-mock dasturi bilan (0.206.0, Docker'da, lokal) va soxta sk_test_… kalit bilan sinaldi. Webhook imzosi — stripe 23.0.0 paketi bilan, tarmoqsiz. Stripe hujjatiga ko'ra (2026-10-28 holati): O'zbekiston Stripe hisob ochish mumkin bo'lgan mamlakatlar ro'yxatida yo'q — hisobni ro'yxatdagi mamlakatda ro'yxatdan o'tgan kompaniya ochadi. UZS esa to'lov valyutasi sifatida ro'yxatda bor va ikki kasrli valyuta (nol kasrlilar qatorida emas): amount tiyinda yuboriladi. Haqiqiy loyihadan oldin ikkalasini ham Stripe hujjatidan qayta tekshiring.

1. Nega bu kerak?

Mahalliy provayderlar O'zbekiston kartalari uchun. Chet ellik mijoz (Visa/Mastercard, Apple Pay), xorijiy SaaS obunasi yoki xalqaro marketplace — bu Stripe va unga o'xshashlar hududi. Stripe API'si sohada namuna hisoblanadi: ko'p provayder uning tushunchalarini (intent, idempotency key, imzoli webhook) takrorlaydi. Stripe'ni tushunsangiz, boshqa xalqaro provayderni ham tez o'qiysiz.

2. Asosiy obyektlar

Obyekt Ma'nosi
PaymentIntent (pi_…) "shu summani olmoqchiman" niyati; holatlar bilan to'lov oxirigacha boradi
Checkout Session (cs_…) Stripe'ning tayyor to'lov sahifasi; ichida PaymentIntent yaratiladi
Customer (cus_…) mijoz yozuvi; saqlangan kartalar va obunalar unga bog'lanadi
Event (evt_…) webhook bilan keladigan hodisa: payment_intent.succeeded va h.k.

Ikki yo'l:

  1. Checkout Session — eng oddiy. Sessiya yaratasiz, mijozni Stripe sahifasiga yuborasiz (redirect), Stripe karta, 3D Secure va xatolarni o'zi boshqaradi.
  2. PaymentIntent + o'z formangiz — Stripe'ning brauzer kutubxonasi (Elements) bilan to'lov formasi sizning sahifangizda. Ko'proq nazorat, ko'proq ish.

3D Secure (3DS) — bank mijozdan qo'shimcha tasdiq so'raydi (SMS kod, bank ilovasi). PaymentIntent bunda requires_action holatiga o'tadi. Stripe hujjatida buni sinash uchun maxsus test kartalar bor. Kanonda sinalmagan: stripe-mock kartalar va 3DS ni taqlid qilmaydi.

sequenceDiagram
  participant M as Mijoz
  participant S as Bizning server
  participant ST as Stripe
  M->>S: Buyurtma 42 ni to'lash
  S->>ST: checkout.sessions.create (Idempotency-Key)
  ST-->>S: cs_..., url
  S-->>M: redirect url
  M->>ST: karta, 3DS
  ST-->>M: success_url ga qaytish
  ST->>S: webhook payment_intent.succeeded (imzo)
  S->>S: imzo, dedup, summa, 'tolangan'
  S-->>ST: 200

Xuddi shu oqim jadvalda — kim kimga va qaysi yo'l bilan murojaat qiladi:

Qadam Kim → kimga Nima ketadi
1 mijoz → bizning server "buyurtmani to'layman"
2 server → Stripe API sessiya yaratish, Idempotency-Key bilan
3 server → mijoz Stripe sahifasiga redirect
4 mijoz → Stripe karta ma'lumoti, 3DS
5 Stripe → bizning server imzoli webhook (hodisa)

Bizning server karta ma'lumotini umuman ko'rmaydi (4-qadam). Pul haqidagi haqiqatni esa faqat 5-qadam olib keladi.

Diagrammadagi tartibga e'tibor bering: mijoz success_url ga qaytishi va webhook kelishi — ikki mustaqil voqea. Ulardan qaysi biri birinchi kelishi kafolatlanmagan. Buyurtmani faqat webhook o'zgartiradi (To'lov ekotizimi va umumiy oqim darsidagi qoida).

3. Idempotency-Key: nega majburiy

Kanon Stripe mijozini shunday yaratadi:

ts
export function stripeMijoz(
  kalit: string,
  mock?: { host: string; port: number },
): Stripe {
  return new Stripe(kalit, {
    // tarmoq xatosida qayta urinadi — shuning uchun POST'larda
    // Idempotency-Key MAJBURIY
    maxNetworkRetries: 2,
    timeout: 10_000,
    ...(mock
      ? {
          host: mock.host,
          port: mock.port,
          protocol: "http" as const,
        }
      : {}),
  });
}

maxNetworkRetries: 2 — tarmoq xatosi yoki timeout'da rasmiy kutubxona so'rovni o'zi qayta yuboradi. Endi tasavvur qiling: birinchi paymentIntents.create Stripe'ga yetib bordi, PaymentIntent yaratildi, lekin javob yo'lda yo'qoldi. Kutubxona qayta yubordi — ikkinchi PaymentIntent. Mijoz ikkita to'lov oynasini ko'radi yoki, yomoni, ikki marta to'laydi.

Yechim — Idempotency-Key darsidagi naqsh. So'rovga noyob kalit qo'shiladi; Stripe shu kalitli takroriy so'rovga birinchi javobni qaytaradi:

ts
/**
 * Buyurtma uchun PaymentIntent: summa — eng kichik birlikda (UZS
 * uchun tiyin; nol kasrli valyutalar — JPY va h.k. — boshqacha!).
 */
export async function tolovNiyati(
  stripe: Stripe,
  b: { id: number; summa: number; valyuta: string },
) {
  return stripe.paymentIntents.create(
    {
      amount: b.summa,
      currency: b.valyuta,
      metadata: { buyurtma_id: String(b.id) },
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey: `buyurtma-${String(b.id)}-pi` },
  );
}

Kalit — buyurtma-42-pi: buyurtmadan deterministik yasalgan. Tasodifiy UUID bo'lsa, server qayta ishga tushganda yoki foydalanuvchi tugmani ikki marta bossa yangi kalit chiqardi va himoya ishlamasdi.

Kanon testi kutubxonaning request hodisasiga quloq solib, Stripe'ga haqiqatan qaysi sarlavha ketganini yozib oldi: ["buyurtma-42-pi"]. Ya'ni kalit faqat kodda yozilgan emas — so'rovda bor.

Uchta summa qoidasi:

  • amount — eng kichik birlikda (sent, tiyin), butun son. Pul bilan ishlash JS'da darsidagi qoida.
  • Ba'zi valyutalar nol kasrli (zero-decimal): Stripe hujjatida ularning ro'yxati bor (masalan, JPY). U yerda amount: 500 — 500 iyena, 5 emas.
  • metadata.buyurtma_id — Stripe obyektini bizning buyurtmaga bog'laydi. Webhook kelganda aynan shu bo'yicha buyurtmani topamiz.

4. Webhook imzosi: Stripe-Signature

Stripe webhook'i POST, tanasi — Event JSON'i. Uni Stripe yuborganini qanday bilamiz? Sarlavha:

text
Stripe-Signature: t=1793095200,v1=4d93498e29ea0b4bd564b000ab4e7cec2ea9412787d796d073ce52d7d4cb3909
  • t — imzo vaqti (unix soniya).
  • v1 — HMAC-SHA256: kalit — webhook siri (whsec_…), matn — "<t>.<xom tana>", natija — hex.

Kalitsiz imzo yasab bo'lmaydi, vaqt esa eski so'rovni qayta yuborishga (replay) qarshi. Stripe hujjatiga ko'ra rasmiy kutubxonalarning sukut vaqt oynasi (tolerance) — 5 daqiqa, ya'ni 300 soniya. Bu sarlavhani biz stripe 23.0.0 ning generateTestHeaderString funksiyasi bilan oldik (biz ishga tushirdik). Endi xuddi shu imzoni paketsiz, brauzerdagi Web Crypto bilan tekshiramiz:

js
const enc = new TextEncoder();

async function hmacHex(sir, matn) {
  const kalit = await crypto.subtle.importKey(
    "raw", enc.encode(sir), { name: "HMAC", hash: "SHA-256" },
    false, ["sign"],
  );
  const baytlar = enc.encode(matn);
  const imzo = await crypto.subtle.sign("HMAC", kalit, baytlar);
  return [...new Uint8Array(imzo)]
    .map((b) => b.toString(16).padStart(2, "0")).join("");
}

async function tekshir(tana, sarlavha, sir, hozir, tolerans = 300) {
  const qism = Object.fromEntries(
    sarlavha.split(",").map((x) => x.split("=")),
  );
  if (!qism.t || !qism.v1) return "sarlavha buzuq";
  const t = Number(qism.t);
  if (Math.abs(hozir - t) > tolerans) return "tolerance tashqarisida";
  const kutilgan = await hmacHex(sir, `${t}.${tana}`);
  return kutilgan === qism.v1 ? "ok" : "imzo mos emas";
}

(async () => {
  const sir = "whsec_SOXTA_lokal";
  const tana = '{"id":"evt_1","type":"payment_intent.succeeded"}';
  const t = 1793095200;
  const sarlavha = `t=${t},v1=${await hmacHex(sir, `${t}.${tana}`)}`;
  console.log(sarlavha);
  console.log(await tekshir(tana, sarlavha, sir, t + 10));
  console.log(await tekshir(tana, sarlavha, sir, t + 301));
  const chiroyli = JSON.stringify(JSON.parse(tana), null, 2);
  console.log(await tekshir(chiroyli, sarlavha, sir, t + 10));
  console.log(await tekshir(tana, sarlavha, "whsec_boshqa", t + 10));
})();

Konsolda birinchi qatorga (sarlavha) va keyingi to'rtta tekshiruv natijasiga qarang:

text
t=1793095200,v1=4d93498e29ea0b4bd564b000ab4e7cec2ea9412787d796d073ce52d7d4cb3909
ok
tolerance tashqarisida
imzo mos emas
imzo mos emas

Birinchi qator stripe paketi yasagan sarlavha bilan belgisigacha bir xil — demak formula to'g'ri. Qolgan to'rttasi:

  1. To'g'ri imzo, 10 soniya ichida — ok.
  2. 301 soniyadan keyin — vaqt oynasidan tashqarida. Stripe paketining sukut chegarasi — 300 soniya.
  3. Bir xil ma'lumot, boshqa formatlash (null, 2 — chiroyli JSON) — imzo mos emas. Bu darsning eng muhim qatori.
  4. Boshqa sir — mos emas.

Stripe sirni (whsec_…) butun satr sifatida kalit qiladi — biz ham enc.encode(sir). Keyingi darsdagi Standard Webhooks formati esa whsec_ dan keyingi qismni base64'dan baytlarga o'giradi. Bir xil ko'rinadigan sir, ikki xil qoida — har formatni hujjatdan o'qing.

Haqiqiy kodda satrlarni === bilan emas, doimiy vaqtli solishtirish bilan tekshiring (Node'da timingSafeEqual). Stripe paketi buni o'zi qiladi.

5. Xom tana: express.raw

Uchinchi tekshiruv (chiroyli JSON) amalda qanday xato beradi? Express'da odatiy sozlama — app.use(express.json()). U tanani o'qiydi, JSON.parse qiladi va req.body ga obyekt qo'yadi. Imzoni tekshirish uchun esa sizda faqat obyekt bor — uni qayta JSON.stringify qilsangiz, bo'shliqlar, kalitlar tartibi yoki unicode ekranlash Stripe yuborgan baytlardan farq qilishi mumkin. Imzo mos kelmaydi va har bir haqiqiy webhook rad etiladi.

Kanon webhook marshrutiga faqat o'zining express.raw ini qo'yadi — req.body Buffer bo'lib qoladi:

ts
r.post(
  "/",
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req, res) => {
    hodisa = s.stripe.webhooks.constructEvent(
      req.body as Buffer,
      req.get("stripe-signature") ?? "",
      s.sir,
      300,
    );
    // ...

Diqqat: Agar ilovada app.use(express.json()) global ulangan bo'lsa va webhook marshruti undan keyin turgan bo'lsa, express.json() tanani birinchi o'qib bo'ladi. Webhook marshrutini global JSON middleware'dan oldin ulang yoki JSON'ni faqat kerakli router'larga qo'ying (Middleware va request lifecycle).

Kanon testi xuddi shu tuzoqni tekshirdi: to'g'ri imzo, lekin tana JSON.stringify(JSON.parse(t), null, 2) bilan qayta formatlangan — HTTP 400.

6. Hodisani qayta ishlash

Imzo to'g'ri bo'lsa, kanon hodisani bitta ma'lumotlar bazasi tranzaksiyasida ishlaydi:

  1. INSERT INTO kiruvchi_hodisalar … ON CONFLICT DO NOTHING — (provayder, hodisa_id) noyob. Qator qo'shilmasa — bu hodisa avval kelgan: javob "takror", boshqa hech narsa qilinmaydi.
  2. payment_intent.succeeded bo'lsa — metadata.buyurtma_id bo'yicha buyurtma FOR UPDATE bilan olinadi.
  3. Summa tekshiruvi: amount_received buyurtma summasiga teng bo'lmasa — "summa-mos-emas", buyurtma to'lanmaydi. Pul olingan, lekin nimadir noto'g'ri — bu qo'lda ko'rib chiqiladi (solishtirish — reconciliation; Refund, reconciliation va moliyaviy hisob darsida).
  4. Tranzaksiya bajarilgan, buyurtma tolangan, ledger yozuvi.
  5. Boshqa hodisa turlari — "e'tiborsiz", lekin baribir saqlanadi (keyin kerak bo'lishi mumkin).

Javob — doim tez 200. Faqat bizning ichki xatomizda (baza uzildi) — 500: Stripe hodisani keyinroq qayta yuboradi (Stripe hujjatiga ko'ra — jonli rejimda 3 kungacha, eksponensial kutish bilan). Imzo xato bo'lsa — 400.

Kanon testi takrorni shunday tekshiradi: bir xil hodisa ikki marta yuboriladi — birinchisiga { qabul: true, natija: "tolandi" }, ikkinchisiga { qabul: true, natija: "takror" }.

Tekshirib ko'ring: Nega takror hodisaga 400 yoki 409 emas, 200 qaytaramiz?

Javob

2xx bo'lmagan javobni Stripe "yetkazilmadi" deb tushunadi va qayta yuboraveradi. Takror hodisa — xato emas, biz uni allaqachon ishlaganmiz. 200 — "qabul qildim, boshqa yubormang".

7. stripe-mock: nima sinaldi, nima yo'q

Stripe'ning rasmiy stripe-mock dasturi Stripe API'ning OpenAPI sxemasiga mos javob qaytaradi. Kanon u bilan (127.0.0.1, Docker, image 2026-10-01) PaymentIntent va Checkout Session yaratdi va testda tekshirdi: pi.id pi_ bilan boshlanadi, amount va currency qaytadi, sessiya cs_ bilan.

Kanon topgan farqlar — lokal soxta server (simulyator) haqiqiy Stripe emasligini ko'rsatadi:

Holat stripe-mock Haqiqiy Stripe (hujjat bo'yicha)
sk_test_ + pastki chiziqli kalit rad: "valid looking" emas haqiqiy kalit formati
amount: -1 qabul qildi minimal summa talab qilinadi
noma'lum parametr StripeInvalidRequestError xuddi shunday
to'lov holati holatsiz — to'lov o'tmaydi holatlar bo'yicha o'tadi
webhook yubormaydi yuboradi
test kartalar, 3DS yo'q bor

Saboq: simulyator shaklni (so'rov va javob formatini) tekshiradi, biznes qoidalarini emas. Manfiy summani o'z kodingizda tekshiring — simulyator sizni ogohlantirmaydi.

Webhook'ni lokal sinash uchun Stripe CLI'da stripe listen buyrug'i bor: u haqiqiy hisobdagi hodisalarni sizning localhost manzilingizga uzatadi. Kanon uni sinamagan (hisob kerak). Kanon webhook testlarida hodisani o'zi yasadi va generateTestHeaderString bilan imzoladi — tarmoqsiz, hisobsiz.

8. Ko'p uchraydigan xatolar

Xato Oqibati To'g'risi
Idempotency-Key siz create qayta urinishda ikki PaymentIntent buyurtmadan deterministik kalit
kalit — tasodifiy UUID qayta bosishda himoya yo'q buyurtma-<id>-pi
express.json() webhook'da har imzo mos emas express.raw
success_url da buyurtmani to'lash to'lovsiz buyurtma faqat webhook
hodisa id'sini saqlamaslik takror — ikki marta ishlov kiruvchi_hodisalar + ON CONFLICT
summani tekshirmaslik boshqa summa — "to'landi" amount_received = buyurtma summasi
ichki xatoda 200 Stripe qayta yubormaydi, hodisa yo'qoladi 500 — qayta yuborilsin
simulyatorga ishonish manfiy summa o'tdi biznes qoidalari o'z kodingizda

9. Mashqlar

Mashqlar kurs/mashqlar/29/32-stripe/ da.

1-mashq (oson): to'g'ri yoki noto'g'ri

  1. success_url ga qaytgan mijozning buyurtmasini "to'langan" qilsa bo'ladi.
  2. Webhook imzosi qayta formatlangan JSON bilan ham mos keladi, chunki ma'lumot bir xil.
  3. Stripe'ga 12.50 dollar uchun amount: 1250 yuboriladi.
  4. stripe-mock manfiy summani rad etadi.
  5. Takror webhook hodisasiga 200 qaytarish kerak.
Yechim
  1. Noto'g'ri — faqat imzoli webhook. 2. Noto'g'ri — imzo baytlar ustida; kanon testida 400. 3. To'g'ri — sentda. 4. Noto'g'ri — kanonda qabul qildi. 5. To'g'ri — aks holda Stripe qayta yuboradi.

2-mashq (o'rta): sarlavhani tahlil qilish

Stripe sirni almashtirish (rotatsiya) paytida sarlavhada bir nechta v1 yuborishi mumkin: t=…,v1=aaa,v1=bbb. sarlavhaniOch(s) yozing: { t, v1: [...] } qaytarsin (Object.fromEntries oxirgi v1 ni qoldirib, qolganini yo'qotadi — undan foydalanmang). Noma'lum kalitlar (v0) e'tiborsiz. Uch holatni sinang.

Yechim
js
function sarlavhaniOch(s) {
  const natija = { t: undefined, v1: [] };
  for (const qism of s.split(",")) {
    const i = qism.indexOf("=");
    if (i < 0) continue;
    const [k, v] = [qism.slice(0, i).trim(), qism.slice(i + 1)];
    if (k === "t") natija.t = Number(v);
    else if (k === "v1") natija.v1.push(v);
  }
  return natija;
}

console.log(sarlavhaniOch("t=1793095200,v1=aaa"));
console.log(sarlavhaniOch("t=1793095200,v1=aaa,v1=bbb,v0=eski"));
console.log(sarlavhaniOch("buzuq"));

Konsolda:

text
{ t: 1793095200, v1: [ 'aaa' ] }
{ t: 1793095200, v1: [ 'aaa', 'bbb' ] }
{ t: undefined, v1: [] }

Tekshiruvda imzo v1 ro'yxatidagi istalgan biri bilan mos kelsa — ok. Darsdagi ixcham tekshir faqat bittasini oladi; Stripe paketi va keyingi darsdagi kanon imzo.ts hammasini tekshiradi.

3-mashq (qiyin): eng kichik birlik

engKichikBirlik(somSatr, valyuta) yozing: "12.50" va "usd" → 1250; nol kasrli valyutalar (NOL_KASR = ["jpy", "krw"] — mashq uchun qisqa ro'yxat; to'liq ro'yxat Stripe hujjatida) uchun kasr bo'lsa — xato, bo'lmasa son o'zi. Float ishlatmang (Pul bilan ishlash JS'da). To'rt holatni sinang: 12.50 usd, 500 jpy, 500.5 jpy, 0.1 usd.

Yechim
js
const NOL_KASR = ["jpy", "krw"];

function engKichikBirlik(s, valyuta) {
  const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(s);
  if (!m) throw new Error(`noto'g'ri summa: ${s}`);
  const [, butun, kasr = ""] = m;
  if (NOL_KASR.includes(valyuta)) {
    if (kasr) throw new Error(`${valyuta} kasrsiz: ${s}`);
    return Number(butun);
  }
  return Number(butun) * 100 + Number(kasr.padEnd(2, "0"));
}

for (const [s, v] of [["12.50", "usd"], ["500", "jpy"],
  ["500.5", "jpy"], ["0.1", "usd"]]) {
  try {
    console.log(s, v, engKichikBirlik(s, v));
  } catch (e) {
    console.log(e.message);
  }
}

Konsolda:

text
12.50 usd 1250
500 jpy 500
jpy kasrsiz: 500.5
0.1 usd 10

0.1 usd → 10 sent (1 emas) — padEnd bilan. UZS Stripe hujjatida ikki kasrli valyuta, ya'ni "12.50" so'm → 1250 tiyin (usd bilan bir xil yo'l). Kanon kodi tiyinda yuboradi; bu stripe-mock'da sinalgan, haqiqiy Stripe'da emas.

10. Loyiha qadami: stripe/stripe.ts

To'liq modul (formatlangan nusxa, ma'nosi kanondagidek):

ts
// stripe/stripe.ts — Stripe: PaymentIntent (idempotency key bilan),
// Checkout Session, webhook (imzo `Stripe-Signature`, XOM tana,
// tolerance, hodisa dedup).
// Lokal: rasmiy `stripe/stripe-mock` image (soxta `sk_test_…` — har
// qanday kalitni qabul qiladi, holatsiz: to'lov o'tmaydi,
// kartalar/3DS/webhook yubormaydi).
// Haqiqiy Stripe hisobi, test kartalar, `stripe listen` (CLI) —
// SINALMAGAN.
import express, { type Router } from "express";
import Stripe from "stripe";
import type { Pool } from "pg";
import { tranzaksiya, tolovYozuvi } from "../db.ts";

export function stripeMijoz(
  kalit: string,
  mock?: { host: string; port: number },
): Stripe {
  return new Stripe(kalit, {
    // tarmoq xatosida qayta urinadi — shuning uchun POST'larda
    // Idempotency-Key MAJBURIY
    maxNetworkRetries: 2,
    timeout: 10_000,
    ...(mock
      ? {
          host: mock.host,
          port: mock.port,
          protocol: "http" as const,
        }
      : {}),
  });
}

/**
 * Buyurtma uchun PaymentIntent: summa — eng kichik birlikda (UZS
 * uchun tiyin; nol kasrli valyutalar — JPY va h.k. — boshqacha!).
 */
export async function tolovNiyati(
  stripe: Stripe,
  b: { id: number; summa: number; valyuta: string },
) {
  return stripe.paymentIntents.create(
    {
      amount: b.summa,
      currency: b.valyuta,
      metadata: { buyurtma_id: String(b.id) },
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey: `buyurtma-${String(b.id)}-pi` },
  );
}

/**
 * Webhook: `express.raw` — imzo XOM baytlar ustida hisoblanadi;
 * `express.json()` dan keyin qayta serializatsiya qilingan tana
 * imzoga mos kelmaydi.
 * Tez 200 qaytaramiz; hodisa `kiruvchi_hodisalar` ga (event.id noyob)
 * — takror yetkazilsa ikkinchi marta ishlanmaydi.
 */
export function stripeWebhook(s: {
  pool: Pool;
  stripe: Stripe;
  sir: string;
  toleransSoniya?: number;
  log?: (e: unknown) => void;
}): Router {
  const r = express.Router();
  r.post(
    "/",
    express.raw({ type: "application/json", limit: "1mb" }),
    async (req, res) => {
      let hodisa: Stripe.Event;
      try {
        hodisa = s.stripe.webhooks.constructEvent(
          req.body as Buffer,
          req.get("stripe-signature") ?? "",
          s.sir,
          s.toleransSoniya ?? 300,
        );
      } catch (e) {
        res
          .status(400)
          .json({
            xato:
              e instanceof Error
                ? e.message.split("\n")[0]
                : "imzo",
          });
        return;
      }
      try {
        const natija = await tranzaksiya(s.pool, async (c) => {
          const yangi = await c.query(
            "INSERT INTO kiruvchi_hodisalar (provayder, hodisa_id, " +
              "tur, tana) VALUES ('stripe', $1, $2, $3) ON " +
              "CONFLICT DO NOTHING",
            [hodisa.id, hodisa.type, hodisa],
          );
          if (yangi.rowCount === 0) return "takror";
          if (hodisa.type === "payment_intent.succeeded") {
            const pi = hodisa.data.object;
            const bid = pi.metadata["buyurtma_id"];
            const b = (
              await c.query<{
                id: string;
                summa: string;
                holat: string;
              }>(
                "SELECT id, summa, holat FROM buyurtmalar WHERE id " +
                  "= $1 FOR UPDATE",
                [bid],
              )
            ).rows[0];
            if (!b) return "buyurtma-yoq";
            if (Number(b.summa) !== pi.amount_received)
              // pul olingan, lekin summa boshqa — qo'lda ko'rib
              // chiqish (reconciliation)
              return "summa-mos-emas";
            if (b.holat === "tolangan") return "allaqachon";
            const t = await c.query<{ id: string }>(
              "INSERT INTO tranzaksiyalar (provayder, tashqi_id, " +
                "buyurtma_id, summa, holat, bajarilgan_vaqt) " +
                "VALUES ('stripe', $1, $2, $3, 'bajarilgan', " +
                "now()) ON CONFLICT (provayder, tashqi_id) DO " +
                "NOTHING RETURNING id",
              [pi.id, b.id, pi.amount_received],
            );
            const tid = t.rows[0]?.id;
            if (tid === undefined) return "allaqachon";
            await c.query(
              "UPDATE buyurtmalar SET holat = 'tolangan', " +
                "yangilangan_vaqt = now() WHERE id = $1",
              [b.id],
            );
            await tolovYozuvi(
              c,
              "stripe",
              pi.amount_received,
              Number(tid),
            );
            await c.query(
              "UPDATE kiruvchi_hodisalar SET ishlangan_vaqt = " +
                "now() WHERE provayder = 'stripe' AND hodisa_id = " +
                "$1",
              [hodisa.id],
            );
            return "tolandi";
          }
          return "e'tiborsiz";
        });
        res.status(200).json({ qabul: true, natija });
      } catch (e) {
        (s.log ?? console.error)(e);
        // Stripe qayta yuboradi (hujjat: 3 kungacha eksponensial)
        res.status(500).json({ qabul: false });
      }
    },
  );
  return r;
}

Testlar — tolov/test/stripe.test.ts, to'rtta. Birinchisi — stripe-mock bilan PaymentIntent va Checkout Session (STRIPE_MOCK muhit o'zgaruvchisi bo'lsa). Qolgan uchtasi webhook'niki: to'g'ri imzo va takror, imzo xatolari (boshqa sir, o'zgartirilgan tana, qayta formatlangan JSON, 301 soniya eski — xatoda "tolerance" so'zi, bo'sh sarlavha), summa mos emas. Docker'siz biz uchtasini ishga tushirdik (Node 24.21.0, Postgres 17.6; stripe-mock testi o'tkazib yuborildi — ﹣ belgisi). Birinchi qatordagi ﹣ va uchta ✔ ga qarang:

text
﹣ stripe-mock bilan PaymentIntent (1.2756ms) # STRIPE_MOCK yo'q
▶ Stripe webhook (imzo, takror, xom tana)
  ✔ to'g'ri imzo → to'landi; xuddi o'sha hodisa yana → takror (dedup) (79.8626ms)
  ✔ imzo xatolari: boshqa sir, o'zgartirilgan tana, qayta formatlangan JSON, eski vaqt (> 300 s) (23.4193ms)
  ✔ summa mos emas — buyurtma to'lanmaydi (qo'lda ko'rish) (7.6143ms)
✔ Stripe webhook (imzo, takror, xom tana) (1164.4241ms)
ℹ tests 3
ℹ suites 2
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2189.1362

Nima bo'ldi? Uchala webhook testi o'tdi: takror hodisa ikkinchi marta ishlanmadi, buzilgan imzolar rad etildi, boshqa summa buyurtmani to'lamadi. skipped 0 ga aldanmang: o'tkazilgan test ﹣ belgisi bilan birinchi qatorda turibdi (node:test uni shunday sanaydi).

Kanonda stripe-mock bilan to'rttasi ham o'tgan (tolov jami 33/33).

Commit xabari:

bash
M='feat(tolov): Stripe — PaymentIntent idempotency, Checkout,'
M+=' xom tanali webhook, dedup'
git commit -m "$M"

11. Real ishda

  • Stripe Dashboard webhook sahifasida har yetkazishning statusi va qayta yuborish tugmasi bor. Imzo xatolari ko'paysa — birinchi gumon: express.json() yoki proxy tanani o'zgartiryapti.
  • Hodisalar tartibi kafolatlanmagan: payment_intent.succeeded payment_intent.created dan oldin kelishi mumkin. Kod hodisa obyektiga, tartibga emas, tayanishi kerak (Kiruvchi webhook'larni qabul qilish).
  • API versiyasi — hisob va webhook endpoint'ida qotiriladi; Event JSON'i shu versiya bo'yicha keladi. Yangilashni rejalashtiring.
  • Intervyuda: "Nega webhook xom tana talab qiladi?", "Idempotency key qanday tanlanadi?"

Xulosa

  • Stripe'da biz API'ni chaqiramiz (PaymentIntent, Checkout Session), natija webhook bilan keladi.
  • Rasmiy kutubxona qayta urinadi — har create'da buyurtmadan yasalgan Idempotency-Key.
  • Stripe-Signature: t va v1 = HMAC-SHA256(whsec, "t.tana"), vaqt oynasi 300 soniya; qayta formatlangan JSON — imzo mos emas.
  • Webhook marshruti — express.raw; hodisa kiruvchi_hodisalar da dedup, summa tekshiriladi, takrorga 200, ichki xatoga 500.
  • stripe-mock shaklni tekshiradi, biznes qoidalarini emas (manfiy summa o'tdi); haqiqiy Stripe, test kartalar, 3DS, stripe listen — sinalmagan.

Keyingi dars: Obuna va davriy to'lovlar — sinov davri, dunning, proratsiya va huquq (entitlement) holat mashinasi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!