mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 13:21:08 +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>
18 KiB
18 KiB
Stripe Integration Implementation Guide für ulo.ad
Übersicht
Diese Anleitung beschreibt die Implementierung von Stripe Checkout für ulo.ad mit einem Freemium-Modell (10 kostenlose Links pro Monat, danach Pro-Abo erforderlich).
Architektur-Entscheidungen
- Payment Method: Stripe Checkout (gehostete Zahlungsseite)
- Subscription Model: Freemium mit monatlichem Pro-Plan
- Enforcement: Server-side Paywall mit Link-Counting
- Database: PocketBase für User-Status und Usage-Tracking
1. Stripe Account Setup
1.1 Account erstellen
- Registriere dich bei stripe.com
- Aktiviere Test-Modus für Entwicklung
- Notiere folgende Keys aus dem Dashboard:
- Publishable Key:
pk_test_... - Secret Key:
sk_test_...
- Publishable Key:
1.2 Webhook Endpoint konfigurieren
- Gehe zu Dashboard → Developers → Webhooks
- Füge Endpoint hinzu:
https://ulo.ad/api/stripe/webhook - Wähle Events:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
- Notiere Webhook Secret:
whsec_...
1.3 Produkte und Preise anlegen
// Im Stripe Dashboard unter "Products":
Produkt: "ulo.ad Pro"
Beschreibung: "Unlimited link creation and premium features"
Preis 1:
- Name: "Monthly"
- Preis: €9.99/Monat
- Price ID: price_monthly_xxx
Preis 2:
- Name: "Yearly"
- Preis: €99/Jahr (2 Monate gratis)
- Price ID: price_yearly_xxx
2. PocketBase Schema Updates
2.1 Users Collection erweitern
// Neue Felder für users collection:
{
"subscription_status": {
"type": "select",
"options": ["free", "pro", "cancelled", "past_due"],
"default": "free",
"required": true
},
"stripe_customer_id": {
"type": "text",
"required": false
},
"stripe_subscription_id": {
"type": "text",
"required": false
},
"current_period_end": {
"type": "date",
"required": false
},
"links_created_this_month": {
"type": "number",
"default": 0,
"min": 0,
"required": true
},
"monthly_reset_date": {
"type": "date",
"required": false
}
}
2.2 Usage Tracking Collection
// Neue collection: usage_logs
{
"user": {
"type": "relation",
"collection": "users",
"required": true
},
"action": {
"type": "select",
"options": ["link_created", "link_deleted", "qr_generated"],
"required": true
},
"timestamp": {
"type": "autodate",
"onCreate": true
},
"link_id": {
"type": "relation",
"collection": "links",
"required": false
}
}
3. Backend Implementation
3.1 Environment Variables
# .env.local
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_MONTHLY=price_monthly_xxx
STRIPE_PRICE_YEARLY=price_yearly_xxx
PUBLIC_APP_URL=http://localhost:5173 # Production: https://ulo.ad
3.2 Stripe Client Setup
// src/lib/server/stripe.ts
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '$env/static/private';
export const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20',
typescript: true
});
3.3 Checkout Session API
// src/routes/api/stripe/checkout/+server.ts
import { json } from '@sveltejs/kit';
import { stripe } from '$lib/server/stripe';
import { PUBLIC_APP_URL } from '$env/static/public';
import { STRIPE_PRICE_MONTHLY } from '$env/static/private';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
try {
const { priceId, userId } = await request.json();
// Verify user is authenticated
if (!locals.pb.authStore.isValid) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const user = locals.pb.authStore.model;
// Create or retrieve Stripe customer
let customerId = user.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
pocketbase_id: user.id
}
});
customerId = customer.id;
// Save customer ID to PocketBase
await locals.pb.collection('users').update(user.id, {
stripe_customer_id: customerId
});
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card', 'sepa_debit'],
line_items: [
{
price: priceId || STRIPE_PRICE_MONTHLY,
quantity: 1
}
],
mode: 'subscription',
success_url: `${PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${PUBLIC_APP_URL}/pricing`,
metadata: {
user_id: user.id
},
allow_promotion_codes: true,
billing_address_collection: 'auto',
locale: 'de'
});
return json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error('Checkout session error:', error);
return json({ error: 'Failed to create checkout session' }, { status: 500 });
}
};
3.4 Webhook Handler
// src/routes/api/stripe/webhook/+server.ts
import { stripe } from '$lib/server/stripe';
import { STRIPE_WEBHOOK_SECRET } from '$env/static/private';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
let event;
try {
event = stripe.webhooks.constructEvent(body, signature!, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Webhook Error', { status: 400 });
}
// Handle the event
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const userId = session.metadata?.user_id;
if (userId) {
await locals.pb.collection('users').update(userId, {
subscription_status: 'pro',
stripe_subscription_id: session.subscription,
current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // Approximate, will be updated by subscription.updated
});
}
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer && !customer.deleted && customer.metadata?.pocketbase_id) {
const status =
subscription.status === 'active'
? 'pro'
: subscription.status === 'past_due'
? 'past_due'
: 'cancelled';
await locals.pb.collection('users').update(customer.metadata.pocketbase_id, {
subscription_status: status,
current_period_end: new Date(subscription.current_period_end * 1000)
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer && !customer.deleted && customer.metadata?.pocketbase_id) {
await locals.pb.collection('users').update(customer.metadata.pocketbase_id, {
subscription_status: 'cancelled',
stripe_subscription_id: null
});
}
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const customer = await stripe.customers.retrieve(invoice.customer as string);
if (customer && !customer.deleted && customer.metadata?.pocketbase_id) {
await locals.pb.collection('users').update(customer.metadata.pocketbase_id, {
subscription_status: 'past_due'
});
// TODO: Send email notification
}
break;
}
}
return new Response('Webhook processed', { status: 200 });
} catch (error) {
console.error('Webhook processing error:', error);
return new Response('Webhook processing failed', { status: 500 });
}
};
4. Frontend Implementation
4.1 Upgrade Button Component
<!-- src/lib/components/UpgradeButton.svelte -->
<script lang="ts">
import { loadStripe } from '@stripe/stripe-js';
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$env/static/public';
export let priceId: string | undefined = undefined;
export let buttonText = 'Upgrade to Pro';
export let className = '';
let loading = false;
async function handleUpgrade() {
loading = true;
try {
// Create checkout session
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ priceId })
});
const { sessionId, url } = await response.json();
if (url) {
// Redirect to Stripe Checkout
window.location.href = url;
} else {
// Fallback to Stripe.js redirect
const stripe = await loadStripe(PUBLIC_STRIPE_PUBLISHABLE_KEY);
await stripe?.redirectToCheckout({ sessionId });
}
} catch (error) {
console.error('Upgrade error:', error);
// TODO: Show error toast
} finally {
loading = false;
}
}
</script>
<button
on:click={handleUpgrade}
disabled={loading}
class="btn btn-primary {className}"
class:loading
>
{#if loading}
Lädt...
{:else}
{buttonText}
{/if}
</button>
4.2 Paywall Banner
<!-- src/lib/components/PaywallBanner.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import UpgradeButton from './UpgradeButton.svelte';
export let linksUsed = 0;
export let maxFreeLinks = 10;
$: isNearLimit = linksUsed >= maxFreeLinks - 2;
$: hasReachedLimit = linksUsed >= maxFreeLinks;
</script>
{#if isNearLimit}
<div class="alert {hasReachedLimit ? 'alert-error' : 'alert-warning'} mb-4 shadow-lg">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 flex-shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<h3 class="font-bold">
{#if hasReachedLimit}
Link-Limit erreicht!
{:else}
Du näherst dich deinem monatlichen Limit
{/if}
</h3>
<div class="text-xs">
Du hast {linksUsed} von {maxFreeLinks} kostenlosen Links diesen Monat verwendet.
{#if hasReachedLimit}
Upgrade auf Pro für unbegrenzte Links!
{/if}
</div>
</div>
</div>
<div class="flex-none">
<UpgradeButton buttonText="Jetzt upgraden" className="btn-sm" />
</div>
</div>
{/if}
5. Server-side Paywall Enforcement
5.1 Usage Tracking Middleware
// src/lib/server/usage.ts
import type { PocketBase } from 'pocketbase';
export async function checkUsageLimit(pb: PocketBase, userId: string): Promise<boolean> {
const user = await pb.collection('users').getOne(userId);
// Pro users have no limits
if (user.subscription_status === 'pro') {
return true;
}
// Check monthly reset
const now = new Date();
const resetDate = user.monthly_reset_date ? new Date(user.monthly_reset_date) : null;
if (!resetDate || resetDate < now) {
// Reset counter at the beginning of each month
await pb.collection('users').update(userId, {
links_created_this_month: 0,
monthly_reset_date: new Date(now.getFullYear(), now.getMonth() + 1, 1)
});
return true;
}
// Check if under limit
return user.links_created_this_month < 10;
}
export async function incrementUsage(pb: PocketBase, userId: string) {
const user = await pb.collection('users').getOne(userId);
await pb.collection('users').update(userId, {
links_created_this_month: (user.links_created_this_month || 0) + 1
});
// Log usage
await pb.collection('usage_logs').create({
user: userId,
action: 'link_created',
timestamp: new Date()
});
}
5.2 API Route Protection
// src/routes/api/links/+server.ts (Example)
import { json } from '@sveltejs/kit';
import { checkUsageLimit, incrementUsage } from '$lib/server/usage';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = locals.pb.authStore.model?.id;
// Check usage limit
const canCreate = await checkUsageLimit(locals.pb, userId);
if (!canCreate) {
return json(
{
error: 'Monthly limit reached. Please upgrade to Pro for unlimited links.',
requiresUpgrade: true
},
{ status: 403 }
);
}
try {
// Create the link
const data = await request.json();
const link = await locals.pb.collection('links').create({
...data,
user: userId
});
// Increment usage counter
await incrementUsage(locals.pb, userId);
return json(link);
} catch (error) {
return json({ error: 'Failed to create link' }, { status: 500 });
}
};
6. Success & Cancel Pages
6.1 Success Page
<!-- src/routes/checkout/success/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let verifying = true;
onMount(async () => {
// Verify the session with backend
const sessionId = $page.url.searchParams.get('session_id');
if (sessionId) {
// Optional: Verify session with backend
// await fetch(`/api/stripe/verify-session/${sessionId}`);
}
// Refresh user data
await fetch('/api/user/refresh');
verifying = false;
// Redirect to dashboard after 3 seconds
setTimeout(() => {
goto('/dashboard');
}, 3000);
});
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
{#if verifying}
<div class="loading loading-spinner loading-lg"></div>
<p class="mt-4">Verifiziere deine Zahlung...</p>
{:else}
<div class="mb-4 text-6xl">🎉</div>
<h1 class="mb-2 text-3xl font-bold">Willkommen bei ulo.ad Pro!</h1>
<p class="mb-4 text-lg">Dein Upgrade war erfolgreich.</p>
<p class="text-sm text-gray-600">
Du wirst in wenigen Sekunden zum Dashboard weitergeleitet...
</p>
{/if}
</div>
</div>
6.2 Cancel Page
<!-- src/routes/checkout/cancel/+page.svelte -->
<script lang="ts">
import { goto } from '$app/navigation';
import UpgradeButton from '$lib/components/UpgradeButton.svelte';
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="max-w-md text-center">
<div class="mb-4 text-6xl">🤔</div>
<h1 class="mb-2 text-2xl font-bold">Upgrade abgebrochen</h1>
<p class="mb-6">
Kein Problem! Du kannst jederzeit upgraden, wenn du bereit bist. Bis dahin kannst du weiterhin
10 Links pro Monat kostenlos erstellen.
</p>
<div class="flex justify-center gap-4">
<button on:click={() => goto('/dashboard')} class="btn btn-outline">
Zurück zum Dashboard
</button>
<UpgradeButton buttonText="Nochmal versuchen" />
</div>
</div>
</div>
7. Testing
7.1 Stripe Test Cards
Erfolgreiche Zahlung: 4242 4242 4242 4242
Zahlung erfordert Auth: 4000 0025 0000 3155
Zahlung abgelehnt: 4000 0000 0000 9995
SEPA Test IBAN: DE89 3704 0044 0532 0130 00
7.2 Webhook Testing mit Stripe CLI
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:5173/api/stripe/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
7.3 Test Checklist
- User kann Checkout Session erstellen
- Redirect zu Stripe funktioniert
- Success Page wird nach Zahlung angezeigt
- User Status wird auf "pro" gesetzt
- Webhooks werden verarbeitet
- Link-Limit wird für Free User enforced
- Pro User haben kein Limit
- Monthly Reset funktioniert
- Subscription Cancellation wird verarbeitet
- Failed Payments werden gehandhabt
8. Production Deployment
8.1 Environment Variables
# Production .env
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_live_xxx
PUBLIC_APP_URL=https://ulo.ad
8.2 Webhook URL Update
- Stripe Dashboard → Webhooks
- Update Endpoint URL zu
https://ulo.ad/api/stripe/webhook - Neues Webhook Secret notieren
8.3 SSL/HTTPS Requirement
Stripe requires HTTPS in production. Stelle sicher, dass deine Domain SSL-Zertifikate hat.
8.4 Monitoring
- Stripe Dashboard für Payment Analytics
- PocketBase Logs für Webhook-Fehler
- User Feedback für UX-Probleme
9. Rechtliche Anforderungen (Deutschland)
9.1 Impressum & AGB Updates
- Zahlungsdienstleister erwähnen
- Widerrufsrecht für digitale Dienstleistungen
- Preise inkl. MwSt. anzeigen
9.2 Rechnungsstellung
Stripe kann automatisch Rechnungen erstellen:
- Dashboard → Settings → Billing → Invoices
- Aktiviere "Email customers their invoices"
- Konfiguriere Rechnungsnummer-Format
- Füge Steuernummer hinzu
9.3 DSGVO
- Erwähne Stripe in Datenschutzerklärung
- Datenverarbeitung in den USA erwähnen
- Auftragsverarbeitungsvertrag mit Stripe
Support & Troubleshooting
Häufige Probleme
Webhook 400 Error
- Webhook Secret prüfen
- Body als raw text, nicht JSON parsen
User Status wird nicht aktualisiert
- Webhook Events in Stripe Dashboard prüfen
- PocketBase Permissions prüfen
CORS Fehler
- PUBLIC_APP_URL korrekt setzen
- Stripe Checkout erlaubt keine custom headers