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.3k शब्द|

स्ट्राइप बिलिंग कार्यान्वयन: सदस्यता, चालान और वेबहुक

स्ट्राइप बिलिंग बुनियादी ढांचे के लिए वास्तविक मानक है, लेकिन इसे सही ढंग से लागू करना - वेबहुक को निष्क्रियता से संभालना, सदस्यता स्थिति परिवर्तन का प्रबंधन करना, और असफल भुगतानों से उबरना - आरंभिक दस्तावेज़ीकरण के सुझावों की तुलना में काफी अधिक देखभाल की आवश्यकता होती है। एक भुगतान प्रणाली जो 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 };
}

ग्राहक पोर्टल उपयोगकर्ताओं को बिना किसी कस्टम यूआई कार्य के सदस्यता रद्द करने, भुगतान विधियों को अपडेट करने, चालान डाउनलोड करने और बिलिंग इतिहास देखने की सुविधा देता है।


फ्रंटएंड इंटीग्रेशन

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

स्थानीय स्तर पर वेबहुक का परीक्षण

स्थानीय वेबहुक विकास के लिए स्ट्राइप सीएलआई आवश्यक है:

# 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

सीएलआई आपका स्थानीय वेबहुक रहस्य प्रदान करता है - इसे अपने .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: स्ट्राइप एपीआई कॉल के लिए पुनः प्रयास करने का तर्क गुम

स्ट्राइप एपीआई कॉल क्षणिक रूप से विफल हो सकती है। निष्क्रिय संचालन के लिए पुनः प्रयास तर्क जोड़ें:

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 और दर्जनों अन्य भुगतान विधियों को स्वचालित रूप से संभालता है। कस्टम भुगतान फ़ॉर्म (स्ट्राइप एलिमेंट्स का उपयोग करके) के लिए आपको स्वयं पीसीआई अनुपालन बनाए रखना होगा और प्रत्येक भुगतान विधि को मैन्युअल रूप से लागू करना होगा। 95% SaaS उत्पादों के लिए, चेकआउट सही विकल्प है।

मैं स्ट्राइप के परीक्षण मोड बनाम लाइव मोड को कैसे संभालूं?

अलग-अलग पर्यावरण कॉन्फ़िगरेशन का उपयोग करें: विकास में STRIPE_SECRET_KEY=sk_test_... और उत्पादन में STRIPE_SECRET_KEY=sk_live_...। स्ट्राइप टेस्ट मोड लाइव मोड से पूरी तरह से अलग है - कोई भी टेस्ट शुल्क वास्तविक धन को प्रभावित नहीं करता है और कोई भी लाइव डेटा टेस्ट मोड में दिखाई नहीं देता है। यह सत्यापित करने के लिए कि आप सही कुंजी का उपयोग कर रहे हैं, अपने कोड में stripe.mode का उपयोग करें, और अपने व्यवस्थापक यूआई में एक प्रमुख संकेतक जोड़ें जो दर्शाता है कि आप किस मोड में हैं।

यदि मेरा वेबहुक सर्वर डाउन हो जाए और स्ट्राइप डिलीवर न हो सके तो क्या होगा?

स्ट्राइप विफल वेबहुक को 72 घंटे तक के घातीय बैकऑफ़ शेड्यूल पर पुन: प्रयास करता है: 5 मिनट, 30 मिनट, 1 घंटा, 2 घंटे और बढ़ते अंतराल। 72 घंटों के बाद, ईवेंट विफल माना जाता है। विफल ईवेंट को मैन्युअल रूप से पुनः वितरित करने के लिए स्ट्राइप के ईवेंट डैशबोर्ड का उपयोग करें, या stripe.events.list() को क्वेरी करें और उन्हें दोबारा चलाएं। अपने संचालकों को इस प्रकार डिज़ाइन करें कि वे निष्क्रिय हों ताकि पुनर्वितरण सुरक्षित रहे।

मैं मीटर्ड/उपयोग-आधारित बिलिंग कैसे लागू करूं?

स्ट्राइप बिलिंग stripe.subscriptionItems.createUsageRecord() के माध्यम से मीटर्ड उपयोग का समर्थन करता है। प्रत्येक बिलिंग अवधि के अंत में उपभोग की गई मात्रा के साथ उपयोग की रिपोर्ट करें। आपके स्ट्राइप डैशबोर्ड में "मीटर्ड" मूल्य निर्धारण के रूप में मूल्य मीटर्ड योजनाएँ। मुख्य कार्यान्वयन विवरण: बिलिंग अवधि समाप्त होने से पहले रिपोर्ट उपयोग, बाद में नहीं - स्ट्राइप अवधि के अंत में चालान को अंतिम रूप देता है।

मुझे अपने डेटाबेस में स्ट्राइप आईडी कैसे संग्रहीत करनी चाहिए?

सभी स्ट्राइप आईडी को text कॉलम (संख्यात्मक नहीं) के रूप में संग्रहीत करें। stripe_customer_id, stripe_subscription_id, और stripe_payment_intent_id पर इंडेक्स बनाएं - आप इनके आधार पर बार-बार रिकॉर्ड देखेंगे। जहां उपयुक्त हो, अद्वितीय बाधाएं जोड़ें (प्रति उपयोगकर्ता एक ग्राहक रिकॉर्ड)। कभी भी क्रेडिट कार्ड नंबर या कच्चा भुगतान डेटा अपने डेटाबेस में संग्रहीत न करें।


अगले चरण

स्ट्राइप बिलिंग को सही ढंग से लागू करना - वेबहुक इडेम्पोटेंसी, सब्सक्रिप्शन जीवनचक्र प्रबंधन, स्वचालित प्रावधान और उचित रिफंड हैंडलिंग के साथ - एक गैर-तुच्छ इंजीनियरिंग उपक्रम है। ECOSIRE स्वचालित लाइसेंस प्रावधान और ग्राहक पोर्टल एकीकरण के साथ उत्पादन में 6 स्ट्राइप वेबहुक इवेंट प्रकारों को संभालता है।

यदि आपको स्ट्राइप इंटीग्रेशन, सब्सक्रिप्शन बिलिंग इंफ्रास्ट्रक्चर, या फुल-स्टैक SaaS डेवलपमेंट सपोर्ट की आवश्यकता है, तो हमारी विकास सेवाओं का पता लगाएं

शेयर करें:
E

लेखक

ECOSIRE Research and Development Team

ECOSIRE में एंटरप्राइज़-ग्रेड डिजिटल उत्पाद बना रहे हैं। Odoo एकीकरण, ई-कॉमर्स ऑटोमेशन, और AI-संचालित व्यावसायिक समाधानों पर अंतर्दृष्टि साझा कर रहे हैं।

WhatsApp पर चैट करें