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 مارچ، 202611 منٹ پڑھیں2.4k الفاظ|

اسٹرائپ بلنگ کا نفاذ: سبسکرپشنز، انوائسز اور ویب ہکس

اسٹرائپ بلنگ کے بنیادی ڈھانچے کے لیے اصل معیار ہے، لیکن اسے درست طریقے سے لاگو کرنا — ویب ہکس کو قابلِ توجہ طریقے سے ہینڈل کرنا، سبسکرپشن اسٹیٹ ٹرانزیشن کا انتظام کرنا، اور ناکام ادائیگیوں سے بازیافت کرنا — شروع ہونے والے دستاویزات کے مشورے سے کافی زیادہ خیال رکھتا ہے۔ ایک ادائیگی کا نظام جو 99% وقت کام کرتا ہے اور خاموشی سے ناکام رہتا ہے باقی 1% اس سے بدتر ہے جو 95% پر زور سے ناکام ہو جاتا ہے۔

اس گائیڈ میں 6 ویب ہک ایونٹ کی اقسام، سبسکرپشن لائف سائیکل مینجمنٹ، خودکار لائسنس کی فراہمی، اور ریفنڈ ہینڈلنگ کے ساتھ پروڈکشن اسٹرائپ کے نفاذ کا احاطہ کیا گیا ہے۔ کوڈ کی مثالیں NestJS 11 کا استعمال کرتی ہیں، لیکن پیٹرن کسی بھی بیک اینڈ فریم ورک پر لاگو ہوتے ہیں۔

اہم ٹیک ویز

  • چیک آؤٹ ری ڈائریکٹ کی بنیاد پر رسائی کی فراہمی کبھی نہ کریں — ہمیشہ checkout.session.completed ویب ہک کا انتظار کریں۔
  • کسی بھی ایونٹ پر کارروائی کرنے سے پہلے stripe.webhooks.constructEvent() کے ساتھ ویب ہک کے دستخطوں کی تصدیق کریں۔
  • تمام ویب ہُک ہینڈلرز کو غیرمعمولی بنائیں — 72 گھنٹے تک ویب ہُکس کی ناکام کوششیں
  • اپنے ڈیٹا بیس میں اسٹرائپ کسٹمر آئی ڈیز، سبسکرپشن آئی ڈیز، اور قیمت آئی ڈیز کو اسٹور کریں - ہر چیز کے لیے اسٹرائپ سے دوبارہ استفسار نہ کریں۔
  • ڈننگ ای میلز اور سبسکرپشن معطلی کو متحرک کرنے کے لیے invoice.payment_failed کو ہینڈل کریں۔
  • charge.refunded اور checkout.session.completed ایک ساتھ مکمل خریداری کے لائف سائیکل کا احاطہ کرتے ہیں۔
  • اسٹرائپ کی metadata فیلڈ کا استعمال اپنے اندرونی ریکارڈ سے اسٹرائپ اشیاء کو لنک کرنے کے لیے کریں۔
  • کبھی بھی خام پٹی والے ویب ہک پے لوڈز کو لاگ ان نہ کریں - ان میں کارڈ کا حساس ڈیٹا ہوتا ہے۔

پروجیکٹ سیٹ اپ

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_...

چیک آؤٹ سیشن کی تخلیق

چیک آؤٹ فلو: آپ کا بیک اینڈ اسٹرائپ چیک آؤٹ سیشن بناتا ہے، فرنٹ اینڈ اسٹرائپ پر ری ڈائریکٹ ہوتا ہے، اسٹرائپ ادائیگی جمع کرتا ہے، پھر آپ کے ویب ہک کو کال کرتا ہے۔ کبھی بھی ادائیگی خود نہ کریں — ہمیشہ اسٹرائپ چیک آؤٹ پر ری ڈائریکٹ کریں۔

// 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;
  }
}

ویب ہک ہینڈلر

ویب ہک ہینڈلر اسٹرائپ انضمام کا سب سے اہم حصہ ہے۔ ہر واقعہ بے ضمیر ہونا چاہیے:

// 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 کو پٹی کے دستخط کی تصدیق کے لیے NestFactory.create() میں rawBody: true کی ضرورت ہے:

