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

Refund, reconciliation va moliyaviy hisob: ikki yoqlama ledger

Qisqacha: To'lov qabul qilish — yarim ish. Qolgan yarmi: pulni qaytarish (refund), bizdagi yozuvlarni provayder hisoboti bilan solishtirish (reconciliation) va har tiyinning harakatini buxgalteriya aniqligida yozish. Kanon uchta vosita qurdi. Birinchisi — qismanQaytar: idempotentlik kaliti bilan, jami to'langandan oshmaydi. Ikkinchisi — solishtir: to'rt xil farqni topadi (faqat bizda, faqat provayderda, summa, holat). Uchinchisi — ikki yoqlama ledger: har operatsiyada debet = kredit, buni Postgres'ning kechiktirilgan triggeri COMMIT paytida tekshiradi, yozuvlar esa hech qachon o'zgartirilmaydi. Kanonning 150 ta parallel to'lovi ledger'da tiyinigacha muvozanatda chiqdi.

Bu darsda

  • To'liq va qisman refund'ni idempotent qilib yozasiz.
  • Ikki yoqlama yozuv (double-entry) asoslarini o'rganasiz: hisob, debet, kredit, operatsiya.
  • Ledger muvozanatini va o'zgarmasligini bazada majburlaysiz.
  • Bizning bazani provayder hisoboti bilan solishtirasiz va yo'qolgan javobni topasiz.
  • Fiskal chek va audit izi qayerga to'g'ri kelishini bilasiz.
  • hisob.ts va db.ts ning ledger qismini yozasiz.

Oldin bilishingiz kerak: Obuna va davriy to'lovlar, Kiruvchi webhook'larni qabul qilish, Triggerlar, Ilg'or constraint'lar va generated ustunlar, View va materialized view.

To'lov blokining oxirgi darsi. Shu paytgacha har bir provayder bilan pulni olishni o'rgandik. Endi uni hisoblash va kerak bo'lsa qaytarish — pul bilan ishlaydigan har tizim oxir-oqibat shu savollarga keladi.

1. Nega bu kerak?

