Stripe Billing Implementation: Subscriptions, Invoices, and Webhooks

Complete Stripe billing implementation: subscription checkout, invoice management, webhook event handling, refunds, failed payment recovery, and NestJS integration patterns.

E
ECOSIRE Research and Development Team
|19 مارس 202610 دقائق قراءة2.1k كلمات|

تنفيذ الفواتير الشريطية: الاشتراكات والفواتير وخطافات الويب

يعد Stripe هو المعيار الفعلي للبنية الأساسية للفوترة، ولكن تنفيذه بشكل صحيح - التعامل مع خطافات الويب بشكل غير فعال، وإدارة انتقالات حالة الاشتراك، والتعافي من المدفوعات الفاشلة - يتطلب عناية أكبر بكثير مما تقترحه وثائق البدء. إن نظام الدفع الذي يعمل بنسبة 99% من الوقت ويفشل بصمت في الـ 1% الأخرى هو أسوأ من النظام الذي يفشل بصوت عالٍ بنسبة 95%.

يغطي هذا الدليل تنفيذ شريط الإنتاج مع 6 أنواع من أحداث خطاف الويب، وإدارة دورة حياة الاشتراك، وتوفير الترخيص التلقائي، ومعالجة استرداد الأموال. تستخدم أمثلة التعليمات البرمجية NestJS 11، لكن الأنماط تنطبق على أي إطار عمل خلفي.

الوجبات الرئيسية

  • لا تقم مطلقًا بتوفير الوصول بناءً على إعادة توجيه الدفع - انتظر دائمًا خطاف الويب checkout.session.completed
  • تحقق من توقيعات webhook باستخدام stripe.webhooks.constructEvent() قبل معالجة أي حدث
  • جعل جميع معالجات خطافات الويب غير فعالة - يقوم الشريط بإعادة محاولة خطافات الويب الفاشلة لمدة تصل إلى 72 ساعة
  • قم بتخزين معرفات عملاء Stripe ومعرفات الاشتراك ومعرفات الأسعار في قاعدة البيانات الخاصة بك - لا تعيد الاستعلام عن Stripe لكل شيء
  • استخدم invoice.payment_failed لتشغيل رسائل البريد الإلكتروني وتعليق الاشتراك
  • يغطي charge.refunded وcheckout.session.completed معًا دورة حياة الشراء الكاملة
  • استخدم حقل Stripe metadata لربط كائنات Stripe بسجلاتك الداخلية
  • لا تقم مطلقًا بتسجيل حمولات خطاف الويب Stripe الأولية - فهي تحتوي على بيانات بطاقة حساسة

إعداد المشروع

pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

قم بتخزين متغيرات البيئة هذه:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...

إنشاء جلسة الخروج

تدفق الدفع: تقوم الواجهة الخلفية لديك بإنشاء جلسة Stripe Checkout، وتعيد الواجهة الأمامية التوجيه إلى Stripe، ويقوم Stripe بجمع الدفعات، ثم يتصل بخطاف الويب الخاص بك. لا تتعامل أبدًا مع الدفع بنفسك - قم دائمًا بإعادة التوجيه إلى Stripe Checkout.

// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';

@Injectable()
export class BillingService {
  async createCheckoutSession(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    // Get or create Stripe customer
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'payment', // or 'subscription' for recurring
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/services`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
      allow_promotion_codes: true,
      invoice_creation: {
        enabled: true,
      },
    });

    return { url: session.url };
  }

  async createSubscriptionCheckout(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [{ price: priceId, quantity: 1 }],
      mode: 'subscription',
      subscription_data: {
        metadata: {
          userId,
          organizationId: orgId,
          productId,
        },
        trial_period_days: 14,
      },
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/pricing`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
    });

    return { url: session.url };
  }

  private async getOrCreateCustomer(userId: string, orgId: string) {
    // Check if customer already exists in your DB
    const existing = await db.query.billingCustomers.findFirst({
      where: and(
        eq(billingCustomers.userId, userId),
        eq(billingCustomers.organizationId, orgId)
      ),
    });

    if (existing) return existing;

    // Fetch user details for Stripe customer creation
    const user = await db.query.users.findFirst({
      where: eq(users.id, userId),
    });

    const stripeCustomer = await stripe.customers.create({
      email: user.email,
      name: user.name,
      metadata: {
        userId,
        organizationId: orgId,
      },
    });

    const [customer] = await db
      .insert(billingCustomers)
      .values({
        userId,
        organizationId: orgId,
        stripeCustomerId: stripeCustomer.id,
      })
      .returning();

    return customer;
  }
}

