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 19 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
92 changes: 67 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 { PaymentPlanId, parsePaymentPlanId } from '../../payment/plans';
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,64 @@ export default function AccountPage({ user }: { user: User }) {
);
}

function UserCurrentSubscriptionStatus(user: User) {
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: string) {
if (!user.subscriptionPlan) {
throw new Error('User is missing a subscriptionPlan');
}
const planName = prettyPaymentPlanName(parsePaymentPlanId(user.subscriptionPlan));
const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(user.datePaid);

const statusToMessage: Record<SubscriptionStatusOptions, string> = {
active: `${planName}`,
past_due: `Payment for your ${planName} plan is past due! Please update your subscription payment information.`,
cancel_at_period_end: `Your ${planName} plan subscription has been canceled, but remains active until the end of the current billing period${endOfBillingPeriod}`,
deleted: `Your previous subscription has been canceled and is no longer active.`
};

return statusToMessage[subscriptionStatus as SubscriptionStatusOptions];
}

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 as SubscriptionStatusOptions !== 'deleted' ? <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 prettyPaymentPlanName (planId: PaymentPlanId): string {
const planToName: Record<PaymentPlanId, string> = {
[PaymentPlanId.Hobby]: 'Hobby',
[PaymentPlanId.Pro]: 'Pro',
[PaymentPlanId.Credits10]: '10 Credits'
};
return planToName[planId];
}

