إدارة الاشتراكات تبدو بسيطة حتى تواجه اشتراكًا متأخرًا في الدفع، وترقية خلال منتصف دورة الفوترة، وعميلًا يريد إلغاء الاشتراك لكن مع الاحتفاظ بالبيانات. هذا المقال يشرح بنية نظام اشتراكات 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/
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.



