Add payment step to onboarding flow
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
- 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();
- 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;
- 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;
},
};
- 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>
);
};
- 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>
);
};
- 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" />,
},
]);
- Environment Variables
Update: packages/web/.env.example REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
oh nice, would love to checkout this pr. Just checking if there are plans to have liabilities, privacies, etc, policies published/drafted
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)
Nice!!!