معالج الويب هوك

يعد معالج webhook الجزء الأكثر أهمية في تكامل Stripe. يجب أن يكون كل حدث عاجزًا:

// billing/webhook.controller.ts
import {
  Controller,
  Post,
  Req,
  Headers,
  RawBodyRequest,
  HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';

@Controller('billing/webhook')
export class WebhookController {
  constructor(private webhookService: BillingWebhookService) {}

  @Post()
  @Public()
  @HttpCode(200) // Always return 200 — Stripe retries on non-2xx
  async handleWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') signature: string
  ) {
    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        req.rawBody!, // Requires raw body middleware
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (err) {
      // Invalid signature — do not process
      throw new Error(`Webhook signature verification failed: ${err.message}`);
    }

    // Route to specific handler
    switch (event.type) {
      case 'checkout.session.completed':
        await this.webhookService.handleCheckoutCompleted(
          event.data.object as Stripe.Checkout.Session
        );
        break;

      case 'invoice.paid':
        await this.webhookService.handleInvoicePaid(
          event.data.object as Stripe.Invoice
        );
        break;

      case 'invoice.payment_failed':
        await this.webhookService.handlePaymentFailed(
          event.data.object as Stripe.Invoice
        );
        break;

      case 'customer.subscription.deleted':
        await this.webhookService.handleSubscriptionDeleted(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'customer.subscription.updated':
        await this.webhookService.handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'charge.refunded':
        await this.webhookService.handleChargeRefunded(
          event.data.object as Stripe.Charge
        );
        break;

      default:
        // Ignore unhandled event types
        break;
    }

    return { received: true };
  }
}

مهم: يتطلب NestJS rawBody: true في NestFactory.create() للتحقق من التوقيع الشريطي:

// main.ts
const app = await NestFactory.create(AppModule, {
  rawBody: true,
});

معالج إتمام عملية الدفع

هذا هو معالج التوفير الأساسي. اجعله عاجزًا:

// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
  async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
    // Check if already processed (idempotency)
    const existing = await db.query.orders.findFirst({
      where: eq(orders.stripeSessionId, session.id),
    });

    if (existing) {
      return; // Already processed — Stripe is retrying
    }

    const { userId, organizationId, productId } = session.metadata!;

    // Create order in transaction
    await db.transaction(async (tx) => {
      // Create order
      const [order] = await tx
        .insert(orders)
        .values({
          userId,
          organizationId,
          productId,
          stripeSessionId: session.id,
          stripePaymentIntentId: session.payment_intent as string,
          amount: session.amount_total! / 100, // Stripe uses cents
          currency: session.currency!,
          status: 'completed',
        })
        .returning();

      // Generate license key
      const licenseKey = generateLicenseKey(productId);

      // Create license
      await tx.insert(licenses).values({
        orderId: order.id,
        userId,
        organizationId,
        productId,
        licenseKey,
        status: 'active',
        issuedAt: new Date(),
      });
    });

    // Send confirmation email (non-blocking — don't fail the webhook)
    this.emailService
      .sendPurchaseConfirmation(userId, productId)
      .catch((err) => this.logger.error('Email send failed', err));
  }

  async handleInvoicePaid(invoice: Stripe.Invoice) {
    // For subscriptions — renewal
    if (!invoice.subscription) return;

    // Find existing subscription/license and extend it
    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    await db
      .update(licenses)
      .set({
        status: 'active',
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
      })
      .where(eq(licenses.id, license.id));

    // Log renewal in audit table
    await db.insert(licenseAuditLogs).values({
      licenseId: license.id,
      action: 'renewed',
      details: { invoiceId: invoice.id },
    });
  }

  async handlePaymentFailed(invoice: Stripe.Invoice) {
    if (!invoice.subscription) return;

    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    // Grace period before suspension (e.g., 3 days)
    const gracePeriod = new Date();
    gracePeriod.setDate(gracePeriod.getDate() + 3);

    if (new Date() > gracePeriod) {
      await db
        .update(licenses)
        .set({ status: 'suspended' })
        .where(eq(licenses.id, license.id));
    }

    // Send dunning email
    this.emailService
      .sendPaymentFailedNotification(license.userId, invoice)
      .catch(console.error);
  }

  async handleChargeRefunded(charge: Stripe.Charge) {
    const order = await db.query.orders.findFirst({
      where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
    });

    if (!order) return;

    await db.transaction(async (tx) => {
      // Update order status
      await tx
        .update(orders)
        .set({ status: 'refunded' })
        .where(eq(orders.id, order.id));

      // Revoke associated licenses
      await tx
        .update(licenses)
        .set({ status: 'revoked', revokedAt: new Date() })
        .where(eq(licenses.orderId, order.id));
    });

    // Log audit trail
    await db.insert(licenseAuditLogs).values({
      licenseId: (await db.query.licenses.findFirst({
        where: eq(licenses.orderId, order.id),
      }))!.id,
      action: 'revoked',
      details: { reason: 'refund', chargeId: charge.id },
    });
  }

  async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
    await db
      .update(licenses)
      .set({ status: 'cancelled', cancelledAt: new Date() })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }

  async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
    const status = subscription.status === 'active' ? 'active' : 'suspended';

    await db
      .update(licenses)
      .set({ status })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }
}

