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 Mart 20269 dk okuma2.0k Kelime|

Şeritli Faturalandırma Uygulaması: Abonelikler, Faturalar ve Web Kancaları

Stripe, faturalandırma altyapısı için fiili standarttır, ancak bunu doğru bir şekilde uygulamak (web kancalarını tam olarak ele almak, abonelik durumu geçişlerini yönetmek ve başarısız ödemelerden kurtulmak), başlangıç ​​belgelerinin önerdiğinden çok daha fazla özen gerektirir. Zamanın %99'unda çalışan ve geri kalan %1'inde sessizce başarısız olan bir ödeme sistemi, %95'inde yüksek sesle başarısız olan bir ödeme sisteminden daha kötüdür.

Bu kılavuz, 6 webhook olay türü, abonelik yaşam döngüsü yönetimi, otomatik lisans sağlama ve geri ödeme işlemlerini içeren bir üretim Stripe uygulamasını kapsar. Kod örnekleri NestJS 11'i kullanıyor ancak desenler tüm arka uç çerçeveleri için geçerli.

Önemli Çıkarımlar

  • Hiçbir zaman ödeme yönlendirmesine dayalı erişim sağlamayın; her zaman checkout.session.completed web kancasını bekleyin
  • Herhangi bir olayı işlemeden önce webhook imzalarını stripe.webhooks.constructEvent() ile doğrulayın
  • Tüm web kancası işleyicilerini bağımsız hale getirin — Stripe, başarısız olan web kancalarını 72 saate kadar yeniden dener
  • Stripe müşteri kimliklerini, abonelik kimliklerini ve fiyat kimliklerini veritabanınızda saklayın; her şey için Stripe'ı yeniden sorgulamayın
  • İhtar e-postalarını ve aboneliğin askıya alınmasını tetiklemek için invoice.payment_failed işlevini kullanın
  • charge.refunded ve checkout.session.completed birlikte tüm satın alma yaşam döngüsünü kapsar
  • Stripe nesnelerini dahili kayıtlarınıza bağlamak için Stripe'ın metadata alanını kullanın
  • Ham Stripe webhook verilerini asla kaydetmeyin; bunlar hassas kart verileri içerir

Proje Kurulumu

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

Bu ortam değişkenlerini saklayın:

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

Ödeme Oturumu Oluşturma

Ödeme akışı: Arka ucunuz bir Stripe Checkout oturumu oluşturur, ön uç Stripe'a yönlendirir, Stripe ödemeyi toplar ve ardından webhook'unuzu çağırır. Ödemeyi asla kendiniz yapmayın; her zaman Stripe Checkout'a yönlendirin.

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

Web Kancası İşleyicisi

Webhook işleyicisi Stripe entegrasyonunun en kritik parçasıdır. Her olay idempotent olmalıdır:

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

Önemli: NestJS, Stripe imza doğrulaması için NestFactory.create() içinde rawBody: true gerektirir:

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

Ödeme Tamamlandı İşleyici

Bu, temel sağlama işleyicisidir. İdempotent yapın:

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

Müşteri Portalı

Müşterilerin destek ekibiyle iletişime geçmeden kendi aboneliklerini yönetmelerine izin verin:

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

Müşteri portalı, herhangi bir özel kullanıcı arayüzü çalışmasına gerek kalmadan kullanıcıların abonelikleri iptal etmesine, ödeme yöntemlerini güncellemesine, faturaları indirmesine ve fatura geçmişini görüntülemesine olanak tanır.


Ön Uç Entegrasyonu

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

Web Kancalarını Yerel Olarak Test Etme

Stripe CLI, yerel webhook geliştirme için gereklidir:

# 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, yerel web kancası sırrınızı sağlar; bunu .env.local öğenizde kullanın:

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

Yaygın Tuzaklar ve Çözümler

Tuzak 1: Webhook yerine ödeme yönlendirmesinde erişim sağlama

Ödeme başarısı yönlendirmesi şunlarla tetiklenebilir: tarayıcı geri/ileri, sekmelerin kopyalanması veya ağda yeniden deneme. Yönlendirme URL'sini değil, her zaman checkout.session.completed web kancasını temel alarak temel hazırlığı yapın.

2. Tuzak: Webhook yeniden denemelerinin gerçekleştirilmemesi (zamansızlık)

Stripe, başarısız olan web kancalarını 72 saat boyunca yeniden dener. Eksiklik kontrolleri olmadan, yeniden denemeler yinelenen siparişler, lisanslar ve e-postalar oluşturur. Stripe oturum kimliğini benzersiz bir kısıtlamayla siparişler tablonuzda saklayın; yinelenen eklemeler sorunsuz bir şekilde başarısız olur.

Tuzak 3: Ham gövde ara yazılımını yanlış kullanmak