// 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 };
}

کسٹمر پورٹل صارفین کو سبسکرپشنز منسوخ کرنے، ادائیگی کے طریقوں کو اپ ڈیٹ کرنے، انوائسز ڈاؤن لوڈ کرنے، اور بلنگ کی تاریخ دیکھنے دیتا ہے — بغیر کسی حسب ضرورت UI کام کے۔


فرنٹ اینڈ انٹیگریشن

// 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');
}

مقامی طور پر ویب ہکس کی جانچ کرنا

پٹی 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 گھنٹے تک ویب ہکس کو ناکام بنانے کی کوشش کی گئی۔ آئیڈیمپوٹینسی چیک کے بغیر، دوبارہ کوششیں ڈپلیکیٹ آرڈرز، لائسنس اور ای میلز بناتی ہیں۔ اسٹرائپ سیشن آئی ڈی کو اپنے آرڈر ٹیبل میں ایک منفرد رکاوٹ کے ساتھ اسٹور کریں — ڈپلیکیٹ انسرٹس خوبصورتی سے ناکام ہو جاتے ہیں۔

خطرہ 3: خام باڈی مڈل ویئر کا غلط استعمال کرنا

پٹی کے دستخط کی توثیق کے لیے خام، غیر تجزیہ شدہ درخواست کا باڈی درکار ہے۔ اگر ایکسپریس کا JSON باڈی پارسر پہلے چلتا ہے، تو خام باڈی ختم ہو جاتی ہے۔ NestJS میں rawBody: true استعمال کریں اور req.rawBody تک رسائی حاصل کریں، یا JSON مڈل ویئر کو خاص طور پر ویب ہک روٹ کے لیے خام باڈی کو محفوظ رکھنے کے لیے ترتیب دیں۔

خطرہ 4: پٹی API کالز کے لیے دوبارہ کوشش کرنے کی منطق غائب ہے

اسٹرائپ API کالز عارضی طور پر ناکام ہو سکتی ہیں۔ idempotent آپریشنز کے لیے دوبارہ کوشش کی منطق شامل کریں:

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');
}

اکثر پوچھے گئے سوالات

کیا مجھے اسٹرائپ چیک آؤٹ یا حسب ضرورت ادائیگی کے فارم استعمال کرنے چاہئیں؟

ہمیشہ اسٹرائپ چیک آؤٹ کا استعمال کریں جب تک کہ آپ کے پاس مخصوص تقاضے نہ ہوں جو اسے ناممکن بناتی ہیں (نایاب)۔ چیک آؤٹ PCI کی تعمیل، 3D سیکیور تصدیق، Apple Pay، Google Pay، SEPA، اور درجنوں دیگر ادائیگی کے طریقے خود بخود ہینڈل کرتا ہے۔ حسب ضرورت ادائیگی کے فارمز (سٹرائپ ایلیمنٹس کا استعمال کرتے ہوئے) آپ سے PCI کی تعمیل خود برقرار رکھنے اور ادائیگی کے ہر طریقہ کو دستی طور پر لاگو کرنے کی ضرورت ہے۔ SaaS پروڈکٹس کے 95% کے لیے، چیک آؤٹ صحیح انتخاب ہے۔

میں اسٹرائپ کے ٹیسٹ موڈ بمقابلہ لائیو موڈ کو کیسے ہینڈل کروں؟

ماحول کی الگ الگ ترتیبیں استعمال کریں: STRIPE_SECRET_KEY=sk_test_... ترقی میں اور STRIPE_SECRET_KEY=sk_live_... پیداوار میں۔ سٹرائپ ٹیسٹ موڈ مکمل طور پر لائیو موڈ سے الگ تھلگ ہے — کوئی ٹیسٹ چارجز حقیقی رقم کو متاثر نہیں کرتا ہے اور ٹیسٹ موڈ میں کوئی لائیو ڈیٹا ظاہر نہیں ہوتا ہے۔ اپنے کوڈ میں stripe.mode استعمال کریں اس بات کی تصدیق کرنے کے لیے کہ آپ صحیح کلید استعمال کر رہے ہیں، اور اپنے منتظم UI میں ایک نمایاں اشارے شامل کریں جس سے یہ ظاہر ہو کہ آپ کس موڈ میں ہیں۔

