Get a quote

إدارة الاشتراكات والعضويات في SaaS بـ Go: البنية والتحديات

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

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

هذا المقال يشرح بنية نظام اشتراكات SaaS ناضج بـ Go يتعامل مع دورة حياة الاشتراك الكاملة، من الإنشاء حتى الإلغاء، مع منطق إعادة المحاولة وآلية dunning للحسابات المتأخرة.

ما هي حالات دورة الحياة التي يجب أن يتعامل معها النظام؟

اشتراك SaaS يمر بالحالات التالية:

  • trialing: فترة تجريبية، الوصول ممنوح بدون دفع
  • active: اشتراك نشط، الدفع ناجح
  • past_due: تأخر في الدفع، لا يزال يملك الوصول خلال فترة السماح
  • suspended: تعليق الوصول بعد استنفاد فترة السماح
  • cancelled: إلغاء من قبل المستخدم، الوصول حتى نهاية الفترة المدفوعة
  • expired: انتهاء الفترة المدفوعة بعد الإلغاء

كيف تصمم قاعدة البيانات؟

CREATE TABLE subscription_plans (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    price_usd   NUMERIC(10,2) NOT NULL,
    billing_cycle TEXT NOT NULL DEFAULT 'monthly',
    max_seats   INT,
    features    JSONB NOT NULL DEFAULT '[]'
);

CREATE TABLE subscriptions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL UNIQUE,
    plan_id         UUID NOT NULL REFERENCES subscription_plans(id),
    status          TEXT NOT NULL DEFAULT 'trialing',
    current_period_start TIMESTAMPTZ NOT NULL,
    current_period_end   TIMESTAMPTZ NOT NULL,
    trial_end       TIMESTAMPTZ,
    cancelled_at    TIMESTAMPTZ,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE billing_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    subscription_id UUID NOT NULL REFERENCES subscriptions(id),
    event_type      TEXT NOT NULL,
    amount_usd      NUMERIC(10,2),
    status          TEXT NOT NULL,
    attempt_count   INT NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ,
    external_charge_id TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

كيف يعمل منطق التجديد التلقائي؟

وظيفة التجديد تعمل كمهمة مجدولة يومية تتحقق من جميع الاشتراكات التي يحين موعد تجديدها:

func (s *SubscriptionService) RunRenewalJob(ctx context.Context) error {
    subs, err := s.db.ListDueForRenewal(ctx, time.Now())
    if err != nil {
        return err
    }

    for _, sub := range subs {
        if err := s.processRenewal(ctx, sub); err != nil {
            // سجّل الخطأ لكن استمر في معالجة الاشتراكات الأخرى
            s.logger.Error("renewal failed",
                "subscription_id", sub.ID,
                "error", err,
            )
        }
    }
    return nil
}

func (s *SubscriptionService) processRenewal(ctx context.Context, sub Subscription) error {
    charge, err := s.billing.ChargeCustomer(ctx, ChargeRequest{
        TenantID:    sub.TenantID,
        AmountUSD:   sub.Plan.PriceUSD,
        Description: fmt.Sprintf("Subscription renewal - %s", sub.Plan.Name),
    })

    if err != nil {
        return s.handleFailedPayment(ctx, sub, err)
    }

    return s.db.UpdateSubscriptionPeriod(ctx, sub.ID, UpdatePeriodParams{
        Status:             "active",
        CurrentPeriodStart: sub.CurrentPeriodEnd,
        CurrentPeriodEnd:   sub.CurrentPeriodEnd.AddDate(0, 1, 0),
    })
}

كيف تتعامل مع فشل الدفع وآلية dunning؟

Dunning هو مصطلح يصف عملية إعادة المحاولة المنظمة عند فشل الدفع. النمط القياسي هو:

  • اليوم 0: فشل الدفع، الاشتراك ينتقل إلى past_due، إشعار أول للعميل
  • اليوم 3: إعادة محاولة تلقائية، إشعار ثانٍ
  • اليوم 7: إعادة محاولة ثانية، إشعار ثالث بتحذير التعليق
  • اليوم 14: تعليق الوصول، suspended، إشعار رابع بتعليق الحساب
  • اليوم 30: إلغاء نهائي، cancelled