Şerit imza doğrulaması, ham, ayrıştırılmamış istek gövdesini gerektirir. Express'in JSON gövde ayrıştırıcısı ilk önce çalışırsa ham gövde kaybolur. NestJS'de rawBody: true kullanın ve req.rawBody'e erişin veya JSON ara yazılımını özellikle webhook rotası için ham gövdeyi koruyacak şekilde yapılandırın.

4. Tuzak: Stripe API çağrıları için yeniden deneme mantığının eksik olması

Stripe API çağrıları geçici olarak başarısız olabilir. Eşdeğer işlemler için yeniden deneme mantığı ekleyin:

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

Sıkça Sorulan Sorular

Stripe Checkout'u mu yoksa özel ödeme formlarını mı kullanmalıyım?

Bunu imkansız hale getiren (nadir) özel gereksinimleriniz olmadığı sürece her zaman Stripe Checkout'u kullanın. Checkout, PCI uyumluluğunu, 3D Secure kimlik doğrulamasını, Apple Pay, Google Pay, SEPA ve düzinelerce diğer ödeme yöntemini otomatik olarak yönetir. Özel ödeme formları (Stripe Elements kullanılarak), PCI uyumluluğunu kendiniz sürdürmenizi ve her ödeme yöntemini manuel olarak uygulamanızı gerektirir. SaaS ürünlerinin %95'i için Checkout doğru seçimdir.

Stripe'ın test modunu ve canlı modunu nasıl ele alacağım?

Ayrı ortam yapılandırmaları kullanın: geliştirmede STRIPE_SECRET_KEY=sk_test_... ve üretimde STRIPE_SECRET_KEY=sk_live_.... Şerit test modu, canlı moddan tamamen izole edilmiştir; hiçbir test ücreti gerçek parayı etkilemez ve test modunda hiçbir canlı veri görünmez. Doğru anahtarı kullandığınızı doğrulamak için kodunuzda stripe.mode kullanın ve yönetici kullanıcı arayüzünüze hangi modda olduğunuzu gösteren belirgin bir gösterge ekleyin.

Webhook sunucum kapalıysa ve Stripe dağıtım yapamıyorsa ne olur?

Stripe, başarısız web kancalarını 72 saate kadar üstel bir geri çekilme planıyla yeniden dener: 5 dakika, 30 dakika, 1 saat, 2 saat ve artan aralıklarla. 72 saat sonra etkinlik başarısız sayılır. Başarısız olan olayları manuel olarak yeniden teslim etmek için Stripe'ın Etkinlik kontrol panelini kullanın veya stripe.events.list() sorgulayıp bunları yeniden oynatın. Yeniden teslimatın güvenli olması için işleyicilerinizi kusursuz olacak şekilde tasarlayın.

Ölçülen/kullanıma dayalı faturalandırmayı nasıl uygularım?

Stripe Billing, stripe.subscriptionItems.createUsageRecord() aracılığıyla ölçülü kullanımı destekler. Kullanımı her fatura döneminin sonunda tüketilen miktarla birlikte raporlayın. Stripe kontrol panelinizde "ölçülü" fiyatlandırma olarak fiyat ölçülü planlar. Temel uygulama detayı: kullanımı fatura dönemi sona erdikten sonra değil, önce rapor edin — Stripe, faturayı dönem sonunda kesinleştirir.

Stripe kimliklerini veritabanımda nasıl saklamalıyım?

Tüm Şerit Kimliklerini text sütunları (sayısal değil) olarak saklayın. stripe_customer_id, stripe_subscription_id ve stripe_payment_intent_id üzerinde dizinler oluşturun; kayıtları bunlara göre sık sık arayacaksınız. Uygun olan yerlere benzersiz kısıtlamalar ekleyin (kullanıcı başına bir müşteri kaydı). Kredi kartı numaralarını veya ham ödeme verilerini asla kendi veritabanınızda saklamayın.


Sonraki Adımlar

Webhook bağımsızlığı, abonelik yaşam döngüsü yönetimi, otomatik provizyon ve uygun geri ödeme yönetimi ile Stripe faturalandırmanın doğru şekilde uygulanması, önemsiz olmayan bir mühendislik taahhüdüdür. ECOSIRE, üretimde otomatik lisans provizyonu ve müşteri portalı entegrasyonu ile 6 Stripe webhook olay türünü yönetir.

Stripe entegrasyonuna, abonelik faturalandırma altyapısına veya tam kapsamlı SaaS geliştirme desteğine ihtiyacınız varsa geliştirme hizmetlerimizi keşfedin.

E

Yazan

ECOSIRE Research and Development Team

ECOSIRE'da kurumsal düzeyde dijital ürünler geliştiriyor. Odoo entegrasyonları, e-ticaret otomasyonu ve yapay zeka destekli iş çözümleri hakkında içgörüler paylaşıyor.

WhatsApp'ta Sohbet Et