Enforce active subscription
Focus: Enforce subscription-based access
Testing Requirements
Unit Tests
- useSubscription.test.ts - Test subscription hook
- SubscriptionRequired.test.tsx - Test subscription required component
- subscription.service.test.ts - Test backend subscription service
Integration Tests
- Access control enforcement across protected routes
- Subscription status updates after webhook events
- Customer portal session creation
- Grace period handling for canceled subscriptions
End-to-End Tests
- Complete subscription flow from signup to cancellation
- Access revocation after subscription expires
- Customer portal integration
Manual Testing Checklist
- Users without subscription cannot access app
- Users with trial/active subscription can access app
- Canceled users get grace period access
- Command palette shows subscription management
- Customer portal opens correctly
- Subscription status displays correctly
- Access is properly enforced on all routes
Acceptance Criteria
- Access control enforced throughout application
- Subscription management replaces donation functionality
- Customer portal integration working
- Grace period handling for canceled subscriptions
- Subscription status visible in UI
- All protected routes require active subscription
- Error handling prevents user lockouts
- All tests pass
Rollback Plan
If issues arise, the subscription middleware can be disabled by:
- Removing requireActiveSubscription from route middlewares
- Updating ProtectedRoute to skip subscription checks
- This will restore pre-subscription functionality while maintaining user data
Security Considerations
- Never trust client-side subscription status - always verify server-side
- Implement proper error handling to prevent lockouts
- Use Stripe webhooks as source of truth for subscription status
- Graceful degradation when Stripe API is unavailable
Proof-of-Concept
An example of how you can implement this feature
Changes:
- Modify ProtectedRoute.tsx to check subscription status
- Add subscription verification to auth flow
- Create subscription management UI (link to Stripe portal)
- Add grace period handling for failed payments
- Update command palette to show subscription status
- Remove old donation link, replace with subscription management
Sample Access Control: // Enhanced ProtectedRoute const ProtectedRoute = ({ children }) => { const { isAuthenticated, hasValidSubscription, isCheckingSubscription } = useAuthCheck();
if (!isAuthenticated) return <Navigate to="/login" />;
if (isCheckingSubscription) return <Loader />;
if (!hasValidSubscription) return <Navigate to="/subscription-required" />;
return children;
};
Testing: Access control, subscription management, cancellation flows
Objective
Implement subscription-based access control throughout the application. Users must have an active subscription (or be in trial period) to access the main app. Replace donation functionality with subscription management.
Prerequisites
- PR 1 (Backend Stripe Infrastructure) must be merged
- PR 2 (Payment Collection Integration) must be merged
- Stripe webhooks should be fully functional and tested
Technical Requirements
- Enhanced User Subscription Verification
Create: packages/backend/src/user/services/subscription.service.ts
import { getUserById } from '../queries/user.queries';
import stripeService from '@backend/stripe/services/stripe.service';
import { Logger } from '@core/logger/winston.logger';
const logger = Logger('app:subscription.service');
export interface SubscriptionStatus {
hasAccess: boolean;
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'none';
trialEnd?: Date;
currentPeriodEnd?: Date;
gracePeriodEnd?: Date;
}
class SubscriptionService {
async checkUserAccess(userId: string): Promise<SubscriptionStatus> {
try {
const user = await getUserById(userId);
if (!user?.stripe?.customerId) {
return { hasAccess: false, status: 'none' };
}
// Get fresh subscription data from Stripe
const subscriptions = await stripeService.getSubscriptionsByCustomer(user.stripe.customerId);
const activeSubscription = subscriptions.find(sub =>
['active', 'trialing', 'past_due'].includes(sub.status)
);
if (!activeSubscription) {
// Check if recently canceled - provide 3-day grace period
const canceledSubscription = subscriptions.find(sub => sub.status === 'canceled');
if (canceledSubscription && this.isInGracePeriod(canceledSubscription.canceled_at)) {
return {
hasAccess: true,
status: 'canceled',
gracePeriodEnd: this.getGracePeriodEnd(canceledSubscription.canceled_at),
};
}
return { hasAccess: false, status: 'none' };
}
const subscriptionStatus: SubscriptionStatus = {
hasAccess: true,
status: activeSubscription.status as any,
currentPeriodEnd: new Date(activeSubscription.current_period_end * 1000),
};
if (activeSubscription.trial_end) {
subscriptionStatus.trialEnd = new Date(activeSubscription.trial_end * 1000);
}
return subscriptionStatus;
} catch (error) {
logger.error(`Failed to check user access for ${userId}:`, error);
// In case of error, allow access to prevent lockouts
return { hasAccess: true, status: 'active' };
}
}
private isInGracePeriod(canceledAt: number | null): boolean {
if (!canceledAt) return false;
const canceledDate = new Date(canceledAt * 1000);
const gracePeriodEnd = new Date(canceledDate.getTime() + (3 * 24 * 60 * 60 * 1000)); // 3 days
return new Date() < gracePeriodEnd;
}
private getGracePeriodEnd(canceledAt: number | null): Date | undefined {
if (!canceledAt) return undefined;
const canceledDate = new Date(canceledAt * 1000);
return new Date(canceledDate.getTime() + (3 * 24 * 60 * 60 * 1000)); // 3 days
}
async createCustomerPortalSession(userId: string, returnUrl: string): Promise<string> {
const user = await getUserById(userId);
if (!user?.stripe?.customerId) {
throw new Error('No Stripe customer found');
}
const session = await stripeService.stripe.billingPortal.sessions.create({
customer: user.stripe.customerId,
return_url: returnUrl,
});
return session.url;
}
}
export default new SubscriptionService();
- Backend Access Control Middleware
Create: packages/backend/src/auth/middleware/subscription.middleware.ts
import { Response, NextFunction } from 'express';
import { SessionRequest } from 'supertokens-node/framework/express';
import subscriptionService from '@backend/user/services/subscription.service';
import { Logger } from '@core/logger/winston.logger';
const logger = Logger('app:subscription.middleware');
export const requireActiveSubscription = async (
req: SessionRequest,
res: Response,
next: NextFunction
) => {
try {
const userId = req.session?.getUserId();
if (!userId) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const subscriptionStatus = await subscriptionService.checkUserAccess(userId);
if (!subscriptionStatus.hasAccess) {
res.status(403).json({
error: 'Active subscription required',
subscriptionStatus
});
return;
}
// Add subscription status to request for downstream use
(req as any).subscriptionStatus = subscriptionStatus;
next();
} catch (error) {
logger.error('Subscription middleware error:', error);
// Allow access on error to prevent lockouts
next();
}
};
- Backend Subscription API Endpoints
Create: packages/backend/src/stripe/controllers/subscription.controller.ts
import { Response } from 'express';
import { SessionRequest } from 'supertokens-node/framework/express';
import subscriptionService from '@backend/user/services/subscription.service';
import { Res_Promise } from '@backend/common/types/express.types';
import { Logger } from '@core/logger/winston.logger';
const logger = Logger('app:subscription.controller');
class SubscriptionController {
getSubscriptionStatus = async (req: SessionRequest, res: Res_Promise) => {
try {
const userId = req.session?.getUserId();
if (!userId) {
res.promise({ error: 'Authentication required' });
return;
}
const status = await subscriptionService.checkUserAccess(userId);
res.promise(status);
} catch (error) {
logger.error('Failed to get subscription status:', error);
res.promise({ error: 'Failed to get subscription status' });
}
};
createPortalSession = async (req: SessionRequest, res: Res_Promise) => {
try {
const userId = req.session?.getUserId();
const { returnUrl } = req.body;
if (!userId) {
res.promise({ error: 'Authentication required' });
return;
}
const portalUrl = await subscriptionService.createCustomerPortalSession(
userId,
returnUrl || `${process.env.FRONTEND_URL}/dashboard`
);
res.promise({ url: portalUrl });
} catch (error) {
logger.error('Failed to create portal session:', error);
res.promise({ error: 'Failed to create portal session' });
}
};
}
export default new SubscriptionController();
Update: packages/backend/src/stripe/stripe.routes.config.ts
import express from 'express';
import { verifySession } from 'supertokens-node/recipe/session/framework/express';
import stripeWebhookController from './controllers/stripe.webhook.controller';
import stripeCheckoutController from './controllers/stripe.checkout.controller';
import subscriptionController from './controllers/subscription.controller';
import { requireActiveSubscription } from '@backend/auth/middleware/subscription.middleware';
const router = express.Router();
// Public webhook endpoint
router.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
stripeWebhookController.handleWebhook
);
// Authenticated endpoints
router.post('/stripe/create-checkout-session', verifySession(), stripeCheckoutController.createCheckoutSession);
router.post('/stripe/verify-checkout-session', verifySession(), stripeCheckoutController.verifyCheckoutSession);
// Subscription management endpoints
router.get('/stripe/subscription-status', verifySession(), subscriptionController.getSubscriptionStatus);
router.post('/stripe/create-portal-session', verifySession(), subscriptionController.createPortalSession);
// Apply subscription requirement to protected routes
// Example: router.use('/api/events', verifySession(), requireActiveSubscription);
export default router;
- Frontend Subscription Hook
Create: packages/web/src/auth/useSubscription.ts
import { useState, useEffect } from 'react';
import { StripeApi } from '@web/common/apis/stripe.api';
export interface SubscriptionStatus {
hasAccess: boolean;
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'none';
trialEnd?: string;
currentPeriodEnd?: string;
gracePeriodEnd?: string;
}
export const useSubscription = () => {
const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const checkSubscription = async () => {
try {
setIsLoading(true);
setError(null);
const status = await StripeApi.getSubscriptionStatus();
setSubscriptionStatus(status);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to check subscription');
// Set fallback status to prevent lockout
setSubscriptionStatus({ hasAccess: true, status: 'active' });
} finally {
setIsLoading(false);
}
};
useEffect(() => {
checkSubscription();
}, []);
const refreshSubscription = () => {
checkSubscription();
};
return {
subscriptionStatus,
isLoading,
error,
refreshSubscription,
hasAccess: subscriptionStatus?.hasAccess ?? false,
isTrialing: subscriptionStatus?.status === 'trialing',
isActive: subscriptionStatus?.status === 'active',
isCanceled: subscriptionStatus?.status === 'canceled',
};
};
- Enhanced Protected Route
Update: packages/web/src/auth/ProtectedRoute.tsx
import React, { ReactNode, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants";
import { ROOT_ROUTES } from "@web/common/constants/routes";
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
import { useAuthCheck } from "./useAuthCheck";
import { useSubscription } from "./useSubscription";
import { SubscriptionRequired } from "@web/components/SubscriptionRequired/SubscriptionRequired";
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
const navigate = useNavigate();
const { isAuthenticated, isCheckingAuth, isGoogleTokenActive } = useAuthCheck();
const { subscriptionStatus, isLoading: isCheckingSubscription } = useSubscription();
useEffect(() => {
const handleAuthCheck = () => {
if (isAuthenticated === false) {
if (isGoogleTokenActive === false) {
navigate(
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.GAUTH_SESSION_EXPIRED}`,
);
} else {
navigate(
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.USER_SESSION_EXPIRED}`,
);
}
}
};
void handleAuthCheck();
}, [isAuthenticated, isGoogleTokenActive, navigate]);
// Show loading while checking auth or subscription
if (isCheckingAuth || isCheckingSubscription) {
return <AbsoluteOverflowLoader />;
}
// Redirect to login if not authenticated
if (isAuthenticated === false) {
return null; // Will redirect via useEffect
}
// Show subscription required if no access
if (subscriptionStatus && !subscriptionStatus.hasAccess) {
return <SubscriptionRequired />;
}
return <>{children}</>;
};
- Subscription Required Component
Create: packages/web/src/components/SubscriptionRequired/SubscriptionRequired.tsx
import React, { useState } from 'react';
import styled from 'styled-components';
import { useNavigate } from 'react-router-dom';
import { StripeApi } from '@web/common/apis/stripe.api';
import { useSubscription } from '@web/auth/useSubscription';
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background: #0f1419;
text-align: center;
`;
const Card = styled.div`
background: #1a1d23;
border-radius: 12px;
padding: 40px;
border: 1px solid #333;
max-width: 500px;
width: 100%;
`;
const Title = styled.h1`
color: #fff;
font-size: 28px;
font-weight: bold;
margin-bottom: 16px;
`;
const Subtitle = styled.p`
color: #d1d5db;
font-size: 18px;
line-height: 1.5;
margin-bottom: 32px;
`;
const ButtonGroup = styled.div`
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
`;
const PrimaryButton = styled.button`
background: #10b981;
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
&:hover:not(:disabled) {
background: #059669;
}
&:disabled {
background: #6b7280;
cursor: not-allowed;
}
`;
const SecondaryButton = styled.button`
background: none;
border: 1px solid #4b5563;
color: #d1d5db;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: #6b7280;
background: #374151;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const StatusMessage = styled.div<{ $isError?: boolean }>`
margin-top: 16px;
padding: 12px;
border-radius: 8px;
background: ${props => props.$isError ? '#7f1d1d' : '#065f46'};
color: ${props => props.$isError ? '#fca5a5' : '#a7f3d0'};
font-size: 14px;
`;
export const SubscriptionRequired: React.FC = () => {
const navigate = useNavigate();
const { subscriptionStatus, refreshSubscription } = useSubscription();
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const handleStartSubscription = async () => {
try {
setIsLoading(true);
setMessage(null);
const { sessionId, error } = await StripeApi.createCheckoutSession();
if (error || !sessionId) {
setMessage(error || 'Failed to create payment session');
setIsError(true);
return;
}
// Redirect to Stripe Checkout
window.location.href = `https://checkout.stripe.com/pay/${sessionId}`;
} catch (err) {
setMessage(err instanceof Error ? err.message : 'An error occurred');
setIsError(true);
} finally {
setIsLoading(false);
}
};
const handleManageSubscription = async () => {
try {
setIsLoading(true);
setMessage(null);
const { url, error } = await StripeApi.createPortalSession();
if (error || !url) {
setMessage(error || 'Failed to open subscription management');
setIsError(true);
return;
}
window.open(url, '_blank');
} catch (err) {
setMessage(err instanceof Error ? err.message : 'An error occurred');
setIsError(true);
} finally {
setIsLoading(false);
}
};
const handleLogout = () => {
navigate('/logout');
};
const getContent = () => {
if (subscriptionStatus?.status === 'canceled' && subscriptionStatus.gracePeriodEnd) {
const gracePeriodEnd = new Date(subscriptionStatus.gracePeriodEnd);
return {
title: 'Subscription Canceled',
subtitle: `Your subscription was canceled but you have access until ${gracePeriodEnd.toLocaleDateString()}. Reactivate
anytime to continue using Compass.`,
primaryAction: { text: 'Reactivate Subscription', handler: handleManageSubscription },
secondaryAction: { text: 'Sign Out', handler: handleLogout },
};
}
return {
title: 'Subscription Required',
subtitle: 'To continue using Compass, you need an active subscription. Start with a 14-day free trial.',
primaryAction: { text: 'Start Free Trial', handler: handleStartSubscription },
secondaryAction: { text: 'Sign Out', handler: handleLogout },
};
};
const content = getContent();
return (
<Container>
<Card>
<Title>{content.title}</Title>
<Subtitle>{content.subtitle}</Subtitle>
<ButtonGroup>
<PrimaryButton
onClick={content.primaryAction.handler}
disabled={isLoading}
>
{isLoading ? 'Loading...' : content.primaryAction.text}
</PrimaryButton>
<SecondaryButton
onClick={content.secondaryAction.handler}
disabled={isLoading}
>
{content.secondaryAction.text}
</SecondaryButton>
</ButtonGroup>
{message && (
<StatusMessage $isError={isError}>
{message}
</StatusMessage>
)}
</Card>
</Container>
);
};
- Update Frontend API
Update: packages/web/src/common/apis/stripe.api.ts
import { compassApi } from './compass.api';
export interface CreateCheckoutSessionResponse {
sessionId?: string;
error?: string;
}
export interface VerifyCheckoutSessionResponse {
success: boolean;
subscriptionStatus?: string;
error?: string;
}
export interface SubscriptionStatus {
hasAccess: boolean;
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'none';
trialEnd?: string;
currentPeriodEnd?: string;
gracePeriodEnd?: string;
}
export interface CreatePortalSessionResponse {
url?: string;
error?: string;
}
export const StripeApi = {
createCheckoutSession: async (): Promise<CreateCheckoutSessionResponse> => {
const response = await compassApi.post('/stripe/create-checkout-session');
return response.data;
},
verifyCheckoutSession: async (sessionId: string): Promise<VerifyCheckoutSessionResponse> => {
const response = await compassApi.post('/stripe/verify-checkout-session', { sessionId });
return response.data;
},
getSubscriptionStatus: async (): Promise<SubscriptionStatus> => {
const response = await compassApi.get('/stripe/subscription-status');
return response.data;
},
createPortalSession: async (returnUrl?: string): Promise<CreatePortalSessionResponse> => {
const response = await compassApi.post('/stripe/create-portal-session', { returnUrl });
return response.data;
},
};
8. Update Command Palette with Subscription Management
Update: packages/web/src/views/CmdPalette/CmdPalette.tsx
Replace the donation item (lines 205-212) with subscription management:
// Replace the donate item with:
{
id: "manage-subscription",
children: "Manage Subscription",
icon: "CreditCardIcon",
onClick: async () => {
try {
const { url, error } = await StripeApi.createPortalSession();
if (url) {
window.open(url, '_blank');
} else {
alert(error || 'Failed to open subscription management');
}
} catch (err) {
alert('Failed to open subscription management');
}
},
},
- Subscription Status Display Component
Create: packages/web/src/components/SubscriptionStatus/SubscriptionStatus.tsx
import React from 'react';
import styled from 'styled-components';
import { useSubscription } from '@web/auth/useSubscription';
const StatusContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
`;
const StatusBadge = styled.span<{ $status: string }>`
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
${props => {
switch (props.$status) {
case 'trialing':
return 'background: #065f46; color: #a7f3d0;';
case 'active':
return 'background: #064e3b; color: #6ee7b7;';
case 'past_due':
return 'background: #92400e; color: #fcd34d;';
case 'canceled':
return 'background: #7f1d1d; color: #fca5a5;';
default:
return 'background: #374151; color: #d1d5db;';
}
}}
`;
const TrialText = styled.span`
color: #9ca3af;
font-size: 12px;
`;
export const SubscriptionStatus: React.FC = () => {
const { subscriptionStatus, isLoading } = useSubscription();
if (isLoading || !subscriptionStatus) {
return null;
}
const formatDate = (dateString: string | undefined) => {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString();
};
return (
<StatusContainer>
<StatusBadge $status={subscriptionStatus.status}>
{subscriptionStatus.status}
</StatusBadge>
{subscriptionStatus.status === 'trialing' && subscriptionStatus.trialEnd && (
<TrialText>
Trial ends {formatDate(subscriptionStatus.trialEnd)}
</TrialText>
)}
{subscriptionStatus.status === 'canceled' && subscriptionStatus.gracePeriodEnd && (
<TrialText>
Access until {formatDate(subscriptionStatus.gracePeriodEnd)}
</TrialText>
)}
</StatusContainer>
);
};