اگر میرا ویب ہک سرور ڈاؤن ہو اور اسٹرائپ ڈیلیور نہ کر سکے تو کیا ہوگا؟

72 گھنٹے تک کے ایکسپونیشنل بیک آف شیڈول پر اسٹرائپ کی ناکام ویب ہکس کی دوبارہ کوششیں: 5 منٹ، 30 منٹ، 1 گھنٹہ، 2 گھنٹے، اور بڑھتے ہوئے وقفے۔ 72 گھنٹے کے بعد، ایونٹ کو ناکام سمجھا جاتا ہے۔ ناکام واقعات کو دستی طور پر دوبارہ ڈیلیور کرنے کے لیے اسٹرائپ کا ایونٹ ڈیش بورڈ استعمال کریں، یا stripe.events.list() سے استفسار کریں اور انہیں دوبارہ چلائیں۔ اپنے ہینڈلرز کو کمزور ہونے کے لیے ڈیزائن کریں تاکہ دوبارہ ڈیلیوری محفوظ رہے۔

میں میٹرڈ/استعمال پر مبنی بلنگ کیسے لاگو کروں؟

اسٹرائپ بلنگ stripe.subscriptionItems.createUsageRecord() کے ذریعے میٹرڈ استعمال کی حمایت کرتی ہے۔ ہر بلنگ کی مدت کے اختتام پر استعمال کی مقدار کے ساتھ استعمال کی اطلاع دیں۔ اپنے اسٹرائپ ڈیش بورڈ میں میٹرڈ پلانز کی قیمت "میٹرڈ" پرائسنگ کے بطور۔ کلیدی نفاذ کی تفصیل: بلنگ کی مدت ختم ہونے سے پہلے استعمال کی اطلاع دیں، اس کے بعد نہیں — اسٹرائپ مدت کے اختتام پر رسید کو حتمی شکل دیتی ہے۔

مجھے اپنے ڈیٹا بیس میں Stripe IDs کیسے ذخیرہ کرنا چاہیے؟

تمام Stripe IDs کو بطور text کالم اسٹور کریں (عددی نہیں)۔ stripe_customer_id، stripe_subscription_id، اور stripe_payment_intent_id پر اشاریہ جات بنائیں — آپ ان کے ذریعہ اکثر ریکارڈ تلاش کریں گے۔ جہاں مناسب ہو منفرد رکاوٹیں شامل کریں (فی صارف ایک صارف کا ریکارڈ)۔ اپنے ڈیٹا بیس میں کبھی بھی کریڈٹ کارڈ نمبر یا خام ادائیگی کا ڈیٹا ذخیرہ نہ کریں۔


اگلے اقدامات

اسٹرائپ بلنگ کو درست طریقے سے لاگو کرنا — ویب ہُک آئیڈیمپوٹینسی، سبسکرپشن لائف سائیکل مینجمنٹ، خودکار پروویژننگ، اور ریفنڈ ہینڈلنگ کے ساتھ — ایک غیر معمولی انجینئرنگ کا کام ہے۔ ECOSIRE خودکار لائسنس کی فراہمی اور کسٹمر پورٹل انضمام کے ساتھ پیداوار میں 6 اسٹرائپ ویب ہک ایونٹ کی اقسام کو ہینڈل کرتا ہے۔

اگر آپ کو اسٹرائپ انٹیگریشن، سبسکرپشن بلنگ انفراسٹرکچر، یا فل اسٹیک SaaS ڈیولپمنٹ سپورٹ کی ضرورت ہے تو، ہماری ڈیولپمنٹ سروسز کو دریافت کریں۔

E

تحریر

ECOSIRE Research and Development Team

ECOSIRE میں انٹرپرائز گریڈ ڈیجیٹل مصنوعات بنانا۔ Odoo انٹیگریشنز، ای کامرس آٹومیشن، اور AI سے چلنے والے کاروباری حل پر بصیرت شیئر کرنا۔

Chat on WhatsApp