From 8425651a819fed0526aa92432afc8190a9a548b0 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Thu, 1 May 2025 14:06:11 +0300 Subject: [PATCH 1/6] feat: Implement subscription management features - Add API endpoint for creating Stripe checkout sessions to handle subscriptions. - Add API endpoint for creating Stripe customers linked to Supabase users. - Implement webhook handling for Stripe subscription events to update subscription status in the database. - Create subscription page with pricing plans and subscription activation flow. - Add success and cancellation pages for subscription checkout. - Integrate subscription checks in workout history and workout generation pages. - Create database migrations for completed exercises and Stripe subscription tables. - Implement row-level security policies for user data access in Supabase. --- app/STRIPE_SETUP.md | 129 ++++++++++++ app/package.json | 3 +- app/pnpm-lock.yaml | 26 +++ app/src/lib/components/NavigationMenu.svelte | 12 ++ .../lib/components/SubscriptionGuard.svelte | 52 +++++ app/src/lib/database/supabase-repository.ts | 34 +++ app/src/lib/server/env.ts | 50 +++++ app/src/lib/server/stripe.ts | 86 ++++++++ app/src/lib/server/supabase-admin.ts | 61 ++++++ app/src/lib/subscription/pricing-plans.ts | 76 +++++++ .../lib/subscription/subscription-service.ts | 182 ++++++++++++++++ .../subscriptions/create-checkout/+server.ts | 98 +++++++++ .../subscriptions/create-customer/+server.ts | 81 ++++++++ .../api/subscriptions/webhook/+server.ts | 196 ++++++++++++++++++ app/src/routes/history/+page.svelte | 146 +++++++------ app/src/routes/subscription/+page.svelte | 186 +++++++++++++++++ .../subscription/cancelled/+page.svelte | 53 +++++ .../routes/subscription/success/+page.svelte | 93 +++++++++ app/src/routes/workout/+page.svelte | 96 +++++++-- app/supabase/.branches/_current_branch | 1 + 20 files changed, 1585 insertions(+), 76 deletions(-) create mode 100644 app/STRIPE_SETUP.md create mode 100644 app/src/lib/components/SubscriptionGuard.svelte create mode 100644 app/src/lib/server/env.ts create mode 100644 app/src/lib/server/stripe.ts create mode 100644 app/src/lib/server/supabase-admin.ts create mode 100644 app/src/lib/subscription/pricing-plans.ts create mode 100644 app/src/lib/subscription/subscription-service.ts create mode 100644 app/src/routes/api/subscriptions/create-checkout/+server.ts create mode 100644 app/src/routes/api/subscriptions/create-customer/+server.ts create mode 100644 app/src/routes/api/subscriptions/webhook/+server.ts create mode 100644 app/src/routes/subscription/+page.svelte create mode 100644 app/src/routes/subscription/cancelled/+page.svelte create mode 100644 app/src/routes/subscription/success/+page.svelte create mode 100644 app/supabase/.branches/_current_branch diff --git a/app/STRIPE_SETUP.md b/app/STRIPE_SETUP.md new file mode 100644 index 0000000..bc76e7f --- /dev/null +++ b/app/STRIPE_SETUP.md @@ -0,0 +1,129 @@ +# Stripe Subscription Setup Guide + +This guide explains how to set up Stripe subscriptions for the Workouts application. + +## Prerequisites + +1. A Stripe account (can be a test account for development) +2. A Supabase project with the database migrations applied + +## Environment Variables + +Add the following environment variables to your `.env` file in the app directory: + +```bash +# Stripe API keys +STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key +STRIPE_SECRET_KEY=sk_test_your_secret_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret + +# Supabase Service Role Key (for webhook operations) +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key +SUPABASE_URL=your_supabase_url +``` + +> Note: For backward compatibility, the application also supports the legacy `VITE_` prefixed versions of these variables, but the non-prefixed versions are recommended. + +## Stripe Product and Price Setup + +1. Log in to your Stripe Dashboard: https://dashboard.stripe.com/ +2. Go to Product Catalogue > Create Product +3. Create the following products and prices: + +### Monthly Subscription +- Name: "Workouts Pro - Monthly" +- Description: "Full access to all workout features with cloud sync" +- Price: + - Currency: USD + - Amount: $8.99 + - Recurring: Monthly + - Save the price ID (starts with "price_") and update in `pricing-plans.ts` + +### Annual Subscription +- Name: "Workouts Pro - Annual" +- Description: "Full access with 2 months free" +- Price: + - Currency: USD + - Amount: $89.99 + - Recurring: Annual + - Save the price ID (starts with "price_") and update in `pricing-plans.ts` + +## Webhook Setup + +1. In the Stripe Dashboard, go to Developers > Webhooks +2. Add an endpoint with your application's URL: + - Local development: Use Stripe CLI or a tunneling service like ngrok + - Production: https://your-domain.com/api/subscriptions/webhook +3. Add these events to listen for: + - customer.subscription.created + - customer.subscription.updated + - customer.subscription.deleted + - checkout.session.completed +4. Copy the Webhook Secret and add it to your `.env` file as `STRIPE_WEBHOOK_SECRET` + +## Update Price IDs in the Application + +Open `/src/lib/subscription/pricing-plans.ts` and update the Stripe product and price IDs with your actual values from Stripe: + +```typescript +export const pricingPlans: PricingPlan[] = [ + // ... free plan ... + { + id: 'monthly', + // ... other properties ... + stripe_product_id: 'prod_your_monthly_product_id', + stripe_price_id: 'price_your_monthly_price_id', + }, + { + id: 'yearly', + // ... other properties ... + stripe_product_id: 'prod_your_yearly_product_id', + stripe_price_id: 'price_your_yearly_price_id', + } +]; +``` + +## Testing Subscriptions + +1. Use Stripe's test credit card numbers for testing: + - Successful payment: 4242 4242 4242 4242 + - Failed payment: 4000 0000 0000 0002 +2. Set an expiration date in the future, any CVC, and any billing address + +### Listening to Webhook Events + +- Ensure your server is running and can receive webhook events +- Use the Stripe CLI to forward events to your local server: + ```bash + stripe listen --forward-to localhost:5173/api/subscriptions/webhook + ``` +- Copy the webhook signing secret from the CLI output and add it to your `.env` file as `STRIPE_WEBHOOK_SECRET` +- Test the webhook by triggering events from the Stripe Dashboard or using the CLI: + ```bash + stripe trigger customer.subscription.created + ``` +- Check your server logs to confirm that the webhook was received and processed correctly +- Verify that the subscription status is updated in your Supabase database +- Check the Stripe Dashboard for the event logs to see if the webhook was successful +- Ensure that the subscription status is updated in your Supabase database + + +## Going Live + +Before going live: + +1. Switch your Stripe keys from test to production +2. Update your webhook endpoint to your production URL +3. Test the full subscription flow in your production environment + +## Troubleshooting + +### Webhook Issues +- Check webhook logs in Stripe Dashboard +- Ensure your webhook secret is correctly configured +- Verify that your server can receive POST requests at the webhook endpoint + +### Subscription Issues +- Validate that the product and price IDs are correct +- Check the Stripe dashboard for subscription status +- Look for error logs in your application server \ No newline at end of file diff --git a/app/package.json b/app/package.json index b241aa8..c559968 100644 --- a/app/package.json +++ b/app/package.json @@ -23,7 +23,8 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.4", "@vitest/coverage-v8": "3.0.8", - "dexie": "^4.0.11" + "dexie": "^4.0.11", + "stripe": "^18.1.0" }, "devDependencies": { "@eslint/compat": "^1.2.8", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 5a610c0..dcf1b0c 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: dexie: specifier: ^4.0.11 version: 4.0.11 + stripe: + specifier: ^18.1.0 + version: 18.1.0(@types/node@22.14.1) devDependencies: '@eslint/compat': specifier: ^1.2.8 @@ -2402,6 +2405,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2637,6 +2644,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@18.1.0: + resolution: {integrity: sha512-MLDiniPTHqcfIT3anyBPmOEcaiDhYa7/jRaNypQ3Rt2SJnayQZBvVbFghIziUCZdltGAndm/ZxVOSw6uuSCDig==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -5137,6 +5153,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} react-is@17.0.2: {} @@ -5448,6 +5468,12 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@18.1.0(@types/node@22.14.1): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 22.14.1 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 diff --git a/app/src/lib/components/NavigationMenu.svelte b/app/src/lib/components/NavigationMenu.svelte index 740a047..757abf4 100644 --- a/app/src/lib/components/NavigationMenu.svelte +++ b/app/src/lib/components/NavigationMenu.svelte @@ -2,6 +2,7 @@ import { page } from "$app/state"; import { base } from "$app/paths"; import AuthDropdown from "$lib/components/AuthDropdown.svelte"; + import { hasActiveSubscription } from "$lib/subscription/subscription-service"; // Track active route to highlight current page let pathname = $derived(page.url.pathname); @@ -72,6 +73,17 @@ Guidelines + + {$hasActiveSubscription ? "★ Pro" : "Upgrade"} + +
diff --git a/app/src/lib/components/SubscriptionGuard.svelte b/app/src/lib/components/SubscriptionGuard.svelte new file mode 100644 index 0000000..2858b7c --- /dev/null +++ b/app/src/lib/components/SubscriptionGuard.svelte @@ -0,0 +1,52 @@ + + +{#if $hasActiveSubscription} + +{:else} +
+
+

Subscription Required

+

+ This feature requires an active subscription to unlock full + functionality. +

+ {#if $user} +
+ +
+ {:else if showLoginRedirect} +
+ + +
+ {/if} +
+
+{/if} diff --git a/app/src/lib/database/supabase-repository.ts b/app/src/lib/database/supabase-repository.ts index c810918..2d9242d 100644 --- a/app/src/lib/database/supabase-repository.ts +++ b/app/src/lib/database/supabase-repository.ts @@ -1,10 +1,19 @@ import { supabase } from "$lib/supabase/client"; import type { CompletedExerciseV2 } from "$lib/exercises"; import { fromSupabaseFormat, toSupabaseFormat } from "./models"; +import { isSubscriptionActive } from "$lib/subscription/subscription-service"; // Table name for completed exercises in Supabase const COMPLETED_EXERCISES_TABLE = "completed_exercises"; +/** + * Check if the user has permission to use Supabase storage + * This will check if the user has an active subscription + */ +async function canUseSupabase(): Promise { + return await isSubscriptionActive(); +} + /** * Save a completed exercise to Supabase * @param exercise - The completed exercise to save @@ -15,6 +24,11 @@ export async function saveCompletedExerciseToSupabase( exercise: CompletedExerciseV2, userId: string, ): Promise { + // Check subscription status + if (!(await canUseSupabase())) { + throw new Error("Subscription required to save exercises to the cloud"); + } + const supabaseExercise = toSupabaseFormat(exercise, userId); try { @@ -48,6 +62,11 @@ export async function getCompletedExercisesByExerciseIdFromSupabase( exerciseId: string, userId: string, ): Promise { + // Check subscription status + if (!(await canUseSupabase())) { + throw new Error("Subscription required to fetch exercises from the cloud"); + } + const { data, error } = await supabase .from(COMPLETED_EXERCISES_TABLE) .select("*") @@ -77,6 +96,11 @@ export async function getCompletedExercisesByDateRangeFromSupabase( endDate: Date, userId: string, ): Promise { + // Check subscription status + if (!(await canUseSupabase())) { + throw new Error("Subscription required to fetch exercises from the cloud"); + } + const { data, error } = await supabase .from(COMPLETED_EXERCISES_TABLE) .select("*") @@ -105,6 +129,11 @@ export async function deleteCompletedExerciseFromSupabase( id: number, userId: string, ): Promise { + // Check subscription status + if (!(await canUseSupabase())) { + throw new Error("Subscription required to delete exercises from the cloud"); + } + const { error } = await supabase .from(COMPLETED_EXERCISES_TABLE) .delete() @@ -129,6 +158,11 @@ export async function syncExercisesToSupabase( exercises: CompletedExerciseV2[], userId: string, ): Promise { + // Check subscription status + if (!(await canUseSupabase())) { + throw new Error("Subscription required to sync exercises to the cloud"); + } + const supabaseExercises = exercises.map((ex) => toSupabaseFormat(ex, userId)); const { error } = await supabase diff --git a/app/src/lib/server/env.ts b/app/src/lib/server/env.ts new file mode 100644 index 0000000..d4c34c5 --- /dev/null +++ b/app/src/lib/server/env.ts @@ -0,0 +1,50 @@ +/** + * Server environment variables + * These are only accessible on the server side for security + */ + +import { env } from "$env/dynamic/private"; + +// The server-side environment variables +// Using platform's env system for private variables first, then import.meta.env as fallback +export const STRIPE_SECRET_KEY = + env.STRIPE_SECRET_KEY || + import.meta.env.STRIPE_SECRET_KEY || + import.meta.env.VITE_STRIPE_SECRET_KEY || + ""; +export const STRIPE_WEBHOOK_SECRET = + env.STRIPE_WEBHOOK_SECRET || + import.meta.env.STRIPE_WEBHOOK_SECRET || + import.meta.env.VITE_STRIPE_WEBHOOK_SECRET || + ""; +export const SUPABASE_SERVICE_ROLE_KEY = + env.SUPABASE_SERVICE_ROLE_KEY || + import.meta.env.SUPABASE_SERVICE_ROLE_KEY || + import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY || + ""; +export const SUPABASE_URL = + env.SUPABASE_URL || + import.meta.env.SUPABASE_URL || + import.meta.env.VITE_SUPABASE_URL || + ""; + +// Function to check if all required environment variables are set +export function validateEnv() { + const requiredVars = [ + { name: "STRIPE_SECRET_KEY", value: STRIPE_SECRET_KEY }, + { name: "STRIPE_WEBHOOK_SECRET", value: STRIPE_WEBHOOK_SECRET }, + { name: "SUPABASE_SERVICE_ROLE_KEY", value: SUPABASE_SERVICE_ROLE_KEY }, + { name: "SUPABASE_URL", value: SUPABASE_URL }, + ]; + + const missingVars = requiredVars.filter((v) => !v.value).map((v) => v.name); + + if (missingVars.length > 0) { + console.error( + `Missing required environment variables: ${missingVars.join(", ")}`, + ); + return false; + } + + return true; +} diff --git a/app/src/lib/server/stripe.ts b/app/src/lib/server/stripe.ts new file mode 100644 index 0000000..cd46e8e --- /dev/null +++ b/app/src/lib/server/stripe.ts @@ -0,0 +1,86 @@ +/** + * Stripe Service + * + * This module centralizes all Stripe API initialization and provides + * a configured instance for use across the application. + */ +import Stripe from "stripe"; +import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from "$lib/server/env"; + +// Stripe API version to use +const STRIPE_API_VERSION = "2025-04-30.basil"; + +/** + * Check for missing Stripe configuration and log warnings + */ +const validateStripeConfig = (): boolean => { + if (!STRIPE_SECRET_KEY) { + console.error( + "STRIPE_SECRET_KEY is not defined. Please check your environment variables.", + ); + return false; + } + + if (!STRIPE_WEBHOOK_SECRET) { + console.warn( + "STRIPE_WEBHOOK_SECRET is not defined. Webhook verification will fail.", + ); + } + + return true; +}; + +// Validate the Stripe configuration when this module is loaded +validateStripeConfig(); + +/** + * Create and export the Stripe instance + * Use a fallback dummy key for initialization to prevent immediate crashes, + * but operations will be guarded at runtime + */ +export const stripe = new Stripe( + STRIPE_SECRET_KEY || "dummy_key_for_initialization", + { + apiVersion: STRIPE_API_VERSION, + }, +); + +/** + * Helper function to verify if Stripe is properly configured + * Use this before making Stripe API calls + */ +export const isStripeConfigured = (): boolean => { + return !!STRIPE_SECRET_KEY; +}; + +/** + * Helper function to verify a Stripe webhook signature + * @param body The raw request body as a string + * @param signature The Stripe signature header + * @returns The parsed Stripe event if valid + * @throws Error if the signature is invalid or STRIPE_WEBHOOK_SECRET is not configured + */ +export const verifyStripeWebhook = ( + body: string, + signature: string, +): Stripe.Event => { + if (!STRIPE_WEBHOOK_SECRET) { + throw new Error( + "STRIPE_WEBHOOK_SECRET is not configured. Cannot verify webhook.", + ); + } + + return stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET); +}; + +/** + * Types for Stripe operations + */ +export type StripeSubscriptionStatus = + | "active" + | "canceled" + | "incomplete" + | "incomplete_expired" + | "past_due" + | "trialing" + | "unpaid"; diff --git a/app/src/lib/server/supabase-admin.ts b/app/src/lib/server/supabase-admin.ts new file mode 100644 index 0000000..47b046c --- /dev/null +++ b/app/src/lib/server/supabase-admin.ts @@ -0,0 +1,61 @@ +/** + * Supabase Admin Client + * + * This module centralizes the Supabase admin client setup, using the service role key + * for administrative operations that require elevated permissions. + */ +import { createClient } from "@supabase/supabase-js"; +import { SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY } from "$lib/server/env"; +import type { Database } from "$lib/database/supabase-types"; + +/** + * Check if Supabase admin credentials are properly configured + */ +const validateSupabaseAdmin = (): boolean => { + if (!SUPABASE_URL) { + console.error( + "SUPABASE_URL is not defined. Please check your environment variables.", + ); + return false; + } + + if (!SUPABASE_SERVICE_ROLE_KEY) { + console.error( + "SUPABASE_SERVICE_ROLE_KEY is not defined. Please check your environment variables.", + ); + return false; + } + + return true; +}; + +// Validate on module initialization +const isConfigValid = validateSupabaseAdmin(); + +/** + * Centralized admin client with service role for backend operations + * We use a safe initialization approach to prevent errors during import + */ +export const supabaseAdmin = isConfigValid + ? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) + : null; + +/** + * Helper function to check if the admin client is properly configured + * Use this before performing operations with supabaseAdmin + */ +export const isSupabaseAdminConfigured = (): boolean => { + return !!supabaseAdmin; +}; + +/** + * Get the properly initialized admin client or throw a descriptive error + */ +export function getSupabaseAdmin(): ReturnType> { + if (!supabaseAdmin) { + throw new Error( + "Supabase admin client is not configured. Check your environment variables.", + ); + } + return supabaseAdmin; +} diff --git a/app/src/lib/subscription/pricing-plans.ts b/app/src/lib/subscription/pricing-plans.ts new file mode 100644 index 0000000..8497fab --- /dev/null +++ b/app/src/lib/subscription/pricing-plans.ts @@ -0,0 +1,76 @@ +/** + * Workout app subscription pricing plans + */ + +export interface PricingPlan { + id: string; + name: string; + description: string; + price: number; + currency: string; + interval: "month" | "year"; + features: string[]; + stripe_product_id: string; + stripe_price_id: string; + active: boolean; +} + +/** + * Pricing plans for the workout app + * These will be synchronized to the database + */ +export const pricingPlans: PricingPlan[] = [ + { + id: "free", + name: "Free Plan", + description: "Basic features with limited data storage", + price: 0, + currency: "usd", + interval: "month", + features: [ + "Access to workout generator", + "Limited exercise history", + "Local storage only", + ], + stripe_product_id: "", // Free plan doesn't need a Stripe product + stripe_price_id: "", + active: true, + }, + { + id: "monthly", + name: "Monthly Plan", + description: "Full access to all features with cloud sync", + price: 500, // €5.00 + currency: "eur", + interval: "month", + features: [ + "All workout exercises", + "Unlimited workout history", + "Cloud data sync", + "Progress analytics", + "Priority support", + ], + stripe_product_id: "prod_S7yURRJSZJHP9d", + stripe_price_id: "price_1RDiXiIMUCSg0j0skDWw4IBg", + active: true, + }, + { + id: "yearly", + name: "Annual Plan", + description: "Full access with 2 months free", + price: 3000, // €30.00 + currency: "eur", + interval: "year", + features: [ + "All workout exercises", + "Unlimited workout history", + "Cloud data sync", + "Progress analytics", + "Priority support", + "Save 50% compared to monthly", + ], + stripe_product_id: "prod_S7yURRJSZJHP9d", + stripe_price_id: "price_1RDiabIMUCSg0j0sJ8ciAdWC", + active: true, + }, +]; diff --git a/app/src/lib/subscription/subscription-service.ts b/app/src/lib/subscription/subscription-service.ts new file mode 100644 index 0000000..833af4f --- /dev/null +++ b/app/src/lib/subscription/subscription-service.ts @@ -0,0 +1,182 @@ +/** + * Subscription service for managing Stripe subscriptions + */ +import { supabase } from "$lib/supabase/client"; +import { getCurrentUserId } from "$lib/supabase/auth"; +import type { Database } from "$lib/database/supabase-types"; +import type Stripe from "stripe"; +import { writable, derived } from "svelte/store"; + +// Types for subscription data +export interface UserSubscription { + id: string; + status: string; + priceId: string | null; + productId: string | null; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: Date | null; + createdAt: Date; + stripeSubscriptionId: string | null; +} + +export type SubscriptionStatus = + | "active" + | "trialing" + | "past_due" + | "canceled" + | "incomplete" + | "incomplete_expired" + | "unpaid" + | "paused" + | null; + +// Store for subscription status +export const subscription = writable(null); + +// Derived store that tells if the user has an active subscription +export const hasActiveSubscription = derived(subscription, ($subscription) => { + if (!$subscription) return false; + + // Check if subscription is in an active state + return ["active", "trialing"].includes($subscription.status); +}); + +/** + * Initialize subscription data for a user + */ +export async function initSubscription(): Promise { + const userId = getCurrentUserId(); + + if (!userId) { + subscription.set(null); + return; + } + + try { + const { data, error } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(1) + .single(); + + if (error) { + console.error("Error fetching subscription:", error); + subscription.set(null); + return; + } + + if (data) { + subscription.set({ + id: data.id, + status: data.status, + priceId: data.stripe_price_id, + productId: data.stripe_product_id, + cancelAtPeriodEnd: data.cancel_at_period_end, + currentPeriodEnd: data.current_period_end + ? new Date(data.current_period_end) + : null, + createdAt: new Date(data.created_at), + stripeSubscriptionId: data.stripe_subscription_id, + }); + } else { + subscription.set(null); + } + } catch (err) { + console.error("Failed to initialize subscription:", err); + subscription.set(null); + } +} + +/** + * Get or create a Stripe customer ID for the current user + * @param user_id - The Supabase user ID + * @returns The Stripe customer ID + */ +export async function getOrCreateCustomerId( + user_id: string, +): Promise { + try { + // Check if the user already has a Stripe customer ID + const { data: existingCustomer, error: fetchError } = await supabase + .from("stripe_customers") + .select("stripe_customer_id") + .eq("user_id", user_id) + .single(); + + if (fetchError && fetchError.code !== "PGRST116") { + // PGRST116 = not found + console.error("Error fetching customer:", fetchError); + return null; + } + + if (existingCustomer?.stripe_customer_id) { + return existingCustomer.stripe_customer_id; + } + + // If no customer exists, a new one needs to be created via the server + // This would normally be done through a server API endpoint that uses the Stripe secret key + // For security, we can't create customers directly from the client + + const response = await fetch("/api/subscriptions/create-customer", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ user_id }), + }); + + if (!response.ok) { + throw new Error("Failed to create customer"); + } + + const { customerId } = await response.json(); + return customerId; + } catch (err) { + console.error("Error in getOrCreateCustomerId:", err); + return null; + } +} + +/** + * Determine if the current user's subscription is active + * @returns Promise that resolves to a boolean indicating if the subscription is active + */ +export async function isSubscriptionActive(): Promise { + const userId = getCurrentUserId(); + + if (!userId) { + return false; + } + + try { + const { data, error } = await supabase + .from("subscriptions") + .select("status") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(1) + .single(); + + if (error || !data) { + return false; + } + + return ["active", "trialing"].includes(data.status); + } catch (err) { + console.error("Error checking subscription status:", err); + return false; + } +} + +// Subscribe to auth changes to keep subscription state in sync +import { user } from "$lib/supabase/client"; + +user.subscribe(($user) => { + if ($user) { + initSubscription(); + } else { + subscription.set(null); + } +}); diff --git a/app/src/routes/api/subscriptions/create-checkout/+server.ts b/app/src/routes/api/subscriptions/create-checkout/+server.ts new file mode 100644 index 0000000..5b06424 --- /dev/null +++ b/app/src/routes/api/subscriptions/create-checkout/+server.ts @@ -0,0 +1,98 @@ +import { json } from "@sveltejs/kit"; +import { stripe, isStripeConfigured } from "$lib/server/stripe"; +import { + getSupabaseAdmin, + isSupabaseAdminConfigured, +} from "$lib/server/supabase-admin"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, url, locals }) => { + try { + // Ensure services are properly configured + if (!isStripeConfigured()) { + return json( + { error: "Stripe API key is not configured" }, + { status: 500 }, + ); + } + + if (!isSupabaseAdminConfigured()) { + return json( + { error: "Supabase admin client is not configured" }, + { status: 500 }, + ); + } + + const supabaseAdmin = getSupabaseAdmin(); + + // Ensure user is authenticated + const session = await locals.getSession(); + if (!session) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + const { priceId } = await request.json(); + + if (!priceId) { + return json({ error: "Price ID is required" }, { status: 400 }); + } + + const user_id = session.user.id; + + // Get the customer ID for this user + const { data: customerData, error: customerError } = await supabaseAdmin + .from("stripe_customers") + .select("stripe_customer_id") + .eq("user_id", user_id) + .single(); + + if (customerError) { + console.error("Error fetching customer:", customerError); + return json({ error: "Customer not found" }, { status: 404 }); + } + + const customerId = customerData.stripe_customer_id; + + // Check if user already has an active subscription + const { data: subscriptionData } = await supabaseAdmin + .from("subscriptions") + .select("*") + .eq("user_id", user_id) + .in("status", ["active", "trialing"]) + .maybeSingle(); + + if (subscriptionData) { + return json( + { error: "User already has an active subscription" }, + { status: 400 }, + ); + } + + // Create a checkout session + const checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: `${url.origin}/subscription/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${url.origin}/subscription/cancelled`, + subscription_data: { + metadata: { + user_id, + }, + }, + }); + + return json({ url: checkoutSession.url }); + } catch (error) { + console.error("Error creating checkout session:", error); + return json( + { error: "Failed to create checkout session" }, + { status: 500 }, + ); + } +}; diff --git a/app/src/routes/api/subscriptions/create-customer/+server.ts b/app/src/routes/api/subscriptions/create-customer/+server.ts new file mode 100644 index 0000000..2152d4f --- /dev/null +++ b/app/src/routes/api/subscriptions/create-customer/+server.ts @@ -0,0 +1,81 @@ +import { json } from "@sveltejs/kit"; +import { stripe, isStripeConfigured } from "$lib/server/stripe"; +import { + getSupabaseAdmin, + isSupabaseAdminConfigured, +} from "$lib/server/supabase-admin"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, locals }) => { + try { + // Ensure services are properly configured + if (!isStripeConfigured()) { + return json( + { error: "Stripe API key is not configured" }, + { status: 500 }, + ); + } + + if (!isSupabaseAdminConfigured()) { + return json( + { error: "Supabase admin client is not configured" }, + { status: 500 }, + ); + } + + const supabaseAdmin = getSupabaseAdmin(); + + // Ensure user is authenticated + const session = await locals.getSession(); + if (!session) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + const { user_id } = await request.json(); + + // Ensure the authenticated user is only creating a customer for themselves + if (session.user.id !== user_id) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch user data to create customer + const { data: userData, error: userError } = await supabaseAdmin + .from("profiles") + .select("*") + .eq("id", user_id) + .single(); + + if (userError) { + console.error("Error fetching user profile:", userError); + return json({ error: "Failed to fetch user profile" }, { status: 500 }); + } + + // Create a Stripe customer + const customer = await stripe.customers.create({ + email: session.user.email, + name: userData.full_name || session.user.email, + metadata: { + user_id, + }, + }); + + // Save the customer ID in our database + const { error: insertError } = await supabaseAdmin + .from("stripe_customers") + .insert({ + user_id, + stripe_customer_id: customer.id, + updated_at: new Date().toISOString(), + }); + + if (insertError) { + console.error("Error saving customer ID:", insertError); + return json({ error: "Failed to save customer ID" }, { status: 500 }); + } + + return json({ customerId: customer.id }); + } catch (error) { + console.error("Error creating customer:", error); + return json({ error: "Failed to create customer" }, { status: 500 }); + } +}; diff --git a/app/src/routes/api/subscriptions/webhook/+server.ts b/app/src/routes/api/subscriptions/webhook/+server.ts new file mode 100644 index 0000000..daf9ccf --- /dev/null +++ b/app/src/routes/api/subscriptions/webhook/+server.ts @@ -0,0 +1,196 @@ +import { json } from "@sveltejs/kit"; +import { + stripe, + isStripeConfigured, + verifyStripeWebhook, +} from "$lib/server/stripe"; +import { + getSupabaseAdmin, + isSupabaseAdminConfigured, +} from "$lib/server/supabase-admin"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.text(); + const signature = request.headers.get("stripe-signature"); + + if (!signature) { + return json({ error: "Missing Stripe signature" }, { status: 400 }); + } + + // Ensure services are properly configured + if (!isStripeConfigured()) { + return json({ error: "Stripe API key is not configured" }, { status: 500 }); + } + + if (!isSupabaseAdminConfigured()) { + return json( + { error: "Supabase admin client is not configured" }, + { status: 500 }, + ); + } + + try { + // Verify the webhook signature using our helper function + const event = verifyStripeWebhook(body, signature); + const supabaseAdmin = getSupabaseAdmin(); + + // Handle specific events + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": + await handleSubscriptionUpdated(event.data.object, supabaseAdmin); + break; + + case "customer.subscription.deleted": + await handleSubscriptionDeleted(event.data.object, supabaseAdmin); + break; + + case "checkout.session.completed": + await handleCheckoutSessionCompleted(event.data.object); + break; + } + + return json({ received: true }); + } catch (err) { + console.error( + `Webhook Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return json( + { + error: `Webhook Error: ${err instanceof Error ? err.message : String(err)}`, + }, + { status: 400 }, + ); + } +}; + +/** + * Handle subscription updates (created or updated) + */ +async function handleSubscriptionUpdated( + subscription: Stripe.Subscription, + supabaseAdmin: ReturnType, +) { + const userId = subscription.metadata.user_id; + if (!userId) { + // Try to get user_id from customer metadata + const customer = await stripe.customers.retrieve( + subscription.customer as string, + ); + if (!customer.deleted && customer.metadata.user_id) { + await updateSubscription( + customer.metadata.user_id, + subscription, + supabaseAdmin, + ); + } else { + console.error( + "Could not find user_id for subscription:", + subscription.id, + ); + } + } else { + await updateSubscription(userId, subscription, supabaseAdmin); + } +} + +/** + * Handle subscription deletions + */ +async function handleSubscriptionDeleted( + subscription: Stripe.Subscription, + supabaseAdmin: ReturnType, +) { + const userId = subscription.metadata.user_id; + if (!userId) { + // Try to get user_id from customer metadata + const customer = await stripe.customers.retrieve( + subscription.customer as string, + ); + if (!customer.deleted && customer.metadata.user_id) { + await updateSubscription( + customer.metadata.user_id, + subscription, + supabaseAdmin, + ); + } else { + console.error( + "Could not find user_id for deleted subscription:", + subscription.id, + ); + } + } else { + await updateSubscription(userId, subscription, supabaseAdmin); + } +} + +/** + * Handle checkout session completion + */ +async function handleCheckoutSessionCompleted( + session: Stripe.Checkout.Session, +) { + // For one-time payments, you might create a purchase record here + // For subscriptions, the subscription events will handle it + if (session.mode === "subscription" && session.subscription) { + // The subscription_id is available, but we'll let the subscription events handle the details + console.log(`Checkout completed for subscription: ${session.subscription}`); + } +} + +/** + * Update subscription in database + */ +async function updateSubscription( + userId: string, + subscription: Stripe.Subscription, + supabaseAdmin: ReturnType, +) { + try { + // Get the price and product details + const priceId = subscription.items.data[0]?.price.id; + const productId = subscription.items.data[0]?.price.product; + + // Prepare the subscription data + const subscriptionData = { + user_id: userId, + stripe_subscription_id: subscription.id, + stripe_price_id: priceId, + stripe_product_id: typeof productId === "string" ? productId : null, + status: subscription.status, + cancel_at_period_end: subscription.cancel_at_period_end, + current_period_start: new Date( + subscription.current_period_start * 1000, + ).toISOString(), + current_period_end: new Date( + subscription.current_period_end * 1000, + ).toISOString(), + updated_at: new Date().toISOString(), + ended_at: subscription.ended_at + ? new Date(subscription.ended_at * 1000).toISOString() + : null, + }; + + // Check if a subscription record already exists + const { data: existingSubscription } = await supabaseAdmin + .from("subscriptions") + .select("id") + .eq("stripe_subscription_id", subscription.id) + .maybeSingle(); + + if (existingSubscription) { + // Update existing subscription + await supabaseAdmin + .from("subscriptions") + .update(subscriptionData) + .eq("id", existingSubscription.id); + } else { + // Insert new subscription + await supabaseAdmin.from("subscriptions").insert(subscriptionData); + } + } catch (error) { + console.error("Error updating subscription in database:", error); + throw error; + } +} diff --git a/app/src/routes/history/+page.svelte b/app/src/routes/history/+page.svelte index 71900a7..52599a8 100644 --- a/app/src/routes/history/+page.svelte +++ b/app/src/routes/history/+page.svelte @@ -1,6 +1,8 @@ + + Workout History | Workouts App + +

Exercise History

@@ -105,70 +122,73 @@

No exercise history found for the selected date range.

{:else} -
- - - - - - - - - - - - - {#each completedExercises as exercise} + + +
+
- Date - - Exercise ID - - Sets - - Reps - - Weight - - Time -
+ - - - - - - + + + + + + - {/each} - -
- {formatDate(exercise.completed_at)} - - {exercise.exercise_id} - - {exercise.metrics.sets ?? "-"} - - {exercise.metrics.reps ?? "-"} - - {exercise.metrics.weight - ? `${exercise.metrics.weight}kg` - : "-"} - - {exercise.metrics.time ?? "-"} - + Date + + Exercise ID + + Sets + + Reps + + Weight + + Time +
-
+ + + {#each completedExercises as exercise} + + + {formatDate(exercise.completed_at)} + + + {exercise.exercise_id} + + + {exercise.metrics.sets ?? "-"} + + + {exercise.metrics.reps ?? "-"} + + + {exercise.metrics.weight + ? `${exercise.metrics.weight}kg` + : "-"} + + + {exercise.metrics.time ?? "-"} + + + {/each} + + +
+ {/if} diff --git a/app/src/routes/subscription/+page.svelte b/app/src/routes/subscription/+page.svelte new file mode 100644 index 0000000..bbeed87 --- /dev/null +++ b/app/src/routes/subscription/+page.svelte @@ -0,0 +1,186 @@ + + + + Subscription Plans | Workouts App + + +
+
+

Choose Your Plan

+ + {#if activeSubscription} +
+ + You already have an active subscription! You can manage your + subscription in your account settings. +
+ {/if} + + {#if error} +
+ + {error} +
+ {/if} + +
+ {#each pricingPlans as plan} +
+
+

{plan.name}

+

+ {#if plan.price === 0} + Free + {:else} + ${(plan.price / 100).toFixed(2)}/{plan.interval} + {/if} +

+

{plan.description}

+ +
    + {#each plan.features as feature} +
  • + + + + {feature} +
  • + {/each} +
+ +
+ +
+
+
+ {/each} +
+ +
+

+ All subscriptions auto-renew at the end of the period. You can cancel + anytime. See our Terms of Service for more + details. +

+
+
+
diff --git a/app/src/routes/subscription/cancelled/+page.svelte b/app/src/routes/subscription/cancelled/+page.svelte new file mode 100644 index 0000000..5a50224 --- /dev/null +++ b/app/src/routes/subscription/cancelled/+page.svelte @@ -0,0 +1,53 @@ + + + + Subscription Cancelled | Workouts App + + +
+
+
+
+ + + +
+

Subscription Cancelled

+

+ Your subscription checkout was cancelled. No worries - you can try again + whenever you're ready. +

+
+ + +
+
+
+
diff --git a/app/src/routes/subscription/success/+page.svelte b/app/src/routes/subscription/success/+page.svelte new file mode 100644 index 0000000..6b817bf --- /dev/null +++ b/app/src/routes/subscription/success/+page.svelte @@ -0,0 +1,93 @@ + + + + Subscription Activated | Workouts App + + +
+
+
+ {#if processing} +
+ +

Processing your subscription...

+

This will only take a moment.

+
+ {:else if error} +
+ + {error} +
+ {:else} +
+ + + +
+

Subscription Activated!

+

+ Thank you for your subscription. You now have full access to all + workout features including full exercise history and cloud sync. +

+
+ +
+ {/if} +
+
+
diff --git a/app/src/routes/workout/+page.svelte b/app/src/routes/workout/+page.svelte index db8bbaf..4676d9d 100644 --- a/app/src/routes/workout/+page.svelte +++ b/app/src/routes/workout/+page.svelte @@ -9,6 +9,9 @@ import { browser } from "$app/environment"; import WorkoutItemComponent from "$lib/components/WorkoutItem.svelte"; import ExerciseFilter from "$lib/components/ExerciseFilter.svelte"; + import SubscriptionGuard from "$lib/components/SubscriptionGuard.svelte"; + import { user } from "$lib/supabase/client"; + import { hasActiveSubscription } from "$lib/subscription/subscription-service"; import type { ExerciseFilters, CompletedExerciseV2 } from "$lib/exercises"; let numberOfExercises = $state(5); @@ -21,6 +24,9 @@ // Stores any error that occurs during the save operation let saveError = $state(null); + // Show subscription upsell if exercise was saved locally due to missing subscription + let showSubscriptionUpsell = $state(false); + let filters = $state({ muscles: [], equipment: [], @@ -66,6 +72,7 @@ if (browser && item && item.exercise.id && updatedItem.completed) { try { savingIndex = index; + showSubscriptionUpsell = false; const metrics = getWorkoutItemMetrics(item); const completedExercise: CompletedExerciseV2 = { @@ -76,14 +83,35 @@ await saveCompletedExercise(completedExercise); saveError = null; + + // If user is authenticated but doesn't have a subscription, show upsell + // This means the exercise was saved locally rather than to the cloud + if ($user && !$hasActiveSubscription) { + showSubscriptionUpsell = true; + } } catch (error) { console.error("Failed to save completed exercise:", error); - saveError = "Failed to save exercise record"; - // Revert the UI state if saving failed - generatedWorkout[index] = updateWorkoutItemService(updatedItem, { - completed: false, - }); + if ( + error instanceof Error && + error.message.includes("Subscription required") + ) { + saveError = + "Subscription required to save workouts to the cloud. Your data was saved locally only."; + showSubscriptionUpsell = true; + } else { + saveError = "Failed to save exercise record"; + } + + // Revert the UI state if saving fully failed + if ( + error instanceof Error && + !error.message.includes("Subscription required") + ) { + generatedWorkout[index] = updateWorkoutItemService(updatedItem, { + completed: false, + }); + } } finally { savingIndex = null; } @@ -91,6 +119,10 @@ } + + Workout Generator | Workouts App + +

Workout Generator

@@ -121,10 +153,7 @@ />
- @@ -153,6 +182,37 @@
+ {#if showSubscriptionUpsell} +
+ +
+

Upgrade to sync your workouts to the cloud!

+
+ Subscribe for cloud backup and access your workout history from any + device. +
+
+
+ +
+
+ {/if} + {#if showRecoveryWarning}

Time for Recovery!

@@ -172,13 +232,25 @@ {/if} {#if saveError} -
-

{saveError}

+
+ + {saveError}
{/if} {#if generatedWorkout.length > 0} -
+

Your Workout

diff --git a/app/supabase/.branches/_current_branch b/app/supabase/.branches/_current_branch new file mode 100644 index 0000000..88d050b --- /dev/null +++ b/app/supabase/.branches/_current_branch @@ -0,0 +1 @@ +main \ No newline at end of file From c6abba9f3c548007b7b27658f9d68ffce3d25d32 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Thu, 1 May 2025 14:36:07 +0300 Subject: [PATCH 2/6] feat: Enhance Supabase integration with session management and improved API calls --- app/src/app.d.ts | 13 ++++- app/src/hooks.server.ts | 29 ++++++++++ .../lib/subscription/subscription-service.ts | 22 ++++++-- app/src/lib/supabase/index.ts | 6 +++ app/src/lib/supabase/server.ts | 53 +++++++++++++++++++ .../subscriptions/create-checkout/+server.ts | 3 +- .../subscriptions/create-customer/+server.ts | 3 +- app/src/routes/subscription/+page.svelte | 2 + app/src/routes/workout/+page.svelte | 4 +- 9 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 app/src/hooks.server.ts create mode 100644 app/src/lib/supabase/index.ts create mode 100644 app/src/lib/supabase/server.ts diff --git a/app/src/app.d.ts b/app/src/app.d.ts index 520c421..7caf91b 100644 --- a/app/src/app.d.ts +++ b/app/src/app.d.ts @@ -1,9 +1,20 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "$lib/database/supabase-types"; + declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + session: { + user: { + id: string; + email: string; + }; + } | null; + getSupabaseServer: () => SupabaseClient; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts new file mode 100644 index 0000000..e58ba06 --- /dev/null +++ b/app/src/hooks.server.ts @@ -0,0 +1,29 @@ +// src/hooks.server.ts +import { createSupabaseServerClient } from "$lib/supabase"; +import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit"; + +async function getSession(event: RequestEvent) { + const supabaseServer = createSupabaseServerClient(event); + const { + data: { session }, + error, + } = await supabaseServer.auth.getSession(); + if (error) { + console.error("Error getting session:", error); + return null; + } + return session; +} + +export const handle: Handle = async ({ event, resolve }) => { + // Set the session in locals for server routes to access + event.locals.session = await getSession(event); + + // Make Supabase available to server-side load functions + event.locals.getSupabaseServer = () => createSupabaseServerClient(event); + + // Continue resolving the request + const response = await resolve(event); + + return response; +}; diff --git a/app/src/lib/subscription/subscription-service.ts b/app/src/lib/subscription/subscription-service.ts index 833af4f..3b3e6c5 100644 --- a/app/src/lib/subscription/subscription-service.ts +++ b/app/src/lib/subscription/subscription-service.ts @@ -59,7 +59,11 @@ export async function initSubscription(): Promise { .eq("user_id", userId) .order("created_at", { ascending: false }) .limit(1) - .single(); + .single() + .headers({ + Accept: "application/json", + "Content-Type": "application/json", + }); if (error) { console.error("Error fetching subscription:", error); @@ -103,7 +107,11 @@ export async function getOrCreateCustomerId( .from("stripe_customers") .select("stripe_customer_id") .eq("user_id", user_id) - .single(); + .single() + .headers({ + Accept: "application/json", + "Content-Type": "application/json", + }); if (fetchError && fetchError.code !== "PGRST116") { // PGRST116 = not found @@ -123,8 +131,10 @@ export async function getOrCreateCustomerId( method: "POST", headers: { "Content-Type": "application/json", + Accept: "application/json", }, body: JSON.stringify({ user_id }), + credentials: "include", }); if (!response.ok) { @@ -135,7 +145,7 @@ export async function getOrCreateCustomerId( return customerId; } catch (err) { console.error("Error in getOrCreateCustomerId:", err); - return null; + throw err; // Re-throw the error to provide better feedback } } @@ -157,7 +167,11 @@ export async function isSubscriptionActive(): Promise { .eq("user_id", userId) .order("created_at", { ascending: false }) .limit(1) - .single(); + .single() + .headers({ + Accept: "application/json", + "Content-Type": "application/json", + }); if (error || !data) { return false; diff --git a/app/src/lib/supabase/index.ts b/app/src/lib/supabase/index.ts new file mode 100644 index 0000000..292baed --- /dev/null +++ b/app/src/lib/supabase/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel file for Supabase exports + */ +export * from "./client"; +export * from "./auth"; +export * from "./server"; diff --git a/app/src/lib/supabase/server.ts b/app/src/lib/supabase/server.ts new file mode 100644 index 0000000..ae1e486 --- /dev/null +++ b/app/src/lib/supabase/server.ts @@ -0,0 +1,53 @@ +/** + * Server-side Supabase client functions + * This file provides utilities for working with Supabase on the server side + */ +import { createClient } from "@supabase/supabase-js"; +import { + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, +} from "$env/static/public"; +import type { RequestEvent } from "@sveltejs/kit"; +import type { Database } from "$lib/database/supabase-types"; + +/** + * Create a Supabase client for server-side use that preserves the user's session + */ +export function createSupabaseServerClient(event: RequestEvent) { + const supabaseUrl = PUBLIC_SUPABASE_URL; + const supabaseKey = PUBLIC_SUPABASE_ANON_KEY; + + // Create a custom storage adapter that uses SvelteKit's cookies + const cookieStorage = { + getItem: (key: string) => { + return event.cookies.get(key) ?? null; + }, + setItem: (key: string, value: string) => { + // Max age is 100 days in seconds (same as Supabase default) + event.cookies.set(key, value, { + path: "/", + maxAge: 60 * 60 * 24 * 100, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); + }, + removeItem: (key: string) => { + event.cookies.delete(key, { path: "/" }); + }, + }; + + return createClient(supabaseUrl, supabaseKey, { + auth: { + autoRefreshToken: false, + persistSession: true, + detectSessionInUrl: false, + storage: cookieStorage, + }, + global: { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + }); +} diff --git a/app/src/routes/api/subscriptions/create-checkout/+server.ts b/app/src/routes/api/subscriptions/create-checkout/+server.ts index 5b06424..17ab7bf 100644 --- a/app/src/routes/api/subscriptions/create-checkout/+server.ts +++ b/app/src/routes/api/subscriptions/create-checkout/+server.ts @@ -26,7 +26,8 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { const supabaseAdmin = getSupabaseAdmin(); // Ensure user is authenticated - const session = await locals.getSession(); + // In SvelteKit 5, we access the session directly from locals + const session = locals.session; if (!session) { return json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/src/routes/api/subscriptions/create-customer/+server.ts b/app/src/routes/api/subscriptions/create-customer/+server.ts index 2152d4f..db4e727 100644 --- a/app/src/routes/api/subscriptions/create-customer/+server.ts +++ b/app/src/routes/api/subscriptions/create-customer/+server.ts @@ -26,7 +26,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { const supabaseAdmin = getSupabaseAdmin(); // Ensure user is authenticated - const session = await locals.getSession(); + // In SvelteKit 5, we access the session directly from locals + const session = locals.session; if (!session) { return json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/src/routes/subscription/+page.svelte b/app/src/routes/subscription/+page.svelte index bbeed87..a4d2a9d 100644 --- a/app/src/routes/subscription/+page.svelte +++ b/app/src/routes/subscription/+page.svelte @@ -49,8 +49,10 @@ method: "POST", headers: { "Content-Type": "application/json", + Accept: "application/json", }, body: JSON.stringify({ priceId: plan.stripe_price_id }), + credentials: "include", }); if (!response.ok) { diff --git a/app/src/routes/workout/+page.svelte b/app/src/routes/workout/+page.svelte index 4676d9d..72edcbc 100644 --- a/app/src/routes/workout/+page.svelte +++ b/app/src/routes/workout/+page.svelte @@ -206,8 +206,8 @@
(window.location.href = "/subscription")} + id="view-subscription-plans">View Plans
From c1bd2365c897255b1d3a940137b32dec76d7be67 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Thu, 1 May 2025 14:46:43 +0300 Subject: [PATCH 3/6] feat: Update Supabase client initialization to use environment variables and add error handling for missing configurations --- app/src/lib/supabase/client.ts | 8 ++----- app/src/lib/supabase/server.ts | 38 +++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/app/src/lib/supabase/client.ts b/app/src/lib/supabase/client.ts index 0897de0..60e2fe4 100644 --- a/app/src/lib/supabase/client.ts +++ b/app/src/lib/supabase/client.ts @@ -2,14 +2,10 @@ import { createClient } from "@supabase/supabase-js"; import { writable } from "svelte/store"; import type { User } from "@supabase/supabase-js"; import { isBrowser } from "@supabase/ssr"; - -// Environment variables should be set in .env files -// https://kit.svelte.dev/docs/modules#$env-dynamic-private -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public"; // Create Supabase client -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY); // Create a store for the authenticated user export const user = writable(null); diff --git a/app/src/lib/supabase/server.ts b/app/src/lib/supabase/server.ts index ae1e486..013dc9d 100644 --- a/app/src/lib/supabase/server.ts +++ b/app/src/lib/supabase/server.ts @@ -3,10 +3,8 @@ * This file provides utilities for working with Supabase on the server side */ import { createClient } from "@supabase/supabase-js"; -import { - PUBLIC_SUPABASE_URL, - PUBLIC_SUPABASE_ANON_KEY, -} from "$env/static/public"; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public"; +import { SUPABASE_SERVICE_ROLE_KEY } from "$env/static/private"; import type { RequestEvent } from "@sveltejs/kit"; import type { Database } from "$lib/database/supabase-types"; @@ -14,8 +12,11 @@ import type { Database } from "$lib/database/supabase-types"; * Create a Supabase client for server-side use that preserves the user's session */ export function createSupabaseServerClient(event: RequestEvent) { - const supabaseUrl = PUBLIC_SUPABASE_URL; - const supabaseKey = PUBLIC_SUPABASE_ANON_KEY; + if (!PUBLIC_SUPABASE_URL || !PUBLIC_SUPABASE_ANON_KEY) { + throw new Error( + "Missing Supabase environment variables. Check your .env file configuration.", + ); + } // Create a custom storage adapter that uses SvelteKit's cookies const cookieStorage = { @@ -36,7 +37,7 @@ export function createSupabaseServerClient(event: RequestEvent) { }, }; - return createClient(supabaseUrl, supabaseKey, { + return createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { auth: { autoRefreshToken: false, persistSession: true, @@ -51,3 +52,26 @@ export function createSupabaseServerClient(event: RequestEvent) { }, }); } + +/** + * Create a Supabase admin client with service role permissions + * CAUTION: This bypasses RLS policies - only use on the server! + */ +export function createSupabaseAdminClient() { + if (!PUBLIC_SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + throw new Error( + "Missing Supabase admin environment variables. Check your .env file configuration.", + ); + } + + return createClient( + PUBLIC_SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + } + } + ); +} From 27aa9b6ae036bc03e5d1347d2ba94ca33ea07be8 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Thu, 1 May 2025 14:48:11 +0300 Subject: [PATCH 4/6] style: Format Supabase client initialization for better readability --- app/src/lib/supabase/client.ts | 10 ++++++++-- app/src/lib/supabase/server.ts | 11 +++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/lib/supabase/client.ts b/app/src/lib/supabase/client.ts index 60e2fe4..7f4f688 100644 --- a/app/src/lib/supabase/client.ts +++ b/app/src/lib/supabase/client.ts @@ -2,10 +2,16 @@ import { createClient } from "@supabase/supabase-js"; import { writable } from "svelte/store"; import type { User } from "@supabase/supabase-js"; import { isBrowser } from "@supabase/ssr"; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public"; +import { + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, +} from "$env/static/public"; // Create Supabase client -export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY); +export const supabase = createClient( + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, +); // Create a store for the authenticated user export const user = writable(null); diff --git a/app/src/lib/supabase/server.ts b/app/src/lib/supabase/server.ts index 013dc9d..ec659ad 100644 --- a/app/src/lib/supabase/server.ts +++ b/app/src/lib/supabase/server.ts @@ -3,7 +3,10 @@ * This file provides utilities for working with Supabase on the server side */ import { createClient } from "@supabase/supabase-js"; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public"; +import { + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, +} from "$env/static/public"; import { SUPABASE_SERVICE_ROLE_KEY } from "$env/static/private"; import type { RequestEvent } from "@sveltejs/kit"; import type { Database } from "$lib/database/supabase-types"; @@ -63,7 +66,7 @@ export function createSupabaseAdminClient() { "Missing Supabase admin environment variables. Check your .env file configuration.", ); } - + return createClient( PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, @@ -71,7 +74,7 @@ export function createSupabaseAdminClient() { auth: { autoRefreshToken: false, persistSession: false, - } - } + }, + }, ); } From c9fa6a911ae7501bf40986dca325fc57b79f23e7 Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Thu, 1 May 2025 15:13:29 +0300 Subject: [PATCH 5/6] feat: Enhance subscription management with improved customer handling and checkout session creation --- app/src/hooks.server.ts | 5 + app/src/lib/server/env.ts | 7 +- app/src/lib/server/subscription.ts | 165 ++++++++++++++++ .../lib/subscription/subscription-service.ts | 187 +++++++++++++++--- app/src/lib/supabase/server.ts | 46 ++++- .../subscriptions/create-checkout/+server.ts | 135 ++++++++----- .../subscriptions/create-customer/+server.ts | 81 ++++---- app/src/routes/subscription/+page.svelte | 45 ++--- 8 files changed, 517 insertions(+), 154 deletions(-) create mode 100644 app/src/lib/server/subscription.ts diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts index e58ba06..bea3fa0 100644 --- a/app/src/hooks.server.ts +++ b/app/src/hooks.server.ts @@ -16,6 +16,11 @@ async function getSession(event: RequestEvent) { } export const handle: Handle = async ({ event, resolve }) => { + // Ignore Chrome DevTools special paths to prevent 404 errors in logs + if (event.url.pathname.startsWith("/.well-known/appspecific")) { + return new Response(null, { status: 200 }); + } + // Set the session in locals for server routes to access event.locals.session = await getSession(event); diff --git a/app/src/lib/server/env.ts b/app/src/lib/server/env.ts index d4c34c5..c53d35f 100644 --- a/app/src/lib/server/env.ts +++ b/app/src/lib/server/env.ts @@ -4,6 +4,7 @@ */ import { env } from "$env/dynamic/private"; +import { PUBLIC_SUPABASE_URL } from "$env/static/public"; // The server-side environment variables // Using platform's env system for private variables first, then import.meta.env as fallback @@ -22,11 +23,7 @@ export const SUPABASE_SERVICE_ROLE_KEY = import.meta.env.SUPABASE_SERVICE_ROLE_KEY || import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY || ""; -export const SUPABASE_URL = - env.SUPABASE_URL || - import.meta.env.SUPABASE_URL || - import.meta.env.VITE_SUPABASE_URL || - ""; +export const SUPABASE_URL = PUBLIC_SUPABASE_URL; // Function to check if all required environment variables are set export function validateEnv() { diff --git a/app/src/lib/server/subscription.ts b/app/src/lib/server/subscription.ts new file mode 100644 index 0000000..26b3a26 --- /dev/null +++ b/app/src/lib/server/subscription.ts @@ -0,0 +1,165 @@ +/** + * Server-side subscription helpers + * + * This module provides functions for managing Stripe subscriptions on the server side. + */ +import { stripe, isStripeConfigured } from "$lib/server/stripe"; +import { pricingPlans } from "$lib/subscription/pricing-plans"; +import type { User } from "@supabase/supabase-js"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "$lib/database/supabase-types"; + +/** + * Gets an existing Stripe customer ID for a user, or creates one if it doesn't exist + */ +export async function getOrCreateCustomerId({ + supabaseAdmin, + user, +}: { + supabaseAdmin: SupabaseClient; + user: User; +}): Promise<{ customerId?: string; error?: any }> { + try { + // Check if the user already has a Stripe customer ID + const { data: existingCustomer, error: fetchError } = await supabaseAdmin + .from("stripe_customers") + .select("stripe_customer_id") + .eq("user_id", user.id) + .single(); + + if (fetchError && fetchError.code !== "PGRST116") { + // PGRST116 = not found + return { error: fetchError }; + } + + if (existingCustomer?.stripe_customer_id) { + return { customerId: existingCustomer.stripe_customer_id }; + } + + // Fetch profile data needed to create customer + const { data: profile, error: profileError } = await supabaseAdmin + .from("profiles") + .select("full_name, website") + .eq("id", user.id) + .single(); + + if (profileError) { + return { error: profileError }; + } + + // Create a stripe customer + if (!isStripeConfigured()) { + return { error: "Stripe is not properly configured" }; + } + + try { + const customer = await stripe.customers.create({ + email: user.email, + name: profile.full_name || user.email || "", + metadata: { + user_id: user.id, + website: profile.website || "", + }, + }); + + if (!customer.id) { + return { error: "Unknown Stripe customer creation error" }; + } + + // Save the customer ID in our database + const { error: insertError } = await supabaseAdmin + .from("stripe_customers") + .insert({ + user_id: user.id, + stripe_customer_id: customer.id, + updated_at: new Date().toISOString(), + }); + + if (insertError) { + return { error: insertError }; + } + + return { customerId: customer.id }; + } catch (e) { + return { error: e }; + } + } catch (error) { + console.error("Error in getOrCreateCustomerId:", error); + return { error }; + } +} + +/** + * Fetches a user's subscription information from Stripe + */ +export async function fetchSubscription({ + customerId, +}: { + customerId: string; +}): Promise<{ + primarySubscription?: { + stripeSubscription: any; + appSubscription: any; + }; + hasEverHadSubscription: boolean; + error?: any; +}> { + if (!isStripeConfigured()) { + return { + hasEverHadSubscription: false, + error: "Stripe is not properly configured", + }; + } + + try { + // Fetch user's subscriptions + const stripeSubscriptions = await stripe.subscriptions.list({ + customer: customerId, + limit: 100, + status: "all", + }); + + // Find "primary" subscription - an active one including trials and past_due in grace period + const primaryStripeSubscription = stripeSubscriptions.data.find((sub) => + ["active", "trialing", "past_due"].includes(sub.status), + ); + + let appSubscription = null; + if (primaryStripeSubscription) { + const productId = + primaryStripeSubscription.items?.data?.[0]?.price.product?.toString() ?? + ""; + + appSubscription = pricingPlans.find( + (plan) => plan.stripe_product_id === productId, + ); + + if (!appSubscription) { + return { + hasEverHadSubscription: stripeSubscriptions.data.length > 0, + error: + "Stripe subscription does not match any plan in pricing-plans.ts", + }; + } + } + + let primarySubscription = null; + if (primaryStripeSubscription && appSubscription) { + primarySubscription = { + stripeSubscription: primaryStripeSubscription, + appSubscription, + }; + } + + return { + primarySubscription, + hasEverHadSubscription: stripeSubscriptions.data.length > 0, + }; + } catch (error) { + console.error("Error fetching subscription:", error); + return { + hasEverHadSubscription: false, + error, + }; + } +} diff --git a/app/src/lib/subscription/subscription-service.ts b/app/src/lib/subscription/subscription-service.ts index 3b3e6c5..8d99f89 100644 --- a/app/src/lib/subscription/subscription-service.ts +++ b/app/src/lib/subscription/subscription-service.ts @@ -6,6 +6,7 @@ import { getCurrentUserId } from "$lib/supabase/auth"; import type { Database } from "$lib/database/supabase-types"; import type Stripe from "stripe"; import { writable, derived } from "svelte/store"; +import { pricingPlans } from "./pricing-plans"; // Types for subscription data export interface UserSubscription { @@ -32,12 +33,12 @@ export type SubscriptionStatus = // Store for subscription status export const subscription = writable(null); +export const isLoading = writable(false); +export const subscriptionError = writable(null); // Derived store that tells if the user has an active subscription export const hasActiveSubscription = derived(subscription, ($subscription) => { if (!$subscription) return false; - - // Check if subscription is in an active state return ["active", "trialing"].includes($subscription.status); }); @@ -52,6 +53,9 @@ export async function initSubscription(): Promise { return; } + isLoading.set(true); + subscriptionError.set(null); + try { const { data, error } = await supabase .from("subscriptions") @@ -59,15 +63,12 @@ export async function initSubscription(): Promise { .eq("user_id", userId) .order("created_at", { ascending: false }) .limit(1) - .single() - .headers({ - Accept: "application/json", - "Content-Type": "application/json", - }); + .single(); if (error) { console.error("Error fetching subscription:", error); subscription.set(null); + subscriptionError.set("Failed to load subscription data"); return; } @@ -90,62 +91,130 @@ export async function initSubscription(): Promise { } catch (err) { console.error("Failed to initialize subscription:", err); subscription.set(null); + subscriptionError.set("Failed to load subscription data"); + } finally { + isLoading.set(false); } } /** * Get or create a Stripe customer ID for the current user * @param user_id - The Supabase user ID - * @returns The Stripe customer ID + * @returns The Stripe customer ID or null with an error */ export async function getOrCreateCustomerId( user_id: string, -): Promise { +): Promise<{ customerId?: string; error?: string }> { try { // Check if the user already has a Stripe customer ID const { data: existingCustomer, error: fetchError } = await supabase .from("stripe_customers") .select("stripe_customer_id") .eq("user_id", user_id) - .single() - .headers({ - Accept: "application/json", - "Content-Type": "application/json", - }); + .single(); if (fetchError && fetchError.code !== "PGRST116") { // PGRST116 = not found console.error("Error fetching customer:", fetchError); - return null; + return { error: "Failed to check for existing customer" }; } if (existingCustomer?.stripe_customer_id) { - return existingCustomer.stripe_customer_id; + return { customerId: existingCustomer.stripe_customer_id }; } - // If no customer exists, a new one needs to be created via the server - // This would normally be done through a server API endpoint that uses the Stripe secret key - // For security, we can't create customers directly from the client + // Get authentication token for the request + const { data: sessionData } = await supabase.auth.getSession(); + const authToken = sessionData?.session?.access_token; + if (!authToken) { + return { error: "Not authenticated" }; + } + + // If no customer exists, create one via the server API const response = await fetch("/api/subscriptions/create-customer", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", + Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ user_id }), credentials: "include", }); if (!response.ok) { - throw new Error("Failed to create customer"); + const errorData = await response + .json() + .catch(() => ({ error: "Unknown error" })); + return { error: errorData.error || response.statusText }; } const { customerId } = await response.json(); - return customerId; + return { customerId }; } catch (err) { console.error("Error in getOrCreateCustomerId:", err); - throw err; // Re-throw the error to provide better feedback + return { error: err instanceof Error ? err.message : "Unknown error" }; + } +} + +/** + * Create a checkout session for a subscription plan + * @param priceId - The Stripe price ID + * @returns The checkout URL or an error + */ +export async function createCheckoutSession( + priceId: string, +): Promise<{ url?: string; error?: string }> { + try { + const user_id = getCurrentUserId(); + if (!user_id) { + return { error: "User not authenticated" }; + } + + // Get customer ID first + const { customerId, error: customerError } = + await getOrCreateCustomerId(user_id); + + if (customerError || !customerId) { + return { error: customerError || "Could not create customer" }; + } + + // Get authentication token + const { data: sessionData } = await supabase.auth.getSession(); + const authToken = sessionData?.session?.access_token; + + if (!authToken) { + return { error: "Not authenticated" }; + } + + // Create checkout session + const response = await fetch("/api/subscriptions/create-checkout", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + priceId, + customerId, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: "Unknown error" })); + return { error: errorData.error || response.statusText }; + } + + const { url } = await response.json(); + return { url }; + } catch (err) { + console.error("Error creating checkout session:", err); + return { error: err instanceof Error ? err.message : "Unknown error" }; } } @@ -167,11 +236,7 @@ export async function isSubscriptionActive(): Promise { .eq("user_id", userId) .order("created_at", { ascending: false }) .limit(1) - .single() - .headers({ - Accept: "application/json", - "Content-Type": "application/json", - }); + .single(); if (error || !data) { return false; @@ -184,6 +249,74 @@ export async function isSubscriptionActive(): Promise { } } +/** + * Get complete information about the current user's subscription + * @returns Detailed subscription information or null + */ +export async function getSubscriptionDetails(): Promise<{ + subscription?: UserSubscription; + plan?: (typeof pricingPlans)[number]; + endDate?: Date; + isActive: boolean; + error?: string; +}> { + const userId = getCurrentUserId(); + + if (!userId) { + return { isActive: false, error: "User not authenticated" }; + } + + try { + const { data, error } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(1) + .single(); + + if (error) { + return { isActive: false, error: "Failed to fetch subscription details" }; + } + + if (!data) { + return { isActive: false }; + } + + const userSub: UserSubscription = { + id: data.id, + status: data.status, + priceId: data.stripe_price_id, + productId: data.stripe_product_id, + cancelAtPeriodEnd: data.cancel_at_period_end, + currentPeriodEnd: data.current_period_end + ? new Date(data.current_period_end) + : null, + createdAt: new Date(data.created_at), + stripeSubscriptionId: data.stripe_subscription_id, + }; + + const isActive = ["active", "trialing"].includes(userSub.status); + + const plan = pricingPlans.find( + (p) => p.stripe_price_id === userSub.priceId, + ); + + return { + subscription: userSub, + plan, + endDate: userSub.currentPeriodEnd, + isActive, + }; + } catch (err) { + console.error("Error getting subscription details:", err); + return { + isActive: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } +} + // Subscribe to auth changes to keep subscription state in sync import { user } from "$lib/supabase/client"; diff --git a/app/src/lib/supabase/server.ts b/app/src/lib/supabase/server.ts index ec659ad..54ca7c6 100644 --- a/app/src/lib/supabase/server.ts +++ b/app/src/lib/supabase/server.ts @@ -13,8 +13,11 @@ import type { Database } from "$lib/database/supabase-types"; /** * Create a Supabase client for server-side use that preserves the user's session + * @param eventOrOptions A RequestEvent or an object with request and cookies properties */ -export function createSupabaseServerClient(event: RequestEvent) { +export function createSupabaseServerClient( + eventOrOptions: RequestEvent | { request: Request; cookies?: any }, +) { if (!PUBLIC_SUPABASE_URL || !PUBLIC_SUPABASE_ANON_KEY) { throw new Error( "Missing Supabase environment variables. Check your .env file configuration.", @@ -24,19 +27,42 @@ export function createSupabaseServerClient(event: RequestEvent) { // Create a custom storage adapter that uses SvelteKit's cookies const cookieStorage = { getItem: (key: string) => { - return event.cookies.get(key) ?? null; + // Handle both standard RequestEvent and custom objects safely + if ( + "cookies" in eventOrOptions && + eventOrOptions.cookies && + typeof eventOrOptions.cookies.get === "function" + ) { + return eventOrOptions.cookies.get(key) ?? null; + } + // Fallback to no session when cookies aren't available + return null; }, setItem: (key: string, value: string) => { - // Max age is 100 days in seconds (same as Supabase default) - event.cookies.set(key, value, { - path: "/", - maxAge: 60 * 60 * 24 * 100, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - }); + // Only set cookies if the cookies object exists and has the set method + if ( + "cookies" in eventOrOptions && + eventOrOptions.cookies && + typeof eventOrOptions.cookies.set === "function" + ) { + // Max age is 100 days in seconds (same as Supabase default) + eventOrOptions.cookies.set(key, value, { + path: "/", + maxAge: 60 * 60 * 24 * 100, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); + } }, removeItem: (key: string) => { - event.cookies.delete(key, { path: "/" }); + // Only delete cookies if the cookies object exists and has the delete method + if ( + "cookies" in eventOrOptions && + eventOrOptions.cookies && + typeof eventOrOptions.cookies.delete === "function" + ) { + eventOrOptions.cookies.delete(key, { path: "/" }); + } }, }; diff --git a/app/src/routes/api/subscriptions/create-checkout/+server.ts b/app/src/routes/api/subscriptions/create-checkout/+server.ts index 17ab7bf..d2ddd8f 100644 --- a/app/src/routes/api/subscriptions/create-checkout/+server.ts +++ b/app/src/routes/api/subscriptions/create-checkout/+server.ts @@ -1,10 +1,9 @@ import { json } from "@sveltejs/kit"; import { stripe, isStripeConfigured } from "$lib/server/stripe"; -import { - getSupabaseAdmin, - isSupabaseAdminConfigured, -} from "$lib/server/supabase-admin"; +import { isSupabaseAdminConfigured } from "$lib/server/supabase-admin"; +import { fetchSubscription } from "$lib/server/subscription"; import type { RequestHandler } from "./$types"; +import { createSupabaseServerClient } from "$lib/supabase"; export const POST: RequestHandler = async ({ request, url, locals }) => { try { @@ -23,74 +22,112 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { ); } - const supabaseAdmin = getSupabaseAdmin(); + // Authentication check - multiple methods for robustness + let session = locals.session; - // Ensure user is authenticated - // In SvelteKit 5, we access the session directly from locals - const session = locals.session; + // 1. Try Authorization header if no session in locals + if (!session) { + const authHeader = request.headers.get("Authorization"); + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + + // Create a Supabase client with the provided token + const supabaseServer = createSupabaseServerClient({ + request, + cookies: locals.cookies, + }); + + const { data, error } = await supabaseServer.auth.getUser(token); + + if (!error && data?.user) { + // Use getSession to get the full session + const { data: sessionData } = await supabaseServer.auth.getSession(); + session = sessionData.session; + } + } + } + + // 2. Try getSupabaseServer if available + if (!session && locals.getSupabaseServer) { + const supabaseServer = locals.getSupabaseServer(); + const { data: sessionData } = await supabaseServer.auth.getSession(); + session = sessionData.session; + } + + // If still no session, return unauthorized if (!session) { return json({ error: "Unauthorized" }, { status: 401 }); } - const { priceId } = await request.json(); + const { priceId, customerId } = await request.json(); if (!priceId) { return json({ error: "Price ID is required" }, { status: 400 }); } - const user_id = session.user.id; + if (!customerId) { + return json({ error: "Customer ID is required" }, { status: 400 }); + } - // Get the customer ID for this user - const { data: customerData, error: customerError } = await supabaseAdmin - .from("stripe_customers") - .select("stripe_customer_id") - .eq("user_id", user_id) - .single(); + // Check if user already has an active subscription + const { primarySubscription, error: subscriptionError } = + await fetchSubscription({ + customerId, + }); - if (customerError) { - console.error("Error fetching customer:", customerError); - return json({ error: "Customer not found" }, { status: 404 }); + if (subscriptionError) { + console.error("Error fetching subscription:", subscriptionError); } - const customerId = customerData.stripe_customer_id; - - // Check if user already has an active subscription - const { data: subscriptionData } = await supabaseAdmin - .from("subscriptions") - .select("*") - .eq("user_id", user_id) - .in("status", ["active", "trialing"]) - .maybeSingle(); - - if (subscriptionData) { + if (primarySubscription) { return json( - { error: "User already has an active subscription" }, + { error: "You already have an active subscription" }, { status: 400 }, ); } - // Create a checkout session - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: "subscription", - success_url: `${url.origin}/subscription/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${url.origin}/subscription/cancelled`, - subscription_data: { + // Create Stripe checkout session + let checkoutSession; + try { + const successUrl = new URL( + "/subscription/success", + url.origin, + ).toString(); + const cancelUrl = new URL( + "/subscription/cancelled", + url.origin, + ).toString(); + + checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: successUrl, + cancel_url: cancelUrl, metadata: { - user_id, + user_id: session.user.id, }, - }, - }); + }); + } catch (error) { + console.error("Error creating checkout session:", error); + return json( + { error: "Failed to create checkout session" }, + { status: 500 }, + ); + } - return json({ url: checkoutSession.url }); + return json({ + sessionId: checkoutSession.id, + url: checkoutSession.url, + }); } catch (error) { - console.error("Error creating checkout session:", error); + console.error("Error in create-checkout:", error); return json( { error: "Failed to create checkout session" }, { status: 500 }, diff --git a/app/src/routes/api/subscriptions/create-customer/+server.ts b/app/src/routes/api/subscriptions/create-customer/+server.ts index db4e727..eaee24e 100644 --- a/app/src/routes/api/subscriptions/create-customer/+server.ts +++ b/app/src/routes/api/subscriptions/create-customer/+server.ts @@ -1,10 +1,12 @@ import { json } from "@sveltejs/kit"; -import { stripe, isStripeConfigured } from "$lib/server/stripe"; +import { isStripeConfigured } from "$lib/server/stripe"; import { getSupabaseAdmin, isSupabaseAdminConfigured, } from "$lib/server/supabase-admin"; +import { getOrCreateCustomerId } from "$lib/server/subscription"; import type { RequestHandler } from "./$types"; +import { createSupabaseServerClient } from "$lib/supabase"; export const POST: RequestHandler = async ({ request, locals }) => { try { @@ -25,9 +27,40 @@ export const POST: RequestHandler = async ({ request, locals }) => { const supabaseAdmin = getSupabaseAdmin(); - // Ensure user is authenticated - // In SvelteKit 5, we access the session directly from locals - const session = locals.session; + // Authentication check - multiple methods for robustness + let session = locals.session; + + // 1. Try Authorization header if no session in locals + if (!session) { + const authHeader = request.headers.get("Authorization"); + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + + // Create a Supabase client with the provided token + const supabaseServer = createSupabaseServerClient({ + request, + cookies: locals.cookies, + }); + + const { data, error } = await supabaseServer.auth.getUser(token); + + if (!error && data?.user) { + // Use getSession to get the full session + const { data: sessionData } = await supabaseServer.auth.getSession(); + session = sessionData.session; + } + } + } + + // 2. Try getSupabaseServer if available + if (!session && locals.getSupabaseServer) { + const supabaseServer = locals.getSupabaseServer(); + const { data: sessionData } = await supabaseServer.auth.getSession(); + session = sessionData.session; + } + + // If still no session, return unauthorized if (!session) { return json({ error: "Unauthorized" }, { status: 401 }); } @@ -39,42 +72,18 @@ export const POST: RequestHandler = async ({ request, locals }) => { return json({ error: "Unauthorized" }, { status: 401 }); } - // Fetch user data to create customer - const { data: userData, error: userError } = await supabaseAdmin - .from("profiles") - .select("*") - .eq("id", user_id) - .single(); - - if (userError) { - console.error("Error fetching user profile:", userError); - return json({ error: "Failed to fetch user profile" }, { status: 500 }); - } - - // Create a Stripe customer - const customer = await stripe.customers.create({ - email: session.user.email, - name: userData.full_name || session.user.email, - metadata: { - user_id, - }, + // Use the server helper to get or create customer ID + const { customerId, error: customerError } = await getOrCreateCustomerId({ + supabaseAdmin, + user: session.user, }); - // Save the customer ID in our database - const { error: insertError } = await supabaseAdmin - .from("stripe_customers") - .insert({ - user_id, - stripe_customer_id: customer.id, - updated_at: new Date().toISOString(), - }); - - if (insertError) { - console.error("Error saving customer ID:", insertError); - return json({ error: "Failed to save customer ID" }, { status: 500 }); + if (customerError || !customerId) { + console.error("Error creating customer:", customerError); + return json({ error: "Failed to create customer" }, { status: 500 }); } - return json({ customerId: customer.id }); + return json({ customerId }); } catch (error) { console.error("Error creating customer:", error); return json({ error: "Failed to create customer" }, { status: 500 }); diff --git a/app/src/routes/subscription/+page.svelte b/app/src/routes/subscription/+page.svelte index a4d2a9d..2640973 100644 --- a/app/src/routes/subscription/+page.svelte +++ b/app/src/routes/subscription/+page.svelte @@ -3,18 +3,19 @@ pricingPlans, type PricingPlan, } from "$lib/subscription/pricing-plans"; - import { onMount, onDestroy } from "svelte"; + import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { user } from "$lib/supabase/client"; import { isSubscriptionActive, - getOrCreateCustomerId, + createCheckoutSession, + isLoading, + subscriptionError, } from "$lib/subscription/subscription-service"; - import { invalidateAll } from "$app/navigation"; + let activeSubscription = false; let loading = false; let error = ""; - let activeSubscription = false; // Check if user already has an active subscription onMount(async () => { @@ -38,29 +39,18 @@ loading = true; error = ""; - // Get or create customer ID - const customerId = await getOrCreateCustomerId($user.id); - if (!customerId) { - throw new Error("Failed to create customer"); - } + // Create checkout session using the improved API + const { url, error: checkoutError } = await createCheckoutSession( + plan.stripe_price_id, + ); - // Create checkout session - const response = await fetch("/api/subscriptions/create-checkout", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ priceId: plan.stripe_price_id }), - credentials: "include", - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to create checkout session"); + if (checkoutError) { + throw new Error(checkoutError); } - const { url } = await response.json(); + if (!url) { + throw new Error("Failed to create checkout session"); + } // Redirect to Stripe Checkout window.location.href = url; @@ -83,7 +73,7 @@

Choose Your Plan

{#if activeSubscription} -
+
+

{plan.name}

@@ -139,7 +130,7 @@
    {#each plan.features as feature} -
  • +
  • Date: Thu, 1 May 2025 15:20:24 +0300 Subject: [PATCH 6/6] feat: Improve error handling in customer ID creation and checkout session functions --- .../lib/subscription/subscription-service.ts | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/app/src/lib/subscription/subscription-service.ts b/app/src/lib/subscription/subscription-service.ts index 8d99f89..8eeb623 100644 --- a/app/src/lib/subscription/subscription-service.ts +++ b/app/src/lib/subscription/subscription-service.ts @@ -136,18 +136,28 @@ export async function getOrCreateCustomerId( method: "POST", headers: { "Content-Type": "application/json", - Accept: "application/json", Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ user_id }), - credentials: "include", }); if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: "Unknown error" })); - return { error: errorData.error || response.statusText }; + const errorText = await response.text(); + let errorMessage = response.statusText; + + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.error || errorMessage; + } catch (e) { + // If not valid JSON, use the text as is + errorMessage = errorText || errorMessage; + } + + console.error( + `Customer creation failed (${response.status}):`, + errorMessage, + ); + return { error: errorMessage }; } const { customerId } = await response.json(); @@ -193,21 +203,31 @@ export async function createCheckoutSession( method: "POST", headers: { "Content-Type": "application/json", - Accept: "application/json", Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ priceId, customerId, }), - credentials: "include", }); if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: "Unknown error" })); - return { error: errorData.error || response.statusText }; + const errorText = await response.text(); + let errorMessage = response.statusText; + + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.error || errorMessage; + } catch (e) { + // If not valid JSON, use the text as is + errorMessage = errorText || errorMessage; + } + + console.error( + `Checkout creation failed (${response.status}):`, + errorMessage, + ); + return { error: errorMessage }; } const { url } = await response.json();