Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor Stripe webhook #200

Merged
merged 30 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7c06409
rename TierIds to PaymentPlanIds
vincanger Jun 28, 2024
5b736ab
refactor webhook and util functions
vincanger Jul 1, 2024
41f8884
pass userDelegate to function
vincanger Jul 2, 2024
cf079ac
Merge branch 'main' into refactor-subscription-logic
vincanger Jul 2, 2024
2ed6d6b
Update dbSeeds.ts
vincanger Jul 2, 2024
1f9c766
Merge branch 'main' into refactor-subscription-logic
vincanger Jul 2, 2024
f8c65b3
update app diff
vincanger Jul 2, 2024
b63924b
Update template/app/src/server/stripe/stripeClient.ts
vincanger Jul 3, 2024
eed60a3
extract event handlers and more
vincanger Jul 3, 2024
3c17bdb
Merge branch 'refactor-subscription-logic' of https://github.com/wasp…
vincanger Jul 3, 2024
4a5a69a
Update AccountPage.tsx
vincanger Jul 4, 2024
77f0517
address filips pro effective typescripting and stuff
vincanger Jul 4, 2024
373cb5e
Martin's attempt at consolidating types.
Martinsos Jul 4, 2024
de108da
fix
Martinsos Jul 4, 2024
b013f21
fix webhook events and validation
vincanger Jul 5, 2024
1b8dae1
small changes
vincanger Jul 5, 2024
3d40416
put stripe event handlers back for marty merge
vincanger Jul 6, 2024
cbc9d66
Merge branch 'refactor-subscription-logic-martin-attempt' into refact…
vincanger Jul 6, 2024
73089dc
merge consilidated types from martin
vincanger Jul 6, 2024
744c7db
move some types around
vincanger Jul 6, 2024
62e918b
add docs for stripe api version
vincanger Jul 6, 2024
71242c0
Update AccountPage.tsx
vincanger Jul 8, 2024
d88ad0f
Update stripe.ts
vincanger Jul 8, 2024
d89a35f
update SubscriptionStatus type
vincanger Jul 8, 2024
d62d186
Update actions.ts
vincanger Jul 8, 2024
6829173
add assertUnreachable util
vincanger Jul 8, 2024
4af43d4
more small changes
vincanger Jul 9, 2024
d5ae7c4
Update deploying.md
vincanger Jul 9, 2024
3f9836f
update accountPage and docs
vincanger Jul 10, 2024
b2174db
update app_diff
vincanger Jul 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion opensaas-sh/app_diff/main.wasp.diff
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,5 @@
+ // the admin dashboard but won't be able to see the other users' data, only mock user data.
+ isMockUser Boolean @default(false)

stripeId String?
stripeId String? @unique
checkoutSessionId String?
8 changes: 5 additions & 3 deletions opensaas-sh/app_diff/src/server/scripts/dbSeeds.ts.diff
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
--- template/app/src/server/scripts/dbSeeds.ts
+++ opensaas-sh/app/src/server/scripts/dbSeeds.ts
@@ -43,5 +43,6 @@
@@ -43,5 +43,8 @@
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null,
subscriptionTier: subscriptionStatus ? faker.helpers.arrayElement([TierIds.HOBBY, TierIds.PRO]) : null,
+ isMockUser: true,
subscriptionTier: subscriptionStatus ? faker.helpers.arrayElement([PaymentPlanIds.HOBBY, PaymentPlanIds.PRO]) : null,
+ // For the demo app, we want to default isMockUser to true so that our admin dash only shows mock users
+ // and not real users signing up to test the app
+ isMockUser: true
};
}
4 changes: 2 additions & 2 deletions template/app/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ entity User {=psl
lastActiveTimestamp DateTime @default(now())
isAdmin Boolean @default(false)

stripeId String?
stripeId String? @unique
checkoutSessionId String?
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted', null
subscriptionTier String? // 'hobby-tier', 'pro-tier', null
subscriptionPlan String? // 'hobby', 'pro', null
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
Expand Down
93 changes: 68 additions & 25 deletions template/app/src/client/app/AccountPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { User } from 'wasp/entities';
import type { SubscriptionStatusOptions } from '../../shared/types';
import { SubscriptionPlanId } from '../../shared/constants';
import { Link } from 'wasp/client/router';
import { type User } from 'wasp/entities';
import { logout } from 'wasp/client/auth';
import { TierIds } from '../../shared/constants';
import { z } from 'zod';

export default function AccountPage({ user }: { user: User }) {
Expand All @@ -27,27 +28,7 @@ export default function AccountPage({ user }: { user: User }) {
)}
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500 dark:text-white'>Your Plan</dt>
{!!user.subscriptionStatus ? (
<>
{user.subscriptionStatus !== 'past_due' ? (
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
{user.subscriptionTier === TierIds.HOBBY ? 'Hobby' : 'Pro'} Plan
</dd>
) : (
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
Your Account is Past Due! Please Update your Payment Information
</dd>
)}
<CustomerPortalButton />
</>
) : (
<>
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
Credits remaining: {user.credits}
</dd>
<BuyMoreButton />
</>
)}
<UserCurrentSubscriptionStatus {...user} />
</div>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500 dark:text-white'>About</dt>
Expand All @@ -70,6 +51,65 @@ export default function AccountPage({ user }: { user: User }) {
);
}

