compass icon indicating copy to clipboard operation
compass copied to clipboard

Enforce active subscription

Open tyler-dane opened this issue 7 months ago • 0 comments

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:

  1. Removing requireActiveSubscription from route middlewares
  2. Updating ProtectedRoute to skip subscription checks
  3. 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

  1. 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();
  1. 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();
    }
  };
  1. 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;
  1. 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',
    };
  };
  1. 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}</>;
  };
  1. 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>
    );
  };
  1. 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');
      }
    },
  },
  1. 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>
    );
  };

tyler-dane avatar Aug 24 '25 10:08 tyler-dane