diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b79d736..9622d51 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,8 +10,13 @@ When improving unit test coverage, run only the unit tests since running e2e tes ## Svelte 5 +Make sure to use the latest Svelte 5 features and syntax. Use the Context7 API to get the current project documentation for Svelte 5. + This project uses Svelte 5. Use the Svelte 5 runes syntax instead of the older `$:` syntax. +Use the new Svelte 5 syntax for $props() + +Use `onclick` instead of `on:click` for event handlers. ## Semantic Classes diff --git a/STRIPE_SETUP.md b/STRIPE_SETUP.md index bc76e7f..1c01fc8 100644 --- a/STRIPE_SETUP.md +++ b/STRIPE_SETUP.md @@ -13,17 +13,15 @@ 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 +PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key +PRIVATE_STRIPE_SECRET_KEY=sk_test_your_secret_key +PRIVATE_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 +PRIVATE_SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key +PUBLIC_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/ diff --git a/app/.env.example b/app/.env.example index 56892c6..e34f98e 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,11 +1,12 @@ -# Supabase configuration -# Replace with your actual Supabase project URL and anon key -VITE_SUPABASE_URL=https://your-project-id.supabase.co -VITE_SUPABASE_ANON_KEY=your-anon-key -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Supabase settings +PUBLIC_SUPABASE_URL=https://your-supabase-url.supabase.co +PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key +PRIVATE_SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key +# Stripe settings +PRIVATE_STRIPE_API_KEY=your-stripe-secret-key +PUBLIC_STRIPE_PUBLISHABLE_KEY=your-stripe-publishable-key +PRIVATE_STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret -# Stripe API keys -STRIPE_PUBLISHABLE_KEY=your-publishable-key -STRIPE_SECRET_KEY=your-secret-key -STRIPE_WEBHOOK_SECRET=your-webhook-secret +# Site settings +PUBLIC_SITE_URL=http://localhost:5173 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/app.d.ts b/app/src/app.d.ts index 520c421..ca0bf0c 100644 --- a/app/src/app.d.ts +++ b/app/src/app.d.ts @@ -1,10 +1,22 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import { SupabaseClient, Session, type AMREntry } from "@supabase/supabase-js"; +import type { Database } from "$lib/database/DatabaseDefinitions"; + declare global { namespace App { + interface Locals { + supabase: SupabaseClient; + supabaseServiceRole: SupabaseClient; + getSession: () => Promise<{ + session: Session | null; + }>; + } + + interface PageData { + session: Session | null; + } // interface Error {} - // interface Locals {} - // 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..bddf874 --- /dev/null +++ b/app/src/hooks.server.ts @@ -0,0 +1,53 @@ +// src/hooks.server.ts +import { PRIVATE_SUPABASE_SERVICE_ROLE_KEY } from "$env/static/private"; +import { + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, +} from "$env/static/public"; +import { createServerClient } from "@supabase/ssr"; +import { createClient } from "@supabase/supabase-js"; +import type { Handle } from "@sveltejs/kit"; +import { sequence } from "@sveltejs/kit/hooks"; + +export const supabase: Handle = async ({ event, resolve }) => { + event.locals.supabase = createServerClient( + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => { + return event.cookies + .getAll() + .map(({ name, value }) => ({ name, value })); + }, + setAll: (cookiesToSet) => { + cookiesToSet.forEach(({ name, value, options }) => { + event.cookies.set(name, value, { ...options, path: "/" }); + }); + }, + }, + }, + ); + + event.locals.supabaseServiceRole = createClient( + PUBLIC_SUPABASE_URL, + PRIVATE_SUPABASE_SERVICE_ROLE_KEY, + { auth: { persistSession: false } }, + ); + + // Add a helper method for getting the session + event.locals.getSession = async () => { + const { + data: { session }, + } = await event.locals.supabase.auth.getSession(); + return { session }; + }; + + return resolve(event, { + filterSerializedResponseHeaders(name) { + return name === "content-range" || name === "x-supabase-api-version"; + }, + }); +}; + +export const handle: Handle = sequence(supabase); diff --git a/app/src/lib/database/DatabaseDefinitions.ts b/app/src/lib/database/DatabaseDefinitions.ts new file mode 100644 index 0000000..12edb1b --- /dev/null +++ b/app/src/lib/database/DatabaseDefinitions.ts @@ -0,0 +1,132 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export interface Database { + public: { + Tables: { + pricing_plans: { + Row: { + id: string; + name: string; + description: string | null; + features: Json | null; + stripe_price_id: string | null; + stripe_product_id: string | null; + amount: number; + currency: string; + interval: string; + active: boolean; + created_at: string | null; + updated_at: string | null; + }; + Insert: { + id?: string; + name: string; + description?: string | null; + features?: Json | null; + stripe_price_id?: string | null; + stripe_product_id?: string | null; + amount: number; + currency?: string; + interval: string; + active?: boolean; + created_at?: string | null; + updated_at?: string | null; + }; + Update: { + id?: string; + name?: string; + description?: string | null; + features?: Json | null; + stripe_price_id?: string | null; + stripe_product_id?: string | null; + amount?: number; + currency?: string; + interval?: string; + active?: boolean; + created_at?: string | null; + updated_at?: string | null; + }; + }; + stripe_customers: { + Row: { + user_id: string; + stripe_customer_id: string; + created_at: string | null; + updated_at: string | null; + }; + Insert: { + user_id: string; + stripe_customer_id: string; + created_at?: string | null; + updated_at?: string | null; + }; + Update: { + user_id?: string; + stripe_customer_id?: string; + created_at?: string | null; + updated_at?: string | null; + }; + }; + subscriptions: { + Row: { + id: string; + user_id: string; + stripe_subscription_id: string | null; + stripe_price_id: string | null; + stripe_product_id: string | null; + status: string; + cancel_at_period_end: boolean | null; + current_period_start: string | null; + current_period_end: string | null; + created_at: string | null; + updated_at: string | null; + ended_at: string | null; + }; + Insert: { + id?: string; + user_id: string; + stripe_subscription_id?: string | null; + stripe_price_id?: string | null; + stripe_product_id?: string | null; + status: string; + cancel_at_period_end?: boolean | null; + current_period_start?: string | null; + current_period_end?: string | null; + created_at?: string | null; + updated_at?: string | null; + ended_at?: string | null; + }; + Update: { + id?: string; + user_id?: string; + stripe_subscription_id?: string | null; + stripe_price_id?: string | null; + stripe_product_id?: string | null; + status?: string; + cancel_at_period_end?: boolean | null; + current_period_start?: string | null; + current_period_end?: string | null; + created_at?: string | null; + updated_at?: string | null; + ended_at?: string | null; + }; + }; + // Include other tables from your database here + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + }; +} diff --git a/app/src/lib/subscription/pricing_plans.ts b/app/src/lib/subscription/pricing_plans.ts new file mode 100644 index 0000000..bc97115 --- /dev/null +++ b/app/src/lib/subscription/pricing_plans.ts @@ -0,0 +1,74 @@ +/** + * Pricing plans configuration + * Define all subscription tiers and their details here + */ + +export type PricingPlan = { + id: string; + name: string; + description: string; + price: number; + interval: "month" | "year"; + currency: string; + features: string[]; + stripe_price_id: string | null; + stripe_product_id: string; + highlight?: boolean; +}; + +// These values should match those in your Supabase pricing_plans table +export const pricingPlans: PricingPlan[] = [ + { + id: "free", + name: "Free", + description: "Basic workout features for casual users", + price: 0, + interval: "month", + currency: "usd", + features: [ + "Generate basic workouts", + "10 saved workouts", + "Access to common exercises", + ], + stripe_price_id: null, // Free plan doesn't have a price ID + stripe_product_id: "prod_free", // You should create this in Stripe dashboard + highlight: false, + }, + { + id: "premium_monthly", + name: "Premium", + description: "Advanced features for fitness enthusiasts", + price: 5, + interval: "month", + currency: "eur", + features: [ + "All free features", + "Unlimited saved workouts", + "Custom exercise library", + "Advanced recovery tracking", + "Priority support", + ], + stripe_price_id: "price_1RDiXiIMUCSg0j0skDWw4IBg", + stripe_product_id: "prod_S7yURRJSZJHP9d", + highlight: true, + }, + { + id: "premium_yearly", + name: "Premium Yearly", + description: "Save 20% with annual billing", + price: 30, + interval: "year", + currency: "eur", + features: [ + "All free features", + "Unlimited saved workouts", + "Custom exercise library", + "Advanced recovery tracking", + "Priority support", + "2 months free compared to monthly plan", + ], + stripe_price_id: "price_1RDiabIMUCSg0j0sJ8ciAdWC", + stripe_product_id: "prod_S7yURRJSZJHP9d", + highlight: false, + }, +]; diff --git a/app/src/lib/subscription/stripe.server.ts b/app/src/lib/subscription/stripe.server.ts new file mode 100644 index 0000000..ae69a94 --- /dev/null +++ b/app/src/lib/subscription/stripe.server.ts @@ -0,0 +1,29 @@ +import { PRIVATE_STRIPE_SECRET_KEY } from "$env/static/private"; +import Stripe from "stripe"; + +/** + * Centralized Stripe service that manages the Stripe API client + * This ensures we initialize Stripe only once with consistent configuration + */ +export const stripe = new Stripe(PRIVATE_STRIPE_SECRET_KEY, { + apiVersion: "2025-04-30.basil", +}); + +/** + * Helper function to format Stripe webhook errors + * @param error - The error from Stripe webhook verification + */ +export function formatWebhookError(error: Error): string { + return `Webhook Error: ${error.message}`; +} + +/** + * Helper function to format Stripe API errors + * @param error - The error from Stripe API calls + */ +export function formatStripeError(error: unknown): string { + if (error instanceof Error) { + return `Stripe API Error: ${error.message}`; + } + return `Unknown Stripe Error: ${String(error)}`; +} diff --git a/app/src/lib/subscription/subscription_helpers.server.ts b/app/src/lib/subscription/subscription_helpers.server.ts new file mode 100644 index 0000000..4b0a1b5 --- /dev/null +++ b/app/src/lib/subscription/subscription_helpers.server.ts @@ -0,0 +1,127 @@ +import type { SupabaseClient, User } from "@supabase/supabase-js"; +import type { Database } from "$lib/database/DatabaseDefinitions"; +import { stripe, formatStripeError } from "$lib/subscription/stripe.server"; +import { pricingPlans } from "$lib/subscription/pricing_plans"; +import type Stripe from "stripe"; + +export const getOrCreateCustomerId = async ({ + supabaseServiceRole, + user, +}: { + supabaseServiceRole: SupabaseClient; + user: User; +}) => { + // First check if customer exists + const { data: dbCustomer, error } = await supabaseServiceRole + .from("stripe_customers") + .select("stripe_customer_id") + .eq("user_id", user.id) + .single(); + + if (error && error.code !== "PGRST116") { + // PGRST116 == no rows found + return { error }; + } + + // If customer exists, return their ID + if (dbCustomer?.stripe_customer_id) { + return { customerId: dbCustomer.stripe_customer_id }; + } + + // Create a new Stripe customer + try { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + user_id: user.id, + }, + }); + + if (!customer.id) { + return { error: "Failed to create Stripe customer" }; + } + + // Store the customer ID in our database + const { error: insertError } = await supabaseServiceRole + .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) { + console.error("Error creating Stripe customer:", formatStripeError(e)); + return { error: e }; + } +}; + +export const fetchSubscription = async ({ + customerId, +}: { + customerId: string; +}) => { + try { + // Fetch user's subscriptions from Stripe + const stripeSubscriptions = await stripe.subscriptions.list({ + customer: customerId, + limit: 100, + status: "all", + }); + + // Find active subscription (includes trialing and past_due in grace period) + const activeStripeSubscription = stripeSubscriptions.data.find((sub) => + ["active", "trialing", "past_due"].includes(sub.status), + ); + + let appSubscription = null; + if (activeStripeSubscription) { + const productId = + activeStripeSubscription?.items?.data?.[0]?.price.product?.toString() || + ""; + + // Find the matching plan in our pricing_plans configuration + appSubscription = pricingPlans.find((plan) => { + return plan.stripe_product_id === productId; + }); + } + + let primarySubscription = null; + if (activeStripeSubscription && appSubscription) { + // Create a properly typed Stripe.Subscription object with camelCase properties + // that match the TypeScript definitions + const normalizedSubscription = + activeStripeSubscription as unknown as Record; + + // Create a new subscription object with the correct TypeScript types + const typedSubscription: Stripe.Subscription = { + ...activeStripeSubscription, + // Explicitly map snake_case properties to camelCase properties that TypeScript expects + currentPeriodStart: normalizedSubscription.current_period_start, + currentPeriodEnd: normalizedSubscription.current_period_end, + cancelAtPeriodEnd: normalizedSubscription.cancel_at_period_end, + // Include any other properties that might be needed and follow the same pattern + } as Stripe.Subscription; + + primarySubscription = { + stripeSubscription: typedSubscription, + appSubscription, + }; + } + + const hasEverHadSubscription = stripeSubscriptions.data.length > 0; + + return { + primarySubscription, + hasEverHadSubscription, + }; + } catch (e) { + console.error("Error fetching subscription:", formatStripeError(e)); + return { error: e }; + } +}; diff --git a/app/src/lib/subscription/subscription_middleware.ts b/app/src/lib/subscription/subscription_middleware.ts new file mode 100644 index 0000000..a400030 --- /dev/null +++ b/app/src/lib/subscription/subscription_middleware.ts @@ -0,0 +1,82 @@ +import { + getOrCreateCustomerId, + fetchSubscription, +} from "$lib/subscription/subscription_helpers.server"; +import { hasActivePremiumSubscription } from "$lib/subscription/subscription_status"; +import type { SupabaseClient, User } from "@supabase/supabase-js"; +import type { Database } from "$lib/database/DatabaseDefinitions"; + +/** + * Middleware to check if a user has an active premium subscription + * @returns Object with subscription status and data + */ +export async function checkSubscription({ + supabaseServiceRole, + user, +}: { + supabaseServiceRole: SupabaseClient; + user: User; +}) { + try { + // Get or create a customer ID for the current user + const { customerId, error: idError } = await getOrCreateCustomerId({ + supabaseServiceRole, + user, + }); + + if (idError || !customerId) { + return { + isSubscribed: false, + subscription: null, + error: idError, + }; + } + + // Fetch subscription data + const { primarySubscription, error: subError } = await fetchSubscription({ + customerId, + }); + + if (subError) { + return { + isSubscribed: false, + subscription: null, + error: subError, + }; + } + + // Check if subscription is active + const isSubscribed = primarySubscription + ? hasActivePremiumSubscription(primarySubscription.stripeSubscription) + : false; + + // Return subscription status and data + return { + isSubscribed, + subscription: primarySubscription + ? { + id: primarySubscription.stripeSubscription.id, + status: primarySubscription.stripeSubscription.status, + plan: primarySubscription.appSubscription, + currentPeriodEnd: primarySubscription.stripeSubscription + .current_period_end + ? new Date( + primarySubscription.stripeSubscription.current_period_end * + 1000, + ).toISOString() + : null, + cancelAtPeriodEnd: + primarySubscription.stripeSubscription.cancel_at_period_end, + } + : null, + error: null, + }; + } catch (e) { + console.error("Error checking subscription:", e); + return { + isSubscribed: false, + subscription: null, + error: e, + }; + } +} diff --git a/app/src/lib/subscription/subscription_status.ts b/app/src/lib/subscription/subscription_status.ts new file mode 100644 index 0000000..c26a247 --- /dev/null +++ b/app/src/lib/subscription/subscription_status.ts @@ -0,0 +1,63 @@ +// Utility functions for checking subscription status + +/** + * Checks if a user has an active premium subscription + */ +export function hasActivePremiumSubscription(subscription: any): boolean { + if (!subscription) return false; + + // Check if subscription exists and has valid status + const validStatuses = ["active", "trialing"]; + return validStatuses.includes(subscription.status); +} + +/** + * Checks if a user is on a free plan or has no subscription + */ +export function isFreeTier(subscription: any): boolean { + return !hasActivePremiumSubscription(subscription); +} + +/** + * Returns a friendly message about subscription status + */ +export function getSubscriptionStatusMessage(subscription: any): string { + if (!subscription) { + return "You are currently on the free plan. Upgrade to access premium features."; + } + + switch (subscription.status) { + case "active": + return "Your premium subscription is active."; + case "trialing": + return "You are currently in a trial period."; + case "past_due": + return "Your payment is past due. Please update your payment method."; + case "canceled": + return "Your subscription has been canceled and will end on your current billing period."; + case "unpaid": + return "We had trouble processing your payment. Please update your payment method."; + default: + return "Your subscription status is unavailable."; + } +} + +/** + * Get feature access based on subscription + * @returns Object with feature flags + */ +export function getFeatureAccess(subscription: any): { + maxSavedWorkouts: number; + customExercises: boolean; + advancedRecovery: boolean; + prioritySupport: boolean; +} { + const isPremium = hasActivePremiumSubscription(subscription); + + return { + maxSavedWorkouts: isPremium ? Infinity : 10, + customExercises: isPremium, + advancedRecovery: isPremium, + prioritySupport: isPremium, + }; +} diff --git a/app/src/lib/supabase/client.ts b/app/src/lib/supabase/client.ts index 0897de0..a5ec713 100644 --- a/app/src/lib/supabase/client.ts +++ b/app/src/lib/supabase/client.ts @@ -2,11 +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"; +import { env } from "$env/dynamic/public"; -// 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; +const supabaseUrl = env.PUBLIC_SUPABASE_URL; +const supabaseAnonKey = env.PUBLIC_SUPABASE_ANON_KEY; // Create Supabase client export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/app/src/routes/account/+page.server.ts b/app/src/routes/account/+page.server.ts new file mode 100644 index 0000000..eb2fcab --- /dev/null +++ b/app/src/routes/account/+page.server.ts @@ -0,0 +1,70 @@ +import { redirect } from "@sveltejs/kit"; +import { + getOrCreateCustomerId, + fetchSubscription, +} from "$lib/subscription/subscription_helpers.server"; + +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ + params, + locals: { supabase, supabaseServiceRole }, +}) => { + // Check if user is authenticated + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + redirect(303, "/login?redirect=/account"); + } + + try { + // Get user profile + const { data: profile } = await supabase + .from("profiles") + .select("*") + .eq("id", session.user.id) + .single(); + + // Get or create a customer ID for the current user + const { customerId } = await getOrCreateCustomerId({ + supabaseServiceRole, + user: session.user, + }); + + let subscription = null; + if (customerId) { + // Fetch subscription data + const { primarySubscription } = await fetchSubscription({ customerId }); + + if (primarySubscription) { + subscription = { + status: primarySubscription.stripeSubscription.status, + plan: primarySubscription.appSubscription, + cancelAtPeriodEnd: + primarySubscription.stripeSubscription.cancel_at_period_end, + currentPeriodEnd: primarySubscription.stripeSubscription + .current_period_end + ? new Date( + primarySubscription.stripeSubscription.current_period_end * + 1000, + ).toISOString() + : null, + }; + } + } + + return { + profile, + subscription, + session, // Add session to satisfy PageData interface requirement + }; + } catch (error) { + console.error("Error loading account page:", error); + return { + profile: null, + subscription: null, + session, // Add session here as well to maintain consistency + }; + } +}; diff --git a/app/src/routes/account/+page.svelte b/app/src/routes/account/+page.svelte new file mode 100644 index 0000000..9b3a9b2 --- /dev/null +++ b/app/src/routes/account/+page.svelte @@ -0,0 +1,269 @@ + + + + My Account - Workouts + + +
+
+

