compass icon indicating copy to clipboard operation
compass copied to clipboard

Add payment step to onboarding flow

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

Overview

Focus: Add payment step to onboarding flow

Changes:

  • Create new onboarding step: StripePaymentStep.tsx
  • Add payment step after Google OAuth success
  • Integrate Stripe Checkout Sessions
  • Add success/cancel redirect handling
  • Update packages/web/src/components/Onboarding/steps/index.ts
  • Modify packages/web/src/routers/index.tsx to handle payment routes

Objective

Add Stripe payment collection step to the onboarding flow. Users will be prompted to set up their subscription after successful Google OAuth but before accessing the main app.

Prerequisites

  • #793
  • Stripe webhook endpoint should be live and configured in Stripe dashboard

Testing Requirements

Unit Tests

  • StripePaymentStep.test.tsx - Test payment initiation flow
  • PaymentSuccessStep.test.tsx - Test success verification
  • stripe.service.test.ts - Test Stripe client service

Integration Tests

  • End-to-end payment flow from onboarding
  • Payment success/cancel redirect handling
  • Checkout session creation and verification

Manual Testing Checklist

  • Payment step appears in onboarding flow
  • Stripe Checkout opens correctly
  • Payment success redirects to success step
  • Payment cancel redirects to cancel step
  • Payment verification works correctly
  • Webhook receives payment events
  • User subscription status updates correctly

Acceptance Criteria

  • Payment step integrated into onboarding flow
  • Stripe Checkout integration working
  • Success/cancel page handling implemented
  • Payment verification endpoint working
  • Webhook receiving and processing events
  • User can complete payment and proceed to app
  • User can cancel payment and retry
  • All tests pass

Notes

  • Keep development skip option for testing
  • Payment step should appear after Google OAuth success
  • Success/cancel URLs must match Stripe dashboard configuration
  • Webhook endpoint must be configured in Stripe dashboard before testing

Proof-of-Concept

  1. Frontend Stripe SDK Setup

Update: packages/web/package.json

  {
    "dependencies": {
      "@stripe/stripe-js": "^2.0.0"
    }
  }

Create: packages/web/src/common/services/stripe.service.ts

  import { loadStripe, Stripe } from '@stripe/stripe-js';

  class StripeClientService {
    private stripePromise: Promise<Stripe | null>;

    constructor() {
      this.stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!);
    }

    async getStripe(): Promise<Stripe | null> {
      return await this.stripePromise;
    }

    async redirectToCheckout(sessionId: string): Promise<void> {
      const stripe = await this.getStripe();
      if (!stripe) throw new Error('Stripe failed to load');

      const { error } = await stripe.redirectToCheckout({ sessionId });
      if (error) {
        throw new Error(error.message);
      }
    }
  }

  export default new StripeClientService();
  1. Backend API Endpoint