function BuyMoreButton() {
return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
Expand All @@ -87,13 +126,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
77 changes: 45 additions & 32 deletions template/app/src/client/app/PricingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,41 @@
import { useAuth } from 'wasp/client/auth';
import { stripePayment } from 'wasp/client/operations';
import { TierIds } from '../../shared/constants';
import { PaymentPlanId, paymentPlans } from '../../payment/plans';
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 = [
{
const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro;

interface PaymentPlanCard {
name: string;
price: string;
description: string;
features: string[];
};

export const paymentPlanCards: Record<PaymentPlanId, PaymentPlanCard> = {
[PaymentPlanId.Hobby]: {
name: 'Hobby',
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
id: TierIds.HOBBY,
price: '$9.99',
description: 'All you need to get started',
features: ['Limited monthly usage', 'Basic support'],
},
{
[PaymentPlanId.Pro]: {
name: 'Pro',
id: TierIds.PRO,
price: '$19.99',
description: 'Our most popular plan',
features: ['Unlimited monthly usage', 'Priority customer support'],
bestDeal: true,
},
{
[PaymentPlanId.Credits10]: {
name: '10 Credits',
id: TierIds.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'],
},
];
}
};

const PricingPage = () => {
const [isStripePaymentLoading, setIsStripePaymentLoading] = useState<boolean | string>(false);
Expand All @@ -39,14 +44,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,18 +91,18 @@ 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) => (
{Object.values(PaymentPlanId).map((planId) => (
<div
key={tier.id}
key={planId}
className={cn(
'relative flex flex-col grow justify-between rounded-3xl ring-gray-900/10 dark:ring-gray-100/10 overflow-hidden p-8 xl:p-10',
{
'ring-2': tier.bestDeal,
'ring-1 lg:mt-8': !tier.bestDeal,
'ring-2': planId === bestDealPaymentPlanId,
'ring-1 lg:mt-8': planId !== bestDealPaymentPlanId,
}
)}
>
{tier.bestDeal && (
{planId === bestDealPaymentPlanId && (
<div className='absolute top-0 right-0 -z-10 w-full h-full transform-gpu blur-3xl' aria-hidden='true'>
<div
className='absolute w-full h-full bg-gradient-to-br from-amber-400 to-purple-300 opacity-30 dark:opacity-50'
Expand All @@ -109,19 +114,23 @@ const PricingPage = () => {
)}
<div className='mb-8'>
<div className='flex items-center justify-between gap-x-4'>
<h3 id={tier.id} className='text-gray-900 text-lg font-semibold leading-8 dark:text-white'>
{tier.name}
<h3 id={planId} className='text-gray-900 text-lg font-semibold leading-8 dark:text-white'>
{paymentPlanCards[planId].name}
</h3>
</div>
<p className='mt-4 text-sm leading-6 text-gray-600 dark:text-white'>{tier.description}</p>
<p className='mt-4 text-sm leading-6 text-gray-600 dark:text-white'>
{paymentPlanCards[planId].description}
</p>
<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-4xl font-bold tracking-tight text-gray-900 dark:text-white'>
{paymentPlanCards[planId].price}
</span>
<span className='text-sm font-semibold leading-6 text-gray-600 dark:text-white'>
{tier.id !== TierIds.CREDITS && '/month'}
{paymentPlans[planId].effect.kind === 'subscription' && '/month'}
</span>
</p>
<ul role='list' className='mt-8 space-y-3 text-sm leading-6 text-gray-600 dark:text-white'>
{tier.features.map((feature) => (
{paymentPlanCards[planId].features.map((feature) => (
<li key={feature} className='flex gap-x-3'>
<AiFillCheckCircle className='h-6 w-5 flex-none text-yellow-500' aria-hidden='true' />
{feature}
Expand All @@ -136,24 +145,28 @@ const PricingPage = () => {
className={cn(
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400',
{
'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400': tier.bestDeal,
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400': !tier.bestDeal,
'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400':
planId === bestDealPaymentPlanId,
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400':
planId !== bestDealPaymentPlanId,
}
)}
>
Manage Subscription
</button>
) : (
<button
onClick={() => handleBuyNowClick(tier.id)}
aria-describedby={tier.id}
onClick={() => handleBuyNowClick(planId)}
aria-describedby={planId}
className={cn(
{
'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400': tier.bestDeal,
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400': !tier.bestDeal,
'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400':
planId === bestDealPaymentPlanId,
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400':
planId !== bestDealPaymentPlanId,
},
{
'opacity-50 cursor-wait cursor-not-allowed': isStripePaymentLoading === tier.id,
'opacity-50 cursor-wait cursor-not-allowed': isStripePaymentLoading === planId,
},
'mt-8 block rounded-md py-2 px-3 text-center text-sm dark:text-white font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
)}
Expand Down
Empty file.
51 changes: 51 additions & 0 deletions template/app/src/payment/plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@

export enum PaymentPlanId {
Hobby = 'hobby',
Pro = 'pro',
Credits10 = 'credits10'
}

export interface PaymentPlan {
getStripePriceId: () => string,
effect: PaymentPlanEffect
}

export type PaymentPlanEffect = { kind: 'subscription' } | { kind: 'credits', amount: number };
export type PaymentPlanEffectKinds = PaymentPlanEffect extends { kind: infer K } ? K : never;

export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
[PaymentPlanId.Hobby]: {
getStripePriceId: () => requireNodeEnvVar('STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID'),
effect: { kind: 'subscription' }
},
[PaymentPlanId.Pro]: {
getStripePriceId: () => requireNodeEnvVar('STRIPE_PRO_SUBSCRIPTION_PRICE_ID'),
effect: { kind: 'subscription' }
},
[PaymentPlanId.Credits10]: {
getStripePriceId: () => requireNodeEnvVar('STRIPE_CREDITS_PRICE_ID'),
effect: { kind: 'credits', amount: 10 }
}
}

// TODO: Move to some server/utils.js?
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
function requireNodeEnvVar(name: string): string {
const value = process.env[name];
if (value === undefined) {
throw new Error(`Env var ${name} is undefined`);
} else {
return value;
}
}

export function parsePaymentPlanId(planId: string): PaymentPlanId {
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
if ((Object.values(PaymentPlanId) as string[]).includes(planId)) {
return planId as PaymentPlanId;
} else {
throw new Error(`Invalid PaymentPlanId: ${planId}`);
}
}

export function getSubscriptionPaymentPlanIds(): PaymentPlanId[] {
return Object.values(PaymentPlanId).filter(planId => paymentPlans[planId].effect.kind === 'subscription');
}
Loading
Loading