بوابة العملاء

السماح للعملاء بإدارة اشتراكاتهم دون الاتصال بالدعم:

async createPortalSession(userId: string, orgId: string) {
  const customer = await db.query.billingCustomers.findFirst({
    where: and(
      eq(billingCustomers.userId, userId),
      eq(billingCustomers.organizationId, orgId)
    ),
  });

  if (!customer) {
    throw new NotFoundException('No billing account found');
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
  });

  return { url: portalSession.url };
}

تتيح بوابة العملاء للمستخدمين إلغاء الاشتراكات وتحديث طرق الدفع وتنزيل الفواتير وعرض سجل الفواتير - دون أي عمل مخصص لواجهة المستخدم.


تكامل الواجهة الأمامية

// web: lib/billing.ts
'use client';

import { apiFetch } from '@/lib/api';

export async function initiateCheckout(priceId: string, productId: string) {
  const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
    method: 'POST',
    body: JSON.stringify({ priceId, productId }),
  });

  // Redirect to Stripe Checkout
  window.location.href = url;
}

export async function openCustomerPortal() {
  const { url } = await apiFetch<{ url: string }>('/billing/portal', {
    method: 'POST',
  });

  window.open(url, '_blank');
}

اختبار خطافات الويب محليًا

يعد Stripe CLI ضروريًا لتطوير خطاف الويب المحلي:

# Install Stripe CLI
npm install -g stripe

# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted

# View event logs
stripe events list

توفر واجهة سطر الأوامر (CLI) سر خطاف الويب المحلي الخاص بك - استخدمه في .env.local الخاص بك:

STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output

المخاطر والحلول الشائعة

المأزق 1: توفير الوصول عند إعادة توجيه الدفع بدلاً من الرد على الويب

يمكن تشغيل إعادة توجيه نجاح الخروج من خلال: رجوع/إعادة توجيه المتصفح، أو علامات التبويب المكررة، أو إعادة محاولة الشبكة. يتم توفير الخدمة دائمًا استنادًا إلى خطاف الويب checkout.session.completed، وليس عنوان URL لإعادة التوجيه.

المأزق 2: عدم التعامل مع إعادة محاولة خطاف الويب (العجز)

فشلت محاولات إعادة المحاولة للخطافات عبر الويب لمدة 72 ساعة. بدون عمليات التحقق من العجز، تؤدي عمليات إعادة المحاولة إلى إنشاء أوامر وتراخيص ورسائل بريد إلكتروني مكررة. قم بتخزين معرف جلسة Stripe في جدول الطلبات الخاص بك مع قيد فريد - تفشل الإدخالات المكررة بأمان.

المأزق 3: استخدام البرامج الوسيطة الخاصة بالجسم الخام بشكل غير صحيح

يتطلب التحقق من التوقيع الشريطي نص الطلب الأولي غير المحلل. إذا تم تشغيل المحلل اللغوي لنص JSON الخاص بـ Express أولاً، فسيختفي النص الخام. استخدم rawBody: true في NestJS وقم بالوصول إلى req.rawBody، أو قم بتكوين البرنامج الوسيط JSON للحفاظ على النص الأساسي لمسار خطاف الويب على وجه التحديد.

المأزق 4: منطق إعادة المحاولة المفقود لاستدعاءات Stripe API

يمكن أن تفشل مكالمات Stripe API بشكل عابر. إضافة منطق إعادة المحاولة للعمليات غير الفعالة:

import Stripe from 'stripe';

