Mundarija (17)
- Bu darsda
- 1. Nega bu kerak?
- 2. Ikki yoqlama yozuv
- 3. Ledger'ni baza himoya qiladi
- 4. Refund: to'liq va qisman
- 5. Reconciliation: provayder bilan solishtirish
- 6. Kanon yakuni: 150 parallel to'lov va ledger
- 7. Fiskal chek va audit izi
- 8. Ko'p uchraydigan xatolar
- 9. Mashqlar
- 1-mashq (oson): debet va kredit
- 2-mashq (o'rta): balanslar
- 3-mashq (qiyin): idempotent qisman qaytarish
- 10. Loyiha qadami: hisob.ts va ledger
- 11. Real ishda
- Xulosa
- Manbalar
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 triggeriCOMMITpaytida 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.tsvadb.tsning 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):
- Qator darajasi:
CHECK ((debet = 0) <> (kredit = 0))— har qatorda yoki debet, yoki kredit, ikkalasi emas. Ikkalasi ham manfiy emas. - Operatsiya darajasi — kechiktirilgan trigger:
CONSTRAINT TRIGGER … DEFERRABLE INITIALLY DEFERRED— yozuvlar bittadan qo'shiladi, lekin tekshiruvCOMMITpaytida: operatsiyaning debet − kredit farqi nol bo'lmasa, butun tranzaksiya rad. - O'zgarmaslik:
BEFORE UPDATE OR DELETEtrigger har doim xato tashlaydi. Xato yozuv — teskari yozuv bilan tuzatiladi.
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:
\set VERBOSITY terse
BEGIN;
INSERT INTO hisob_yozuvlari (operatsiya_id, hisob, debet)
VALUES ('00000000-0000-0000-0000-000000000001',
'provayder:payme', 1250000);
COMMIT;BEGIN
INSERT 0 1
ERROR: operatsiya 00000000-0000-0000-0000-000000000001 muvozanatda emas: debet - kredit = 1250000INSERT 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:
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;BEGIN
INSERT 0 2
COMMITQisman qaytarish va balanslar (hisob_balanslari — GROUP BY hisob ko'rinishi):
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;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:
\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;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
UPDATEtaqiqlangan, 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):
- 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). - Tranzaksiya
FOR UPDATEbilan qulflanadi — parallel ikki qaytarish ketma-ket bo'ladi. - Faqat
bajarilgantranzaksiya qaytariladi. - Jami ≤ to'langan: oldingi qaytarishlar + yangisi to'langandan oshsa —
ortiqcha. - Ledger teskari yozuvi (
qaytarishdebet /provayder:<nom>kredit). - 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: holatKunlik 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):
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:
{
mos: 1,
farqlar: [ 'summa:b', 'holat:c', 'faqat-bizda:d', 'faqat-provayderda:e' ]
}
takror id: xTo'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):
150 to'lov: 1032 ms, muvaffaqiyatli 150/150Undan oldin har provayderdan 100 tadan ketma-ket to'lov ham o'tgan edi. Ikkalasidan keyingi ledger:
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=150O'qiymiz:
- 450 operatsiya = 300 ketma-ket + 150 parallel to'lov; har biri ikki yozuv (provayder debet, daromad kredit).
daromadda 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 solishtirishdafaqat-bizdabo'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):
- Click orqali 20 000 so'm to'lov, komissiyasiz.
- Uzum orqali 10 000 so'm to'lov, komissiya 1 %.
- Click to'lovidan 5 000 so'm qaytarish.
Yechim
provayder:clickdebet 2 000 000;daromadkredit 2 000 000.provayder:uzumdebet 990 000;komissiya:uzumdebet 10 000;daromadkredit 1 000 000 — uch yozuv, debetlar 1 000 000 = kredit (kanontolovYozuviaynan shunday yozadi).qaytarishdebet 500 000;provayder:clickkredit 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
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:
daromad -3000000
komissiya:uzum 10000
provayder:click 1500000
provayder:uzum 990000
qaytarish 500000
jami 0Jami 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
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:
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):
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:
// 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:
✔ 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.0541Nima 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:
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,
solishtirni 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
COMMITda tekshiradi;UPDATE/DELETEtaqiq — tuzatish teskari yozuv bilan. - Qisman refund: idempotentlik kaliti,
FOR UPDATE, jami ≤ to'langan, ledger teskari yozuvi, to'liq qaytgachqaytarilgan. - Solishtirish (reconciliation): provayder hisoboti bilan to'rt xil farq;
faqat-provayderda— eng xavfli; yo'qolgan Performholatfarqi 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
- Ikki yoqlama buxgalteriya (umumiy): https://en.wikipedia.org/wiki/Double-entry_bookkeeping
- PostgreSQL
CREATE TRIGGER(CONSTRAINT,DEFERRABLE): https://www.postgresql.org/docs/18/sql-createtrigger.html - Payme
GetStatement,CancelTransaction,SetFiscalData: https://developer.help.paycom.uz — kanon 2026-10-27 da o'qigan; fiskal qism sinalmagan - Stripe refund'lar: https://docs.stripe.com/refunds
- Kanon tajribasi (IlmHamroh, 2026-10-27):
tolov/src/{hisob,db}.ts,sql/001-tolov.sql, 150 parallel to'lov va ledger o'lchovi, Postgres 18.6, Node 24.21.0. SQL va testlar — biz ishga tushirdik, PostgreSQL 17.6 (2026-10-28).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!