Create: packages/backend/src/stripe/controllers/stripe.checkout.controller.ts

  import { Response } from 'express';
  import { SessionRequest } from 'supertokens-node/framework/express';
  import stripeService from '../services/stripe.service';
  import { getUserById } from '@backend/user/queries/user.queries';
  import { STRIPE_PRICE_ID } from '@backend/common/constants/env.constants';
  import { Logger } from '@core/logger/winston.logger';
  import { Res_Promise } from '@backend/common/types/express.types';

  const logger = Logger('app:stripe.checkout');

  class StripeCheckoutController {
    createCheckoutSession = async (req: SessionRequest, res: Res_Promise) => {
      try {
        const userId = req.session?.getUserId();
        if (!userId) {
          res.promise({ error: 'No authenticated user' });
          return;
        }

        const user = await getUserById(userId);
        if (!user) {
          res.promise({ error: 'User not found' });
          return;
        }

        if (!user.stripe?.customerId) {
          res.promise({ error: 'Stripe customer not found' });
          return;
        }

        // Check if user already has active subscription
        const hasActive = await stripeService.hasActiveSubscription(user.stripe.customerId);
        if (hasActive) {
          res.promise({ error: 'User already has active subscription' });
          return;
        }

        const baseUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
        const session = await stripeService.createCheckoutSession(
          user.stripe.customerId,
          STRIPE_PRICE_ID,
          `${baseUrl}/onboarding/payment-success?session_id={CHECKOUT_SESSION_ID}`,
          `${baseUrl}/onboarding/payment-canceled`
        );

        res.promise({ sessionId: session.id });
      } catch (error) {
        logger.error('Failed to create checkout session:', error);
        res.promise({ error: 'Failed to create checkout session' });
      }
    };

    verifyCheckoutSession = async (req: SessionRequest, res: Res_Promise) => {
      try {
        const { sessionId } = req.body;
        const userId = req.session?.getUserId();

        if (!sessionId || !userId) {
          res.promise({ error: 'Missing session ID or user' });
          return;
        }

        const session = await stripeService.stripe.checkout.sessions.retrieve(sessionId);

        if (session.payment_status === 'paid') {
          // Session is valid and paid
          res.promise({ success: true, subscriptionStatus: 'active' });
        } else {
          res.promise({ success: false, error: 'Payment not completed' });
        }
      } catch (error) {
        logger.error('Failed to verify checkout session:', error);
        res.promise({ error: 'Failed to verify payment' });
      }
    };
  }

  export default new StripeCheckoutController();

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';

  const router = express.Router();

  // Webhook endpoint - must be raw body for signature verification
  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);

  export default router;
  1. Frontend API Integration

Create: 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 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;
    },
  };
  1. Payment Onboarding Step Component

