Ş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.completedweb 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_failedişlevini kullanıncharge.refundedvecheckout.session.completedbirlikte 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
metadataalanı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.
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.
İlgili Makaleler
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.