Oy oxiri. Buxgalter so'raydi: "Payme orqali qancha tushdi, qanchasi qaytarildi, provayder hisobotiga mosmi?" Agar javob — SELECT sum(summa) FROM buyurtmalar WHERE holat = 'tolangan' bo'lsa, muammolar:

  • qisman qaytarilgan buyurtmalar "to'langan" holatida turibdi — summa noto'g'ri;
  • kimdir bazada bitta qatorni "tuzatib" qo'ygan — iz yo'q;
  • Payme'da bajarilgan, bizda esa "yaratilgan" bo'lib qolgan tranzaksiya (javob yo'qolgan) — hech kim bilmaydi;
  • qaytarish tugmasi ikki marta bosilgan — ikki marta qaytarilgan.

Buxgalteriya 500 yildan beri bunday savollarga bitta usul bilan javob beradi: ikki yoqlama yozuv. Kanon uni kodga va bazaga ko'chirdi.

2. Ikki yoqlama yozuv

Har pul harakati kamida ikki hisobga yoziladi: biridan chiqadi (kredit), boshqasiga kiradi (debet). Bitta operatsiyadagi debetlar yig'indisi kreditlar yig'indisiga teng. Pul yo'qdan paydo bo'lmaydi va izsiz yo'qolmaydi.

Kanon hisoblari:

Hisob Ma'nosi
provayder:<nom> provayderda turgan, hali bankka o'tkazilmagan pulimiz
daromad sotuvdan daromad
qaytarish qaytarilgan pul (daromadni kamaytiradi)
komissiya:<nom> provayder komissiyasi (xarajat)

Ikki operatsiya:

Operatsiya Debet Kredit
to'lov 12 500 so'm (Payme) provayder:payme 1 250 000 daromad 1 250 000
qaytarish 3 000 so'm qaytarish 300 000 provayder:payme 300 000

Balans = debet − kredit. Ikki operatsiyadan keyin: provayder:payme +950 000 (Payme'da shuncha pulimiz bor), daromad −1 250 000 (kredit tomonda), qaytarish +300 000. Hamma balanslar yig'indisi — nol. Bu ledger'ning "sog'liq testi".

flowchart LR
  M((Mijoz)) -->|to'lov 1 250 000| P[provayder:payme]
  D[daromad] -.->|kredit 1 250 000| P
  P -->|qaytarish 300 000| Q[qaytarish]
  P -->|o'tkazma| B[(Bank hisobi)]

Diagramma — bitta tiyinning yo'li: mijozdan provayderga, provayderdan (qaytarish bo'lsa) mijozga qaytib, qolgani bankka. Har strelka — ledger'dagi bitta operatsiya.

3. Ledger'ni baza himoya qiladi

Kanon hisob_yozuvlari jadvalida uchta himoya qo'ydi (tolov/sql/001-tolov.sql):

  1. Qator darajasi: CHECK ((debet = 0) <> (kredit = 0)) — har qatorda yoki debet, yoki kredit, ikkalasi emas. Ikkalasi ham manfiy emas.
  2. Operatsiya darajasi — kechiktirilgan trigger: CONSTRAINT TRIGGER … DEFERRABLE INITIALLY DEFERRED — yozuvlar bittadan qo'shiladi, lekin tekshiruv COMMIT paytida: operatsiyaning debet − kredit farqi nol bo'lmasa, butun tranzaksiya rad.
  3. O'zgarmaslik: BEFORE UPDATE OR DELETE trigger har doim xato tashlaydi. Xato yozuv — teskari yozuv bilan tuzatiladi.
sql
CREATE FUNCTION operatsiya_muvozanati() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE farq bigint;
BEGIN
  SELECT sum(debet) - sum(kredit) INTO farq
    FROM hisob_yozuvlari WHERE operatsiya_id = NEW.operatsiya_id;
  IF farq <> 0 THEN
    RAISE EXCEPTION
      'operatsiya % muvozanatda emas: debet - kredit = %',
      NEW.operatsiya_id, farq USING ERRCODE = 'check_violation';
  END IF;
  RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER hisob_yozuvlari_muvozanat
  AFTER INSERT ON hisob_yozuvlari
  DEFERRABLE INITIALLY DEFERRED
  FOR EACH ROW EXECUTE FUNCTION operatsiya_muvozanati();

Uchalasini o'z Postgres 17.6 klasterimizda kanon sxemasi ustida sinaymiz (biz ishga tushirdik). Operatsiya id'lari — qo'lda berilgan UUID'lar, natija takrorlansin deb.

Muvozanatsiz operatsiya — faqat debet:

sql
\set VERBOSITY terse
BEGIN;
INSERT INTO hisob_yozuvlari (operatsiya_id, hisob, debet)
VALUES ('00000000-0000-0000-0000-000000000001',
        'provayder:payme', 1250000);
COMMIT;
text
BEGIN
INSERT 0 1
ERROR:  operatsiya 00000000-0000-0000-0000-000000000001 muvozanatda emas: debet - kredit = 1250000

INSERT o'tdi (INSERT 0 1) — xato COMMIT da keldi. Kechiktirilgan trigger shu: operatsiya yozuvlari bir nechta INSERT bilan qo'shilishi mumkin, oraliq holat muvozanatsiz bo'lishi tabiiy. Muhimi — oxirgi holat.

To'lov operatsiyasi — muvozanatda:

sql
BEGIN;
INSERT INTO hisob_yozuvlari (operatsiya_id, hisob, debet, kredit)
VALUES ('00000000-0000-0000-0000-000000000002',
        'provayder:payme', 1250000, 0),
       ('00000000-0000-0000-0000-000000000002',
        'daromad', 0, 1250000);
COMMIT;
text
BEGIN
INSERT 0 2
COMMIT

Qisman qaytarish va balanslar (hisob_balanslari — GROUP BY hisob ko'rinishi):

sql
INSERT INTO hisob_yozuvlari (operatsiya_id, hisob, debet, kredit)
VALUES ('00000000-0000-0000-0000-000000000003',
        'qaytarish', 300000, 0),
       ('00000000-0000-0000-0000-000000000003',
        'provayder:payme', 0, 300000);
SELECT hisob, balans, yozuvlar
  FROM hisob_balanslari ORDER BY hisob;
text
INSERT 0 2
      hisob      |  balans  | yozuvlar 
-----------------+----------+----------
 daromad         | -1250000 |        1
 provayder:payme |   950000 |        2
 qaytarish       |   300000 |        1
(3 rows)

«Ikki yoqlama yozuv» bo'limidagi jadval bilan aynan bir xil. BEGIN siz bitta INSERT ham tranzaksiya — trigger uning oxirida ishladi.

"Tuzatish" urinishlari:

sql
\set VERBOSITY terse
UPDATE hisob_yozuvlari SET debet = 1 WHERE hisob = 'qaytarish';
DELETE FROM hisob_yozuvlari;
INSERT INTO hisob_yozuvlari (operatsiya_id, hisob, debet, kredit)
VALUES ('00000000-0000-0000-0000-000000000004', 'x', 5, 5);
SELECT sum(debet) AS debet, sum(kredit) AS kredit
  FROM hisob_yozuvlari;
text
ERROR:  hisob_yozuvlari o'zgartirilmaydi (teskari yozuv qo'shing)
ERROR:  hisob_yozuvlari o'zgartirilmaydi (teskari yozuv qo'shing)
ERROR:  new row for relation "hisob_yozuvlari" violates check constraint "hisob_yozuvlari_check"
  debet  | kredit  
---------+---------
 1550000 | 1550000
(1 row)

UPDATE va DELETE — taqiq; bir qatorda debet ham, kredit ham — CHECK rad etdi. Oxirgi so'rov — ledger'ning sog'liq testi: jami debet = jami kredit.

