mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 15:39:40 +02:00
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
20 KiB
20 KiB
Stripe Integration Code Snippets
Komplette Code-Beispiele für Copy & Paste
1. Complete Checkout API Route
// src/routes/api/stripe/checkout/+server.ts
import { json } from '@sveltejs/kit';
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY, STRIPE_PRICE_MONTHLY, STRIPE_PRICE_YEARLY } from '$env/static/private';
import { PUBLIC_APP_URL } from '$env/static/public';
import type { RequestHandler } from './$types';
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20',
});
export const POST: RequestHandler = async ({ request, locals }) => {
try {
// Verify authentication
if (!locals.pb.authStore.isValid) {
return json({ error: 'Bitte erst einloggen' }, { status: 401 });
}
const user = locals.pb.authStore.model;
const { interval = 'month' } = await request.json();
// Check if user already has active subscription
if (user?.subscription_status === 'pro') {
return json({ error: 'Du hast bereits ein aktives Abo' }, { status: 400 });
}
// Create or get Stripe customer
let stripeCustomerId = user?.stripe_customer_id;
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name || undefined,
metadata: {
pocketbase_id: user.id,
username: user.username || '',
},
});
stripeCustomerId = customer.id;
// Save customer ID for future use
await locals.pb.collection('users').update(user.id, {
stripe_customer_id: stripeCustomerId,
});
}
// Choose price based on interval
const priceId = interval === 'year' ? STRIPE_PRICE_YEARLY : STRIPE_PRICE_MONTHLY;
// Create Stripe Checkout session
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
payment_method_types: ['card', 'sepa_debit', 'paypal'],
billing_address_collection: 'required',
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
allow_promotion_codes: true,
subscription_data: {
metadata: {
pocketbase_user_id: user.id,
},
},
success_url: `${PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${PUBLIC_APP_URL}/pricing?cancelled=true`,
locale: 'de',
metadata: {
user_id: user.id,
user_email: user.email,
},
});
return json({
sessionId: session.id,
url: session.url,
});
} catch (error) {
console.error('Stripe checkout error:', error);
return json(
{
error: 'Fehler beim Erstellen der Checkout-Session',
},
{ status: 500 }
);
}
};
2. Complete Webhook Handler
// src/routes/api/stripe/webhook/+server.ts
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from '$env/static/private';
import type { RequestHandler } from './$types';
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20',
});
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('No signature', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err: any) {
console.error(`Webhook signature verification failed: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Handle different event types
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('✅ Checkout completed:', session.id);
const userId = session.metadata?.user_id;
if (!userId) {
console.error('No user_id in session metadata');
break;
}
// Get subscription details
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
// Update user in PocketBase
await locals.pb.collection('users').update(userId, {
subscription_status: 'pro',
stripe_customer_id: session.customer,
stripe_subscription_id: subscription.id,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
subscription_interval: subscription.items.data[0].price.recurring?.interval || 'month',
});
// Reset usage counter for new subscribers
await locals.pb.collection('users').update(userId, {
links_created_this_month: 0,
monthly_reset_date: new Date(
new Date().getFullYear(),
new Date().getMonth() + 1,
1
).toISOString(),
});
console.log(`User ${userId} upgraded to Pro`);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
console.log('📝 Subscription updated:', subscription.id);
// Get PocketBase user ID from customer
const customer = (await stripe.customers.retrieve(
subscription.customer as string
)) as Stripe.Customer;
const userId = customer.metadata?.pocketbase_id;
if (!userId) {
console.error('No pocketbase_id in customer metadata');
break;
}
// Map Stripe status to our status
let status = 'free';
switch (subscription.status) {
case 'active':
status = 'pro';
break;
case 'past_due':
status = 'past_due';
break;
case 'canceled':
case 'unpaid':
status = 'cancelled';
break;
}
// Update user subscription status
await locals.pb.collection('users').update(userId, {
subscription_status: status,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
});
console.log(`User ${userId} subscription status: ${status}`);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
console.log('❌ Subscription cancelled:', subscription.id);
const customer = (await stripe.customers.retrieve(
subscription.customer as string
)) as Stripe.Customer;
const userId = customer.metadata?.pocketbase_id;
if (!userId) {
console.error('No pocketbase_id in customer metadata');
break;
}
// Downgrade user to free
await locals.pb.collection('users').update(userId, {
subscription_status: 'free',
stripe_subscription_id: null,
current_period_end: null,
});
console.log(`User ${userId} downgraded to Free`);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
console.log('💳 Payment failed:', invoice.id);
const customer = (await stripe.customers.retrieve(
invoice.customer as string
)) as Stripe.Customer;
const userId = customer.metadata?.pocketbase_id;
if (!userId) break;
// Mark as past_due
await locals.pb.collection('users').update(userId, {
subscription_status: 'past_due',
});
// TODO: Send email notification to user
console.log(`User ${userId} payment failed`);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
console.log('✅ Payment succeeded:', invoice.id);
// If user was past_due, reactivate
const customer = (await stripe.customers.retrieve(
invoice.customer as string
)) as Stripe.Customer;
const userId = customer.metadata?.pocketbase_id;
if (!userId) break;
const user = await locals.pb.collection('users').getOne(userId);
if (user.subscription_status === 'past_due') {
await locals.pb.collection('users').update(userId, {
subscription_status: 'pro',
});
console.log(`User ${userId} reactivated after payment`);
}
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return new Response('Webhook processed', { status: 200 });
} catch (error) {
console.error('Webhook processing error:', error);
return new Response('Webhook processing failed', { status: 500 });
}
};
3. Customer Portal Route
// src/routes/api/stripe/portal/+server.ts
import { json } from '@sveltejs/kit';
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '$env/static/private';
import { PUBLIC_APP_URL } from '$env/static/public';
import type { RequestHandler } from './$types';
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20',
});
export const POST: RequestHandler = async ({ locals }) => {
try {
if (!locals.pb.authStore.isValid) {
return json({ error: 'Nicht authentifiziert' }, { status: 401 });
}
const user = locals.pb.authStore.model;
if (!user?.stripe_customer_id) {
return json({ error: 'Kein Stripe-Kunde gefunden' }, { status: 400 });
}
// Create portal session
const session = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${PUBLIC_APP_URL}/account`,
locale: 'de',
});
return json({ url: session.url });
} catch (error) {
console.error('Portal session error:', error);
return json({ error: 'Fehler beim Erstellen der Portal-Session' }, { status: 500 });
}
};
4. Usage Tracking Service
// src/lib/server/subscription.ts
import type { PocketBase } from 'pocketbase';
export class SubscriptionService {
constructor(private pb: PocketBase) {}
async canCreateLink(userId: string): Promise<{ allowed: boolean; reason?: string }> {
const user = await this.pb.collection('users').getOne(userId);
// Pro users have unlimited access
if (user.subscription_status === 'pro') {
return { allowed: true };
}
// Past due users should pay first
if (user.subscription_status === 'past_due') {
return {
allowed: false,
reason: 'Bitte aktualisiere deine Zahlungsmethode',
};
}
// Check monthly limit for free users
await this.checkAndResetMonthlyCounter(userId, user);
const updatedUser = await this.pb.collection('users').getOne(userId);
const linksUsed = updatedUser.links_created_this_month || 0;
if (linksUsed >= 10) {
return {
allowed: false,
reason: `Du hast bereits ${linksUsed} von 10 kostenlosen Links diesen Monat erstellt`,
};
}
return { allowed: true };
}
async incrementUsage(userId: string): Promise<void> {
const user = await this.pb.collection('users').getOne(userId);
// Don't count for pro users
if (user.subscription_status === 'pro') return;
const currentCount = user.links_created_this_month || 0;
await this.pb.collection('users').update(userId, {
links_created_this_month: currentCount + 1,
});
// Log usage
await this.pb.collection('usage_logs').create({
user: userId,
action: 'link_created',
timestamp: new Date().toISOString(),
});
}
private async checkAndResetMonthlyCounter(userId: string, user: any): Promise<void> {
const now = new Date();
const resetDate = user.monthly_reset_date ? new Date(user.monthly_reset_date) : null;
// Reset if needed (first of the month or no reset date)
if (!resetDate || resetDate <= now) {
const nextReset = new Date(now.getFullYear(), now.getMonth() + 1, 1);
await this.pb.collection('users').update(userId, {
links_created_this_month: 0,
monthly_reset_date: nextReset.toISOString(),
});
}
}
async getUsageStats(userId: string): Promise<{
used: number;
limit: number;
unlimited: boolean;
daysUntilReset: number;
}> {
const user = await this.pb.collection('users').getOne(userId);
if (user.subscription_status === 'pro') {
return {
used: 0,
limit: 0,
unlimited: true,
daysUntilReset: 0,
};
}
await this.checkAndResetMonthlyCounter(userId, user);
const updatedUser = await this.pb.collection('users').getOne(userId);
const resetDate = new Date(updatedUser.monthly_reset_date);
const now = new Date();
const daysUntilReset = Math.ceil((resetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return {
used: updatedUser.links_created_this_month || 0,
limit: 10,
unlimited: false,
daysUntilReset,
};
}
}
5. React/Svelte Components
<!-- src/lib/components/PricingCard.svelte -->
<script lang="ts">
import { loadStripe } from '@stripe/stripe-js';
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$env/static/public';
import { page } from '$app/stores';
export let title: string;
export let price: string;
export let interval: 'month' | 'year' = 'month';
export let features: string[] = [];
export let recommended = false;
let loading = false;
let error = '';
async function handleCheckout() {
loading = true;
error = '';
try {
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Checkout fehlgeschlagen');
}
const { url, sessionId } = await response.json();
if (url) {
window.location.href = url;
} else if (sessionId) {
const stripe = await loadStripe(PUBLIC_STRIPE_PUBLISHABLE_KEY);
const { error: stripeError } = await stripe!.redirectToCheckout({ sessionId });
if (stripeError) throw stripeError;
}
} catch (err: any) {
error = err.message;
loading = false;
}
}
</script>
<div class="card {recommended ? 'border-primary border-2' : 'border'}">
{#if recommended}
<div class="badge badge-primary absolute -top-3 left-1/2 -translate-x-1/2">Empfohlen</div>
{/if}
<div class="card-body">
<h2 class="card-title justify-center">{title}</h2>
<div class="my-4 text-center">
<span class="text-4xl font-bold">{price}</span>
<span class="text-gray-600">/{interval === 'year' ? 'Jahr' : 'Monat'}</span>
</div>
<ul class="mb-6 space-y-2">
{#each features as feature}
<li class="flex items-center gap-2">
<svg class="text-success h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
{feature}
</li>
{/each}
</ul>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
<button
class="btn {recommended ? 'btn-primary' : 'btn-outline'} w-full"
on:click={handleCheckout}
disabled={loading}
>
{#if loading}
<span class="loading loading-spinner"></span>
Lädt...
{:else}
Jetzt upgraden
{/if}
</button>
</div>
</div>
6. Hooks for Protection
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { SubscriptionService } from '$lib/server/subscription';
export const handle: Handle = async ({ event, resolve }) => {
// Check subscription for API routes
if (event.url.pathname.startsWith('/api/links') && event.request.method === 'POST') {
if (!event.locals.pb.authStore.isValid) {
return new Response('Unauthorized', { status: 401 });
}
const service = new SubscriptionService(event.locals.pb);
const userId = event.locals.pb.authStore.model?.id;
const { allowed, reason } = await service.canCreateLink(userId);
if (!allowed) {
return new Response(
JSON.stringify({
error: reason,
requiresUpgrade: true,
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
return resolve(event);
};
7. Account Management Page
<!-- src/routes/account/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
export let data;
let loadingPortal = false;
async function openCustomerPortal() {
loadingPortal = true;
try {
const response = await fetch('/api/stripe/portal', {
method: 'POST',
});
const { url } = await response.json();
if (url) {
window.location.href = url;
}
} catch (error) {
console.error('Portal error:', error);
} finally {
loadingPortal = false;
}
}
$: isPro = data.user?.subscription_status === 'pro';
$: nextBillingDate = data.user?.current_period_end
? new Date(data.user.current_period_end).toLocaleDateString('de-DE')
: null;
</script>
<div class="container mx-auto p-4">
<h1 class="mb-6 text-2xl font-bold">Account Einstellungen</h1>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Subscription Status</h2>
<div class="stats shadow">
<div class="stat">
<div class="stat-title">Aktueller Plan</div>
<div class="stat-value text-primary">
{isPro ? 'Pro' : 'Free'}
</div>
{#if nextBillingDate}
<div class="stat-desc">Nächste Zahlung: {nextBillingDate}</div>
{/if}
</div>
{#if !isPro}
<div class="stat">
<div class="stat-title">Links diesen Monat</div>
<div class="stat-value">{data.usage?.used || 0}/10</div>
<div class="stat-desc">
Reset in {data.usage?.daysUntilReset || 0} Tagen
</div>
</div>
{/if}
</div>
<div class="card-actions mt-4 justify-end">
{#if isPro}
<button class="btn btn-primary" on:click={openCustomerPortal} disabled={loadingPortal}>
{#if loadingPortal}
<span class="loading loading-spinner"></span>
{/if}
Abo verwalten
</button>
{:else}
<a href="/pricing" class="btn btn-primary"> Upgrade to Pro </a>
{/if}
</div>
</div>
</div>
</div>
8. Testing Utilities
// src/lib/server/stripe-test.ts
import type { PocketBase } from 'pocketbase';
export async function createTestSubscription(pb: PocketBase, userId: string) {
// Simulate Pro subscription for testing
await pb.collection('users').update(userId, {
subscription_status: 'pro',
stripe_customer_id: 'cus_test_' + Date.now(),
stripe_subscription_id: 'sub_test_' + Date.now(),
current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
});
}
export async function simulateWebhook(eventType: string, data: any) {
const response = await fetch('/api/stripe/webhook', {
method: 'POST',
headers: {
'stripe-signature': 'test_signature',
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: eventType,
data: { object: data },
}),
});
return response;
}
9. Migration Script
// scripts/migrate-to-stripe.ts
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function addStripeFields() {
// Get users collection
const collection = await pb.collections.getOne('users');
// Add new fields
const updatedSchema = [
...collection.schema,
{
name: 'subscription_status',
type: 'select',
options: {
values: ['free', 'pro', 'cancelled', 'past_due'],
},
required: true,
},
{
name: 'stripe_customer_id',
type: 'text',
required: false,
},
{
name: 'stripe_subscription_id',
type: 'text',
required: false,
},
{
name: 'current_period_end',
type: 'date',
required: false,
},
{
name: 'links_created_this_month',
type: 'number',
min: 0,
required: true,
},
{
name: 'monthly_reset_date',
type: 'date',
required: false,
},
];
// Update collection
await pb.collections.update('users', {
schema: updatedSchema,
});
console.log('✅ Migration completed');
}
addStripeFields().catch(console.error);
Usage Examples
Check subscription before action
// In your API route
const service = new SubscriptionService(locals.pb);
const { allowed, reason } = await service.canCreateLink(userId);
if (!allowed) {
return json({ error: reason, requiresUpgrade: true }, { status: 403 });
}
Display usage in UI
{#if $page.data.usage && !$page.data.usage.unlimited}
<div class="alert alert-info">
Du hast {$page.data.usage.used} von {$page.data.usage.limit} Links erstellt
</div>
{/if}
Handle upgrade flow
try {
const response = await createLink(data);
// Success
} catch (error) {
if (error.requiresUpgrade) {
goto('/pricing');
}
}