## Stripe Checkout Rename
Alte Legacy-Tier-Namen 'standard/pro' (von alter Tier-Struktur) waren
irreführend — heute heißt es 'pro/legend'. Cleanup:
- ENV-Var-Namen: STRIPE_PRICE_<PLAN>_<BILLING> (computed) statt
hardcoded STANDARD/PRO Mapping. Erwartet:
STRIPE_PRICE_PRO_MONTHLY
STRIPE_PRICE_PRO_YEARLY
STRIPE_PRICE_LEGEND_MONTHLY
STRIPE_PRICE_LEGEND_YEARLY
- 'quarterly' billing entfernt (Strategist-Verdict: nur monthly + yearly,
'2 Monate gratis' bei yearly).
- metadata enthält jetzt billing zusätzlich zu plan.
Webhook-Audit: bereits korrekt (mapped session.metadata.plan → pro/legend/free
via simple switch).
User-Action benötigt (Stripe Test-Dashboard):
- 4 Products + Prices anlegen mit 14-Tage-Trial
- Pricing pro Strategist: Pro 3,99/Mo + 39,90/Yr (2mo gratis),
Legend 7,99/Mo + 79,90/Yr
- Webhook-Endpoint: https://staging.rebreak.org/api/stripe/webhook
(Events: checkout.session.completed, customer.subscription.{updated,deleted})
- ENV-Vars (incl. STRIPE_WEBHOOK_SECRET) in Infisical pflegen
## TTS Audio-Button in LyraBubble
DiGA-Accessibility: Screen-Reader-Alternative + Lese-Hürden-Mitigation.
- lib/lyraSpeech.ts: one-shot TTS-Helper (vereinfacht aus SosTtsQueue)
- Fetch /api/coach/speak mit Auth-Token
- Bytes → Base64 → temp-file → expo-av Audio.Sound
- Stop-fn: abortet in-flight fetch + unloaded sound
- Status-callback: idle | loading | playing
- LyraBubble: Audio-Button rechts oben (orange Pill, 34×34)
- Icon: volume-medium / hourglass / stop je nach status
- Auto-stop bei text-change (Slide-Switch) + unmount
- A11y-Labels in 4 Sprachen (audio_play / audio_loading / audio_stop)
Bubble-paddingRight erhöht auf 50 für Button-Platz.
## Locales
de/en/fr/ar: onboarding.lyra.audio_play / audio_loading / audio_stop
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
import Stripe from "stripe";
|
||
|
||
/**
|
||
* POST /api/stripe/checkout
|
||
*
|
||
* Erstellt eine Stripe-Checkout-Session für Pro oder Legend Subscription.
|
||
* Frontend (Web) öffnet die zurückgegebene URL — User landet im Stripe-Hosted
|
||
* Checkout, gibt Zahlungsmethode ein, wird zu success_url umgeleitet.
|
||
*
|
||
* iOS muss via RevenueCat/Apple-IAP — Stripe ist Web-only-Path (Apple-Guideline
|
||
* 3.1.1 verbietet externe Bezahlwege für digitale Subs in iOS-Apps).
|
||
*
|
||
* Body: { plan: 'pro' | 'legend', billing: 'monthly' | 'yearly' }
|
||
*
|
||
* ENV-Vars (in Infisical):
|
||
* STRIPE_PRICE_PRO_MONTHLY — Test/Live Stripe price_xxx ID
|
||
* STRIPE_PRICE_PRO_YEARLY
|
||
* STRIPE_PRICE_LEGEND_MONTHLY
|
||
* STRIPE_PRICE_LEGEND_YEARLY
|
||
*
|
||
* Tier-Mapping (post Free-Drop Pivot, 2026-05):
|
||
* Pro → 3,99 €/mo · 39,90 €/yr (2 Monate gratis)
|
||
* Legend → 7,99 €/mo · 79,90 €/yr (Multi-Device-Hardening)
|
||
*
|
||
* 14-Tage-Trial wird im Stripe-Dashboard pro Price konfiguriert (Trial-Period-
|
||
* Days) — der Endpoint passt sich automatisch an wenn der Price die Trial hat.
|
||
*/
|
||
export default defineEventHandler(async (event) => {
|
||
const config = useRuntimeConfig();
|
||
|
||
if (!config.stripeSecretKey) {
|
||
throw createError({
|
||
statusCode: 500,
|
||
message: "Stripe nicht konfiguriert – STRIPE_SECRET_KEY fehlt",
|
||
});
|
||
}
|
||
|
||
const stripe = new Stripe(config.stripeSecretKey);
|
||
const user = await requireUser(event);
|
||
|
||
const body = await readBody(event);
|
||
const plan = body?.plan as string;
|
||
const billing = (body?.billing as string) || "monthly";
|
||
|
||
if (!plan || !["pro", "legend"].includes(plan)) {
|
||
throw createError({
|
||
statusCode: 400,
|
||
message: "Ungültiger Plan (erwartet: 'pro' oder 'legend')",
|
||
});
|
||
}
|
||
if (!["monthly", "yearly"].includes(billing)) {
|
||
throw createError({
|
||
statusCode: 400,
|
||
message: "Ungültiger Billing-Zyklus (erwartet: 'monthly' oder 'yearly')",
|
||
});
|
||
}
|
||
|
||
// ENV-Var-Naming-Pattern: STRIPE_PRICE_<PLAN>_<BILLING> in Großbuchstaben
|
||
const envKey = `STRIPE_PRICE_${plan.toUpperCase()}_${billing.toUpperCase()}`;
|
||
const priceId = process.env[envKey];
|
||
|
||
if (!priceId || !priceId.startsWith("price_")) {
|
||
throw createError({
|
||
statusCode: 503,
|
||
message: `Dieser Plan ist noch nicht verfügbar. (${envKey} nicht in Infisical gesetzt)`,
|
||
});
|
||
}
|
||
|
||
const appUrl = config.public.appUrl || "https://rebreak.org";
|
||
|
||
const session = await stripe.checkout.sessions.create({
|
||
mode: "subscription",
|
||
line_items: [{ price: priceId, quantity: 1 }],
|
||
success_url: `${appUrl}/app/settings?upgraded=true`,
|
||
cancel_url: `${appUrl}/pricing`,
|
||
client_reference_id: user.id,
|
||
metadata: {
|
||
user_id: user.id,
|
||
plan,
|
||
billing,
|
||
},
|
||
});
|
||
|
||
return { url: session.url };
|
||
});
|