Ilova tomonda ham tekshiruv bor: ledgerYoz yozishdan oldin debet va kreditni solishtiradi va xatoni darhol tashlaydi (ledger: debet 1 ≠ kredit 2). Baza — oxirgi chiziq: kimdir ilovani chetlab, to'g'ridan psql da yozsa ham muvozanat buzilmaydi.

Tekshirib ko'ring: Nega ledger'da UPDATE taqiqlangan, axir xato yozuvni tuzatish kerak-ku?

Javob

Tuzatish kerak, lekin izi bilan. UPDATE eski qiymatni o'chiradi: kecha hisobot 1 250 000 degan, bugun 1 000 000 deydi va nega o'zgargani noma'lum. Teskari yozuv (masalan, daromad debet 250 000 / provayder:payme kredit 250 000, izohi bilan) eski yozuvni saqlaydi va tuzatishni alohida operatsiya qiladi. Bu audit izi (audit trail).

4. Refund: to'liq va qisman

Payme CancelTransaction — faqat to'liq qaytarish (Payme darsi). Karta provayderlari va Stripe qisman qaytarishga ruxsat beradi: mijoz uchta kitobdan bittasini qaytardi. Kanon qismanQaytar(pool, tranzaksiyaId, summa, sabab, kalit):

  1. Idempotentlik kaliti — qaytarishlar.idempotentlik_kaliti UNIQUE. O'sha kalit bilan qayta so'rov — yangi qaytarish emas, birinchisining natijasi (yangi: false). O'sha kalit boshqa summa yoki boshqa tranzaksiya bilan — xato: kalit boshqa so'rov uchun ishlatilgan (Idempotency-Key qoidasi).
  2. Tranzaksiya FOR UPDATE bilan qulflanadi — parallel ikki qaytarish ketma-ket bo'ladi.
  3. Faqat bajarilgan tranzaksiya qaytariladi.
  4. Jami ≤ to'langan: oldingi qaytarishlar + yangisi to'langandan oshsa — ortiqcha.
  5. Ledger teskari yozuvi (qaytarish debet / provayder:<nom> kredit).
  6. To'liq qaytgach — tranzaksiya va buyurtma qaytarilgan.

Kanon testi bir to'lov ustida oltita qadam qiladi:

Qadam So'rov Natija
1 100 000 tiyin to'landi —
2 q-1, 30 000 qaytdi, qoldi 70 000
3 q-1, 30 000 (takror) yangi: false, o'sha id
4 q-2, 70 001 ortiqcha
5 q-1, 1 tiyin holat xatosi
6 q-3, 70 000 qoldi 0, buyurtma qaytarilgan

Oxirida ledger: daromad −100 000, provayder:payme 0, qaytarish +100 000.

Komissiya haqida: kanon izohi — komissiya odatda qaytmaydi, bu provayder shartnomasiga bog'liq (hujjat bo'yicha). Shuning uchun qaytarishYozuvi komissiyaga tegmaydi.

5. Reconciliation: provayder bilan solishtirish

Server-to-server oqimida bitta javob yo'qolishi mumkin: Payme PerformTransaction ni yubordi, biz bajardik… yoki bajarmadik? Tarmoq uzildi. Payme o'zida "bajarilgan" deb yozdi, bizda esa yaratilgan. Bunday holatlarni kunlik solishtirish (reconciliation) topadi: provayder hisobotini (Payme GetStatement, Click/Uzum reyestri, Stripe balans tranzaksiyalari) olib, bizning bazamiz bilan qatorma-qator taqqoslash.

Yo'qolgan javob qanday yuz beradi va qachon topiladi — ketma-ketlikda:

sequenceDiagram
  participant P as Payme
  participant S as Bizning server
  participant C as Kunlik vazifa
  P->>S: PerformTransaction
  S--xP: javob yo'lda yo'qoldi
  Note over P,S: Payme'da "bajarilgan", bizda "yaratilgan"
  C->>P: GetStatement (kecha)
  P-->>C: tranzaksiyalar ro'yxati
  C->>C: solishtir(biz, provayder)
  Note over C: farq: holat

Kunlik vazifa ikki ro'yxatni oladi va ularni bir-biriga qo'yib chiqadi. Kanon solishtir — ikki ro'yxat, tashqi id bo'yicha, Map bilan O(n):

js
function solishtir(biz, provayder) {
  const p = new Map();
  for (const q of provayder) {
    if (p.has(q.tashqiId)) {
      throw new Error(`takror id: ${q.tashqiId}`);
    }
    p.set(q.tashqiId, q);
  }
  const farqlar = [];
  let mos = 0;
  for (const b of biz) {
    const q = p.get(b.tashqiId);
    p.delete(b.tashqiId);
    if (!q) farqlar.push(`faqat-bizda:${b.tashqiId}`);
    else if (q.summa !== b.summa) farqlar.push(`summa:${b.tashqiId}`);
    else if (q.holat !== b.holat) farqlar.push(`holat:${b.tashqiId}`);
    else mos++;
  }
  for (const q of p.values()) {
    farqlar.push(`faqat-provayderda:${q.tashqiId}`);
  }
  return { mos, farqlar };
}