func (s *SubscriptionService) handleFailedPayment(
    ctx context.Context,
    sub Subscription,
    paymentErr error,
) error {
    event, err := s.db.GetLatestBillingEvent(ctx, sub.ID)
    if err != nil {
        return err
    }

    nextAttemptDays := []int{3, 7, 14}
    if event.AttemptCount >= len(nextAttemptDays) {
        // استنفدنا جميع المحاولات، علّق الاشتراك
        return s.db.UpdateSubscriptionStatus(ctx, sub.ID, "suspended")
    }

    daysUntilNext := nextAttemptDays[event.AttemptCount]
    nextAttempt := time.Now().AddDate(0, 0, daysUntilNext)

    if err := s.db.UpdateBillingEvent(ctx, event.ID, UpdateBillingEventParams{
        Status:         "failed",
        AttemptCount:   event.AttemptCount + 1,
        NextAttemptAt:  &nextAttempt,
    }); err != nil {
        return err
    }

    // جدول إشعار التذكير
    return s.notifications.SchedulePaymentReminder(ctx, sub.TenantID, nextAttempt)
}

كيف تتعامل مع الترقية خلال منتصف دورة الفوترة؟

هذا أكثر جوانب إدارة الاشتراكات تعقيدًا. عندما يرقّي العميل من خطة $29 إلى خطة $99 في منتصف الشهر، لديك خياران:

Proration فوري: احسب القيمة المتبقية من الخطة القديمة، واحسب تكلفة الخطة الجديدة للفترة المتبقية، وأصدر فاتورة بالفرق فوراً.

تأجيل إلى التجديد القادم: الترقية تسري فوراً لكن الفوترة تتغير فقط في دورة التجديد التالية.

نوصي بالخيار الثاني لمعظم SaaS في المنطقة لأنه أبسط في التنفيذ وأوضح للعميل:

func (s *SubscriptionService) UpgradePlan(
    ctx context.Context,
    tenantID uuid.UUID,
    newPlanID uuid.UUID,
) error {
    sub, err := s.db.GetByTenantID(ctx, tenantID)
    if err != nil {
        return err
    }

    newPlan, err := s.db.GetPlan(ctx, newPlanID)
    if err != nil {
        return err
    }

    // الخطة الجديدة تسري فوراً، الفوترة تتغير في التجديد القادم
    return s.db.UpdateSubscription(ctx, sub.ID, UpdateSubscriptionParams{
        PlanID:               newPlanID,
        ScheduledPlanChange:  nil, // تطبيق فوري
    })
}

كيف تتحكم في الوصول بناءً على حالة الاشتراك؟

middleware للتحقق من حالة الاشتراك في كل طلب:

func (s *SubscriptionMiddleware) RequireActiveSubscription(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := r.Context().Value("tenant_id").(uuid.UUID)

        sub, err := s.db.GetByTenantID(r.Context(), tenantID)
        if err != nil || sub == nil {
            http.Error(w, "subscription not found", http.StatusPaymentRequired)
            return
        }

        switch sub.Status {
        case "active", "trialing":
            next.ServeHTTP(w, r)
        case "past_due":
            // منح الوصول خلال فترة السماح لكن أضف رأس تحذير
            w.Header().Set("X-Subscription-Warning", "payment_past_due")
            next.ServeHTTP(w, r)
        case "cancelled":
            if time.Now().Before(sub.CurrentPeriodEnd) {
                next.ServeHTTP(w, r)
                return
            }
            http.Error(w, "subscription cancelled", http.StatusPaymentRequired)
        default:
            http.Error(w, "subscription suspended", http.StatusPaymentRequired)
        }
    })
}

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

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

هل تحتاج إلى مساعدة في بناء SaaS؟

فوكسير تصمم وتبني منصات SaaS كاملة للشركات في لبنان ومنطقة الشرق الأوسط وشمال إفريقيا، من بنية قاعدة البيانات وحتى نظام الفوترة والاشتراكات. تواصل معنا.

https://voxire.com/get-a-quote/

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Back to blog
Chat on WhatsApp