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/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..bea3fa0 --- /dev/null +++ b/app/src/hooks.server.ts @@ -0,0 +1,34 @@ +// 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 }) => { + // 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); + + // 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/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..c53d35f --- /dev/null +++ b/app/src/lib/server/env.ts @@ -0,0 +1,47 @@ +/** + * Server environment variables + * These are only accessible on the server side for security + */ + +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 +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 = PUBLIC_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/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/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..8eeb623 --- /dev/null +++ b/app/src/lib/subscription/subscription-service.ts @@ -0,0 +1,349 @@ +/** + * 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"; +import { pricingPlans } from "./pricing-plans"; + +// 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); +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; + 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; + } + + isLoading.set(true); + subscriptionError.set(null); + + 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); + subscriptionError.set("Failed to load subscription data"); + 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); + 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 or null with an error + */ +export async function getOrCreateCustomerId( + user_id: string, +): 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(); + + if (fetchError && fetchError.code !== "PGRST116") { + // PGRST116 = not found + console.error("Error fetching customer:", fetchError); + return { error: "Failed to check for existing customer" }; + } + + if (existingCustomer?.stripe_customer_id) { + return { customerId: existingCustomer.stripe_customer_id }; + } + + // 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", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ user_id }), + }); + + if (!response.ok) { + 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(); + return { customerId }; + } catch (err) { + console.error("Error in getOrCreateCustomerId:", err); + 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", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + priceId, + customerId, + }), + }); + + if (!response.ok) { + 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(); + return { url }; + } catch (err) { + console.error("Error creating checkout session:", err); + return { error: err instanceof Error ? err.message : "Unknown error" }; + } +} + +/** + * 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; + } +} + +/** + * 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"; + +user.subscribe(($user) => { + if ($user) { + initSubscription(); + } else { + subscription.set(null); + } +}); diff --git a/app/src/lib/supabase/client.ts b/app/src/lib/supabase/client.ts index 0897de0..7f4f688 100644 --- a/app/src/lib/supabase/client.ts +++ b/app/src/lib/supabase/client.ts @@ -2,14 +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"; - -// 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/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..54ca7c6 --- /dev/null +++ b/app/src/lib/supabase/server.ts @@ -0,0 +1,106 @@ +/** + * 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 { SUPABASE_SERVICE_ROLE_KEY } from "$env/static/private"; +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 + * @param eventOrOptions A RequestEvent or an object with request and cookies properties + */ +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.", + ); + } + + // Create a custom storage adapter that uses SvelteKit's cookies + const cookieStorage = { + getItem: (key: string) => { + // 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) => { + // 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) => { + // 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: "/" }); + } + }, + }; + + return createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + auth: { + autoRefreshToken: false, + persistSession: true, + detectSessionInUrl: false, + storage: cookieStorage, + }, + global: { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + }); +} + +/** + * 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, + }, + }, + ); +} 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..d2ddd8f --- /dev/null +++ b/app/src/routes/api/subscriptions/create-checkout/+server.ts @@ -0,0 +1,136 @@ +import { json } from "@sveltejs/kit"; +import { stripe, isStripeConfigured } from "$lib/server/stripe"; +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 { + // 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 }, + ); + } + + // 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 }); + } + + const { priceId, customerId } = await request.json(); + + if (!priceId) { + return json({ error: "Price ID is required" }, { status: 400 }); + } + + if (!customerId) { + return json({ error: "Customer ID is required" }, { status: 400 }); + } + + // Check if user already has an active subscription + const { primarySubscription, error: subscriptionError } = + await fetchSubscription({ + customerId, + }); + + if (subscriptionError) { + console.error("Error fetching subscription:", subscriptionError); + } + + if (primarySubscription) { + return json( + { error: "You already have an active subscription" }, + { status: 400 }, + ); + } + + // 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: session.user.id, + }, + }); + } catch (error) { + console.error("Error creating checkout session:", error); + return json( + { error: "Failed to create checkout session" }, + { status: 500 }, + ); + } + + return json({ + sessionId: checkoutSession.id, + url: checkoutSession.url, + }); + } catch (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 new file mode 100644 index 0000000..eaee24e --- /dev/null +++ b/app/src/routes/api/subscriptions/create-customer/+server.ts @@ -0,0 +1,91 @@ +import { json } from "@sveltejs/kit"; +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 { + // 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(); + + // 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 }); + } + + 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 }); + } + + // Use the server helper to get or create customer ID + const { customerId, error: customerError } = await getOrCreateCustomerId({ + supabaseAdmin, + user: session.user, + }); + + if (customerError || !customerId) { + console.error("Error creating customer:", customerError); + return json({ error: "Failed to create customer" }, { status: 500 }); + } + + 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/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..2640973 --- /dev/null +++ b/app/src/routes/subscription/+page.svelte @@ -0,0 +1,179 @@ + + + + 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..72edcbc 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