const q = (tashqiId, summa, holat = "bajarilgan") =>
  ({ tashqiId, summa, holat });
const biz = [q("a", 100), q("b", 200), q("c", 300),
  q("d", 1, "yaratilgan")];
const prov = [q("a", 100), q("b", 250), q("c", 300, "qaytarilgan"),
  q("e", 5)];
console.log(solishtir(biz, prov));
try {
  solishtir([], [q("x", 1), q("x", 1)]);
} catch (e) {
  console.log(e.message);
}

Konsolda:

text
{
  mos: 1,
  farqlar: [ 'summa:b', 'holat:c', 'faqat-bizda:d', 'faqat-provayderda:e' ]
}
takror id: x

To'rt farq turi va ularning odatiy sababi:

Farq Ehtimoliy sabab Kim hal qiladi
summa summa tekshiruvidagi bug, valyuta dasturchi + buxgalter
holat yo'qolgan javob (Perform, Cancel) provayder holati bo'yicha tuzatish
faqat-bizda provayderga yetib bormagan urinish odatda bekor qilinadi
faqat-provayderda biz ko'rmagan to'lov — eng xavfli pul olingan, xizmat berilmagan

Hisobotda takror id — xato tashlanadi: buzilgan hisobot bilan "mos" degan xulosa chiqarmaymiz. Kanon kodi Farq obyektlarini qaytaradi (turi, id va ikkala tomon qiymati); darsdagi ixcham nusxa ularni satr qilib chiqardi.

Kanonning eng qiziq testi aynan yo'qolgan javobni modellaydi: ikki to'lov — biri to'liq, ikkinchisida faqat CreateTransaction. GetStatement natijasi paymeQator bilan umumiy formatga o'giriladi (Payme state 2 → bajarilgan, 1 → yaratilgan, −2 → qaytarilgan, −1 → bekor). Keyin provayder hisobotida ikkinchisi "bajarilgan" qilib o'zgartiriladi — "Perform javobi bizga yetib kelmagan" holati. solishtir aniq bitta farq topdi: { tur: "holat", tashqiId: <id>, biz: "yaratilgan", provayder: "bajarilgan" }.

6. Kanon yakuni: 150 parallel to'lov va ledger

Kanon tajribasi oxirida uch provayderdan bir vaqtda 50 tadan to'lov o'tkazdi (lokal simulyatorlar, Postgres 18.6, bitta mashina):

text
150 to'lov: 1032 ms, muvaffaqiyatli 150/150

Undan oldin har provayderdan 100 tadan ketma-ket to'lov ham o'tgan edi. Ikkalasidan keyingi ledger:

text
daromad                -379516.00 so'm  (450 yozuv)
provayder:click         110000.00 so'm  (150 yozuv)
provayder:payme         159516.00 so'm  (150 yozuv)
provayder:uzum          110000.00 so'm  (150 yozuv)
jami debet 37951600 = kredit 37951600 tiyin; operatsiyalar 450
tranzaksiyalar: click/bajarilgan=150, click/yaratilgan=1, payme/bajarilgan=150, payme/yaratilgan=1, uzum/bajarilgan=150

O'qiymiz:

  • 450 operatsiya = 300 ketma-ket + 150 parallel to'lov; har biri ikki yozuv (provayder debet, daromad kredit). daromad da 450 yozuv, har provayderda 150.
  • Debet = kredit, tiyinigacha: 37 951 600. Parallel yuk ostida ham muvozanat buzilmadi — buni trigger kafolatlaydi.
  • click/yaratilgan=1, payme/yaratilgan=1 — chala qolgan ikki urinish (tajriba ichidagi alohida misollardan); ledger'ga tushmagan, chunki pul yechilmagan. Aynan shunday qatorlar kunlik solishtirishda faqat-bizda bo'lib chiqadi.

7. Fiskal chek va audit izi