function UserCurrentSubscriptionStatus(user: User) {
const prettyPrintSubscriptionPlan = (userSubscriptionPlan: User['subscriptionPlan']) => {
vincanger marked this conversation as resolved.
Show resolved Hide resolved
const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
if (!userSubscriptionPlan) console.error('User subscription plan is missing');
if (userSubscriptionPlan === SubscriptionPlanId.HOBBY) {
return capitalizeFirstLetter(SubscriptionPlanId.HOBBY);
}
if (userSubscriptionPlan === SubscriptionPlanId.PRO) {
return capitalizeFirstLetter(SubscriptionPlanId.HOBBY);
}
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
};

const prettyPrintEndOfBillingPeriod = (date: Date | null) => {
if (!date) {
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
console.error('User date paid is missing');
return '.';
}
const oneMonthFromNow = new Date(date);
oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1);
return ': ' + oneMonthFromNow.toLocaleDateString();
};

function getSubscriptionMessage(subscriptionStatus: User['subscriptionStatus']) {
vincanger marked this conversation as resolved.
Show resolved Hide resolved
vincanger marked this conversation as resolved.
Show resolved Hide resolved
const plan = prettyPrintSubscriptionPlan(user.subscriptionPlan);
vincanger marked this conversation as resolved.
Show resolved Hide resolved
const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(user.datePaid);

switch (subscriptionStatus) {
case 'active' satisfies SubscriptionStatusOptions:
vincanger marked this conversation as resolved.
Show resolved Hide resolved
return `${plan}`;
case 'past_due' satisfies SubscriptionStatusOptions:
return `Payment for your ${plan} plan is past due! Please update your subscription payment information.`;
case 'canceled' satisfies SubscriptionStatusOptions:
return `Your ${plan} plan subscription has been canceled, but remains active until the end of the current billing period${endOfBillingPeriod}`;
case 'deleted' satisfies SubscriptionStatusOptions:
return `Your previous subscription has been canceled and is no longer active.`;
}
}

if (user.subscriptionStatus) {
return (
<>
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
{getSubscriptionMessage(user.subscriptionStatus)}
</dd>
{user.subscriptionStatus !== ('deleted' satisfies SubscriptionStatusOptions) ? <CustomerPortalButton /> : <BuyMoreButton />}
</>
);
}

return (
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
<>
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
Credits remaining: {user.credits}
</dd>
<BuyMoreButton />
</>
);
}

function BuyMoreButton() {
return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
Expand All @@ -87,13 +127,16 @@ function CustomerPortalButton() {
const customerPortalUrl = schema.parse(import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL);
window.open(customerPortalUrl, '_blank');
} catch (err) {
console.error(err)
console.error(err);
}
};

return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
<button onClick={handleClick} className='font-medium text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300'>
<button
onClick={handleClick}
className='font-medium text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300'
>
Manage Subscription
</button>
</div>
Expand Down
30 changes: 20 additions & 10 deletions template/app/src/client/app/PricingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
import { useAuth } from 'wasp/client/auth';
import { stripePayment } from 'wasp/client/operations';
import { TierIds } from '../../shared/constants';
import { type PaymentPlanId } from '../../shared/types';
import { CreditsPlanId, SubscriptionPlanId } from '../../shared/constants';
import { AiFillCheckCircle } from 'react-icons/ai';
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { cn } from '../../shared/utils';
import { z } from 'zod';