My Account

+ + +
+
+

Subscription

+ +
+ {getPlanName()} + + {#if subscription} + + {subscription.status.charAt(0).toUpperCase() + + subscription.status.slice(1)} + + {/if} +
+ +

{getSubscriptionStatusMessage(subscription)}

+ + {#if subscription && subscription.currentPeriodEnd} +

+ {subscription.cancelAtPeriodEnd + ? "Access until" + : "Next billing date"}: + {formatDate(new Date(subscription.currentPeriodEnd))} +

+ {/if} + +
+ + Manage Billing + + + {#if !subscription} + + Upgrade Plan + + {/if} +
+
+
+ + +
+
+

Your Features

+ +
+
+ + + + Up to {features.maxSavedWorkouts === Infinity + ? "unlimited" + : features.maxSavedWorkouts} saved workouts +
+ +
+ + {#if features.customExercises} + + {:else} + + {/if} + + Custom exercise library +
+ +
+ + {#if features.advancedRecovery} + + {:else} + + {/if} + + Advanced recovery tracking +
+ +
+ + {#if features.prioritySupport} + + {:else} + + {/if} + + Priority support +
+
+ + {#if !subscription} + + {/if} +
+
+ + +
+
+

Account Settings

+ +
+ + +
+ + +
+
+
+
diff --git a/app/src/routes/account/billing/+page.server.ts b/app/src/routes/account/billing/+page.server.ts new file mode 100644 index 0000000..f36e595 --- /dev/null +++ b/app/src/routes/account/billing/+page.server.ts @@ -0,0 +1,81 @@ +import { PRIVATE_STRIPE_API_KEY } from "$env/static/private"; +import { error, redirect } from "@sveltejs/kit"; +import { stripe } from "$lib/subscription/stripe.server"; +import { + getOrCreateCustomerId, + fetchSubscription, +} from "$lib/subscription/subscription_helpers.server"; + +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ + params, + locals: { supabase, supabaseServiceRole, getSession }, +}) => { + // Check if user is authenticated + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + redirect(303, "/login?redirect=/account/billing"); + } + + try { + // Get or create a customer ID for the current user + const { error: idError, customerId } = await getOrCreateCustomerId({ + supabaseServiceRole, + user: session.user, + }); + + if (idError || !customerId) { + console.error("Error creating customer id", idError); + error(500, { + message: + "There was a problem creating your customer record. Please try again later.", + }); + } + + // Fetch subscription data + const { + primarySubscription, + hasEverHadSubscription, + error: subError, + } = await fetchSubscription({ + customerId, + }); + + if (subError) { + console.error("Error fetching subscription", subError); + error(500, { + message: + "There was a problem checking your subscription status. Please try again later.", + }); + } + + return { + session, + subscription: primarySubscription + ? { + id: primarySubscription.stripeSubscription.id, + status: primarySubscription.stripeSubscription.status, + currentPeriodEnd: primarySubscription.stripeSubscription + .currentPeriodEnd + ? new Date( + primarySubscription.stripeSubscription.currentPeriodEnd * + 1000, + ).toISOString() + : null, + cancelAtPeriodEnd: + primarySubscription.stripeSubscription.cancelAtPeriodEnd, + plan: primarySubscription.appSubscription, + } + : null, + hasEverHadSubscription, + }; + } catch (e) { + console.error("Error in billing page:", e); + error(500, { + message: "An unexpected error occurred. Please try again later.", + }); + } +}; diff --git a/app/src/routes/account/billing/+page.svelte b/app/src/routes/account/billing/+page.svelte new file mode 100644 index 0000000..cbe662e --- /dev/null +++ b/app/src/routes/account/billing/+page.svelte @@ -0,0 +1,180 @@ + + + + Billing & Subscription - Workouts + + +
+
+

Billing & Subscription

+ + {#if subscription} +
+
+

Current Subscription

+ +
+ +
+
+
Plan
+
{formatPlanName(subscription.plan)}
+
+ +
+
Status
+
+ + {formatStatus(subscription.status)} + +
+
+ +
+
+ Current Period Ends +
+
+ {subscription.currentPeriodEnd + ? formatDate(new Date(subscription.currentPeriodEnd)) + : "N/A"} +
+
+ +
+
+ Renews Automatically +
+
+ {subscription.cancelAtPeriodEnd ? "No" : "Yes"} +
+
+
+ +
+ +
+
+
+ {:else} +
+
+

+ {hasEverHadSubscription + ? "No Active Subscription" + : "No Subscription"} +

+

+ {hasEverHadSubscription + ? "You don't have an active subscription. Subscribe to access premium features!" + : "You haven't subscribed yet. Subscribe to unlock premium features!"} +

+ + +
+
+ {/if} + +
+
+

Need Help?

+

+ If you have any questions about your subscription or billing, please + contact our support team. +

+ + +
+
+
+
diff --git a/app/src/routes/account/select-plan/+page.svelte b/app/src/routes/account/select-plan/+page.svelte new file mode 100644 index 0000000..75a5b07 --- /dev/null +++ b/app/src/routes/account/select-plan/+page.svelte @@ -0,0 +1,23 @@ + + + + Select a Plan - Workouts + + +
+
+
+

Select a Plan

+
+ Choose the plan that fits your fitness goals. You can change your plan + anytime. +
+ +
+
+
diff --git a/app/src/routes/account/subscribe/[slug]/+page.server.ts b/app/src/routes/account/subscribe/[slug]/+page.server.ts new file mode 100644 index 0000000..0fa26ef --- /dev/null +++ b/app/src/routes/account/subscribe/[slug]/+page.server.ts @@ -0,0 +1,89 @@ +import { PUBLIC_SITE_URL } from "$env/static/public"; +import { error, redirect } from "@sveltejs/kit"; +import { stripe } from "$lib/subscription/stripe.server"; +import { + getOrCreateCustomerId, + fetchSubscription, +} from "$lib/subscription/subscription_helpers.server"; + +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ + params, + locals: { supabase, supabaseServiceRole, getSession }, +}) => { + // Check if user is authenticated + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + redirect(303, "/login?redirect=/account/select-plan"); + } + + // If free plan is selected, just redirect to account page + if (params.slug === "free") { + redirect(303, "/account"); + } + + try { + // Get or create a customer ID for the current user + const { error: idError, customerId } = await getOrCreateCustomerId({ + supabaseServiceRole, + user: session.user, + }); + + if (idError || !customerId) { + console.error("Error creating customer id", idError); + error(500, { + message: + "There was a problem creating your customer record. Please try again later.", + }); + } + + // Check if user already has an active subscription + const { primarySubscription, error: subError } = await fetchSubscription({ + customerId, + }); + + if (subError) { + console.error("Error fetching subscription", subError); + error(500, { + message: + "There was a problem checking your subscription status. Please try again later.", + }); + } + + if (primarySubscription) { + // User already has an active subscription, redirect to billing page + redirect(303, "/account/billing"); + } + + // Create a Stripe checkout session + const checkoutSession = await stripe.checkout.sessions.create({ + line_items: [ + { + price: params.slug, + quantity: 1, + }, + ], + mode: "subscription", + customer: customerId, + success_url: `${PUBLIC_SITE_URL}/account/subscription-success`, + cancel_url: `${PUBLIC_SITE_URL}/account/select-plan`, + }); + + if (!checkoutSession.url) { + error(500, { + message: "Failed to create checkout session. Please try again later.", + }); + } + + // Redirect to Stripe Checkout + redirect(303, checkoutSession.url); + } catch (e) { + console.error("Error in subscribe route:", e); + error(500, { + message: "An unexpected error occurred. Please try again later.", + }); + } +}; diff --git a/app/src/routes/account/subscription-success/+page.svelte b/app/src/routes/account/subscription-success/+page.svelte new file mode 100644 index 0000000..946acb5 --- /dev/null +++ b/app/src/routes/account/subscription-success/+page.svelte @@ -0,0 +1,58 @@ + + + + Subscription Successful - Workouts + + +
+
+
+
+
+ + + + + +
+
+ +

Subscription Successful!

+

+ Thank you for subscribing! Your premium features are now available. +

+ +
+
+

+ You can manage your subscription from your account billing page at + any time. +

+
+
+ + +
+
+
diff --git a/app/src/routes/api/create-portal-session/+server.ts b/app/src/routes/api/create-portal-session/+server.ts new file mode 100644 index 0000000..20960f9 --- /dev/null +++ b/app/src/routes/api/create-portal-session/+server.ts @@ -0,0 +1,42 @@ +import { PUBLIC_SITE_URL } from "$env/static/public"; +import { error, json } from "@sveltejs/kit"; +import { stripe, formatStripeError } from "$lib/subscription/stripe.server"; +import { getOrCreateCustomerId } from "$lib/subscription/subscription_helpers.server"; + +export async function POST({ locals: { supabase, supabaseServiceRole } }) { + // Check if user is authenticated + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + error(401, { message: "Unauthorized" }); + } + + try { + // Get customer ID for the current user + const { error: idError, customerId } = await getOrCreateCustomerId({ + supabaseServiceRole, + user: session.user, + }); + + if (idError || !customerId) { + console.error("Error retrieving customer id", idError); + error(500, { + message: "There was a problem retrieving your customer record", + }); + } + + // Create a Stripe billing portal session + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${PUBLIC_SITE_URL}/account/billing`, + }); + + return json({ url: portalSession.url }); + } catch (e) { + console.error("Error creating portal session:", formatStripeError(e)); + error(500, { + message: "An unexpected error occurred. Please try again later.", + }); + } +} diff --git a/app/src/routes/api/stripe/webhook/+server.ts b/app/src/routes/api/stripe/webhook/+server.ts new file mode 100644 index 0000000..4054e55 --- /dev/null +++ b/app/src/routes/api/stripe/webhook/+server.ts @@ -0,0 +1,214 @@ +import { PRIVATE_STRIPE_WEBHOOK_SECRET } from "$env/static/private"; +import { error, json } from "@sveltejs/kit"; +import { stripe, formatWebhookError } from "$lib/subscription/stripe.server"; + +/** + * Webhook handler for Stripe events + * Used to keep subscription data in sync with Stripe + */ +export async function POST({ + request, + locals: { supabase, supabaseServiceRole }, +}) { + const payload = await request.text(); + const signature = request.headers.get("stripe-signature"); + + if (!signature) { + console.error("Missing Stripe signature"); + error(400, { message: "Missing stripe-signature header" }); + return; + } + + let event; + + try { + // Verify webhook signature + event = stripe.webhooks.constructEvent( + payload, + signature, + PRIVATE_STRIPE_WEBHOOK_SECRET ?? "", // In development, you may want to skip verification + ); + } catch (err) { + console.error( + `Webhook signature verification failed:`, + formatWebhookError(err), + ); + error(400, { message: formatWebhookError(err) }); + return; + } + + // Handle specific event types + try { + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": + await handleSubscriptionChange(event.data.object, supabaseServiceRole); + break; + + case "customer.subscription.deleted": + await handleSubscriptionDeletion( + event.data.object, + supabaseServiceRole, + ); + break; + + case "invoice.paid": + await handleSuccessfulPayment(event.data.object, supabaseServiceRole); + break; + + case "invoice.payment_failed": + await handleFailedPayment(event.data.object, supabaseServiceRole); + break; + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + // Return success response + return json({ received: true }); + } catch (err) { + console.error(`Error processing webhook:`, err); + error(500, { message: `Error processing webhook: ${err.message}` }); + } +} + +/** + * Handle subscription creation or update + */ +async function handleSubscriptionChange(subscription, supabaseClient) { + // Get customer data + const stripeCustomerId = subscription.customer; + + // Find user_id from stripe_customers table + const { data: customerData, error: customerError } = await supabaseClient + .from("stripe_customers") + .select("user_id") + .eq("stripe_customer_id", stripeCustomerId) + .single(); + + if (customerError || !customerData) { + console.error("Customer not found in database:", customerError); + return; + } + + const userId = customerData.user_id; + + // Check if subscription exists + const { data: existingSub, error: subQueryError } = await supabaseClient + .from("subscriptions") + .select("id") + .eq("stripe_subscription_id", subscription.id) + .single(); + + if (subQueryError && subQueryError.code !== "PGRST116") { + console.error("Error querying subscription:", subQueryError); + } + + // Get price and product details + const priceId = subscription.items.data[0]?.price.id; + const productId = subscription.items.data[0]?.price.product; + + // Update or insert subscription + const subscriptionData = { + user_id: userId, + stripe_subscription_id: subscription.id, + stripe_price_id: priceId, + stripe_product_id: productId, + 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, + }; + + if (existingSub?.id) { + // Update existing subscription + const { error: updateError } = await supabaseClient + .from("subscriptions") + .update(subscriptionData) + .eq("id", existingSub.id); + + if (updateError) { + console.error("Error updating subscription:", updateError); + } + } else { + // Insert new subscription + const { error: insertError } = await supabaseClient + .from("subscriptions") + .insert(subscriptionData); + + if (insertError) { + console.error("Error inserting subscription:", insertError); + } + } +} + +/** + * Handle subscription deletion + */ +async function handleSubscriptionDeletion(subscription, supabaseClient) { + // Update subscription status to canceled + const { error } = await supabaseClient + .from("subscriptions") + .update({ + status: "canceled", + updated_at: new Date().toISOString(), + ended_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", subscription.id); + + if (error) { + console.error("Error updating subscription to canceled:", error); + } +} + +/** + * Handle successful payment + */ +async function handleSuccessfulPayment(invoice, supabaseClient) { + const subscriptionId = invoice.subscription; + + if (!subscriptionId) return; + + // Update subscription status to active + const { error } = await supabaseClient + .from("subscriptions") + .update({ + status: "active", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", subscriptionId); + + if (error) { + console.error("Error updating subscription after payment:", error); + } +} + +/** + * Handle failed payment + */ +async function handleFailedPayment(invoice, supabaseClient) { + const subscriptionId = invoice.subscription; + + if (!subscriptionId) return; + + // Update subscription status to past_due + const { error } = await supabaseClient + .from("subscriptions") + .update({ + status: "past_due", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", subscriptionId); + + if (error) { + console.error("Error updating subscription after failed payment:", error); + } +} diff --git a/app/src/routes/pricing/+page.svelte b/app/src/routes/pricing/+page.svelte new file mode 100644 index 0000000..e73d2af --- /dev/null +++ b/app/src/routes/pricing/+page.svelte @@ -0,0 +1,81 @@ + + + + Subscription Plans - Workouts + + + +
+
+

Choose Your Plan

+

+ Select the plan that fits your workout goals. Upgrade anytime as your + fitness journey progresses. +

+
+ + + +
+

Frequently Asked Questions

+ +
+
+ +
+ Can I cancel anytime? +
+
+

+ Yes, you can cancel your subscription at any time. Your premium + access will continue until the end of your billing period. +

+
+
+ +
+ +
+ What payment methods do you accept? +
+
+

+ We accept all major credit cards, including Visa, Mastercard, and + American Express through our secure payment processor, Stripe. +

+
+
+ +
+ +
+ Are there any refunds? +
+
+

+ We offer a 7-day satisfaction guarantee. If you're not happy with + your subscription within the first week, contact our support team + for a full refund. +

+
+
+
+
+
diff --git a/app/src/routes/pricing/PricingModule.svelte b/app/src/routes/pricing/PricingModule.svelte new file mode 100644 index 0000000..9239fc2 --- /dev/null +++ b/app/src/routes/pricing/PricingModule.svelte @@ -0,0 +1,105 @@ + + + +
+
+
+
+ + +
+
+ +
+ {#each pricingPlans.filter((plan) => plan.interval === (isYearly ? "year" : "month") || plan.price === 0) as plan} +
+
+

{plan.name}

+
+ + {#if plan.price === 0} + Free + {:else} + ${plan.price} + {/if} + + {#if plan.price > 0} + /{plan.interval} + {/if} +
+

{plan.description}

+ +
+ +
    + {#each plan.features as feature} +
  • + + + + {feature} +
  • + {/each} +
+ + +
+
+ {/each} +
+
+
diff --git a/app/src/routes/recovery/advanced/+page.server.ts b/app/src/routes/recovery/advanced/+page.server.ts new file mode 100644 index 0000000..11b1314 --- /dev/null +++ b/app/src/routes/recovery/advanced/+page.server.ts @@ -0,0 +1,39 @@ +import { error, redirect } from "@sveltejs/kit"; +import { checkSubscription } from "$lib/subscription/subscription_middleware"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ + locals: { supabase, supabaseServiceRole, getSession }, +}) => { + // Check if user is authenticated + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + redirect(303, "/login?redirect=/recovery/advanced"); + } + + // Check subscription status + const { + isSubscribed, + subscription, + error: subError, + } = await checkSubscription({ + supabaseServiceRole, + user: session.user, + }); + + if (subError) { + console.error("Error checking subscription:", subError); + } + + // If user is not subscribed, redirect to pricing + if (!isSubscribed) { + redirect(303, "/pricing?feature=advanced-recovery"); + } + + // User has access to premium feature + return { + subscription, + }; +}; diff --git a/app/svelte.config.js b/app/svelte.config.js index 61903e9..72ae8c0 100644 --- a/app/svelte.config.js +++ b/app/svelte.config.js @@ -21,6 +21,10 @@ const config = { exclude: [""], }, }), + env: { + publicPrefix: "PUBLIC_", + privatePrefix: "PRIVATE_", + }, }, };