> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# المحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledger
- URL: https://arabroot.io/articles/كيف-تبني-محفظة-رقمية-ledger/
- Published: 2026-10-10T08:00:00.000Z
- Updated: 2026-10-11T06:04:46.000Z
- Description: كيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, قواعد البيانات, الاستضافة الذاتية

عندما تشحن محفظتك في تطبيق توصيل بمئة ريال ثم تدفع منها ثمن طلبين، فأنت تتوقع شيئاً واحداً بسيطاً: أن يكون الرصيد صحيحاً بالهللة. ولكن خلف هذا التوقع أسئلة لا تظهر في البداية: ماذا لو ضغط العميل «ادفع» مرتين؟ ماذا لو وصل طلبان للخصم في اللحظة نفسها؟ ماذا لو قال مزود الدفع إن الشحن فشل بعد أن أضفنا الرصيد؟ وأغلب الأنظمة تبدأ بعمود balance في جدول المستخدمين، ثم تكتشف هذه الأسئلة واحداً بعد الآخر في بيئة الإنتاج، ومع أموال حقيقية. وفي [مقال محرك التوزيع](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D9%85%D8%AD%D8%B1%D9%83-%D8%AA%D9%88%D8%B2%D9%8A%D8%B9-%D8%A7%D9%84%D8%B7%D9%84%D8%A8%D8%A7%D8%AA-dispatch/) ذكرنا أن كل طلب يغير شيئاً يحتاج مفتاح منع التكرار، وفي هذا المقال سوف نبني ذلك مع بقية ما تحتاجه المحفظة في قاعدة بيانات حقيقية، وسوف نناقش ما يلي:

- لماذا لا يكفي عمود الرصيد، وما هو دفتر القيد المزدوج Double-Entry Ledger بلغة المهندس.
- المعمارية الكاملة للمحفظة: المكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو والأعطال.
- الدفتر الذي لا يقبل التعديل Immutable Ledger، والرصيد المحسوب مقابل الرصيد المحفوظ.
- المبالغ بأعداد صحيحة بالهللة بدل الكسور.
- مفتاح منع التكرار Idempotency Key داخل المعاملة نفسها.
- السباق بين طلبين على الرصيد نفسه، ومستويات العزل Isolation Levels، و SELECT ... FOR UPDATE.
- الحجز Hold والتحصيل Capture والاسترداد Refund.
- المطابقة Reconciliation مع مزود الدفع PSP والبنك.
- ما يقوله نظام المدفوعات في المملكة ومعيار PCI DSS باختصار.
- مختبر كامل على PostgreSQL 18.6 بمخرجات حقيقية، فيه سباق يضيع فيه 95% من الدفعات.

## لماذا لا يكفي عمود الرصيد؟

الحل البسيط أن تضيف عمود balance إلى جدول المستخدمين، فإذا دفع العميل خمسة وعشرين ريالاً نقصتها من العمود. وهذا الحل يعمل في العرض التجريبي، ولكنه يفشل في ثلاثة أشياء: الأول أنك لا تعرف لماذا الرصيد 75 وليس 80، لأن العمود يحفظ النتيجة ويرمي السبب، والثاني أن أي خطأ في الكود أو تعديل يدوي يغير المال دون أثر، والثالث أن المال في النظام كله لم يعد يخضع لأي معادلة تستطيع أن تتحقق منها، فإذا زاد رصيد أحد فلا تعرف من أين جاءت الزيادة. والمحاسبون حلوا هذه المشكلة قبل الحاسب بقرون بفكرة بسيطة: لا يتحرك المال إلا من حساب إلى حساب، ويسجل كل تحرك مرتين.

## المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟

قبل أن ندخل في القيد المزدوج والأقفال والمطابقة، سوف نرى المحفظة كاملة كنظام، والسبب أن الدفتر نفسه جدولان ودالة، ولكن حوله خدمات تطلب منه أن يكتب، ومزود دفع وبنك يرسلان ما يجب أن نطابقه، وفريق مالي ينتظر قائمة الحالات المفتوحة كل صباح. والصورة التالية تبين ذلك في طبقات من اليسار إلى اليمين: الكلاينت، ثم الحافة Edge، ثم الخدمات، ثم البيانات، ثم ما يحدث بعد القيد خارج الطلب، ثم الجهات الخارجية:

![ست طبقات. CLIENTS فيها تطبيق المحفظة للعميل والتاجر والفريق المالي. EDGE فيها بوابة API بتشفير TLS والتحقق من الهوية وحد معدل الطلبات، وتطلب مفتاح Idempotency-Key. SERVICES فيها Wallet API للرصيد والتاريخ والتحويل، وOrder service للحجز والتحصيل والإعادة، وLedger وهو الكاتب الوحيد في الدفتر بالدالتين post وtransfer، وTop-up لجلسة الدفع واستقبال ال Webhook، وReconciliation اليومية بمرجع المزود، وIntegrity check الليلي الذي يتحقق أن الدفتر يساوي صفراً. DATA فيها PostgreSQL بجداول accounts وjournal وpostings وbalances وoutbox، والنسخ المتماثلة المتزامنة وللقراءة، والنسخ الاحتياطية مع الاستعادة لنقطة زمنية وسجلات عشر سنوات. ASYNC فيها RabbitMQ يستقبل ledger.posted من outbox ويوصله إلى الإشعارات. EXTERNAL فيها مزود الدفع PSP بصفحة دفع أو حزمة للهاتف، والبنك بكشف الحساب وحساب حماية أموال العملاء. ورقم البطاقة يذهب من الهاتف إلى مزود الدفع مباشرة، وملف التسوية وكشف البنك يصلان إلى المطابقة](https://arabroot.io/content/images/2026/10/wallet-ledger-06-architecture.webp)

كل الخدمات تطلب من الدفتر، والدفتر وحده يكتب القيود في معاملة واحدة، ورقم البطاقة لا يمر بسيرفراتك

في الصورة أعلاه لاحظ التالي:

- الخدمات الثلاث التي تحرك المال، المحفظة والطلبات والشحن، لا تكتب في جداول الدفتر مباشرة، وإنما تستدعي دالة واحدة هي `post()` التي سوف تراها في التجربة العملية، فتبقى القواعد كلها في مكان واحد.
- السهم الأخضر الطويل من الهاتف إلى مزود الدفع هو ما يصغر نطاق PCI DSS، فرقم البطاقة لا يمر ببوابتك ولا بخدماتك، والذي يصلك هو إشعار Webhook بمرجع العملية.
- المطابقة والفحص الليلي مهمتان مجدولتان خارج طريق العميل، ولكنهما جزء أساسي من التصميم وليستا إضافة، لأنهما الطريقة الوحيدة لاكتشاف ما فات الكود.

### المكونات ومسؤولية كل منها

| المكون                  | مسؤوليته                                                                                 | أداة مفتوحة المصدر مقترحة                                                                     | لماذا هي                                                                                                                                                                                                                                                                                                                                                          |
| ----------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| بوابة ال API            | TLS والتحقق من الهوية وحد معدل الطلبات، ورفض أي طلب يحرك المال دون مفتاح Idempotency-Key | NGINX (رخصة BSD-2-Clause) أو HAProxy (رخصة GPLv2)                                             | مجربان، ويكفيان لحمل المحافظ الذي يقاس بعشرات العمليات في الثانية وليس بالآلاف                                                                                                                                                                                                                                                                                    |
| خدمة المحفظة Wallet API | الرصيد وكشف الحركات والتحويل بين المحافظ                                                 | كودك                                                                                          | القراءة من جدول الأرصدة المحفوظة أو من نسخة القراءة، والكتابة عبر الدفتر فقط                                                                                                                                                                                                                                                                                      |
| الدفتر Ledger           | كتابة القيود والسطور وتحديث الأرصدة في معاملة واحدة، ومنع التكرار والسحب بأكثر من الرصيد | PostgreSQL (رخصة PostgreSQL License) مع الدوال والقيود التي في التجربة العملية                | القيود والـ Triggers والأقفال داخل قاعدة البيانات نفسها، فلا يستطيع أي كود أن يتجاوزها                                                                                                                                                                                                                                                                            |
| تجميع الاتصالات         | حماية قاعدة البيانات من كثرة الاتصالات                                                   | PgBouncer (رخصة ISC)                                                                          | كل استدعاء للدالة معاملة قصيرة، فيناسبه التجميع على مستوى المعاملة                                                                                                                                                                                                                                                                                                |
| التوفر العالي           | نسخة متماثلة متزامنة تحل مكان الرئيسية عند العطل                                         | Patroni (رخصة MIT) مع النسخ المتزامن Synchronous Replication في PostgreSQL                    | القيد الذي أكدته للعميل يجب ألا يضيع عند التحويل إلى النسخة الأخرى، وشرحنا الفكرة في [مقال التوفر العالي](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D8%AA%D9%88%D9%81%D8%B1-%D8%A7%D9%84%D8%B9%D8%A7%D9%84%D9%8A-high-availability-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)                                  |
| النسخ الاحتياطي         | استعادة لأي نقطة زمنية، والاحتفاظ بالسجلات عشر سنوات                                     | pgBackRest (رخصة MIT)                                                                         | نسخ كاملة وتزايدية مع أرشيف WAL، وشرحنا قاعدة 3-2-1 واختبار الاستعادة في [مقال النسخ الاحتياطي](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/) |
| خدمة الشحن Top-up       | إنشاء جلسة الدفع لدى المزود، واستقبال ال Webhook والتحقق من توقيعه، ثم قيد الشحن         | كودك، والمزود خارجي                                                                           | معرف الحدث من المزود فريد، ومرجع العملية يحفظ في العمود ref                                                                                                                                                                                                                                                                                                       |
| الطابور Queue           | نقل الحدث ledger.posted إلى الإشعارات والتقارير                                          | RabbitMQ (رخصة MPL 2.0)، أو Debezium (رخصة Apache-2.0) ليقرأ جدول outbox من سجل التغييرات     | الإشعار يتأخر أو يتكرر دون أن يمس المال، لأن الحدث يكتب في المعاملة نفسها مع القيد                                                                                                                                                                                                                                                                                |
| المطابقة والفحص الليلي  | مقارنة ملف التسوية وكشف البنك بالدفتر، والتحقق أن الدفتر يساوي صفراً                     | استعلامات SQL كالتي في التجربة، تجدولها بأداة pg\_cron (رخصة PostgreSQL License) أو cron عادي | المطابقة على المرجع بربط FULL JOIN واحد، والنتيجة قائمة حالات مفتوحة للفريق المالي                                                                                                                                                                                                                                                                                |

### نموذج البيانات

الجداول الأربعة الأساسية هي نفسها جداول الملف ledger.sql في التجربة العملية، ولن نكررها هنا، وإنما نلخصها ونضيف إليها جدولين يحتاجهما النظام الحقيقي حول الدفتر:

| الجدول      | الأعمدة الأساسية                                                   | دوره                                                                                   |
| ----------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| accounts    | id, code, type, currency, allow\_negative                          | شجرة الحسابات: wallet:1001 وholds:1001 وpsp:clearing وbank:main وfees:psp وmerchant:77 |
| journal     | id, idempotency\_key UNIQUE, request\_hash, kind, ref, created\_at | سطر لكل حدث مالي، ومعه مفتاح منع التكرار ومرجع المزود أو الطلب                         |
| postings    | id, journal\_id, account\_id, amount, currency                     | سطور القيد، مجموعها صفر، ولا تعدل ولا تحذف                                             |
| balances    | account\_id, balance, allow\_negative                              | الرصيد المحفوظ، يتحدث في نفس المعاملة، وعليه قيد CHECK يمنع السالب                     |
| psp\_report | psp\_ref, amount, fee, settled\_on                                 | ملف التسوية بعد تحميله، للمطابقة                                                       |

```sql
-- Around the lab tables in a real system:
CREATE TABLE webhook_events (                    -- the PSP may send the same event twice
    event_id    text        PRIMARY KEY,         -- the provider's event id
    psp_ref     text        NOT NULL,            -- becomes journal.ref of the top-up entry
    type        text        NOT NULL,            -- payment.captured, payment.refunded ...
    payload     jsonb       NOT NULL,
    received_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE outbox (                            -- written in the same transaction as post()
    id           bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    journal_id   bigint      NOT NULL REFERENCES journal (id),
    topic        text        NOT NULL,           -- ledger.posted
    created_at   timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz                     -- NULL until the relay sends it to RabbitMQ
);
```

### الواجهات الرئيسية

أسماء المفاتيح في الجدول هي نفس الصيغ التي تستخدمها التجربة العملية، والقاعدة أن المفتاح يصف العملية التجارية نفسها، فالإعادة بعد انقطاع الشبكة تحمل المفتاح نفسه دائماً:

| الطلب | المسار                                  | ما يفعله                                        | المفتاح والملاحظات                                                     |
| ----- | --------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| GET   | /wallets/me                             | الرصيد المتاح من جدول balances                  | قراءة بالمفتاح الأساسي، دون SUM على السطور                             |
| GET   | /wallets/me/entries?cursor=             | كشف الحركات صفحة بعد صفحة                       | من نسخة القراءة، بترقيم Cursor وليس OFFSET                             |
| POST  | /topups                                 | إنشاء جلسة دفع لدى المزود وإعادة رابط صفحته     | Idempotency-Key، ورقم البطاقة لا يصل إلى هذا الطلب                     |
| POST  | /webhooks/psp                           | تأكيد الشحن من المزود، فيضاف قيد الشحن          | التحقق من التوقيع، وevent\_id فريد، ومفتاح القيد topup-pay\_A1         |
| POST  | /transfers                              | تحويل من محفظة إلى أخرى                         | المفتاح من التطبيق مثل tr-8f3a، والمفتاح نفسه بمبلغ مختلف يرفض         |
| POST  | /orders/{id}/hold ثم /capture و/release | حجز مبلغ الطلب ثم تحصيل النهائي وإعادة الباقي   | داخلي من خدمة الطلبات، والمفاتيح hold-5531 وcapture-5531 وrelease-5531 |
| POST  | /orders/{id}/refunds                    | استرداد جزئي أو كامل بقيد جديد                  | المفتاح refund-5531-1 ثم \-2 لكل استرداد مستقل، ومرجعه الطلب الأصلي    |
| حدث   | ledger.posted                           | من outbox إلى RabbitMQ، للإشعار وتحديث التقارير | قد يصل مرتين، فالمستهلك يتجاهل journal\_id الذي عالجه                  |

### تقدير السعة بالأرقام

المحفظة نظام صحة قبل أن يكون نظام حمل، ولكن الأرقام تبين لك أين يقع الضغط الحقيقي، والافتراضات التالية كلها افتراضات مكتوبة صراحة وليست أرقام شركة:

- افتراض: مليونا محفظة، و10% منها نشطة كل يوم، وكل محفظة نشطة تقوم بثلاث عمليات مالية في اليوم.
- افتراض: الذروة خمسة أضعاف متوسط اليوم، وثلث العمليات شحن يأتي معه Webhook.
- افتراض: كل قيد مع سطريه وفهارسه قرابة 500 بايت، وكل مستخدم نشط يفتح الرصيد خمس مرات في اليوم.
- من اللائحة التنفيذية: الاحتفاظ بالقيود عشر سنوات على الأقل.

| الكمية                    | الحساب                   | النتيجة                                  |
| ------------------------- | ------------------------ | ---------------------------------------- |
| القيود في اليوم           | 2,000,000 × 10% × 3      | 600,000 قيد                              |
| متوسط القيود              | 600,000 ÷ 86,400         | قرابة 7 قيود في الثانية                  |
| القيود في الذروة          | 7 × 5                    | 35 قيداً في الثانية                      |
| الأسطر المكتوبة في الذروة | 35 × (سطر قيد + سطران)   | 105 أسطر جديدة و70 تحديث رصيد في الثانية |
| قراءة الرصيد              | 200,000 × 5 ÷ 86,400 × 5 | قرابة 58 قراءة في الثانية في الذروة      |
| إشعارات الشحن Webhooks    | 600,000 ÷ 3 ÷ 86,400 × 5 | قرابة 12 في الثانية في الذروة            |
| التخزين في اليوم          | 600,000 × 500 بايت       | 300 ميغابايت                             |
| التخزين لعشر سنوات        | 300 ميغابايت × 365 × 10  | قرابة 1.1 تيرابايت                       |

والخلاصة أن 35 قيداً في الثانية حمل صغير على قاعدة PostgreSQL واحدة، وفي التجربة العملية كتبت دالة الدفتر 507 قيود في الثانية على حساب واحد ساخن على جهاز تطوير، أما الضغط الحقيقي فهو في الحساب الذي يمر به كل شيء، مثل حساب الرسوم أو حساب تاجر كبير، لأن كل القيود تنتظر القفل على سطره نفسه. فإذا نما النظام عشر مرات صار هذا الحساب 350 قيداً في الثانية على سطر واحد، والحل المعروف أن تقسم حساب الشركة الساخن إلى عدة حسابات فرعية مثل `fees:psp:1` إلى `fees:psp:8`، يختار كل قيد واحداً منها، وتجمعها في التقارير. وأما التخزين فيكبر ببطء، ولكن لعشر سنوات، لذلك خطط لتقسيم جدول السطور بالشهر من البداية.

### مراحل النمو: ماذا تبني اليوم، وماذا تؤجل؟

| الجزء            | البداية MVP: سيرفر أو اثنان                       | النمو: آلاف العمليات في الساعة                               | الحجم الكبير: ملايين المحافظ                                                                                   |
| ---------------- | ------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| الدفتر           | PostgreSQL واحدة بالجداول والدوال التي في التجربة | نسخة متماثلة متزامنة مع Patroni، ونسخة للقراءة لكشوف الحركات | تقسيم postings بالشهر، وأرشفة السنوات المغلقة، ودفتر لكل عملة أو منطقة                                         |
| الحسابات الساخنة | حساب واحد لكل شيء                                 | تقسيم حسابات الشركة الساخنة إلى حسابات فرعية                 | قاعدة متخصصة في القيود المالية مثل TigerBeetle (رخصة Apache-2.0) إذا وصلت الكتابة إلى ما لا تتحمله قاعدة واحدة |
| ما بعد القيد     | الإشعار داخل الطلب نفسه                           | جدول outbox وRabbitMQ                                        | بث الأحداث للتقارير ومكافحة الاحتيال                                                                           |
| المطابقة         | استعلام يومي يراجع نتيجته إنسان                   | مطابقة آلية وقائمة حالات مفتوحة للفريق المالي                | أكثر من مزود دفع وأكثر من بنك، ولكل منها مطابقة                                                                |
| النسخ الاحتياطي  | نسخة يومية مع اختبار استعادة                      | pgBackRest مع أرشيف WAL واستعادة لأي لحظة                    | نسخ في منطقة أخرى، وتمرين استعادة دوري موثق                                                                    |

والذي لا تبنيه في اليوم الأول: لا تفصل الدفتر إلى خدمات صغيرة لكل نوع حساب، ولا تبن معاملات موزعة بين قواعد بيانات مختلفة، ولا تستخدم قاعدة متخصصة أو Kafka قبل أن تحتاجها، والسبب أن القيد الواحد يجب أن يكون معاملة واحدة في قاعدة واحدة، وكل تقسيم يكسر هذه القاعدة يحتاج حلولاً أصعب بكثير. أما ما يجب أن يكون موجوداً من البداية فهو القيد المزدوج، والمبالغ بالهللة، ومفتاح منع التكرار داخل المعاملة، والدفتر الذي يقبل الإضافة فقط، والمطابقة اليومية، لأن تغيير أي منها بعد ملايين القيود مكلف جداً.

### ماذا لو تعطل جزء منها؟

| ماذا لو تعطل          | الأثر                                                          | كيف تخفف الأثر                                                                                                      |
| --------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| PostgreSQL الرئيسية   | لا دفع ولا تحويل حتى يكتمل التحويل إلى النسخة الأخرى           | نسخة متزامنة فلا يضيع قيد مؤكد، والتطبيق يعيد الطلب بالمفتاح نفسه فيحصل على القيد نفسه دون تكرار                    |
| مزود الدفع            | لا يمكن شحن المحفظة                                            | الدفع من الرصيد والتحويل بين المحافظ يستمران لأنهما داخل الدفتر، ورسالة واضحة للعميل عن الشحن                       |
| Webhook لم يصل        | المزود خصم من البطاقة ولم يضف الرصيد، كحالة pay\_E4 في التجربة | مهمة تسأل المزود عن الجلسات المعلقة بعد دقائق، والمطابقة اليومية تكشف ما بقي                                        |
| RabbitMQ أو الإشعارات | تتأخر الإشعارات                                                | المال صحيح لأن الحدث محفوظ في outbox مع القيد، ويرسل عند العودة                                                     |
| خطأ في الكود          | محاولة قيد غير متوازن أو سحب بأكثر من الرصيد أو تعديل قيد قديم | القيود في قاعدة البيانات ترفضها كما في قسم نكسر القواعد، والفحص الليلي يوقف العمل إذا اختلف شيء، والتصحيح بقيد عكسي |
| حساب ساخن             | طوابير على القفل وبطء في كل الدفعات                            | معاملات قصيرة، وتقسيم الحساب الساخن إلى حسابات فرعية، ومراقبة زمن انتظار الأقفال                                    |

## القيد المزدوج Double-Entry للمهندسين

في القيد المزدوج كل عملية مالية تسمى قيداً Journal Entry، وكل قيد له سطران أو أكثر يسمى كل منهما Posting، وكل سطر إما مدين Debit أو دائن Credit، ومجموع المدين يساوي مجموع الدائن في كل قيد. وفي الكود نكتب المدين موجباً والدائن سالباً، فتصبح القاعدة كلها سطراً واحداً: مجموع سطور كل قيد يساوي صفراً. والصورة التالية تبين أول قيدين في المختبر:

![ثلاثة حسابات بشكل حرف T: الأول psp:clearing وهو أصل لأن مزود الدفع يدين لنا، في جانبه المدين 10000 هللة ورصيده 100.00. والثاني wallet:1001 وهو التزام لأننا ندين للعميل 1001، في المدين 2500 وفي الدائن 10000 ورصيده 75.00. والثالث wallet:1002 في الدائن 2500 ورصيده 25.00. وتحتها قيدان: الأول شحن pay_A1 فيه psp:clearing موجب 10000 وwallet:1001 سالب 10000 والمجموع صفر، والثاني تحويل tr-8f3a فيه wallet:1001 موجب 2500 وwallet:1002 سالب 2500 والمجموع صفر](https://arabroot.io/content/images/2026/10/wallet-ledger-01-double-entry.webp)

كل قيد مجموعه صفر، والرصيد هو مجموع سطور الحساب، فلا يتحرك المال إلا من حساب إلى آخر

وأكثر نقطة تربك المهندسين هنا هي نوع الحساب: محفظة العميل في دفاترك ليست مالك، وإنما مال تدين به للعميل، أي التزام Liability، فعندما يشحن العميل مئة ريال فإن حساب المحفظة يزيد في الجانب الدائن، وحساب مزود الدفع psp:clearing يزيد في الجانب المدين لأنه أصل Asset، فالمزود سوف يحول لك هذا المبلغ لاحقاً. ولهذا يحتاج كل حساب نوعاً، والرصيد الذي يراه العميل هو مجموع سطور محفظته بإشارة معكوسة. ولا تحتاج أن تصبح محاسباً لتبني هذا، ولكن تحتاج أن تتفق مع المحاسب على شجرة الحسابات من اليوم الأول، لأن تغييرها بعد ملايين القيود مكلف جداً.

ويعطيك هذا التصميم ثلاثة أشياء لا يعطيها عمود الرصيد: كل ريال له قيد يشرح من أين جاء وإلى أين ذهب، والدفتر كله يجب أن يساوي صفراً في كل لحظة فتستطيع أن تتحقق منه كل ليلة باستعلام واحد، والقيود القديمة لا تتغير، فتصحيح أي خطأ يكون بقيد جديد يعكسه وليس بتعديل القديم.

## دفتر لا يقبل التعديل، ورصيد محسوب أم محفوظ؟

الدفتر الذي لا يقبل التعديل Immutable Ledger يعني أن جدولي القيود والسطور يقبلان الإضافة فقط، فلا UPDATE ولا DELETE حتى من مطور يظن أنه يصلح خطأ، وفي المختبر نفرض هذا بـ Trigger يرفض أي تعديل. وإذا أردت أن تكشف العبث حتى من مدير قاعدة البيانات نفسه فالفكرة نفسها التي شرحناها في [مقال سجل التدقيق](https://arabroot.io/articles/%D8%B3%D8%AC%D9%84-%D8%A7%D9%84%D8%AA%D8%AF%D9%82%D9%8A%D9%82-audit-trail-%D9%85%D9%86-%D9%81%D8%B9%D9%84-%D9%85%D8%A7%D8%B0%D8%A7-%D9%88%D9%85%D8%AA%D9%89/#tamper-evidence) تنطبق هنا: سلسلة هاش تربط كل قيد بما قبله، ومرساة موقعة تحفظ خارج السيرفر.

وقد يتساءل البعض: إذا كان الرصيد هو مجموع السطور، فهل نحسب SUM على ملايين السطور في كل عملية دفع؟ والإجابة أن هناك طريقتين، ولكل منهما مكانها:

- **الرصيد المحسوب Derived Balance:** SUM على سطور الحساب عند الطلب، وهو الحقيقة دائماً، ولكنه يبطؤ مع نمو الحساب، وتحتاج معه طريقة لمنع السحب بأكثر من الرصيد بين طلبين متزامنين.
- **الرصيد المحفوظ Cached Balance:** جدول فيه سطر لكل حساب يتحدث في المعاملة نفسها التي تضيف السطور، ويقفل عند الخصم، وعليه قيد CHECK يمنع السالب. وهذا ما يفعله المختبر، بشرط مهم: الجدول نسخة من الحقيقة وليس الحقيقة، لذلك نتحقق كل ليلة أن كل رصيد محفوظ يساوي مجموع سطوره، وأي فرق يعني خطأ في الكود يجب أن يوقف العمل حتى يفهم.

## المبالغ بالهللة وليس بالكسور

لا تحفظ المال في float أو double أبداً، والسبب أن 0.1 لا يمكن تمثيلها بدقة في النظام الثنائي، والفروق الصغيرة تتراكم مع ملايين العمليات، وقد شرحنا هذا بالتفصيل في [مقال الأرقام العشرية](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/#decimal-types) وفي [قسم المبالغ المالية في مقال الأنظمة العددية](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%B9%D8%AF%D8%AF%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D8%AB%D9%86%D8%A7%D8%A6%D9%8A-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/#money). والطريقة الأبسط في الدفتر أن تحفظ المبلغ عدداً صحيحاً بأصغر وحدة للعملة، أي بالهللة للريال، فمئة ريال تصبح 10000 في عمود bigint، ومعها رمز العملة في عمود مستقل، ولا تجمع أبداً سطرين بعملتين مختلفتين. والتحويل إلى ريالات يكون عند العرض فقط. ولاحظ أن العملات لا تتفق في عدد الخانات، فبعضها له ثلاث خانات بعد الفاصلة وبعضها ليس له خانات، لذلك اجعل عدد الخانات صفة للعملة وليس رقماً ثابتاً في الكود.

## مفتاح منع التكرار داخل المعاملة

شرحنا فكرة مفتاح منع التكرار Idempotency Key في [مقال تصميم ال API](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-rest-%D9%88-grpc-%D9%88-graphql/#idempotency): التطبيق ينشئ معرفاً فريداً لكل عملية ويرسله مع الطلب، فإذا أعاد الطلب بعد انقطاع الشبكة عرف السيرفر أنه نفس الطلب. وما يخص المحفظة هنا نقطتان. الأولى أن المفتاح يحفظ في المعاملة نفسها التي تضيف القيد، كعمود فريد UNIQUE في جدول القيود، وليس في Cache منفصل، والسبب أنك إذا حفظت المفتاح ثم سقط السيرفر قبل القيد فقد رفضت الإعادة ولم يتحرك المال، وإذا حفظت القيد ثم سقط قبل المفتاح فقد خصمت مرتين. والثانية أن المفتاح المكرر بطلب مختلف خطأ في الكود يجب أن يرفض، لذلك نحفظ معه بصمة للطلب، وهذا ما تبينه الصورة التالية:

![ثلاثة أعمدة: تطبيق المحفظة وواجهة المحفظة والدفتر. في الخطوة الأولى يرسل التطبيق تحويلاً بخمسة وعشرين ريالاً مع المفتاح tr-8f3a، فتكتب الواجهة سطر القيد مع المفتاح والسطور في معاملة واحدة وتحصل على القيد رقم 2، ولكن الرد يضيع في الشبكة. في الخطوة الثانية يعيد التطبيق الطلب بالمفتاح نفسه، فتجد الواجهة المفتاح موجوداً ببصمة الطلب نفسها وترد بالقيد رقم 2 دون خصم ثان. وفي الخطوة الثالثة يأتي طلب بالمفتاح نفسه ومبلغ 99 فيرفض بخطأ أن المفتاح استخدم لطلب مختلف](https://arabroot.io/content/images/2026/10/wallet-ledger-03-idempotency.webp)

الإعادة بالمفتاح نفسه تعطي النتيجة نفسها، والمفتاح نفسه مع طلب مختلف يرفض

وفي المختبر نستخدم `INSERT ... ON CONFLICT (idempotency_key) DO NOTHING`، ويذكر [توثيق INSERT في PostgreSQL](https://www.postgresql.org/docs/current/sql-insert.html?ref=arabroot.io) أن هذه الصيغة تضمن نتيجة الإضافة أو عدمها حتى مع التزامن، فإذا وصل طلبان بالمفتاح نفسه في اللحظة نفسها انتظر الثاني حتى تنتهي معاملة الأول، ثم لا يضيف شيئاً ويعيد القيد نفسه. وإذا فشلت معاملة الأول بسبب نقص الرصيد مثلاً فلم يحفظ المفتاح أصلاً، فيعيد العميل المحاولة لاحقاً بالمفتاح نفسه بشكل طبيعي.

## السباق على الرصيد ومستويات العزل

أخطر خطأ في المحافظ ليس الطلب المكرر، وإنما طلبان مختلفان على الرصيد نفسه في اللحظة نفسها. والطريقة البسيطة أن يقرأ الكود الرصيد، ثم يحسب الرصيد الجديد في التطبيق، ثم يكتبه، وبين القراءة والكتابة يقرأ طلب آخر الرصيد القديم نفسه، فيكتب كل منهما نتيجته فوق الآخر، وهذا يسمى التحديث الضائع Lost Update، كما في الجهة اليسرى من الصورة التالية:

![لوحتان والزمن ينزل من الأعلى. في اليسرى يقرأ الطلب A الرصيد 100.00، ثم يقرأ الطلب B الرصيد 100.00 أيضاً، ثم يكتب A القيمة 99.00 ويلتزم، ثم يكتب B القيمة 99.00 ويلتزم، فتكون النتيجة دفعتين ورصيد 99.00 أي ضاعت دفعة. وفي اليمنى يقرأ A بـ SELECT FOR UPDATE فيقفل السطر، ويحاول B القراءة نفسها فينتظر حتى يلتزم A، ثم يقرأ 99.00 ويكتب 98.00، والنتيجة صحيحة](https://arabroot.io/content/images/2026/10/wallet-ledger-02-race.webp)

القراءة ثم الكتابة في التطبيق تضيع دفعة، والقفل على السطر يجعل الطلب الثاني ينتظر الأول

وقد شرحنا مستويات العزل بتجارب على جلستين في [الجزء الثالث](https://arabroot.io/articles/%D8%AA%D8%B9%D9%84%D9%85-sql-3-cte-%D9%88%D8%AF%D9%88%D8%A7%D9%84-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D9%81%D8%B0-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%85%D9%84%D8%A7%D8%AA/#isolation) من سلسلة [تعلم SQL](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#isolation)، والمستوى الافتراضي في PostgreSQL هو Read Committed، وفيه لا يمنع النظام هذا السباق لأن كل جملة ترى آخر ما التزم قبلها، كما يشرح [توثيق مستويات العزل](https://www.postgresql.org/docs/current/transaction-iso.html?ref=arabroot.io). وأمامك ثلاثة حلول صحيحة:

- **التعديل الذري في جملة واحدة:** `UPDATE wallets SET balance = balance - 100 WHERE id = 1 AND balance >= 100`، فقاعدة البيانات تقرأ وتكتب في خطوة واحدة على السطر المقفل، وإذا لم يتغير أي سطر فالرصيد لا يكفي. وهذا يكفي لحساب واحد، ولكنه لا يكفي عندما يتحرك المال بين حسابين ومعه سطور الدفتر.
- **القفل الصريح SELECT ... FOR UPDATE:** داخل معاملة، فيقفل السطر حتى نهاية المعاملة، وأي طلب آخر يريد قفله ينتظر، كما يشرح [توثيق الأقفال الصريحة](https://www.postgresql.org/docs/current/explicit-locking.html?ref=arabroot.io). وعندما تقفل أكثر من حساب فاقفلها دائماً بترتيب ثابت، مثل رقم الحساب، وإلا قفل طلب A ثم B، وقفل طلب آخر B ثم A، فانتظر كل منهما الآخر إلى أن يكتشف PostgreSQL الجمود Deadlock ويلغي أحدهما.
- **المستوى التسلسلي Serializable:** لا تقفل بنفسك، وإنما تكتشف قاعدة البيانات أن معاملتين لا يمكن أن تكونا حدثتا بالتتابع، فتلغي إحداهما بخطأ رمزه 40001، وعلى تطبيقك أن يعيد المعاملة كلها، كما يشرح [توثيق التعامل مع فشل التسلسل](https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html?ref=arabroot.io). وهو آمن ومريح للمنطق المعقد، ولكن على حساب ساخن Hot Account يدفع منه الجميع ينتج إعادات كثيرة جداً، كما سوف ترى في المختبر.

📌

في مايو 2015 نشر الباحث Egor Homakov من شركة Sakurity [تفاصيل ثغرة سباق في بطاقات هدايا Starbucks](https://sakurity.com/blog/2015/05/21/starbucks.html?ref=arabroot.io): التحويل بين البطاقات كان على خطوتين، فأرسل الخطوة الثانية من جلستين في اللحظة نفسها، فنفذ تحويلان بخمسة دولارات من بطاقة فيها خمسة دولارات فقط، وصار مجموع بطاقاته 20 دولاراً بدل 15، ثم اشترى بالفرق شطيرة ليثبت أن المال حقيقي. وذكر أنه أبلغ الشركة في 23 مارس 2015، وأن ردها جاء في 29 أبريل. والدرس أن فحص الرصيد ثم الخصم في خطوتين منفصلتين ثغرة مالية، وليس خطأ أداء.

## الحجز والتحصيل والاسترداد

في تطبيق التوصيل لا يعرف أحد المبلغ النهائي لحظة الطلب، فقد ينقص صنف من المطعم، أو يتغير رسم التوصيل، لذلك لا تخصم المبلغ مباشرة، وإنما تحجزه Hold، وفي البطاقات يسمى هذا التفويض Authorization. والحجز في الدفتر ليس حالة خاصة، وإنما قيد عادي ينقل المال من محفظة العميل إلى حساب حجز للعميل نفسه، فيقل ما يستطيع أن يصرفه، ويبقى المال ماله. ثم عند التسليم تحصل Capture المبلغ النهائي إلى حساب المطعم، وتعيد Release الباقي إلى المحفظة. وإذا طلب العميل استرداداً بعد يوم فهذا قيد جديد يعكس جزءاً من التحصيل، ولا تعدل قيد التحصيل القديم أبداً. والصورة التالية تبين طلباً بأربعين ريالاً كما في المختبر:

![ثلاثة حسابات: wallet:1001 ما ندين به للعميل ويستطيع صرفه ورصيده النهائي 50.00، وholds:1001 المحجوز للطلب 5531 ورصيده النهائي 0.00، وmerchant:77 ما ندين به للمطعم ورصيده النهائي 25.00. وبينها أربعة أسهم: الأول حجز 40.00 من المحفظة إلى الحجز، والثاني تحصيل 35.00 من الحجز إلى المطعم، والثالث إعادة 5.00 من الحجز إلى المحفظة، والرابع في اليوم التالي استرداد 10.00 من المطعم إلى المحفظة. وتحتها أن قيد التحصيل يبقى كما هو والاسترداد قيد جديد مرجعه order-5531، وأن المتاح للصرف هو المحفظة فقط](https://arabroot.io/content/images/2026/10/wallet-ledger-04-holds.webp)

كل خطوة قيد جديد مرجعه رقم الطلب، والمحجوز ما زال مال العميل وليس مال المطعم

ولاحظ ثلاث نقاط عملية: الحجز يحتاج مدة انتهاء، فإذا ألغي الطلب أو نسي النظام التحصيل يعيد مؤقت المال بعد مدة محددة، والتحصيل لا يجوز أن يتجاوز المحجوز، والاسترداد يربط بالعملية الأصلية، وهذا ما تطلبه أيضاً اللائحة التنفيذية لنظام المدفوعات كما سوف نرى.

## المطابقة مع مزود الدفع والبنك

دفترك يقول ما تظن أنه حدث، وملف التسوية Settlement File من مزود الدفع يقول ما يعترف به هو، وكشف البنك يقول ما وصل فعلاً، والمطابقة Reconciliation هي المقارنة اليومية بين الثلاثة. ولن تتطابق كل يوم، والسبب ليس خطأ في كودك بالضرورة: إشعار Webhook من المزود لم يصل، أو شحن قبلته ثم رفضه بنك العميل، أو مبلغ اختلف بسبب رسوم أو عملة. لذلك فالقاعدة أن الحساب الوسيط psp:clearing يجب أن يقترب من الصفر بعد كل تسوية، وما يبقى فيه هو بالضبط قائمة الحالات المفتوحة التي يجب أن يعالجها إنسان. والصورة التالية تبين نتيجة المطابقة في المختبر:

![على اليسار جدول من ثلاثة أعمدة: ما في دفترنا، وما في ملف مزود الدفع بتاريخ 2026-10-08، والحالة. pay_A1 مئة ريال في الطرفين مطابق، وpay_B7 عشرة آلاف ريال في الطرفين مطابق، وpay_C2 خمسون في دفترنا وخمسة وأربعون في الملف المبلغ مختلف، وpay_D9 عشرون في دفترنا وغير موجود في الملف، وpay_E4 ثلاثون في الملف وغير موجود في دفترنا. وعلى اليمين قيد التسوية للمطابق فقط: bank:main موجب 10,080.70 وfees:psp موجب 19.30 وpsp:clearing سالب 10,100.00، ويبقى في psp:clearing سبعون ريالاً تساوي pay_C2 وpay_D9. وفي الأسفل المادة 93 من اللائحة التنفيذية: أموال العملاء في حساب منفصل لدى بنك مرخص مع مطابقة يومية](https://arabroot.io/content/images/2026/10/wallet-ledger-05-recon.webp)

نسوي المطابق فقط، وما يبقى في الحساب الوسيط هو قائمة الحالات المفتوحة

والمطابقة تتم على مرجع المزود وليس على المبلغ، لأن مبلغين متساويين لا يعنيان العملية نفسها، ولذلك يجب أن تحفظ مرجع المزود مع كل قيد شحن، كما في العمود ref في المختبر، وهذه هي مهمة Reconciliation في المعمارية الكاملة أعلاه. والإشعارات التي يرسلها المزود إلى سيرفرك تعالج بالمنطق نفسه: لها معرف فريد يمنع تكرارها، وتكتب في المعاملة نفسها مع القيد، ثم ينشر الحدث لبقية الخدمات بنمط Outbox الذي شرحناه في [مقال الأنظمة الموزعة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/#outbox).

## التنظيم في المملكة وPCI DSS باختصار

هذا القسم للمهندس الذي يريد أن يعرف لماذا يسأله القسم القانوني أسئلة معينة، وليس بديلاً عن مستشار قانوني. ففي المملكة يحكم المحافظ الإلكترونية [نظام المدفوعات وخدماتها](https://rulebook.sama.gov.sa/en/law-payments-and-payment-services?ref=arabroot.io) الصادر بالمرسوم الملكي م/26 بتاريخ 1443/3/22هـ (28 أكتوبر 2021)، وهو يعد المحافظ في صورة حسابات أو سجلات إلكترونية من خدمات الدفع، وينص على أنه لا يجوز لأحد أن يشغل نظام مدفوعات أو يقدم خدمات دفع في المملكة إلا بترخيص من البنك المركزي السعودي SAMA. وفي [صفحة أنواع التراخيص](https://rulebook.sama.gov.sa/en/types-licenses-2?ref=arabroot.io) أربعة أنواع: مؤسسة مدفوعات صغرى، ومؤسسة مدفوعات كبرى، ومؤسسة نقود إلكترونية صغرى للمحافظ، ومؤسسة نقود إلكترونية كبرى للمحافظ. وفي [اللائحة التنفيذية للنظام](https://rulebook.sama.gov.sa/en/implementing-regulations-payments-and-payment-services-law?ref=arabroot.io) متطلبات تمس التصميم مباشرة، منها:

- **حماية أموال العملاء Safeguarded Funds:** تطلب المادة 93 حمايتها فور استلامها، وإذا بقيت لدى مقدم الخدمة إلى نهاية يوم العمل التالي فتوضع في حساب منفصل لدى بنك مرخص لهذا الغرض فقط، ولا تخلط بأموال الرسوم أو المصاريف، ويخضع الحساب لمطابقة يومية مع العمليات والنقود الإلكترونية القائمة. وهذا يعني أن حساباً مثل bank:safeguarding في دفترك ليس تفصيلاً محاسبياً، وإنما شيء يجب أن تستطيع مطابقته كل يوم.
- **السجلات:** تطلب اللائحة الاحتفاظ بسجلات العمليات والسجلات المحاسبية، ومنها دفاتر الأستاذ والقيود والمطابقات، عشر سنوات على الأقل، وحفظها بالصيغة التي أنشئت بها دون إضافة أو حذف أو تعديل، وتسجيل كل إجراء على السجل، وهذا وصف قريب جداً للدفتر الذي لا يقبل التعديل.
- **الاسترداد:** تطلب اللائحة تسجيل الاسترداد مقابل العملية الأصلية برقم تسلسلها الأصلي، والاحتفاظ بسجلات الاسترداد والتعويض عشر سنوات.

وأما [معيار PCI DSS](https://www.pcisecuritystandards.org/standards/pci-dss/?ref=arabroot.io) فهو من مجلس معايير أمن صناعة بطاقات الدفع، ويطبق على كل جهة تخزن أو تعالج أو تنقل بيانات حامل البطاقة Cardholder Data أو بيانات المصادقة الحساسة، أو تستطيع أن تؤثر في أمن البيئة التي تمر بها هذه البيانات. وهذا التعريف نفسه هو النصيحة الهندسية: نطاق التدقيق يتحدد بالأماكن التي يمر بها رقم البطاقة، فإذا استخدمت صفحة الدفع أو حزمة الهاتف التي يقدمها مزود الدفع، بحيث يذهب رقم البطاقة من جهاز العميل إلى المزود مباشرة ويعود إليك رمز Token فقط، فإن سيرفراتك لا تخزن الرقم ولا تعالجه، ويصغر ما يدخل في النطاق كثيراً. وحدد النطاق النهائي مع مزود الدفع أو المدقق المعتمد، ولا تفترضه.

## التجربة العملية: دفتر قيود على PostgreSQL 18.6

في هذه التجربة سوف نبني الدفتر كاملاً في قاعدة بيانات واحدة: الجداول والقيود التي تحمي المعادلة، ودالة التحويل التي لا تتكرر، ثم نختبر السباق بأداة pgbench التي تأتي مع PostgreSQL، ونطابق الشحنات مع ملف تسوية من مزود الدفع. ونشغل PostgreSQL في Container بمنفذ مربوط على 127.0.0.1 فقط، ونربط مجلد التجربة داخله لتقرأ psql الملفات منه:

```bash
docker run -d --name lab-case-ledger -e POSTGRES_PASSWORD=lab -p 127.0.0.1:15440:5432 \
  -v "$PWD:/lab" -w /lab postgres:18.6
docker exec lab-case-ledger psql -U postgres -tAc 'select version()'
```

```text
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
```

### الجداول والقيود ودالة التحويل

الملف ledger.sql فيه كل الدفتر:

```sql
-- Wallet ledger: double entry, immutable postings, idempotent posting function.
-- Amounts are bigint in halalas (1 SAR = 100 halalas). A posting is a debit when positive and a credit when negative.

CREATE TABLE accounts (
    id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code           text   NOT NULL UNIQUE,               -- wallet:1001, holds:1001, psp:clearing, bank:main, fees:psp, merchant:77
    type           text   NOT NULL CHECK (type IN ('asset', 'liability', 'revenue', 'expense')),
    currency       char(3) NOT NULL DEFAULT 'SAR',
    allow_negative boolean NOT NULL DEFAULT false
);

CREATE TABLE journal (                                   -- one row per business event
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    idempotency_key text   NOT NULL UNIQUE,
    request_hash    text   NOT NULL,                     -- the same key with a different request is an error
    kind            text   NOT NULL,                     -- topup, transfer, hold, capture, release, refund, settlement
    ref             text,                                -- the PSP or the original journal reference
    created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE postings (                                  -- the lines of each event; never updated, never deleted
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    journal_id bigint NOT NULL REFERENCES journal (id),
    account_id bigint NOT NULL REFERENCES accounts (id),
    amount     bigint NOT NULL CHECK (amount <> 0),
    currency   char(3) NOT NULL
);
CREATE INDEX ON postings (account_id);
CREATE INDEX ON postings (journal_id);

CREATE TABLE balances (                                  -- a cache of SUM(postings), kept in the same transaction
    account_id     bigint PRIMARY KEY REFERENCES accounts (id),
    balance        bigint NOT NULL DEFAULT 0,            -- on the account's normal side (credit for a wallet)
    allow_negative boolean NOT NULL,
    CHECK (allow_negative OR balance >= 0)
);

-- 1) Debits = credits for every journal entry, checked at COMMIT (deferred), per currency.
CREATE FUNCTION check_balanced() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bad record;
BEGIN
    SELECT currency, SUM(amount) AS diff INTO bad
      FROM postings WHERE journal_id = NEW.journal_id
     GROUP BY currency HAVING SUM(amount) <> 0 LIMIT 1;
    IF FOUND THEN
        RAISE EXCEPTION 'journal % is not balanced: % % off', NEW.journal_id, bad.diff, bad.currency;
    END IF;
    RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER postings_balanced AFTER INSERT ON postings
    DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_balanced();

-- 2) The ledger is append-only: no UPDATE or DELETE, even by mistake.
CREATE FUNCTION forbid_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    RAISE EXCEPTION '% is append-only: write a reversing entry instead', TG_TABLE_NAME;
END $$;
CREATE TRIGGER postings_append_only BEFORE UPDATE OR DELETE ON postings FOR EACH ROW EXECUTE FUNCTION forbid_change();
CREATE TRIGGER journal_append_only  BEFORE UPDATE OR DELETE ON journal  FOR EACH ROW EXECUTE FUNCTION forbid_change();

-- 3) post(): one idempotent entry. lines = [{"account": "wallet:1001", "amount": -2500}, ...]
CREATE FUNCTION post(p_key text, p_kind text, p_ref text, p_lines jsonb) RETURNS bigint
LANGUAGE plpgsql AS $$
DECLARE
    v_hash text := md5(p_kind || coalesce(p_ref, '') || p_lines::text);
    v_id   bigint;
    v_old  journal%ROWTYPE;
    l      record;
BEGIN
    -- the unique key makes a second request with the same key wait for the first, then do nothing
    INSERT INTO journal (idempotency_key, request_hash, kind, ref)
    VALUES (p_key, v_hash, p_kind, p_ref)
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING id INTO v_id;
    IF v_id IS NULL THEN
        SELECT * INTO v_old FROM journal WHERE idempotency_key = p_key;
        IF v_old.request_hash <> v_hash THEN
            RAISE EXCEPTION 'idempotency key % was used for a different request', p_key;
        END IF;
        RETURN v_old.id;                                  -- replay: same answer, no new money movement
    END IF;

    -- lock the cached balances in a fixed order (by id) so two transfers cannot deadlock
    PERFORM 1 FROM balances b JOIN accounts a ON a.id = b.account_id
     WHERE a.code IN (SELECT x->>'account' FROM jsonb_array_elements(p_lines) x)
     ORDER BY b.account_id FOR UPDATE OF b;

    FOR l IN SELECT a.id, a.type, a.currency, (x->>'amount')::bigint AS amount
               FROM jsonb_array_elements(p_lines) x JOIN accounts a ON a.code = x->>'account'
    LOOP
        INSERT INTO postings (journal_id, account_id, amount, currency) VALUES (v_id, l.id, l.amount, l.currency);
        UPDATE balances
           SET balance = balance + CASE WHEN l.type IN ('asset', 'expense') THEN l.amount ELSE -l.amount END
         WHERE account_id = l.id;                         -- the CHECK on balances refuses an overdraft
    END LOOP;
    IF (SELECT count(*) FROM jsonb_array_elements(p_lines)) <>
       (SELECT count(*) FROM postings WHERE journal_id = v_id) THEN
        RAISE EXCEPTION 'unknown account in %', p_lines;
    END IF;
    RETURN v_id;
END $$;

-- 4) transfer(): the common case, two lines: p_debit is debited, p_credit is credited.
--    Wallet A pays wallet B: debit wallet:A (we owe A less), credit wallet:B (we owe B more).
CREATE FUNCTION transfer(p_key text, p_debit text, p_credit text, p_amount bigint, p_kind text DEFAULT 'transfer', p_ref text DEFAULT NULL)
RETURNS bigint LANGUAGE sql AS $$
    SELECT post(p_key, p_kind, p_ref,
                jsonb_build_array(jsonb_build_object('account', p_debit,  'amount',  p_amount),
                                  jsonb_build_object('account', p_credit, 'amount', -p_amount)));
$$;

CREATE FUNCTION open_account(p_code text, p_type text, p_allow_negative boolean DEFAULT false) RETURNS void
LANGUAGE sql AS $$
    WITH a AS (INSERT INTO accounts (code, type, allow_negative) VALUES (p_code, p_type, p_allow_negative) RETURNING id, allow_negative)
    INSERT INTO balances (account_id, allow_negative) SELECT id, allow_negative FROM a;
$$;
```

في الملف أعلاه لاحظ التالي:

- المبالغ bigint بالهللة، والقيد CHECK يمنع سطراً بمبلغ صفر، والعملة في كل سطر.
- الـ Trigger المسمى postings\_balanced من نوع CONSTRAINT TRIGGER مؤجل DEFERRABLE INITIALLY DEFERRED، أي أنه يعمل عند COMMIT وليس بعد كل سطر، لأن القيد يكون غير متوازن مؤقتاً بين إضافة سطره الأول والثاني، كما يشرح [توثيق CREATE TRIGGER](https://www.postgresql.org/docs/current/sql-createtrigger.html?ref=arabroot.io).
- الـ Triggers المسماة append\_only ترفض UPDATE وDELETE على القيود والسطور، وفي الإنتاج أضف إليها صلاحيات تمنع مستخدم التطبيق من هذه الأوامر أصلاً.
- الدالة post تبدأ بإضافة سطر القيد مع المفتاح، ثم تقفل الأرصدة المحفوظة بترتيب رقم الحساب، ثم تضيف السطور وتحدث الأرصدة، والقيد CHECK على جدول balances هو الذي يرفض السحب بأكثر من الرصيد.
- الدالة transfer تسمي الطرفين p\_debit وp\_credit عمداً، لأن «من» و«إلى» مربكة في الشحن: المدين في الشحن هو حساب مزود الدفع وليس العميل.

```bash
docker exec lab-case-ledger psql -U postgres -q -v ON_ERROR_STOP=1 -f /lab/ledger.sql
```

### الحسابات والشحن والتحويل والطلب المكرر

الملف 01-accounts.sql يفتح الحسابات:

```sql
SELECT open_account('psp:clearing', 'asset',     true);   -- money the PSP owes us
SELECT open_account('bank:main',    'asset',     true);   -- our bank account
SELECT open_account('fees:psp',     'expense',   true);   -- what the PSP charges us
SELECT open_account('wallet:1001',  'liability');         -- what we owe customer 1001
SELECT open_account('wallet:1002',  'liability');
SELECT open_account('holds:1001',   'liability');         -- 1001's money reserved for an order
SELECT open_account('merchant:77',  'liability');         -- what we owe restaurant 77
```

والملف 02-flow.sql يشحن المحفظة، ثم يحول، ثم يعيد التحويل بالمفتاح نفسه، ثم يستخدم المفتاح نفسه بمبلغ آخر:

```sql
-- 1001 tops up 100.00 SAR with a card; the PSP confirms with reference pay_A1
SELECT transfer('topup-pay_A1', 'psp:clearing', 'wallet:1001', 10000, 'topup', 'pay_A1');
-- 1001 sends 25.00 SAR to 1002
SELECT transfer('tr-8f3a', 'wallet:1001', 'wallet:1002', 2500);
-- the phone did not get the answer and sends the same request again
SELECT transfer('tr-8f3a', 'wallet:1001', 'wallet:1002', 2500);
-- a bug reuses the key for another amount
SELECT transfer('tr-8f3a', 'wallet:1001', 'wallet:1002', 9900);

SELECT a.code, round(b.balance / 100.0, 2) AS sar
  FROM balances b JOIN accounts a ON a.id = b.account_id
 WHERE b.balance <> 0 ORDER BY a.id;
SELECT j.id AS journal, j.kind, a.code, p.amount
  FROM postings p JOIN journal j ON j.id = p.journal_id JOIN accounts a ON a.id = p.account_id
 ORDER BY p.id;
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/01-accounts.sql -o /dev/null
docker exec lab-case-ledger psql -U postgres -q -f /lab/02-flow.sql
```

والمخرج سوف يكون كما يلي:

```text
 transfer 
----------
        1
(1 row)

 transfer 
----------
        2
(1 row)

 transfer 
----------
        2
(1 row)

psql:/lab/02-flow.sql:8: ERROR:  idempotency key tr-8f3a was used for a different request
CONTEXT:  PL/pgSQL function post(text,text,text,jsonb) line 16 at RAISE
     code     |  sar   
--------------+--------
 psp:clearing | 100.00
 wallet:1001  |  75.00
 wallet:1002  |  25.00
(3 rows)

 journal |   kind   |     code     | amount 
---------+----------+--------------+--------
       1 | topup    | psp:clearing |  10000
       1 | topup    | wallet:1001  | -10000
       2 | transfer | wallet:1001  |   2500
       2 | transfer | wallet:1002  |  -2500
(4 rows)
```

في المخرج أعلاه لاحظ التالي:

- الإعادة بالمفتاح tr-8f3a أعادت القيد رقم 2 نفسه، والرصيد 75.00 وليس 50.00، أي أن التحويل لم يتكرر.
- المفتاح نفسه مع مبلغ 99.00 رفض برسالة واضحة، فالخطأ في الكود يظهر بدل أن يمر بصمت.
- سطور القيود الأربعة مجموعها صفر، والرصيد الموجب لحساب psp:clearing يعني أن المزود يدين لنا بمئة ريال حتى يحولها.

### نكسر القواعد عمداً

الملف 03-guards.sql يحاول أربعة أشياء يجب أن تفشل: قيد يخلق مالاً، وتعديل سطر، وحذف قيد، وتحويل بأكثر من الرصيد:

```sql
-- an entry that creates money: debit 10.00, credit 9.00
SELECT post('bad-1', 'transfer', NULL,
            '[{"account":"wallet:1001","amount":1000},{"account":"wallet:1002","amount":-900}]');
-- someone "fixes" a posting by hand
UPDATE postings SET amount = 1 WHERE id = 1;
DELETE FROM journal WHERE id = 2;
-- 1001 has 75.00 and tries to send 80.00
SELECT transfer('tr-9c01', 'wallet:1001', 'wallet:1002', 8000);
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/03-guards.sql
```

```text
psql:/lab/03-guards.sql:3: ERROR:  journal 5 is not balanced: 100 SAR off
CONTEXT:  PL/pgSQL function check_balanced() line 8 at RAISE
psql:/lab/03-guards.sql:5: ERROR:  postings is append-only: write a reversing entry instead
CONTEXT:  PL/pgSQL function forbid_change() line 3 at RAISE
psql:/lab/03-guards.sql:6: ERROR:  journal is append-only: write a reversing entry instead
CONTEXT:  PL/pgSQL function forbid_change() line 3 at RAISE
psql:/lab/03-guards.sql:8: ERROR:  new row for relation "balances" violates check constraint "balances_check"
DETAIL:  Failing row contains (4, -500, f).
CONTEXT:  SQL statement "UPDATE balances
           SET balance = balance + CASE WHEN l.type IN ('asset', 'expense') THEN l.amount ELSE -l.amount END
         WHERE account_id = l.id"
PL/pgSQL function post(text,text,text,jsonb) line 30 at SQL statement
```

كل المحاولات الأربع رفضت من قاعدة البيانات نفسها، وليس من كود التطبيق، وهذا هو الفرق بين قاعدة تحمي نفسها وقاعدة تثق بكل من يكتب فيها، كما شرحنا في [قسم القيود في مقال تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#constraints). ولاحظ أن رقم القيد المرفوض 5 وليس 3، لأن العداد في PostgreSQL يستهلك رقماً حتى عندما تفشل الإضافة أو يتعارض المفتاح، والفجوات في أرقام القيود طبيعية ولا تعني ضياع عملية، فلا تبن أي منطق مالي على تتابع هذه الأرقام.

### الحجز والتحصيل والاسترداد

```sql
-- order #5531 for 40.00: reserve the money (authorisation hold)
SELECT transfer('hold-5531',    'wallet:1001', 'holds:1001',  4000, 'hold',    'order-5531');
-- the restaurant had one item missing: capture 35.00 only, release the other 5.00
SELECT transfer('capture-5531', 'holds:1001',  'merchant:77', 3500, 'capture', 'order-5531');
SELECT transfer('release-5531', 'holds:1001',  'wallet:1001',  500, 'release', 'order-5531');
-- next day: a 10.00 refund for a cold meal is a new entry, the capture stays as it was
SELECT transfer('refund-5531-1', 'merchant:77', 'wallet:1001', 1000, 'refund', 'order-5531');

SELECT a.code, round(b.balance / 100.0, 2) AS sar
  FROM balances b JOIN accounts a ON a.id = b.account_id
 WHERE a.code IN ('wallet:1001', 'holds:1001', 'merchant:77') ORDER BY a.id;
SELECT j.kind, round(SUM(p.amount) FILTER (WHERE p.amount > 0) / 100.0, 2) AS sar, j.ref
  FROM journal j JOIN postings p ON p.journal_id = j.id
 WHERE j.ref = 'order-5531' GROUP BY j.id ORDER BY j.id;
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/04-holds.sql
```

```text
 transfer 
----------
        7
(1 row)

 transfer 
----------
        8
(1 row)

 transfer 
----------
        9
(1 row)

 transfer 
----------
       10
(1 row)

    code     |  sar  
-------------+-------
 wallet:1001 | 50.00
 holds:1001  |  0.00
 merchant:77 | 25.00
(3 rows)

  kind   |  sar  |    ref     
---------+-------+------------
 hold    | 40.00 | order-5531
 capture | 35.00 | order-5531
 release |  5.00 | order-5531
 refund  | 10.00 | order-5531
(4 rows)
```

المحفظة كانت 75.00، حجز منها 40.00، ثم عاد 5.00، ثم استرد 10.00، فأصبحت 50.00، والمطعم حصل 35.00 ثم رد 10.00 فبقي له 25.00، وحساب الحجز رجع صفراً، والقيود الأربعة كلها مرتبطة بالمرجع order-5531.

### السباق: عشرة آلاف دفعة من عشرين اتصالاً

الآن نجهز محفظة بسيطة بعمود رصيد واحد فيها 10,000.00 ريال، وندفع منها ريالاً واحداً عشرة آلاف مرة من عشرين اتصالاً متزامناً بالأداة [pgbench](https://www.postgresql.org/docs/current/pgbench.html?ref=arabroot.io)، والنتيجة الصحيحة هي صفر بالضبط:

```sql
CREATE TABLE wallet_simple (id int PRIMARY KEY, balance bigint NOT NULL);
INSERT INTO wallet_simple VALUES (1, 1000000);   -- 10,000.00 SAR, to be spent 1.00 SAR at a time
```

الطريقة البسيطة في race-naive.sql: نقرأ الرصيد إلى متغير بالأمر gset، ثم نكتب القيمة المحسوبة:

```sql
-- read, compute in the application, write back
SELECT balance AS bal FROM wallet_simple WHERE id = 1 \gset
UPDATE wallet_simple SET balance = :bal - 100 WHERE id = 1;
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/05-race-setup.sql
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-naive.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"
```

```text
number of transactions actually processed: 10000/10000
number of failed transactions: 0 (0.000%)
tps = 373.571128 (without initial connection time)
950000
```

نجحت العشرة آلاف عملية دون أي خطأ، ومع ذلك بقي في المحفظة 950000 هللة، أي 9,500 ريال، فقد ضاعت 9,500 دفعة من 10,000 لأن كل طلب كتب فوق طلب آخر قرأ الرصيد نفسه. ولاحظ أن pgbench لم يبلغ عن أي فشل، وهذه أخطر صفة لهذا الخطأ: لا يظهر في السجلات ولا في المراقبة، وإنما في المطابقة بعد أيام.

والآن الطريقة نفسها مع قفل السطر في race-for-update.sql:

```sql
BEGIN;
SELECT balance AS bal FROM wallet_simple WHERE id = 1 FOR UPDATE \gset
UPDATE wallet_simple SET balance = :bal - 100 WHERE id = 1;
COMMIT;
```

```bash
docker exec lab-case-ledger psql -U postgres -qc "UPDATE wallet_simple SET balance = 1000000 WHERE id = 1"
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-for-update.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"
```

```text
number of transactions actually processed: 10000/10000
number of failed transactions: 0 (0.000%)
tps = 558.236580 (without initial connection time)
0
```

الرصيد صفر بالضبط، والسبب أن كل طلب انتظر الذي قبله على السطر المقفل. ثم نجرب المستوى التسلسلي دون قفل في race-serializable.sql، مرة دون إعادة ومرة مع إعادة حتى 200 مرة بالخيار max-tries:

```sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance AS bal FROM wallet_simple WHERE id = 1 \gset
UPDATE wallet_simple SET balance = :bal - 100 WHERE id = 1;
COMMIT;
```

```bash
docker exec lab-case-ledger psql -U postgres -qc "UPDATE wallet_simple SET balance = 1000000 WHERE id = 1"
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-serializable.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"
```

```text
number of transactions actually processed: 730/10000
number of failed transactions: 9270 (92.700%)
tps = 398.468571 (without initial connection time)
927000
```

```bash
docker exec lab-case-ledger psql -U postgres -qc "UPDATE wallet_simple SET balance = 1000000 WHERE id = 1"
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 --max-tries=200 -f /lab/race-serializable.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"
```

```text
number of transactions actually processed: 9996/10000
number of failed transactions: 4 (0.040%)
number of transactions retried: 7039 (70.390%)
total number of retries: 127888
tps = 328.683894 (without initial connection time)
400
```

في المخرجين أعلاه لاحظ التالي:

- دون إعادة رفضت قاعدة البيانات 9,270 معاملة بخطأ التسلسل، ونجحت 730 فقط، والرصيد 927000 أي 10,000 ناقص 730 ريالاً بالضبط، فلم يضع شيء، ولكن أغلب الدفعات فشلت.
- مع الإعادة نجحت 9,996 معاملة وفشلت 4 بعد 200 محاولة، والرصيد 400 هللة أي أربعة ريالات تساوي الأربع الفاشلة، فالنتيجة صحيحة مرة أخرى.
- ولكن ثمن ذلك 127,888 إعادة، أي قرابة 13 إعادة لكل دفعة، على حساب واحد يدفع منه الجميع، لذلك فالقفل الصريح أنسب للحساب الساخن، والمستوى التسلسلي أنسب للمنطق المعقد الذي يندر فيه التعارض.

### السباق نفسه على دالة الدفتر

الآن نشحن محفظتين، الأولى بعشرة آلاف ريال والثانية بخمسين ريالاً، ونضيف دالة مساعدة للمختبر فقط تحول خطأ نقص الرصيد إلى قيمة نعدها، لأن pgbench يوقف الاتصال عند أي خطأ آخر غير التسلسل والجمود:

```sql
SELECT open_account('wallet:2001', 'liability');
SELECT open_account('wallet:2002', 'liability');
SELECT transfer('topup-pay_B7', 'psp:clearing', 'wallet:2001', 1000000, 'topup', 'pay_B7');   -- 10,000.00
SELECT transfer('topup-pay_C2', 'psp:clearing', 'wallet:2002',    5000, 'topup', 'pay_C2');   --     50.00
INSERT INTO wallet_simple VALUES (2, 5000);                                                    --     50.00
-- lab helper: pgbench stops a client on an error, so turn "insufficient funds" into a return value
CREATE FUNCTION try_transfer(p_key text, p_debit text, p_credit text, p_amount bigint) RETURNS text
LANGUAGE plpgsql AS $$
BEGIN
    PERFORM transfer(p_key, p_debit, p_credit, p_amount);
    RETURN 'ok';
EXCEPTION WHEN check_violation THEN
    RETURN 'insufficient';
END $$;
CREATE TABLE attempts (result text);
```

وهذه عشرة آلاف دفعة بريال من المحفظة الأولى إلى المطعم، كل منها بمفتاح عشوائي:

```sql
\set k random(1, 9000000000000000000)
SELECT transfer('pay-' || :k, 'wallet:2001', 'merchant:77', 100);
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/06-race-ledger-setup.sql -o /dev/null
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-ledger.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -c "SELECT a.code, b.balance FROM balances b JOIN accounts a ON a.id = b.account_id WHERE a.code = 'wallet:2001'"
```

```text
number of transactions actually processed: 10000/10000
number of failed transactions: 0 (0.000%)
tps = 507.353892 (without initial connection time)
    code     | balance 
-------------+---------
 wallet:2001 |       0
(1 row)
```

الرصيد صفر، وكل دفعة لها قيد وسطران، وسرعة 507 دفعات في الثانية على حساب واحد ساخن على هذا الجهاز قياس محلي يعطيك فكرة فقط، فكل دفعة هنا معاملة كاملة تكتب ثلاثة أسطر وتنتظر القفل وتنتظر حفظ السجل على القرص.

ثم السحب بأكثر من الرصيد: المحفظة فيها خمسون ريالاً، وعشرون اتصالاً يحاول كل منها عشر مرات أن يصرف عشرة ريالات. والطريقة البسيطة تفحص الرصيد أولاً ثم تخصم:

```sql
-- check the balance, then spend 10.00 if it is enough
SELECT balance AS bal FROM wallet_simple WHERE id = 2 \gset
\if :bal >= 1000
UPDATE wallet_simple SET balance = balance - 1000 WHERE id = 2;
\endif
```

```bash
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 10 -f /lab/race-overdraft-naive.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 2"
```

```text
number of transactions actually processed: 200/200
number of failed transactions: 0 (0.000%)
tps = 3748.196181 (without initial connection time)
-19000
```

الرصيد سالب 190 ريالاً، أي أن المحفظة صرفت 240 ريالاً وفيها 50، وهذه بالضبط ثغرة Starbucks التي ذكرناها، مع أن الخصم نفسه balance - 1000 ذري، فالخطأ في أن الفحص والخصم خطوتان. والآن الشيء نفسه بدالة الدفتر:

```sql
\set k random(1, 9000000000000000000)
INSERT INTO attempts SELECT try_transfer('od-' || :k, 'wallet:2002', 'merchant:77', 1000);
```

```bash
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 10 -f /lab/race-overdraft-ledger.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -c "SELECT result, count(*) FROM attempts GROUP BY result" -c "SELECT a.code, b.balance FROM balances b JOIN accounts a ON a.id = b.account_id WHERE a.code = 'wallet:2002'"
```

```text
number of transactions actually processed: 200/200
number of failed transactions: 0 (0.000%)
tps = 988.347384 (without initial connection time)
    result    | count 
--------------+-------
 ok           |     5
 insufficient |   195
(2 rows)

    code     | balance 
-------------+---------
 wallet:2002 |       0
(1 row)
```

خمس دفعات نجحت، أي خمسون ريالاً بالضبط، و195 رفضت لنقص الرصيد، والرصيد صفر وليس سالباً.

### المطابقة مع ملف مزود الدفع

نفترض أن مزود الدفع أرسل ملف التسوية التالي ليوم 2026-10-08، وفيه المبلغ والرسوم بالهللة:

```text
psp_ref,amount,fee,settled_on
pay_A1,10000,180,2026-10-08
pay_B7,1000000,1750,2026-10-08
pay_C2,4500,90,2026-10-08
pay_E4,3000,60,2026-10-08
```

والملف 08-recon.sql يضيف أولاً شحناً سجلناه ثم رفضه بنك العميل، ثم يحمل ملف التسوية، ويقارن، ويسوي المطابق فقط:

```sql
-- a top-up our app recorded, but the PSP later reversed (the customer's bank declined it)
SELECT transfer('topup-pay_D9', 'psp:clearing', 'wallet:1002', 2000, 'topup', 'pay_D9');

CREATE TABLE psp_report (psp_ref text PRIMARY KEY, amount bigint NOT NULL, fee bigint NOT NULL, settled_on date NOT NULL);
\copy psp_report FROM '/lab/psp-settlement-2026-10-08.csv' CSV HEADER

CREATE VIEW recon AS
WITH ours AS (
    SELECT j.ref, p.amount
      FROM journal j JOIN postings p ON p.journal_id = j.id JOIN accounts a ON a.id = p.account_id
     WHERE j.kind = 'topup' AND a.code = 'psp:clearing'
)
SELECT COALESCE(o.ref, r.psp_ref) AS ref, o.amount AS ledger, r.amount AS psp, r.fee,
       CASE WHEN o.ref IS NULL         THEN 'missing in ledger'
            WHEN r.psp_ref IS NULL     THEN 'missing at PSP'
            WHEN o.amount <> r.amount  THEN 'amount differs'
            ELSE 'matched' END AS status
  FROM ours o FULL JOIN psp_report r ON r.psp_ref = o.ref;

SELECT * FROM recon ORDER BY ref;

-- settle only what matched: the bank received gross minus fee
SELECT post('settle-2026-10-08', 'settlement', 'psp-2026-10-08',
       jsonb_build_array(
         jsonb_build_object('account', 'bank:main',    'amount',  SUM(psp - fee)),
         jsonb_build_object('account', 'fees:psp',     'amount',  SUM(fee)),
         jsonb_build_object('account', 'psp:clearing', 'amount', -SUM(psp))))
  FROM recon WHERE status = 'matched';

SELECT a.code, round(b.balance / 100.0, 2) AS sar
  FROM balances b JOIN accounts a ON a.id = b.account_id
 WHERE a.code IN ('psp:clearing', 'bank:main', 'fees:psp') ORDER BY a.id;
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/08-recon.sql
```

```text
 transfer 
----------
    10213
(1 row)

  ref   | ledger  |   psp   | fee  |      status       
--------+---------+---------+------+-------------------
 pay_A1 |   10000 |   10000 |  180 | matched
 pay_B7 | 1000000 | 1000000 | 1750 | matched
 pay_C2 |    5000 |    4500 |   90 | amount differs
 pay_D9 |    2000 |         |      | missing at PSP
 pay_E4 |         |    3000 |   60 | missing in ledger
(5 rows)

 post  
-------
 10214
(1 row)

     code     |   sar    
--------------+----------
 psp:clearing |    70.00
 bank:main    | 10080.70
 fees:psp     |    19.30
(3 rows)
```

في المخرج أعلاه لاحظ التالي:

- الربط FULL JOIN على مرجع المزود يظهر الحالات الأربع في استعلام واحد: مطابق، ومبلغ مختلف، وموجود عندنا فقط، وموجود عند المزود فقط.
- قيد التسوية أدخل في البنك صافي المطابق 10,080.70 ريال، وفي المصاريف رسوم المزود 19.30، وأخرج من psp:clearing الإجمالي 10,100.00.
- بقي في psp:clearing سبعون ريالاً، وهي بالضبط pay\_C2 بخمسين وpay\_D9 بعشرين، أي قائمة الحالات المفتوحة، أما pay\_E4 فشحن نجح عند المزود ولم يصلنا إشعاره، فيجب أن يضاف رصيده للعميل بعد التحقق.

### فحص الليلة

وأخيراً الاستعلامان اللذان يجب أن يعملا كل ليلة، وهما مهمة Integrity check في المعمارية الكاملة، والمتوقع منهما صفر وصفر سطور:

```sql
-- 1) the whole ledger sums to zero per currency (trial balance)
SELECT currency, SUM(amount) AS total, count(*) AS postings FROM postings GROUP BY currency;
-- 2) the cached balances equal the sum of the postings, for every account
SELECT a.code, b.balance AS cached,
       COALESCE(SUM(CASE WHEN a.type IN ('asset','expense') THEN p.amount ELSE -p.amount END), 0) AS from_postings
  FROM accounts a JOIN balances b ON b.account_id = a.id LEFT JOIN postings p ON p.account_id = a.id
 GROUP BY a.code, b.balance, a.id
HAVING b.balance <> COALESCE(SUM(CASE WHEN a.type IN ('asset','expense') THEN p.amount ELSE -p.amount END), 0)
 ORDER BY a.id;
```

```bash
docker exec lab-case-ledger psql -U postgres -q -f /lab/07-integrity.sql
```

```text
 currency | total | postings 
----------+-------+----------
 SAR      |     0 |    20031
(1 row)

 code | cached | from_postings 
------+--------+---------------
(0 rows)
```

بعد أكثر من عشرين ألف سطر من السباقات والتحويلات والتسوية ما زال الدفتر يساوي صفراً، ولا يوجد حساب واحد يختلف رصيده المحفوظ عن مجموع سطوره.

### حذف المختبر

```bash
docker rm -f lab-case-ledger
```

## أخطاء شائعة

- **المال في float:** استخدم أعداداً صحيحة بأصغر وحدة للعملة، أو numeric إذا احتجت كسوراً للفوائد والضرائب.
- **فحص الرصيد ثم الخصم في خطوتين:** ثغرة سحب بأكثر من الرصيد كما رأينا، فاجعل الفحص والخصم في قفل واحد أو جملة واحدة.
- **تعديل قيد قديم لتصحيحه:** يمحو الأثر، فصحح بقيد عكسي جديد.
- **مفتاح منع التكرار خارج المعاملة:** يعطيك خصماً مكرراً أو طلباً ضائعاً عند أول سقوط للسيرفر.
- **قفل الحسابات بترتيب عشوائي:** جمود Deadlock تحت الضغط، فاقفل بترتيب ثابت.
- **المستوى التسلسلي دون إعادة في الكود:** أغلب الدفعات تفشل على الحساب الساخن كما رأينا.
- **المطابقة على المبلغ وليس المرجع:** مبلغان متساويان ليسا عملية واحدة.
- **رقم البطاقة على سيرفرك دون حاجة:** يدخل كل النظام في نطاق PCI DSS.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- المحفظة دفتر قيود مزدوجة: كل حركة قيد مجموعه صفر، والرصيد مجموع السطور، ومحفظة العميل التزام عليك وليست مالك.
- الدفتر يقبل الإضافة فقط، والتصحيح بقيد جديد، والرصيد المحفوظ نسخة تتحقق منها كل ليلة.
- المبالغ أعداد صحيحة بالهللة مع رمز العملة.
- مفتاح منع التكرار عمود فريد في جدول القيود يحفظ في المعاملة نفسها، مع بصمة للطلب.
- في المختبر ضاعت 9,500 دفعة من 10,000 بالقراءة ثم الكتابة، وصرفت محفظة 240 ريالاً وفيها 50، بينما أعطت دالة الدفتر مع FOR UPDATE النتيجة الصحيحة في الحالتين.
- المطابقة اليومية مع مزود الدفع على المرجع، وما يبقى في الحساب الوسيط هو قائمة الحالات المفتوحة.
- تقديم المحافظ في المملكة يحتاج ترخيصاً من البنك المركزي السعودي بحسب نظام المدفوعات وخدماتها، ولائحته تطلب حماية أموال العملاء في حساب منفصل مع مطابقة يومية، وسجلات عشر سنوات.

[![](https://arabroot.io/content/images/2026/10/dispatch-engine-cover-cover.webp)دليلكيف يختار تطبيق التوصيل المندوب؟ محرك التوزيع Dispatchكيف تختار تطبيقات التوصيل المندوب لكل طلب، ولماذا يكون التوزيع على دفعات أفضل من إعطاء كل طلب أقرب مندوب.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D9%85%D8%AD%D8%B1%D9%83-%D8%AA%D9%88%D8%B2%D9%8A%D8%B9-%D8%A7%D9%84%D8%B7%D9%84%D8%A8%D8%A7%D8%AA-dispatch/)[![](https://arabroot.io/content/images/2026/10/live-location-cover-cover.webp)دليلتتبع مواقع المندوبين لحظياً: من الهاتف إلى الخريطةكيف تستقبل مواقع آلاف المندوبين كل بضع ثوان، وتحفظ آخر موقع لكل منهم، وتوصله إلى العميل الذي ينتظر طلبه.](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D9%85%D9%88%D8%A7%D9%82%D8%B9-%D8%A7%D9%84%D9%85%D9%86%D8%AF%D9%88%D8%A8%D9%8A%D9%86-%D9%84%D8%AD%D8%B8%D9%8A%D8%A7/)[![](https://arabroot.io/content/images/2026/10/sql-basics-cover-cover.webp)دليلتعلم SQL (1): ما هو SQL وكيف تكتب أول استعلامتتعلم SQL من الصفر: ما هي قاعدة البيانات العلائقية، وكيف تنشئ الجداول وتضيف البيانات وتكتب أول استعلام بأمان.](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/sql-advanced-cover-cover.webp)دليلتعلم SQL (3): CTE ودوال النوافذ والمعاملاتكيف تكتب استعلامات أوضح بجمل WITH ودوال النوافذ، وتحمي بياناتك بالمعاملات ومستويات العزل ومن حقن SQL.](https://arabroot.io/articles/%D8%AA%D8%B9%D9%84%D9%85-sql-3-cte-%D9%88%D8%AF%D9%88%D8%A7%D9%84-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D9%81%D8%B0-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%85%D9%84%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/floating-point-cover-cover.webp)دليلFloating Point من الداخل: كيف يخزن الحاسب الكسوركيف يحفظ معيار IEEE 754 الكسور في البتات، ولماذا تكبر الفجوة بين الأرقام وتضيع خانات المعرفات، ومتى تحتاج النوع العشري.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/)[![](https://arabroot.io/content/images/2026/10/audit-trail-cover-cover.webp)دليلAudit Trail: كيف تبني سجل تدقيق لا يقبل العبثكيف تصمم سجلاً يجيب عن سؤال من غير هذه الصلاحية ومتى، وتحميه من الحذف والتعديل بعد كتابته.](https://arabroot.io/articles/%D8%B3%D8%AC%D9%84-%D8%A7%D9%84%D8%AA%D8%AF%D9%82%D9%8A%D9%82-audit-trail-%D9%85%D9%86-%D9%81%D8%B9%D9%84-%D9%85%D8%A7%D8%B0%D8%A7-%D9%88%D9%85%D8%AA%D9%89/)[![](https://arabroot.io/content/images/2026/10/distributed-systems-cover-cover.webp)دليلالأنظمة الموزعة للمبتدئين: ماذا يحدث عندما تنقطع الشبكةلماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/)

## سجل التحديثات

- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.
- أكتوبر 2026: كتابة المقال، والتحقق من نظام المدفوعات وخدماتها ولائحته التنفيذية وأنواع التراخيص في دليل أنظمة البنك المركزي السعودي، وصفحة PCI DSS، وتوثيق PostgreSQL، وتشغيل المختبر على PostgreSQL 18.6 مع pgbench.