Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
731 lines
21 KiB
Markdown
731 lines
21 KiB
Markdown
# Subscription Billing mit Stripe
|
||
|
||
**Meta-Description:** Stripe Subscription Billing implementieren. Checkout, Webhooks, Customer Portal und Metered Billing für SaaS.
|
||
|
||
**Keywords:** Stripe, Subscription, Billing, Webhooks, Customer Portal, Metered Billing, SaaS, Payment
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
**Stripe** ist der De-facto-Standard für SaaS Billing. Von einfachen Subscriptions bis komplexem **Metered Billing** – Stripe bietet alle Tools. Dieser Guide zeigt die komplette Implementation für Next.js Anwendungen.
|
||
|
||
---
|
||
|
||
## Stripe Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ STRIPE BILLING ARCHITECTURE │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ User Flow: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ 1. User clicks "Subscribe" │ │
|
||
│ │ ↓ │ │
|
||
│ │ 2. Create Stripe Customer (if new) │ │
|
||
│ │ ↓ │ │
|
||
│ │ 3. Create Checkout Session │ │
|
||
│ │ ↓ │ │
|
||
│ │ 4. Redirect to Stripe Checkout │ │
|
||
│ │ ↓ │ │
|
||
│ │ 5. User completes payment │ │
|
||
│ │ ↓ │ │
|
||
│ │ 6. Stripe sends webhook │ │
|
||
│ │ ↓ │ │
|
||
│ │ 7. Update database, activate subscription │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Key Objects: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ Customer: │ │
|
||
│ │ ├── email, name, metadata │ │
|
||
│ │ └── payment methods, invoices │ │
|
||
│ │ │ │
|
||
│ │ Product: │ │
|
||
│ │ ├── name, description │ │
|
||
│ │ └── has many Prices │ │
|
||
│ │ │ │
|
||
│ │ Price: │ │
|
||
│ │ ├── amount, currency, interval │ │
|
||
│ │ └── recurring or one-time │ │
|
||
│ │ │ │
|
||
│ │ Subscription: │ │
|
||
│ │ ├── customer, items (prices) │ │
|
||
│ │ ├── status: active, past_due, canceled │ │
|
||
│ │ └── billing cycle, trial │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Webhooks to Handle: │
|
||
│ ├── checkout.session.completed │
|
||
│ ├── customer.subscription.created │
|
||
│ ├── customer.subscription.updated │
|
||
│ ├── customer.subscription.deleted │
|
||
│ ├── invoice.paid │
|
||
│ ├── invoice.payment_failed │
|
||
│ └── customer.updated │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Stripe Setup
|
||
|
||
```typescript
|
||
// lib/stripe/client.ts
|
||
import Stripe from 'stripe';
|
||
|
||
if (!process.env.STRIPE_SECRET_KEY) {
|
||
throw new Error('STRIPE_SECRET_KEY is required');
|
||
}
|
||
|
||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||
apiVersion: '2024-06-20',
|
||
typescript: true
|
||
});
|
||
|
||
// lib/stripe/config.ts
|
||
export const STRIPE_CONFIG = {
|
||
prices: {
|
||
pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
|
||
pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY!,
|
||
enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
|
||
enterprise_yearly: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY!
|
||
},
|
||
products: {
|
||
pro: process.env.STRIPE_PRODUCT_PRO!,
|
||
enterprise: process.env.STRIPE_PRODUCT_ENTERPRISE!
|
||
},
|
||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!
|
||
};
|
||
|
||
// Map price IDs to plan names
|
||
export const PRICE_TO_PLAN: Record<string, string> = {
|
||
[STRIPE_CONFIG.prices.pro_monthly]: 'pro',
|
||
[STRIPE_CONFIG.prices.pro_yearly]: 'pro',
|
||
[STRIPE_CONFIG.prices.enterprise_monthly]: 'enterprise',
|
||
[STRIPE_CONFIG.prices.enterprise_yearly]: 'enterprise'
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Customer Management
|
||
|
||
```typescript
|
||
// lib/stripe/customers.ts
|
||
import { stripe } from './client';
|
||
import { db } from '@/lib/db';
|
||
|
||
export async function getOrCreateStripeCustomer(
|
||
userId: string
|
||
): Promise<string> {
|
||
// Check if user already has a Stripe customer ID
|
||
const user = await db.user.findUnique({
|
||
where: { id: userId },
|
||
select: { stripeCustomerId: true, email: true, name: true }
|
||
});
|
||
|
||
if (!user) {
|
||
throw new Error('User not found');
|
||
}
|
||
|
||
if (user.stripeCustomerId) {
|
||
return user.stripeCustomerId;
|
||
}
|
||
|
||
// Create new Stripe customer
|
||
const customer = await stripe.customers.create({
|
||
email: user.email!,
|
||
name: user.name || undefined,
|
||
metadata: {
|
||
userId
|
||
}
|
||
});
|
||
|
||
// Save customer ID to database
|
||
await db.user.update({
|
||
where: { id: userId },
|
||
data: { stripeCustomerId: customer.id }
|
||
});
|
||
|
||
return customer.id;
|
||
}
|
||
|
||
export async function getCustomerSubscription(
|
||
customerId: string
|
||
): Promise<Stripe.Subscription | null> {
|
||
const subscriptions = await stripe.subscriptions.list({
|
||
customer: customerId,
|
||
status: 'all',
|
||
limit: 1,
|
||
expand: ['data.default_payment_method']
|
||
});
|
||
|
||
return subscriptions.data[0] || null;
|
||
}
|
||
|
||
export async function updateCustomerEmail(
|
||
customerId: string,
|
||
email: string
|
||
): Promise<void> {
|
||
await stripe.customers.update(customerId, { email });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Checkout Session
|
||
|
||
```typescript
|
||
// lib/stripe/checkout.ts
|
||
import { stripe } from './client';
|
||
import { STRIPE_CONFIG } from './config';
|
||
import { getOrCreateStripeCustomer } from './customers';
|
||
|
||
interface CreateCheckoutOptions {
|
||
userId: string;
|
||
priceId: string;
|
||
quantity?: number;
|
||
trialDays?: number;
|
||
couponId?: string;
|
||
metadata?: Record<string, string>;
|
||
}
|
||
|
||
export async function createCheckoutSession(
|
||
options: CreateCheckoutOptions
|
||
): Promise<string> {
|
||
const customerId = await getOrCreateStripeCustomer(options.userId);
|
||
|
||
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
|
||
customer: customerId,
|
||
mode: 'subscription',
|
||
payment_method_types: ['card'],
|
||
line_items: [
|
||
{
|
||
price: options.priceId,
|
||
quantity: options.quantity || 1
|
||
}
|
||
],
|
||
subscription_data: {
|
||
trial_period_days: options.trialDays,
|
||
metadata: {
|
||
userId: options.userId,
|
||
...options.metadata
|
||
}
|
||
},
|
||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
|
||
allow_promotion_codes: true,
|
||
billing_address_collection: 'auto',
|
||
customer_update: {
|
||
address: 'auto',
|
||
name: 'auto'
|
||
}
|
||
};
|
||
|
||
// Add coupon if provided
|
||
if (options.couponId) {
|
||
sessionConfig.discounts = [{ coupon: options.couponId }];
|
||
}
|
||
|
||
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||
|
||
return session.url!;
|
||
}
|
||
|
||
// API Route
|
||
// app/api/checkout/route.ts
|
||
import { NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { createCheckoutSession } from '@/lib/stripe/checkout';
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await getServerSession();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const { priceId, quantity } = await request.json();
|
||
|
||
const checkoutUrl = await createCheckoutSession({
|
||
userId: session.user.id,
|
||
priceId,
|
||
quantity,
|
||
trialDays: 14
|
||
});
|
||
|
||
return NextResponse.json({ url: checkoutUrl });
|
||
} catch (error) {
|
||
console.error('Checkout error:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Failed to create checkout session' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Webhook Handler
|
||
|
||
```typescript
|
||
// app/api/webhooks/stripe/route.ts
|
||
import { NextResponse } from 'next/server';
|
||
import { headers } from 'next/headers';
|
||
import { stripe } from '@/lib/stripe/client';
|
||
import { STRIPE_CONFIG, PRICE_TO_PLAN } from '@/lib/stripe/config';
|
||
import { db } from '@/lib/db';
|
||
import Stripe from 'stripe';
|
||
|
||
export async function POST(request: Request) {
|
||
const body = await request.text();
|
||
const headersList = await headers();
|
||
const signature = headersList.get('stripe-signature')!;
|
||
|
||
let event: Stripe.Event;
|
||
|
||
try {
|
||
event = stripe.webhooks.constructEvent(
|
||
body,
|
||
signature,
|
||
STRIPE_CONFIG.webhookSecret
|
||
);
|
||
} catch (error) {
|
||
console.error('Webhook signature verification failed:', error);
|
||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
switch (event.type) {
|
||
case 'checkout.session.completed':
|
||
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
||
break;
|
||
|
||
case 'customer.subscription.created':
|
||
case 'customer.subscription.updated':
|
||
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
|
||
break;
|
||
|
||
case 'customer.subscription.deleted':
|
||
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||
break;
|
||
|
||
case 'invoice.paid':
|
||
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
||
break;
|
||
|
||
case 'invoice.payment_failed':
|
||
await handlePaymentFailed(event.data.object as Stripe.Invoice);
|
||
break;
|
||
|
||
default:
|
||
console.log(`Unhandled event type: ${event.type}`);
|
||
}
|
||
|
||
return NextResponse.json({ received: true });
|
||
} catch (error) {
|
||
console.error('Webhook handler error:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Webhook handler failed' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||
const customerId = session.customer as string;
|
||
const subscriptionId = session.subscription as string;
|
||
|
||
// Get user by Stripe customer ID
|
||
const user = await db.user.findFirst({
|
||
where: { stripeCustomerId: customerId }
|
||
});
|
||
|
||
if (!user) {
|
||
throw new Error(`User not found for customer: ${customerId}`);
|
||
}
|
||
|
||
// Get subscription details
|
||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||
const priceId = subscription.items.data[0].price.id;
|
||
const plan = PRICE_TO_PLAN[priceId] || 'free';
|
||
|
||
// Update user subscription
|
||
await db.user.update({
|
||
where: { id: user.id },
|
||
data: {
|
||
stripeSubscriptionId: subscriptionId,
|
||
plan,
|
||
subscriptionStatus: subscription.status,
|
||
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
|
||
}
|
||
});
|
||
|
||
// Send welcome email
|
||
await sendWelcomeEmail(user.email!, plan);
|
||
}
|
||
|
||
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
|
||
const customerId = subscription.customer as string;
|
||
|
||
const user = await db.user.findFirst({
|
||
where: { stripeCustomerId: customerId }
|
||
});
|
||
|
||
if (!user) return;
|
||
|
||
const priceId = subscription.items.data[0].price.id;
|
||
const plan = PRICE_TO_PLAN[priceId] || 'free';
|
||
|
||
await db.user.update({
|
||
where: { id: user.id },
|
||
data: {
|
||
plan,
|
||
subscriptionStatus: subscription.status,
|
||
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||
cancelAtPeriodEnd: subscription.cancel_at_period_end
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||
const customerId = subscription.customer as string;
|
||
|
||
const user = await db.user.findFirst({
|
||
where: { stripeCustomerId: customerId }
|
||
});
|
||
|
||
if (!user) return;
|
||
|
||
await db.user.update({
|
||
where: { id: user.id },
|
||
data: {
|
||
plan: 'free',
|
||
subscriptionStatus: 'canceled',
|
||
stripeSubscriptionId: null
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||
// Record payment in database
|
||
if (invoice.subscription) {
|
||
await db.payment.create({
|
||
data: {
|
||
stripeInvoiceId: invoice.id,
|
||
stripeCustomerId: invoice.customer as string,
|
||
amount: invoice.amount_paid,
|
||
currency: invoice.currency,
|
||
status: 'paid',
|
||
paidAt: new Date()
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handlePaymentFailed(invoice: Stripe.Invoice) {
|
||
const customerId = invoice.customer as string;
|
||
|
||
const user = await db.user.findFirst({
|
||
where: { stripeCustomerId: customerId }
|
||
});
|
||
|
||
if (!user) return;
|
||
|
||
// Update subscription status
|
||
await db.user.update({
|
||
where: { id: user.id },
|
||
data: { subscriptionStatus: 'past_due' }
|
||
});
|
||
|
||
// Send payment failed email
|
||
await sendPaymentFailedEmail(user.email!);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Customer Portal
|
||
|
||
```typescript
|
||
// lib/stripe/portal.ts
|
||
import { stripe } from './client';
|
||
|
||
export async function createBillingPortalSession(
|
||
customerId: string,
|
||
returnUrl?: string
|
||
): Promise<string> {
|
||
const session = await stripe.billingPortal.sessions.create({
|
||
customer: customerId,
|
||
return_url: returnUrl || `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`
|
||
});
|
||
|
||
return session.url;
|
||
}
|
||
|
||
// API Route
|
||
// app/api/billing/portal/route.ts
|
||
import { NextResponse } from 'next/server';
|
||
import { getServerSession } from 'next-auth';
|
||
import { createBillingPortalSession } from '@/lib/stripe/portal';
|
||
import { db } from '@/lib/db';
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await getServerSession();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const user = await db.user.findUnique({
|
||
where: { id: session.user.id },
|
||
select: { stripeCustomerId: true }
|
||
});
|
||
|
||
if (!user?.stripeCustomerId) {
|
||
return NextResponse.json(
|
||
{ error: 'No billing account found' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const portalUrl = await createBillingPortalSession(user.stripeCustomerId);
|
||
|
||
return NextResponse.json({ url: portalUrl });
|
||
} catch (error) {
|
||
console.error('Portal error:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Failed to create portal session' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Metered Billing
|
||
|
||
```typescript
|
||
// lib/stripe/metered.ts
|
||
import { stripe } from './client';
|
||
import { db } from '@/lib/db';
|
||
|
||
export async function reportUsage(
|
||
userId: string,
|
||
quantity: number,
|
||
idempotencyKey?: string
|
||
): Promise<void> {
|
||
const user = await db.user.findUnique({
|
||
where: { id: userId },
|
||
select: { stripeSubscriptionId: true }
|
||
});
|
||
|
||
if (!user?.stripeSubscriptionId) {
|
||
throw new Error('User has no active subscription');
|
||
}
|
||
|
||
// Get the subscription item for metered billing
|
||
const subscription = await stripe.subscriptions.retrieve(
|
||
user.stripeSubscriptionId,
|
||
{ expand: ['items.data.price'] }
|
||
);
|
||
|
||
const meteredItem = subscription.items.data.find(
|
||
item => (item.price as Stripe.Price).recurring?.usage_type === 'metered'
|
||
);
|
||
|
||
if (!meteredItem) {
|
||
throw new Error('No metered billing item found');
|
||
}
|
||
|
||
// Report usage
|
||
await stripe.subscriptionItems.createUsageRecord(
|
||
meteredItem.id,
|
||
{
|
||
quantity,
|
||
timestamp: Math.floor(Date.now() / 1000),
|
||
action: 'increment'
|
||
},
|
||
{
|
||
idempotencyKey
|
||
}
|
||
);
|
||
}
|
||
|
||
// Get usage for current period
|
||
export async function getCurrentUsage(userId: string): Promise<{
|
||
used: number;
|
||
limit: number | null;
|
||
}> {
|
||
const user = await db.user.findUnique({
|
||
where: { id: userId },
|
||
select: { stripeSubscriptionId: true }
|
||
});
|
||
|
||
if (!user?.stripeSubscriptionId) {
|
||
return { used: 0, limit: null };
|
||
}
|
||
|
||
const subscription = await stripe.subscriptions.retrieve(
|
||
user.stripeSubscriptionId
|
||
);
|
||
|
||
const meteredItem = subscription.items.data.find(
|
||
item => item.price.recurring?.usage_type === 'metered'
|
||
);
|
||
|
||
if (!meteredItem) {
|
||
return { used: 0, limit: null };
|
||
}
|
||
|
||
// Get usage records
|
||
const usageRecords = await stripe.subscriptionItems.listUsageRecordSummaries(
|
||
meteredItem.id,
|
||
{ limit: 1 }
|
||
);
|
||
|
||
const currentUsage = usageRecords.data[0]?.total_usage || 0;
|
||
|
||
return {
|
||
used: currentUsage,
|
||
limit: null // or get from subscription metadata
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Subscription Management UI
|
||
|
||
```tsx
|
||
// components/billing/SubscriptionStatus.tsx
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useSubscription } from '@/hooks/useSubscription';
|
||
|
||
export function SubscriptionStatus() {
|
||
const { subscription, isLoading } = useSubscription();
|
||
const [isPortalLoading, setIsPortalLoading] = useState(false);
|
||
|
||
const openBillingPortal = async () => {
|
||
setIsPortalLoading(true);
|
||
try {
|
||
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
||
const { url } = await res.json();
|
||
window.location.href = url;
|
||
} catch (error) {
|
||
console.error('Portal error:', error);
|
||
}
|
||
setIsPortalLoading(false);
|
||
};
|
||
|
||
if (isLoading) return <div>Loading...</div>;
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<h2 className="text-lg font-semibold mb-4">Subscription</h2>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Current Plan</span>
|
||
<span className="font-semibold capitalize">
|
||
{subscription?.plan || 'Free'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Status</span>
|
||
<StatusBadge status={subscription?.status || 'inactive'} />
|
||
</div>
|
||
|
||
{subscription?.currentPeriodEnd && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">
|
||
{subscription.cancelAtPeriodEnd ? 'Cancels on' : 'Renews on'}
|
||
</span>
|
||
<span>
|
||
{new Date(subscription.currentPeriodEnd).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<hr />
|
||
|
||
<button
|
||
onClick={openBillingPortal}
|
||
disabled={isPortalLoading}
|
||
className="w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"
|
||
>
|
||
{isPortalLoading ? 'Loading...' : 'Manage Subscription'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const colors: Record<string, string> = {
|
||
active: 'bg-green-100 text-green-800',
|
||
trialing: 'bg-blue-100 text-blue-800',
|
||
past_due: 'bg-yellow-100 text-yellow-800',
|
||
canceled: 'bg-red-100 text-red-800',
|
||
inactive: 'bg-gray-100 text-gray-800'
|
||
};
|
||
|
||
return (
|
||
<span className={`px-2 py-1 rounded-full text-sm ${colors[status] || colors.inactive}`}>
|
||
{status.replace('_', ' ')}
|
||
</span>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
| Aspect | Recommendation |
|
||
|--------|----------------|
|
||
| **Webhooks** | Always verify signatures |
|
||
| **Idempotency** | Use idempotency keys |
|
||
| **Retries** | Handle webhook retries |
|
||
| **Testing** | Use Stripe CLI for local testing |
|
||
| **Errors** | Log all Stripe errors |
|
||
| **Security** | Never expose secret keys |
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Stripe Subscription Billing erfordert:
|
||
|
||
1. **Customer Management**: Stripe Customer ID syncen
|
||
2. **Webhooks**: Alle relevanten Events handeln
|
||
3. **Portal**: Kunden Self-Service ermöglichen
|
||
4. **Metered**: Usage-Based Billing für Flexibilität
|
||
|
||
Robustes Billing ist Grundlage für SaaS-Erfolg.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Stripe checkout page, credit card form with security badges"
|
||
2. "Subscription management dashboard, plan upgrade flow"
|
||
3. "Webhook event flow diagram, Stripe to application"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [Stripe Billing Documentation](https://stripe.com/docs/billing)
|
||
- [Stripe Webhooks](https://stripe.com/docs/webhooks)
|
||
- [Stripe Customer Portal](https://stripe.com/docs/billing/subscriptions/customer-portal)
|
||
- [Stripe Testing](https://stripe.com/docs/testing)
|