Create: packages/web/src/components/Onboarding/steps/StripePaymentStep.tsx

  import React, { useState } from 'react';
  import styled from 'styled-components';
  import { StripeApi } from '@web/common/apis/stripe.api';
  import stripeService from '@web/common/services/stripe.service';
  import { OnboardingStepProps } from '../components/Onboarding';
  import { OnboardingStepBoilerplate } from '../components/OnboardingStepBoilerplate';

  const PaymentContainer = styled.div`
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 20px;
    text-align: center;
  `;

  const PricingCard = styled.div`
    background: #1a1d23;
    border-radius: 12px;
    padding: 24px;
    border: 1px solid #333;
    max-width: 400px;
  `;

  const PriceText = styled.div`
    font-size: 32px;
    font-weight: bold;
    color: #fff;
    margin-bottom: 8px;
  `;

  const TrialText = styled.div`
    color: #10b981;
    font-size: 16px;
    margin-bottom: 16px;
  `;

  const FeatureList = styled.ul`
    list-style: none;
    padding: 0;
    text-align: left;

    li {
      padding: 8px 0;
      color: #d1d5db;

      &:before {
        content: '✓';
        color: #10b981;
        margin-right: 12px;
        font-weight: bold;
      }
    }
  `;

  const PaymentButton = 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;
    min-width: 200px;

    &:hover:not(:disabled) {
      background: #059669;
    }

    &:disabled {
      background: #6b7280;
      cursor: not-allowed;
    }
  `;

  const ErrorMessage = styled.div`
    color: #ef4444;
    font-size: 14px;
    margin-top: 8px;
  `;

  const SkipButton = styled.button`
    background: none;
    border: 1px solid #4b5563;
    color: #9ca3af;
    border-radius: 8px;
    padding: 8px 16px;
    font-size: 14px;
    cursor: pointer;
    margin-top: 12px;

    &:hover {
      border-color: #6b7280;
      color: #d1d5db;
    }
  `;

  export const StripePaymentStep: React.FC<OnboardingStepProps> = ({ onNext, onSkip }) => {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    const handleStartTrial = async () => {
      try {
        setIsLoading(true);
        setError(null);

        const { sessionId, error: apiError } = await StripeApi.createCheckoutSession();

        if (apiError || !sessionId) {
          setError(apiError || 'Failed to create payment session');
          return;
        }

        // Redirect to Stripe Checkout
        await stripeService.redirectToCheckout(sessionId);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'An error occurred');
      } finally {
        setIsLoading(false);
      }
    };

    const handleSkip = () => {
      // Allow skipping for now during development
      if (process.env.NODE_ENV === 'development') {
        onSkip();
      }
    };

    return (
      <OnboardingStepBoilerplate
        title="Start Your Free Trial"
        subtitle="Get full access to Compass with a 14-day free trial"
      >
        <PaymentContainer>
          <PricingCard>
            <PriceText>$9/month</PriceText>
            <TrialText>14-day free trial</TrialText>

            <FeatureList>
              <li>Unlimited events and planning</li>
              <li>Google Calendar sync</li>
              <li>Week and month planning</li>
              <li>Focus notes and priorities</li>
              <li>All future features included</li>
            </FeatureList>
          </PricingCard>

          <PaymentButton
            onClick={handleStartTrial}
            disabled={isLoading}
          >
            {isLoading ? 'Loading...' : 'Start Free Trial'}
          </PaymentButton>

          {error && <ErrorMessage>{error}</ErrorMessage>}

          {process.env.NODE_ENV === 'development' && (
            <SkipButton onClick={handleSkip}>
              Skip for now (Dev Only)
            </SkipButton>
          )}
        </PaymentContainer>
      </OnboardingStepBoilerplate>
    );
  };
  1. Success/Cancel Pages

Create: packages/web/src/components/Onboarding/steps/PaymentSuccessStep.tsx

  import React, { useEffect, useState } from 'react';
  import { useSearchParams } from 'react-router-dom';
  import styled from 'styled-components';
  import { StripeApi } from '@web/common/apis/stripe.api';
  import { OnboardingStepProps } from '../components/Onboarding';
  import { OnboardingStepBoilerplate } from '../components/OnboardingStepBoilerplate';

  const SuccessContainer = styled.div`
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 20px;
    text-align: center;
  `;

  const CheckIcon = styled.div`
    width: 64px;
    height: 64px;
    background: #10b981;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 32px;
    color: white;
  `;

  const SuccessText = styled.div`
    font-size: 18px;
    color: #d1d5db;
    max-width: 400px;
  `;

  const ContinueButton = styled.button`
    background: #10b981;
    color: white;
    border: none;
    border-radius: 8px;
    padding: 12px 24px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;

    &:hover:not(:disabled) {
      background: #059669;
    }

    &:disabled {
      background: #6b7280;
      cursor: not-allowed;
    }
  `;

  export const PaymentSuccessStep: React.FC<OnboardingStepProps> = ({ onNext }) => {
    const [searchParams] = useSearchParams();
    const [isVerifying, setIsVerifying] = useState(true);
    const [verificationError, setVerificationError] = useState<string | null>(null);

    useEffect(() => {
      const verifyPayment = async () => {
        const sessionId = searchParams.get('session_id');

        if (!sessionId) {
          setVerificationError('No payment session found');
          setIsVerifying(false);
          return;
        }

        try {
          const result = await StripeApi.verifyCheckoutSession(sessionId);

          if (result.success) {
            setIsVerifying(false);
          } else {
            setVerificationError(result.error || 'Payment verification failed');
            setIsVerifying(false);
          }
        } catch (error) {
          setVerificationError('Failed to verify payment');
          setIsVerifying(false);
        }
      };

      verifyPayment();
    }, [searchParams]);

    const handleContinue = () => {
      onNext({ paymentCompleted: true });
    };

    if (isVerifying) {
      return (
        <OnboardingStepBoilerplate
          title="Verifying Payment"
          subtitle="Please wait while we confirm your subscription..."
        />
      );
    }

    if (verificationError) {
      return (
        <OnboardingStepBoilerplate
          title="Payment Error"
          subtitle={verificationError}
        />
      );
    }

    return (
      <OnboardingStepBoilerplate
        title="Welcome to Compass!"
        subtitle="Your free trial has started successfully"
      >
        <SuccessContainer>
          <CheckIcon>✓</CheckIcon>

          <SuccessText>
            Great! Your 14-day free trial is now active. You have full access to all Compass features.
            Your subscription will begin after the trial period ends.
          </SuccessText>

          <ContinueButton onClick={handleContinue}>
            Continue to Compass
          </ContinueButton>
        </SuccessContainer>
      </OnboardingStepBoilerplate>
    );
  };

Create: packages/web/src/components/Onboarding/steps/PaymentCanceledStep.tsx

  import React from 'react';
  import styled from 'styled-components';
  import { OnboardingStepProps } from '../components/Onboarding';
  import { OnboardingStepBoilerplate } from '../components/OnboardingStepBoilerplate';

  const CancelContainer = styled.div`
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 20px;
    text-align: center;
  `;

  const CancelIcon = styled.div`
    width: 64px;
    height: 64px;
    background: #ef4444;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 32px;
    color: white;
  `;

  const CancelText = styled.div`
    font-size: 18px;
    color: #d1d5db;
    max-width: 400px;
  `;

  const RetryButton = styled.button`
    background: #10b981;
    color: white;
    border: none;
    border-radius: 8px;
    padding: 12px 24px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;

    &:hover {
      background: #059669;
    }
  `;

  export const PaymentCanceledStep: React.FC<OnboardingStepProps> = ({ onPrevious }) => {
    return (
      <OnboardingStepBoilerplate
        title="Payment Canceled"
        subtitle="No worries, you can try again anytime"
      >
        <CancelContainer>
          <CancelIcon>×</CancelIcon>

          <CancelText>
            You canceled the payment process. To access Compass, you'll need to complete the subscription setup.
          </CancelText>

          <RetryButton onClick={onPrevious}>
            Try Again
          </RetryButton>
        </CancelContainer>
      </OnboardingStepBoilerplate>
    );
  };
  1. Update Onboarding Flow

Update: packages/web/src/components/Onboarding/steps/index.ts

  // Add exports
  export { StripePaymentStep } from './StripePaymentStep';
  export { PaymentSuccessStep } from './PaymentSuccessStep';
  export { PaymentCanceledStep } from './PaymentCanceledStep';

Update: packages/web/src/components/Onboarding/OnboardingDemo.tsx

  // Add payment step after Google auth success
  import {
    // ... existing imports
    StripePaymentStep,
    PaymentSuccessStep,
    PaymentCanceledStep,
  } from './steps';

  // Update steps array to include payment step
  const steps: OnboardingStep[] = [
    // ... existing steps
    {
      id: 'stripe-payment',
      component: StripePaymentStep,
    },
    {
      id: 'payment-success',
      component: PaymentSuccessStep,
    },
    // ... remaining steps
  ];

  // Add route handling for payment pages

  7. Router Updates

  Update: packages/web/src/routers/index.tsx
  // Add payment success/cancel routes
  const router = createBrowserRouter([
    // ... existing routes
    {
      path: '/onboarding/payment-success',
      element: <OnboardingDemo initialStep="payment-success" />,
    },
    {
      path: '/onboarding/payment-canceled',
      element: <OnboardingDemo initialStep="payment-canceled" />,
    },
  ]);
  1. Environment Variables

Update: packages/web/.env.example REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...

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

oh nice, would love to checkout this pr. Just checking if there are plans to have liabilities, privacies, etc, policies published/drafted

ChTiSh avatar Aug 24 '25 17:08 ChTiSh

Hey @ChTiSh!

I'll be adding the payment flow before the end of month, so PRs are coming soon.

Policies are here: https://compasscalendar.com/privacy https://compasscalendar.com/terms

They're also available from the CMD+K palette in the app (though I might remove those to declutter soon)

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

Nice!!!

ChTiSh avatar Aug 24 '25 20:08 ChTiSh