Get a quote

بناء نظام فواتير ومدفوعات احترافي للشركات في لبنان والمنطقة

أدوات الفوترة الجاهزة مناسبة لسير العمل اليدوي. تصبح قيداً حين تحتاج فوترة مدمجة في منتج SaaS، دعماً للعملات المتعددة، امتثالاً لضريبة القيمة المضافة في الخليج، أو متابعة تلقائية للمتأخرات.

لماذا الأنظمة المالية تحتاج افتراضات هندسية مختلفة

افتراضان هندسيان مناسبان في أغلب الأنظمة يُسببان مشاكل حقيقية في أنظمة الفوترة:

الفاصلة العائمة: حساب الأموال بـ float يُدخل أخطاء تقريب في الخانة الرابعة بعد الفاصلة. في الحسابات المالية، هذا يتراكم. نظام SaaS يُعالج 10,000 فاتورة شهرياً سيُظهر فروقاً في الأرصدة تنمو مع الوقت. نوع NUMERIC في PostgreSQL يخزن القيم العشرية الدقيقة بدون خطأ تمثيل. كل عمود مالي يجب أن يكون NUMERIC(15,2).

الاتساق اللاحق في سير عمل المدفوعات يخلق خطر الشحن المزدوج. إذا نقر المستخدم على "ادفع" مرتين ووصل الطلبان قبل تأكيد الدفع الأول، تحتاج ضمانات idempotency على مستوى الفاتورة والدفعة.

مخطط قاعدة البيانات لنظام فوترة متعدد العملات

CREATE TABLE invoices (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    customer_id     UUID NOT NULL,
    invoice_number  TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'draft',
    currency        CHAR(3) NOT NULL DEFAULT 'USD',
    subtotal        NUMERIC(15, 2) NOT NULL DEFAULT 0,
    tax_amount      NUMERIC(15, 2) NOT NULL DEFAULT 0,
    total_amount    NUMERIC(15, 2) NOT NULL DEFAULT 0,
    due_date        DATE,
    paid_at         TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, invoice_number)
);

CREATE TABLE invoice_items (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id   UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
    description  TEXT NOT NULL,
    quantity     NUMERIC(10, 3) NOT NULL,
    unit_price   NUMERIC(15, 2) NOT NULL,
    tax_rate     NUMERIC(5, 4) NOT NULL DEFAULT 0,
    line_total   NUMERIC(15, 2) NOT NULL
);

قيد UNIQUE (tenant_id, invoice_number) يمنع تكرار أرقام الفواتير على مستوى قاعدة البيانات.

توليد أرقام الفواتير بطريقة ذرية

أرقام الفواتير يجب أن تكون متسلسلة، مُحدَّدة النطاق لكل مستأجر، ومُولَّدة بطريقة ذرية:

func (s *InvoiceService) nextInvoiceNumber(ctx context.Context, tx pgx.Tx, tenantID uuid.UUID) (string, error) {
    var seq int64
    err := tx.QueryRow(ctx, `
        INSERT INTO invoice_sequences (tenant_id, last_seq)
        VALUES ($1, 1)
        ON CONFLICT (tenant_id) DO UPDATE
            SET last_seq = invoice_sequences.last_seq + 1
        RETURNING last_seq
    `, tenantID).Scan(&seq)
    if err != nil {
        return "", fmt.Errorf("invoice sequence: %w", err)
    }
    return fmt.Sprintf("INV-%d-%06d", time.Now().Year(), seq), nil
}

هذا يعمل داخل نفس المعاملة التي تُنشئ الفاتورة. إذا فشلت العملية، ينتكس تحديث التسلسل معها. الثغرات في التسلسل مقبولة. تكرار أرقام الفواتير غير مقبول.

احتساب الضريبة لولايات قضائية متعددة

معدلات VAT في دول الخليج متباينة: 15% في السعودية، 5% في الإمارات والبحرين، لا ضريبة في لبنان. الشركات اللبنانية التي تُفوتر عملاء خليجيين تحتاج المعدل الصحيح بناءً على الولاية القضائية للعميل:

CREATE TABLE tax_rules (
    country_code  CHAR(2) NOT NULL,
    tax_name      TEXT NOT NULL,
    rate          NUMERIC(5, 4) NOT NULL,
    effective_from DATE NOT NULL,
    UNIQUE (country_code, effective_from)
);

INSERT INTO tax_rules (country_code, tax_name, rate, effective_from) VALUES
    ('SA', 'VAT', 0.1500, '2020-07-01'),
    ('AE', 'VAT', 0.0500, '2018-01-01'),
    ('LB', 'No Tax', 0.0000, '2000-01-01');

عمود effective_from يعني أن الفواتير التاريخية تعكس المعدل الذي كان سارياً وقت إصدارها. المعدلات تتغير؛ مراجعة الفواتير التاريخية تتطلب معرفة المعدل المطبق وقت الإصدار.

متابعة الفواتير المتأخرة تلقائياً

المتابعة اليدوية للفواتير غير المدفوعة هي عنق الزجاجة الأكثر شيوعاً في فوترة الشركات SaaS في المنطقة. مهمة خلفية يومية ترسل تذكيرات متدرجة:

var overduePipeline = []OverdueStage{
    {MinDays: 1,  MaxDays: 6,    Tone: "gentle"},
    {MinDays: 7,  MaxDays: 29,   Tone: "firm"},
    {MinDays: 30, MaxDays: 9999, Tone: "final"},
}

func (s *InvoiceService) ProcessOverdueNotifications(ctx context.Context) error {
    for _, stage := range overduePipeline {
        invoices, err := s.db.GetInvoicesOverdueByDayRange(ctx, stage.MinDays, stage.MaxDays)
        if err != nil {
            return err
        }
        for _, inv := range invoices {
            alreadySent, _ := s.db.WasOverdueNotificationSentToday(ctx, inv.ID, stage.Tone)
            if alreadySent {
                continue
            }
            s.notifications.SendOverdueReminder(ctx, inv, stage.Tone)
            s.db.RecordOverdueNotification(ctx, inv.ID, stage.Tone, time.Now())
        }
    }
    return nil
}

تحقق الـ idempotency يمنع إرسال تذكيرات متعددة في نفس اليوم إذا عملت المهمة أكثر من مرة.

الدروس الأساسية من الإنتاج

استخدم NUMERIC لجميع الأعمدة المالية من البداية. تحويل أعمدة FLOAT إلى NUMERIC بعد وجود بيانات إنتاج يتطلب ترحيلاً ومراجعة فروق التقريب.

ولّد أرقام الفواتير داخل نفس المعاملة التي تُنشئ الفاتورة. لا تولّد الرقم قبل بدء المعاملة.

خزّن معدل الضريبة على بند الفاتورة وقت الإصدار. المعدلات تتغير؛ الفواتير التاريخية يجب أن تعكس المعدل الذي كان سارياً عند الإصدار.


هل تحتاج نظام فوترة احترافياً مدمجاً في منتجك أو بنية مدفوعات من الصفر؟ تواصل مع فريق Voxire على https://voxire.com/get-a-quote/

العودة إلى المدونة
Chat on WhatsApp