O'zbekistonda onlayn to'lov uchun fiskal chek — soliq organlariga yuboriladigan elektron chek (onlayn kassa, OFD — fiskal ma'lumotlar operatori, soliq.uz tizimlari) talab qilinadi. Payme'da buning uchun SetFiscalData metodi va chek tafsilotlari maydonlari bor. Kanon buni sinamagan — hujjat bo'yicha tanishtiruv. Amalda: chek ma'lumotlari (tovar, soni, narx, QQS, MXIK kodi) buyurtma bilan birga saqlanadi va to'lovdan keyin navbat orqali yuboriladi.

Audit izi — "kim, qachon, nima qildi". Ledger'ning o'zgarmasligi — uning bir qismi. Qolgani: qaytarishlarda sabab va kim bosgani, buyurtma holati o'zgarishlari tarixi (Soft delete, timestamps va tarix jadvallari).

8. Ko'p uchraydigan xatolar

Xato Oqibati To'g'risi
daromadni buyurtmalar dan hisoblash qaytarishlar ko'rinmaydi ledger balanslari
bitta yozuvli "ledger" pul qayerdan kelgani noma'lum ikki yoqlama, debet = kredit
muvozanatni faqat kodda tekshirish psql dagi qo'lda yozuv buzadi kechiktirilgan trigger
ledger'da UPDATE iz yo'qoladi teskari yozuv
refund kalitsiz ikki marta qaytarish idempotentlik_kaliti UNIQUE
qaytarish jamisini tekshirmaslik to'langandan ko'p qaytadi FOR UPDATE + jami ≤ to'langan
solishtirmaslik yo'qolgan javoblar oylab yashirin kunlik solishtir
faqat-provayderda ni e'tiborsiz qoldirish pul olingan, xizmat yo'q birinchi navbatda ko'rib chiqish

9. Mashqlar

Mashqlar kurs/mashqlar/29/36-hisob/ da.

1-mashq (oson): debet va kredit

Har operatsiyani ikki yozuv qilib yozing (hisob, debet yoki kredit, tiyinda):

  1. Click orqali 20 000 so'm to'lov, komissiyasiz.
  2. Uzum orqali 10 000 so'm to'lov, komissiya 1 %.
  3. Click to'lovidan 5 000 so'm qaytarish.
Yechim
  1. provayder:click debet 2 000 000; daromad kredit 2 000 000.
  2. provayder:uzum debet 990 000; komissiya:uzum debet 10 000; daromad kredit 1 000 000 — uch yozuv, debetlar 1 000 000 = kredit (kanon tolovYozuvi aynan shunday yozadi).
  3. qaytarish debet 500 000; provayder:click kredit 500 000.

2-mashq (o'rta): balanslar

Yozuvlar ro'yxatidan balanslar(yozuvlar) yozing: har hisob uchun debet − kredit, alifbo tartibida; oxirida jami (nol bo'lishi kerak). 1-mashqdagi uch operatsiyani kiriting.

Yechim
js
function balanslar(yozuvlar) {
  const m = new Map();
  for (const y of yozuvlar) {
    const oldin = m.get(y.hisob) ?? 0;
    m.set(y.hisob, oldin + (y.debet ?? 0) - (y.kredit ?? 0));
  }
  return [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
}

const y = [
  { hisob: "provayder:click", debet: 2_000_000 },
  { hisob: "daromad", kredit: 2_000_000 },
  { hisob: "provayder:uzum", debet: 990_000 },
  { hisob: "komissiya:uzum", debet: 10_000 },
  { hisob: "daromad", kredit: 1_000_000 },
  { hisob: "qaytarish", debet: 500_000 },
  { hisob: "provayder:click", kredit: 500_000 },
];
const b = balanslar(y);
for (const [hisob, s] of b) console.log(hisob.padEnd(16), s);
console.log("jami", b.reduce((a, [, s]) => a + s, 0));

Konsolda:

text
daromad          -3000000
komissiya:uzum   10000
provayder:click  1500000
provayder:uzum   990000
qaytarish        500000
jami 0

Jami nol — ledger sog'. Sof daromad = −(daromad + qaytarish) = 2 500 000 tiyin, komissiyadan keyin — 2 490 000. Bu kanondagi hisob_balanslari ko'rinishining xotiradagi nusxasi.

3-mashq (qiyin): idempotent qisman qaytarish

Bazasiz qaytaruvchi(tolangan) yozing: qaytar(summa, kalit) — kanon qismanQaytar qoidalari bilan. Kalit avval ishlatilgan va summa o'sha — { yangi: false, qoldi }; summa boshqa — "holat" xatosi; jami to'langandan oshsa — "ortiqcha"; aks holda { yangi: true, qoldi }. Kanon testidagi ketma-ketlikni takrorlang: 100 000 to'langan, q-1 30 000, q-1 30 000, q-2 70 001, q-1 1, q-3 70 000.

Yechim
js
function qaytaruvchi(tolangan) {
  const kalitlar = new Map();
  let jami = 0;
  return function qaytar(summa, kalit) {
    const bor = kalitlar.get(kalit);
    if (bor !== undefined) {
      if (bor !== summa) throw new Error("holat");
      return { yangi: false, qoldi: tolangan - jami };
    }
    if (jami + summa > tolangan) throw new Error("ortiqcha");
    kalitlar.set(kalit, summa);
    jami += summa;
    return { yangi: true, qoldi: tolangan - jami };
  };
}

const qaytar = qaytaruvchi(100_000);
for (const [s, k] of [[30_000, "q-1"], [30_000, "q-1"],
  [70_001, "q-2"], [1, "q-1"], [70_000, "q-3"]]) {
  try {
    console.log(k, qaytar(s, k));
  } catch (e) {
    console.log(k, "xato:", e.message);
  }
}

Konsolda:

text
q-1 { yangi: true, qoldi: 70000 }
q-1 { yangi: false, qoldi: 70000 }
q-2 xato: ortiqcha
q-1 xato: holat
q-3 { yangi: true, qoldi: 0 }

Kanon testi bilan bir xil natija. Rad etilgan q-2 kaliti saqlanmadi — keyin to'g'ri summa bilan ishlatilsa o'tadi. Kanonda ham shunday: xato tranzaksiyani ROLLBACK qiladi, qaytarishlar ga qator tushmaydi.

10. Loyiha qadami: hisob.ts va ledger

db.ts ning ledger qismi (formatlangan, ma'nosi kanondagidek):

ts
export type Yozuv = {
  hisob: string;
  debet?: number;
  kredit?: number;
};

/**
 * Bitta operatsiya — muvozanatda bo'lishi shart (baza ham COMMIT'da
 * tekshiradi: deferred trigger).
 */
export async function ledgerYoz(
  c: PoolClient,
  yozuvlar: Yozuv[],
  o: { tranzaksiyaId?: number; izoh?: string } = {},
): Promise<string> {
  const debet = yozuvlar.reduce((a, y) => a + (y.debet ?? 0), 0);
  const kredit = yozuvlar.reduce((a, y) => a + (y.kredit ?? 0), 0);
  if (debet !== kredit)
    throw new Error(
      `ledger: debet ${String(debet)} ≠ kredit ${String(kredit)}`,
    );
  const op = randomUUID();
  for (const y of yozuvlar) {
    await c.query(
      "INSERT INTO hisob_yozuvlari (operatsiya_id, hisob, debet, " +
        "kredit, tranzaksiya_id, izoh) VALUES ($1, $2, $3, $4, $5, " +
        "$6)",
      [
        op,
        y.hisob,
        y.debet ?? 0,
        y.kredit ?? 0,
        o.tranzaksiyaId ?? null,
        o.izoh ?? "",
      ],
    );
  }
  return op;
}

/**
 * To'lov bajarildi: provayderdagi pulimiz (+), daromad (+); komissiya
 * berilsa — xarajat.
 */
export const tolovYozuvi = (
  c: PoolClient,
  provayder: string,
  summa: number,
  tranzaksiyaId: number,
  komissiya = 0,
) =>
  ledgerYoz(
    c,
    [
      { hisob: `provayder:${provayder}`, debet: summa - komissiya },
      ...(komissiya > 0
        ? [{ hisob: `komissiya:${provayder}`, debet: komissiya }]
        : []),
      { hisob: "daromad", kredit: summa },
    ],
    { tranzaksiyaId, izoh: "to'lov" },
  );

/**
 * Qaytarish (refund) — o'chirish emas, teskari yozuv. Komissiya
 * odatda qaytmaydi (provayder shartnomasiga bog'liq — hujjat
 * bo'yicha).
 */
export const qaytarishYozuvi = (
  c: PoolClient,
  provayder: string,
  summa: number,
  tranzaksiyaId: number,
) =>
  ledgerYoz(
    c,
    [
      { hisob: "qaytarish", debet: summa },
      { hisob: `provayder:${provayder}`, kredit: summa },
    ],
    { tranzaksiyaId, izoh: "qaytarish" },
  );

hisob.ts — qaytarish va solishtirish, to'liq:

ts
// hisob.ts — qaytarish (refund) va solishtirish (reconciliation).
// qismanQaytar — idempotent (kalit bilan), jami ≤ to'langan, ledger
// teskari yozuvi bilan.
// solishtir — provayder hisobotidagi (Payme GetStatement, Click/Uzum
// reyestr, Stripe balance transactions) tranzaksiyalar bilan bizning
// bazani
// kunlik solishtirish: faqat bizda / faqat provayderda / summa farqi
// / holat farqi. Fiskal chek (soliq.uz OFD) — hujjat bo'yicha,
// SINALMAGAN.
import type { Pool } from "pg";
import { tranzaksiya, qaytarishYozuvi } from "./db.ts";

export class QaytarishXato extends Error {
  readonly kod: "topilmadi" | "holat" | "ortiqcha";
  constructor(
    kod: "topilmadi" | "holat" | "ortiqcha",
    xabar: string,
  ) {
    super(xabar);
    this.kod = kod;
  }
}

export async function qismanQaytar(
  pool: Pool,
  tranzaksiyaId: number,
  summa: number,
  sabab: string,
  kalit: string,
): Promise<{ id: number; yangi: boolean; qoldi: number }> {
  return tranzaksiya(pool, async (c) => {
    const bor = (
      await c.query<{
        id: string;
        tranzaksiya_id: string;
        summa: string;
      }>(
        "SELECT id, tranzaksiya_id, summa FROM qaytarishlar WHERE " +
          "idempotentlik_kaliti = $1",
        [kalit],
      )
    ).rows[0];
    const t = (
      await c.query<{
        id: string;
        provayder: string;
        summa: string;
        holat: string;
        buyurtma_id: string;
      }>(
        "SELECT id, provayder, summa, holat, buyurtma_id FROM " +
          "tranzaksiyalar WHERE id = $1 FOR UPDATE",
        [tranzaksiyaId],
      )
    ).rows[0];
    if (!t)
      throw new QaytarishXato("topilmadi", "tranzaksiya yo'q");
    const jami = async () =>
      Number(
        (
          await c.query<{ s: string }>(
            "SELECT coalesce(sum(summa), 0) AS s FROM qaytarishlar " +
              "WHERE tranzaksiya_id = $1",
            [t.id],
          )
        ).rows[0]?.s ?? 0,
      );
    if (bor) {
      if (
        Number(bor.tranzaksiya_id) !== tranzaksiyaId ||
        Number(bor.summa) !== summa
      )
        throw new QaytarishXato(
          "holat",
          "idempotentlik kaliti boshqa so'rov bilan ishlatilgan",
        );
      return {
        id: Number(bor.id),
        yangi: false,
        qoldi: Number(t.summa) - (await jami()),
      };
    }
    if (t.holat !== "bajarilgan")
      throw new QaytarishXato(
        "holat",
        `tranzaksiya holati: ${t.holat}`,
      );
    const oldin = await jami();
    if (oldin + summa > Number(t.summa))
      throw new QaytarishXato(
        "ortiqcha",
        `qaytarish ${String(oldin + summa)} > to'langan ${t.summa}`,
      );
    const q = (
      await c.query<{ id: string }>(
        "INSERT INTO qaytarishlar (tranzaksiya_id, summa, sabab, " +
          "idempotentlik_kaliti) VALUES ($1, $2, $3, $4) RETURNING " +
          "id",
        [t.id, summa, sabab, kalit],
      )
    ).rows[0];
    await qaytarishYozuvi(c, t.provayder, summa, Number(t.id));
    if (oldin + summa === Number(t.summa)) {
      await c.query(
        "UPDATE tranzaksiyalar SET holat = 'qaytarilgan', " +
          "bekor_vaqt = now() WHERE id = $1",
        [t.id],
      );
      await c.query(
        "UPDATE buyurtmalar SET holat = 'qaytarilgan', " +
          "yangilangan_vaqt = now() WHERE id = $1",
        [t.buyurtma_id],
      );
    }
    return {
      id: Number(q?.id),
      yangi: true,
      qoldi: Number(t.summa) - oldin - summa,
    };
  });
}

export type Qator = {
  tashqiId: string;
  summa: number;
  holat: "yaratilgan" | "bajarilgan" | "bekor" | "qaytarilgan";
};
export type Farq =
  | { tur: "faqat-bizda"; tashqiId: string; biz: Qator }
  | { tur: "faqat-provayderda"; tashqiId: string; provayder: Qator }
  | {
      tur: "summa";
      tashqiId: string;
      biz: number;
      provayder: number;
    }
  | {
      tur: "holat";
      tashqiId: string;
      biz: string;
      provayder: string;
    };

/**
 * Ikki ro'yxatni tashqi id bo'yicha solishtiradi (O(n)). Bir tomonda
 * takror id — xato (hisobot buzilgan).
 */
export function solishtir(
  biz: Qator[],
  provayder: Qator[],
): { mos: number; farqlar: Farq[] } {
  const p = new Map<string, Qator>();
  for (const q of provayder) {
    if (p.has(q.tashqiId))
      throw new Error(
        `provayder hisobotida takror id: ${q.tashqiId}`,
      );
    p.set(q.tashqiId, q);
  }
  const farqlar: Farq[] = [];
  let mos = 0;
  for (const b of biz) {
    const q = p.get(b.tashqiId);
    p.delete(b.tashqiId);
    if (!q) {
      farqlar.push({
        tur: "faqat-bizda",
        tashqiId: b.tashqiId,
        biz: b,
      });
      continue;
    }
    if (q.summa !== b.summa)
      farqlar.push({
        tur: "summa",
        tashqiId: b.tashqiId,
        biz: b.summa,
        provayder: q.summa,
      });
    else if (q.holat !== b.holat)
      farqlar.push({
        tur: "holat",
        tashqiId: b.tashqiId,
        biz: b.holat,
        provayder: q.holat,
      });
    else mos++;
  }
  for (const q of p.values())
    farqlar.push({
      tur: "faqat-provayderda",
      tashqiId: q.tashqiId,
      provayder: q,
    });
  return { mos, farqlar };
}

/** Payme GetStatement natijasi → Qator (state: 1, 2, -1, -2). */
export const paymeQator = (t: {
  id: string;
  amount: number;
  state: number;
}): Qator => ({
  tashqiId: t.id,
  summa: t.amount,
  holat:
    t.state === 2
      ? "bajarilgan"
      : t.state === 1
        ? "yaratilgan"
        : t.state === -2
          ? "qaytarilgan"
          : "bekor",
});

export async function bizningQatorlar(
  pool: Pool,
  provayder: string,
  dan: Date,
  gacha: Date,
): Promise<Qator[]> {
  const r = await pool.query<{
    tashqi_id: string;
    summa: string;
    holat: Qator["holat"];
  }>(
    "SELECT tashqi_id, summa, holat FROM tranzaksiyalar WHERE " +
      "provayder = $1 AND yaratilgan_vaqt >= $2 AND " +
      "yaratilgan_vaqt < $3",
    [provayder, dan, gacha],
  );
  return r.rows.map((q) => ({
    tashqiId: q.tashqi_id,
    summa: Number(q.summa),
    holat: q.holat,
  }));
}

Testlar — tolov/test/hisob.test.ts, to'rtta: solishtir (bazasiz), qisman qaytarish, ledger himoyasi (muvozanatsiz operatsiya COMMIT da rad, ledgerYoz dagi tekshiruv, UPDATE/DELETE taqiq) va yo'qolgan Perform'ni solishtirish. Biz ishga tushirdik (kanon nusxasi, Node 24.21.0, Postgres 17.6). To'rtta ✔ ga, ayniqsa oxirgisiga qarang:

text
✔ solishtir: mos, faqat bizda, faqat provayderda, summa, holat; takror id — xato (3.3047ms)
▶ qaytarish va ledger (Postgres)
  ✔ qisman qaytarish: idempotent, ortiqcha — rad, to'liq qaytgach holat 'qaytarilgan'; ledger muvozanatda (145.5219ms)
  ✔ ledger himoyasi: muvozanatsiz operatsiya COMMIT'da rad; UPDATE/DELETE taqiq (65.1493ms)
  ✔ rekonsilyatsiya: Payme GetStatement va baza — bitta yo'qolgan Perform aniqlanadi (91.7323ms)
✔ qaytarish va ledger (Postgres) (1329.2742ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1907.0541

Nima bo'ldi? To'rttala test o'tdi. Oxirgi ✔ — yuqoridagi diagrammaning o'zi: provayder hisobotida "bajarilgan", bizda "yaratilgan" bo'lgan bitta tranzaksiya topildi. Test nomidagi "rekonsilyatsiya" — kanon yozgan nom, darsda biz "solishtirish (reconciliation)" deymiz.

Kanonda (Postgres 18.6) butun tolov paketi — tsc 0, eslint 0, testlar 33/33; bizda Postgres 17.6 bilan 32 o'tdi va bitta (stripe-mock, Docker kerak) o'tkazib yuborildi.

Commit xabari:

bash
M='feat(tolov): refund, reconciliation va ikki yoqlama ledger'
M+=" — deferred trigger, o'zgarmas yozuvlar"
git commit -m "$M"

11. Real ishda

  • Buxgalteriya tizimlari (1C va boshqalar) bilan integratsiya ko'pincha ledger eksporti orqali: har operatsiya — hisob, debet, kredit, sana, izoh.
  • Solishtirish (reconciliation) avtomatlashtiriladi: har kecha rejali vazifa (Cron va rejali vazifalar) provayder hisobotini oladi, solishtir ni ishga tushiradi va farqlarni moliya bo'limiga yuboradi. Farq yo'q kun — yaxshi kun.
  • Stripe kabi provayderlarda komissiya va to'lov o'tkazmalari (payout) alohida balans tranzaksiyalari bo'lib keladi — ledger'da ham alohida hisoblar.
  • Intervyuda: "Double-entry nima va nega kerak?", "To'lov holati bizda va provayderda farq qilsa nima qilasiz?", "Refund'ni qanday idempotent qilasiz?"

Xulosa

  • Ikki yoqlama yozuv: har operatsiyada debet = kredit; balanslar yig'indisi nol.
  • Muvozanat bazada: kechiktirilgan trigger COMMIT da tekshiradi; UPDATE/DELETE taqiq — tuzatish teskari yozuv bilan.
  • Qisman refund: idempotentlik kaliti, FOR UPDATE, jami ≤ to'langan, ledger teskari yozuvi, to'liq qaytgach qaytarilgan.
  • Solishtirish (reconciliation): provayder hisoboti bilan to'rt xil farq; faqat-provayderda — eng xavfli; yo'qolgan Perform holat farqi bo'lib chiqadi.
  • Kanon: 450 operatsiya (150 tasi parallel), debet = kredit = 37 951 600 tiyin.
  • Fiskal chek (OFD, SetFiscalData) — hujjat bo'yicha, sinalmagan.

To'lov bloki tugadi: pul, ekotizim, Payme, Click, Uzum, Stripe, obuna, webhook'lar va hisob. Bu tolov paketi 38-qismdagi bozor loyihasida haqiqiy do'konga ulanadi.

Keyingi dars: Realtime usullari va tanlov — polling, long polling, SSE va WebSocket o'lchov bilan.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

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