export const tiers = [
type PaymentPlan = {
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
name: string;
id: PaymentPlanId;
price: string;
description: string;
features: string[];
bestDeal?: boolean;
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
};

export const paymentPlans: PaymentPlan[] = [
{
name: 'Hobby',
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
id: TierIds.HOBBY,
id: SubscriptionPlanId.HOBBY,
price: '$9.99',
description: 'All you need to get started',
features: ['Limited monthly usage', 'Basic support'],
},
{
name: 'Pro',
id: TierIds.PRO,
id: SubscriptionPlanId.PRO,
price: '$19.99',
description: 'Our most popular plan',
features: ['Unlimited monthly usage', 'Priority customer support'],
bestDeal: true,
},
{
name: '10 Credits',
id: TierIds.CREDITS,
id: CreditsPlanId.TEN_CREDITS,
price: '$9.99',
description: 'One-time purchase of 10 credits for your account',
features: ['Use credits for e.g. OpenAI API calls', 'No expiration date'],
Expand All @@ -39,14 +49,14 @@ const PricingPage = () => {

const history = useHistory();

async function handleBuyNowClick(tierId: string) {
async function handleBuyNowClick(paymentPlanId: PaymentPlanId) {
if (!user) {
history.push('/login');
return;
}
try {
setIsStripePaymentLoading(tierId);
let stripeResults = await stripePayment(tierId);
setIsStripePaymentLoading(paymentPlanId);
let stripeResults = await stripePayment(paymentPlanId);

if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self');
Expand Down Expand Up @@ -86,7 +96,7 @@ const PricingPage = () => {
<span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
</p>
<div className='isolate mx-auto mt-16 grid max-w-md grid-cols-1 gap-y-8 lg:gap-x-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3'>
{tiers.map((tier) => (
{paymentPlans.map((tier) => (
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
<div
key={tier.id}
className={cn(
Expand Down Expand Up @@ -117,7 +127,7 @@ const PricingPage = () => {
<p className='mt-6 flex items-baseline gap-x-1 dark:text-white'>
<span className='text-4xl font-bold tracking-tight text-gray-900 dark:text-white'>{tier.price}</span>
<span className='text-sm font-semibold leading-6 text-gray-600 dark:text-white'>
{tier.id !== TierIds.CREDITS && '/month'}
{tier.id !== CreditsPlanId.TEN_CREDITS && '/month'}
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
</span>
</p>
<ul role='list' className='mt-8 space-y-3 text-sm leading-6 text-gray-600 dark:text-white'>
Expand Down
20 changes: 10 additions & 10 deletions template/app/src/server/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
type DeleteTask,
type UpdateTask,
} from 'wasp/server/operations';
import { SubscriptionPlanId, CreditsPlanId } from '../shared/constants.js';
import type { GeneratedSchedule, StripePaymentResult, PaymentPlanId } from '../shared/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './stripe/checkoutUtils.js';
import Stripe from 'stripe';
import type { GeneratedSchedule, StripePaymentResult } from '../shared/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './payments/stripeUtils.js';
import { TierIds } from '../shared/constants.js';
import OpenAI from 'openai';

const openai = setupOpenAI();
Expand All @@ -23,7 +23,7 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}

export const stripePayment: StripePayment<string, StripePaymentResult> = async (tier, context) => {
export const stripePayment: StripePayment<string, StripePaymentResult> = async (paymentPlanId, context) => {
if (!context.user) {
throw new HttpError(401);
}
Expand All @@ -36,14 +36,14 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
}

let priceId;
if (tier === TierIds.HOBBY) {
if (paymentPlanId === SubscriptionPlanId.HOBBY) {
priceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.PRO) {
} else if (paymentPlanId === SubscriptionPlanId.PRO) {
priceId = process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.CREDITS) {
} else if (paymentPlanId === CreditsPlanId.TEN_CREDITS) {
priceId = process.env.STRIPE_CREDITS_PRICE_ID!;
} else {
throw new HttpError(404, 'Invalid tier');
throw new HttpError(404, 'Invalid paymentPlanId');
}
Martinsos marked this conversation as resolved.
Show resolved Hide resolved

let customer: Stripe.Customer | undefined;
Expand All @@ -56,7 +56,7 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
session = await createStripeCheckoutSession({
priceId,
customerId: customer.id,
mode: tier === TierIds.CREDITS ? 'payment' : 'subscription',
mode: paymentPlanId === CreditsPlanId.TEN_CREDITS ? 'payment' : 'subscription',
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
});
if (!session) {
throw new HttpError(500, 'Error creating session');
Expand All @@ -67,7 +67,7 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
throw new HttpError(statusCode, errorMessage);
}

const updatedUser = await context.entities.User.update({
await context.entities.User.update({
where: {
id: context.user.id,
},
Expand Down
4 changes: 2 additions & 2 deletions template/app/src/server/scripts/dbSeeds.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type User } from 'wasp/entities';
import { faker } from '@faker-js/faker';
import type { PrismaClient } from '@prisma/client';
import { TierIds } from '../../shared/constants.js';
import { SubscriptionPlanId } from '../../shared/constants.js';
import { type SubscriptionStatusOptions } from '../../shared/types.js';

type MockUserData = Omit<User, 'id'>;
Expand Down Expand Up @@ -42,6 +42,6 @@ function generateMockUserData(): MockUserData {
stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null,
subscriptionTier: subscriptionStatus ? faker.helpers.arrayElement([TierIds.HOBBY, TierIds.PRO]) : null,
subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement([SubscriptionPlanId.HOBBY, SubscriptionPlanId.PRO]) : null,
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
};
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import Stripe from 'stripe';
import { HttpError } from 'wasp/server';

const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
import { stripe } from './stripeClient';

// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
Expand Down
11 changes: 11 additions & 0 deletions template/app/src/server/stripe/stripeClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_KEY!, {
// NOTE:
// API version below should ideally match the API version in your Stripe dashboard.
// If that is not the case, you will most likely want to (up/down)grade the `stripe`
// npm package to the API version that matches your Stripe dashboard's one.
// For more details and alternative setups check
// https://docs.stripe.com/api/versioning .
apiVersion: '2022-11-15',
});
Loading
Loading