Subscription System
JCV Fitness uses a robust subscription system built on Supabase with MercadoPago payment integration. The system handles plan management, automatic expiration, and user access control.Overview
The subscription system provides:- Three-tier subscription plans (Básico, Pro, Premium)
- MercadoPago payment integration with webhook handling
- Automatic subscription expiration tracking
- User profile synchronization with subscription status
- Subscription history and audit logging
- Cloudflare Worker for webhook processing
Subscription Plans
Plan Types & Pricing
// src/features/subscription/types/index.ts
export type PlanType = "PLAN_BASICO" | "PLAN_PRO" | "PLAN_PREMIUM";
export interface SubscriptionPlan {
id: PlanType;
name: string;
durationMonths: number;
price: number;
priceDisplay: string;
features: string[];
popular?: boolean;
}
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
{
id: "PLAN_BASICO",
name: "Basico",
durationMonths: 1,
price: 49900,
priceDisplay: "$49.900",
features: [
"Plan de alimentacion 7 dias",
"Rutina de entrenamiento casa",
"Acceso a la app",
"Soporte por email",
],
},
{
id: "PLAN_PRO",
name: "Pro",
durationMonths: 1,
price: 89900,
priceDisplay: "$89.900",
features: [
"Plan de alimentacion personalizado",
"Rutina gimnasio + casa",
"Videos de ejercicios",
"Soporte prioritario",
"Seguimiento semanal",
],
popular: true,
},
{
id: "PLAN_PREMIUM",
name: "Premium",
durationMonths: 1,
price: 149900,
priceDisplay: "$149.900",
features: [
"Todo lo del plan Pro",
"Coaching 1 a 1",
"Ajustes mensuales",
"Acceso a comunidad VIP",
"Garantia de resultados",
],
},
];
Plan Básico
$49.900/mesPerfect for beginners starting their fitness journey.
- 7-day meal plan
- Home workouts
- App access
- Email support
Plan Pro
$89.900/mes • Most PopularFor serious athletes wanting comprehensive guidance.
- Custom meal plans
- Gym + home routines
- Exercise videos
- Priority support
- Weekly tracking
Plan Premium
$149.900/mesElite coaching with guaranteed results.
- Everything in Pro
- 1-on-1 coaching
- Monthly adjustments
- VIP community
- Results guarantee
Database Schema
Subscriptions Table
create table subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
plan_type text not null check (plan_type in ('PLAN_BASICO', 'PLAN_PRO', 'PLAN_PREMIUM')),
status text not null check (status in ('active', 'expired', 'cancelled')),
start_date timestamptz not null,
end_date timestamptz not null,
payment_provider text not null check (payment_provider in ('mercadopago', 'wompi')),
payment_reference text not null,
amount_paid integer not null,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Index for quick lookups
create index idx_subscriptions_user_status on subscriptions(user_id, status);
create index idx_subscriptions_end_date on subscriptions(end_date);
Profile Integration
User profiles are automatically updated when subscriptions change:create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text,
full_name text,
has_active_subscription boolean default false,
current_plan text,
subscription_end_date timestamptz,
-- ... other fields
);
Subscription Service
Core Service Class
// src/features/subscription/services/subscription-service.ts
import { createClient } from '@/lib/supabase/client';
import type { PlanType, PaymentProvider, Subscription } from '../types';
export class SubscriptionService {
private getSupabase() {
const supabase = createClient();
if (!supabase) {
throw new Error("Supabase not initialized");
}
return supabase;
}
async getActiveSubscription(userId: string): Promise<Subscription | null> {
const { data, error } = await this.getSupabase()
.from("subscriptions")
.select("*")
.eq("user_id", userId)
.eq("status", "active")
.gte("end_date", new Date().toISOString())
.order("end_date", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
console.error("[SubscriptionService] Error:", error);
return null;
}
return data;
}
async createSubscription(params: {
userId: string;
planType: PlanType;
paymentProvider: PaymentProvider;
paymentReference: string;
amountPaid: number;
}): Promise<Subscription> {
const durationMonths = getPlanDuration(params.planType);
const startDate = new Date();
const endDate = new Date();
endDate.setMonth(endDate.getMonth() + durationMonths);
const { data, error } = await this.getSupabase()
.from("subscriptions")
.insert({
user_id: params.userId,
plan_type: params.planType,
status: "active",
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
payment_provider: params.paymentProvider,
payment_reference: params.paymentReference,
amount_paid: params.amountPaid,
})
.select()
.single();
if (error) throw new Error(error.message);
if (!data) throw new Error("Failed to create subscription");
// Update profile
await this.getSupabase()
.from("profiles")
.update({
has_active_subscription: true,
current_plan: params.planType,
subscription_end_date: endDate.toISOString(),
})
.eq("id", params.userId);
return data;
}
async cancelSubscription(subscriptionId: string): Promise<void> {
const { data: subscription } = await this.getSupabase()
.from("subscriptions")
.select("user_id")
.eq("id", subscriptionId)
.single();
if (!subscription) throw new Error("Subscription not found");
// Mark as cancelled
await this.getSupabase()
.from("subscriptions")
.update({ status: "cancelled" })
.eq("id", subscriptionId);
// Check for other active subscriptions
const { data: otherSubs } = await this.getSupabase()
.from("subscriptions")
.select("id")
.eq("user_id", subscription.user_id)
.eq("status", "active")
.neq("id", subscriptionId)
.limit(1);
if (!otherSubs || otherSubs.length === 0) {
await this.getSupabase()
.from("profiles")
.update({
has_active_subscription: false,
current_plan: null,
subscription_end_date: null,
})
.eq("id", subscription.user_id);
}
}
}
export const subscriptionService = new SubscriptionService();
Automatic Expiration
A scheduled function checks and expires subscriptions:async checkAndExpireSubscriptions(): Promise<void> {
const now = new Date().toISOString();
// Get all expired subscriptions
const { data: expiredSubs } = await this.getSupabase()
.from("subscriptions")
.select("id, user_id")
.eq("status", "active")
.lt("end_date", now);
if (!expiredSubs) return;
for (const sub of expiredSubs) {
// Mark as expired
await this.getSupabase()
.from("subscriptions")
.update({ status: "expired" })
.eq("id", sub.id);
// Check if user has other active subscriptions
const { data: otherSubs } = await this.getSupabase()
.from("subscriptions")
.select("id")
.eq("user_id", sub.user_id)
.eq("status", "active")
.limit(1);
if (!otherSubs || otherSubs.length === 0) {
await this.getSupabase()
.from("profiles")
.update({
has_active_subscription: false,
current_plan: null,
subscription_end_date: null,
})
.eq("id", sub.user_id);
}
}
}
React Hook
useSubscription Hook
// src/features/subscription/hooks/useSubscription.ts
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/features/auth";
import { subscriptionService } from "../services/subscription-service";
export function useSubscription() {
const { user, profile } = useAuth();
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadSubscription = useCallback(async () => {
if (!user) {
setSubscription(null);
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const sub = await subscriptionService.getActiveSubscription(user.id);
setSubscription(sub);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Error loading subscription");
} finally {
setIsLoading(false);
}
}, [user]);
useEffect(() => {
loadSubscription();
}, [loadSubscription]);
const createSubscription = async (params: {
planType: PlanType;
paymentProvider: PaymentProvider;
paymentReference: string;
amountPaid: number;
}) => {
if (!user) throw new Error("User not authenticated");
const newSub = await subscriptionService.createSubscription({
userId: user.id,
...params,
});
setSubscription(newSub);
return newSub;
};
const cancelSubscription = async () => {
if (!subscription) throw new Error("No active subscription");
await subscriptionService.cancelSubscription(subscription.id);
setSubscription(null);
};
const hasActiveSubscription = profile?.has_active_subscription ?? false;
const daysRemaining = subscription
? Math.max(0, Math.ceil(
(new Date(subscription.end_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
))
: 0;
return {
subscription,
isLoading,
error,
hasActiveSubscription,
daysRemaining,
createSubscription,
cancelSubscription,
refresh: loadSubscription,
};
}
Usage Example
import { useSubscription } from '@/features/subscription';
function SubscriptionStatus() {
const {
subscription,
hasActiveSubscription,
daysRemaining,
isLoading
} = useSubscription();
if (isLoading) return <div>Loading...</div>;
if (!hasActiveSubscription) {
return <div>No active subscription</div>;
}
return (
<div>
<h2>Plan: {subscription?.plan_type}</h2>
<p>Days remaining: {daysRemaining}</p>
<p>Expires: {new Date(subscription!.end_date).toLocaleDateString()}</p>
</div>
);
}
Payment Integration
MercadoPago Webhook Flow
Cloudflare Worker Webhook Handler
// cloudflare-worker/src/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === 'POST' && new URL(request.url).pathname === '/webhook') {
const body = await request.json();
// Verify webhook signature
const isValid = await verifyMercadoPagoSignature(request, body);
if (!isValid) {
return new Response('Invalid signature', { status: 401 });
}
// Process payment
if (body.type === 'payment' && body.data?.id) {
const payment = await fetchPaymentDetails(body.data.id, env);
if (payment.status === 'approved') {
await createSubscriptionFromPayment(payment, env);
}
}
return new Response('OK', { status: 200 });
}
}
};
Access Control
Protected Routes
Require active subscription for premium content:import { useSubscription } from '@/features/subscription';
import { useRouter } from 'next/navigation';
export function ProtectedContent({ children }: { children: React.ReactNode }) {
const { hasActiveSubscription, isLoading } = useSubscription();
const router = useRouter();
useEffect(() => {
if (!isLoading && !hasActiveSubscription) {
router.push('/pricing');
}
}, [hasActiveSubscription, isLoading, router]);
if (isLoading) return <LoadingSpinner />;
if (!hasActiveSubscription) return null;
return <>{children}</>;
}
Feature Gates
Limit features by plan tier:function canAccessFeature(feature: string, planType: PlanType | null): boolean {
const featureAccess = {
basic_workouts: ['PLAN_BASICO', 'PLAN_PRO', 'PLAN_PREMIUM'],
custom_meal_plans: ['PLAN_PRO', 'PLAN_PREMIUM'],
exercise_videos: ['PLAN_PRO', 'PLAN_PREMIUM'],
coaching: ['PLAN_PREMIUM'],
};
if (!planType) return false;
return featureAccess[feature]?.includes(planType) ?? false;
}
Testing
// src/features/subscription/services/__tests__/subscription-service.test.ts
import { subscriptionService } from '../subscription-service';
describe('SubscriptionService', () => {
it('creates subscription with correct end date', async () => {
const sub = await subscriptionService.createSubscription({
userId: 'test-user',
planType: 'PLAN_PRO',
paymentProvider: 'mercadopago',
paymentReference: 'MP-123',
amountPaid: 89900,
});
const startDate = new Date(sub.start_date);
const endDate = new Date(sub.end_date);
const monthsDiff = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
(endDate.getMonth() - startDate.getMonth());
expect(monthsDiff).toBe(1);
expect(sub.status).toBe('active');
});
});
See Also
- Authentication - User authentication required for subscriptions
- Workout Wizard - Premium feature requiring subscription
- Meal Planning - Access controlled by subscription tier