عمود رصيد واحد يفشل تحت الاستخدام المتزامن ويجعل التدقيق مستحيلاً. بناء نظام محفظة قائم على السجل يتطلب عمليات ذرية وقفل SELECT FOR UPDATE ومفاتيح ضمان عدم التكرار.
كثير من منتجات SaaS في لبنان والمنطقة العربية تستخدم محافظ رصيد مسبق الدفع. يدفع العميل مبلغاً مقدماً، يُخزَّن كرصيد في حسابه، وكل خدمة يستهلكها تُخصم منه. التنفيذ الساذج يُخزن الرصيد في عمود واحد. هذا التنفيذ يفشل تحت الاستخدام المتزامن، يُنتج أرصدة سالبة، ويجعل التدقيق مستحيلاً.
لماذا عمود الرصيد يفشل في الإنتاج؟
طلبان متزامنان يخصمان من نفس المحفظة يُنشئان حالة تنافس. كلاهما يقرأ نفس الرصيد. كلاهما يحسب خصماً من نفس القيمة الابتدائية. خصم واحد يضيع بصمت. عند انخفاض التزامن يظهر هذا كأرصدة سالبة متقطعة يصعب إعادة إنتاجها.
المشكلة الأعمق هي إمكانية التدقيق. عندما يتصل عميل يسأل لماذا تغير رصيده، عمود واحد لا يعطيك تاريخاً. لديك رقم فحسب.
تصميم بنية جدول السجل
السجل يُخزّن كل معاملة كسجل غير قابل للتغيير يُضاف فقط:
CREATE TABLE wallet_ledger (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
customer_id bigint NOT NULL,
amount decimal(12, 2) NOT NULL, -- موجب للإيداع، سالب للخصم
balance_after decimal(12, 2) NOT NULL, -- لقطة الرصيد المتراكم
type text NOT NULL, -- 'top_up', 'service_charge', 'refund'
reference_id text,
idempotency_key text UNIQUE,
description text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_ledger_customer ON wallet_ledger (tenant_id, customer_id, created_at DESC);
عمود balance_after يُخزّن الرصيد المتراكم في وقت كل معاملة. الحصول على الرصيد الحالي هو بحث مفهرس واحد عن آخر إدخال، ليس SUM على كل التاريخ.
عمليات الخصم والإيداع الذرية في PostgreSQL و Go
كل عملية على المحفظة يجب أن تكون ذرية. خصم من محفظة وإنشاء طلب يجب أن ينجحا معاً أو يفشلا معاً. التنفيذ الصحيح يستخدم معاملة PostgreSQL مع قفل SELECT FOR UPDATE:
func (r *WalletRepository) Debit(ctx context.Context, req DebitRequest) error {
return r.withTx(ctx, func(tx *sql.Tx) error {
var currentBalance decimal.Decimal
err := tx.QueryRowContext(ctx, `
SELECT balance_after FROM wallet_ledger
WHERE tenant_id = $1 AND customer_id = $2
ORDER BY id DESC LIMIT 1
FOR UPDATE
`, req.TenantID, req.CustomerID).Scan(¤tBalance)
if err == sql.ErrNoRows {
currentBalance = decimal.Zero
} else if err != nil {
return fmt.Errorf("قفل الرصيد: %w", err)
}
if currentBalance.LessThan(req.Amount) {
return ErrInsufficientBalance
}
newBalance := currentBalance.Sub(req.Amount)
_, err = tx.ExecContext(ctx, `
INSERT INTO wallet_ledger
(tenant_id, customer_id, amount, balance_after, type,
reference_id, idempotency_key, description)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, req.TenantID, req.CustomerID, req.Amount.Neg(), newBalance,
"service_charge", req.ReferenceID, req.IdempotencyKey, req.Description)
return err
})
}
SELECT FOR UPDATE على آخر صف في السجل يُسلسل محاولات الخصم المتزامنة. الخصم المتزامن الثاني سيُحجب حتى تنتهي المعاملة الأولى، ثم يقرأ قيمة balance_after المحدّثة.
ضمان عدم تكرار العمليات على المحفظة
أنظمة الدفع ومنطق إعادة المحاولة يتطلبان إمكانية إرسال نفس العملية عدة مرات دون خصم مزدوج. عمود idempotency_key يتعامل مع هذا:
var existing WalletTransaction
err := tx.QueryRowContext(ctx, `
SELECT id, amount, balance_after, created_at
FROM wallet_ledger WHERE idempotency_key = $1
`, req.IdempotencyKey).Scan(&existing.ID, &existing.Amount,
&existing.BalanceAfter, &existing.CreatedAt)
if err == nil {
return &existing, nil // تمت المعالجة سابقاً، أعد النتيجة الموجودة
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("فحص ضمان عدم التكرار: %w", err)
}
// لم يُجد: المضي في الإدراج
اعتبارات تشغيلية خاصة بمنطقة MENA
عمليات الإيداع النقدي شائعة. كثير من العملاء في لبنان لا يمتلكون بطاقات ائتمان أو يفضلون عدم استخدامها. نظام المحفظة يحتاج دعم الإيداع النقدي عبر وكلاء، يُتحقق منه يدوياً من قِبل فريق العمليات، ويُضاف كإدخال تعديل.
انتهاء صلاحية الرصيد متوقع. كثير من منتجات SaaS اللبنانية تُصدر رصيداً ترويجياً بتاريخ انتهاء. جدول السجل يحتاج عمود expiry_at على إدخالات الإيداع ومهمة خلفية تُنهي الرصيد غير المستخدم بإدراج إدخال خصم مقابل.
تعدد العملات شائع. المنتجات التي تخدم لبنان ودول الخليج تتعامل مع USD وLBP وعملات خليجية. احتفظ بإدخالات سجل منفصلة لكل عملة بدلاً من التحويل وقت المعاملة.
الخلاصة
عمود رصيد واحد هو خطأ تزامن وفجوة تدقيق في انتظار الحدوث. جدول السجل مع تسلسل SELECT FOR UPDATE يُعالج الخصومات المتزامنة بشكل صحيح. مفاتيح ضمان عدم التكرار تمنع الخصم المزدوج. لقطة balance_after المتراكمة تجعل قراءة الرصيد الحالي بحثاً واحداً بدلاً من تجميع كامل الجدول.