async function retryStripeOperation<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (err) {
      if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, 1000 * attempt));
        continue;
      }
      throw err;
    }
  }
  throw new Error('Max retries exceeded');
}

الأسئلة المتداولة

هل يجب علي استخدام Stripe Checkout أو نماذج الدفع المخصصة؟

استخدم دائمًا Stripe Checkout ما لم تكن لديك متطلبات محددة تجعل ذلك مستحيلًا (نادرًا). يتعامل Checkout مع امتثال PCI والمصادقة الآمنة ثلاثية الأبعاد وApple Pay وGoogle Pay وSEPA وعشرات طرق الدفع الأخرى تلقائيًا. تتطلب نماذج الدفع المخصصة (باستخدام Stripe Elements) الحفاظ على امتثال PCI بنفسك وتنفيذ كل طريقة دفع يدويًا. بالنسبة لـ 95% من منتجات SaaS، يعد Checkout هو الخيار الصحيح.

كيف أتعامل مع وضع اختبار Stripe مقابل الوضع المباشر؟

استخدم تكوينات بيئة منفصلة: STRIPE_SECRET_KEY=sk_test_... في التطوير وSTRIPE_SECRET_KEY=sk_live_... في الإنتاج. وضع الاختبار الشريطي معزول تمامًا عن الوضع المباشر — لا تؤثر رسوم الاختبار على الأموال الحقيقية ولا تظهر أي بيانات مباشرة في وضع الاختبار. استخدم stripe.mode في التعليمات البرمجية الخاصة بك للتحقق من أنك تستخدم المفتاح الصحيح، وأضف مؤشرًا بارزًا في واجهة المستخدم الإدارية الخاصة بك يوضح الوضع الذي أنت فيه.

ماذا يحدث إذا كان خادم webhook الخاص بي معطلاً ولم يتمكن Stripe من التسليم؟

فشلت محاولات إعادة المحاولة الشريطية لخطافات الويب الفاشلة وفقًا لجدول تراجع أسي لمدة تصل إلى 72 ساعة: 5 دقائق، 30 دقيقة، ساعة واحدة، ساعتين، وفترات زمنية متزايدة. وبعد مرور 72 ساعة، يعتبر الحدث فاشلاً. استخدم لوحة معلومات الأحداث الخاصة بـ Stripe لإعادة تسليم الأحداث الفاشلة يدويًا، أو الاستعلام عن stripe.events.list() وإعادة تشغيلها. صمم معالجاتك لتكون عاجزة حتى تكون إعادة التسليم آمنة.

كيف يمكنني تنفيذ الفوترة المقننة/المستندة إلى الاستخدام؟

تدعم Stripe Billing الاستخدام المحدود عبر stripe.subscriptionItems.createUsageRecord(). تقرير الاستخدام في نهاية كل فترة فوترة بالكمية المستهلكة. خطط الأسعار المقاسة في لوحة معلومات Stripe الخاصة بك كتسعير "مقاس". تفاصيل التنفيذ الرئيسية: تقرير الاستخدام قبل انتهاء فترة الفاتورة، وليس بعد ذلك - يقوم Stripe بوضع اللمسات النهائية على الفاتورة في نهاية الفترة.

كيف يمكنني تخزين معرفات الشريط في قاعدة البيانات الخاصة بي؟

قم بتخزين كافة معرفات الشريط كأعمدة text (ليست رقمية). قم بإنشاء فهارس على stripe_customer_id، وstripe_subscription_id، وstripe_payment_intent_id - ستبحث عن السجلات بواسطة هذه بشكل متكرر. أضف قيودًا فريدة عند الاقتضاء (سجل عميل واحد لكل مستخدم). لا تقم أبدًا بتخزين أرقام بطاقات الائتمان أو بيانات الدفع الأولية في قاعدة البيانات الخاصة بك.


الخطوات التالية

يعد تنفيذ فواتير Stripe بشكل صحيح - مع عدم فعالية خطاف الويب، وإدارة دورة حياة الاشتراك، والتزويد التلقائي، والتعامل المناسب مع استرداد الأموال - بمثابة مهمة هندسية غير تافهة. يتعامل ECOSIRE مع 6 أنواع من أحداث Stripe webhook في الإنتاج من خلال توفير الترخيص التلقائي وتكامل بوابة العميل.

إذا كنت بحاجة إلى تكامل Stripe، أو البنية الأساسية لفوترة الاشتراك، أو دعم تطوير SaaS الكامل، استكشف خدمات التطوير لدينا.

E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب