refactor Stripe webhook (#200)

* rename TierIds to PaymentPlanIds

* refactor webhook and util functions

* pass userDelegate to function

* Update dbSeeds.ts

* update app diff

* Update template/app/src/server/stripe/stripeClient.ts

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>

* extract event handlers and more

* Update AccountPage.tsx

* address filips pro effective typescripting and stuff

* Martin's attempt at consolidating types.

* fix

* fix webhook events and validation

* small changes

* put stripe event handlers back for marty merge

* merge consilidated types from martin

* move some types around

* add docs for stripe api version

* Update AccountPage.tsx

* Update stripe.ts

* update SubscriptionStatus type

* Update actions.ts

* add assertUnreachable util

* more small changes

* Update deploying.md

* update accountPage and docs

* update app_diff

---------

Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>
Co-authored-by: Martin Sosic <sosic.martin@gmail.com>
This commit is contained in:
vincanger
2024-07-10 16:08:20 +02:00
committed by GitHub
parent 138552d541
commit 78a9189e32
50 changed files with 582 additions and 456 deletions

View File

@@ -94,5 +94,5 @@
+ // the admin dashboard but won't be able to see the other users' data, only mock user data. + // the admin dashboard but won't be able to see the other users' data, only mock user data.
+ isMockUser Boolean @default(false) + isMockUser Boolean @default(false)
stripeId String? stripeId String? @unique
checkoutSessionId String? checkoutSessionId String?

View File

@@ -3,7 +3,7 @@
@@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
const [email, setEmail] = useState<string | undefined>(undefined); const [email, setEmail] = useState<string | undefined>(undefined);
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined); const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
const [statusOptions, setStatusOptions] = useState<SubscriptionStatusOptions[]>([]); const [statusOptions, setStatusOptions] = useState<SubscriptionStatus[]>([]);
+ const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false); + const [isDemoInfoVisible, setIsDemoInfoVisible] = useState(false);
const { data, isLoading, error } = useQuery(getPaginatedUsers, { const { data, isLoading, error } = useQuery(getPaginatedUsers, {
skip, skip,

View File

@@ -21,8 +21,8 @@
+ testimonials, + testimonials,
+} from './contentSections'; +} from './contentSections';
import DropdownUser from '../components/DropdownUser'; import DropdownUser from '../components/DropdownUser';
-import { DOCS_URL } from '../../shared/constants'; -import { DocsUrl } from '../../common';
+import { DOCS_URL, GITHUB_URL } from '../../shared/constants'; +import { DocsUrl, GithubUrl } from '../../common';
import { UserMenuItems } from '../components/UserMenuItems'; import { UserMenuItems } from '../components/UserMenuItems';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher'; import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
@@ -137,14 +137,14 @@
</p> </p>
<div className='mt-10 flex items-center justify-center gap-x-6'> <div className='mt-10 flex items-center justify-center gap-x-6'>
<a <a
href={DOCS_URL} href={DocsUrl}
- className='rounded-md px-3.5 py-2.5 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white' - className='rounded-md px-3.5 py-2.5 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white'
+ className='rounded-md px-6 py-4 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white' + className='rounded-md px-6 py-4 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white'
> >
Get Started <span aria-hidden='true'>→</span> Get Started <span aria-hidden='true'>→</span>
</a> </a>
+ <a + <a
+ href={GITHUB_URL} + href={GithubUrl}
+ className='group relative flex items-center justify-center rounded-md bg-gray-100 px-6 py-4 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-200 dark:bg-gray-700 hover:ring-2 hover:ring-yellow-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600' + className='group relative flex items-center justify-center rounded-md bg-gray-100 px-6 py-4 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-200 dark:bg-gray-700 hover:ring-2 hover:ring-yellow-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
+ > + >
+ {/* <AiFillGithub size='1.25rem' className='mr-2' /> */} + {/* <AiFillGithub size='1.25rem' className='mr-2' /> */}

View File

@@ -1,18 +1,17 @@
--- template/app/src/client/landing-page/contentSections.ts --- template/app/src/client/landing-page/contentSections.ts
+++ opensaas-sh/app/src/client/landing-page/contentSections.ts +++ opensaas-sh/app/src/client/landing-page/contentSections.ts
@@ -1,74 +1,150 @@ @@ -1,74 +1,126 @@
-import { DOCS_URL, BLOG_URL } from '../../shared/constants'; -import { DocsUrl, BlogUrl } from '../../common';
-import daBoiAvatar from '../static/da-boi.png'; -import daBoiAvatar from '../static/da-boi.png';
-import avatarPlaceholder from '../static/avatar-placeholder.png'; -import avatarPlaceholder from '../static/avatar-placeholder.png';
import { routes } from 'wasp/client/router'; -import { routes } from 'wasp/client/router';
+import { DOCS_URL, BLOG_URL, GITHUB_URL } from '../../shared/constants'; +import { DocsUrl, BlogUrl, GithubUrl } from '../../common';
+import daBoiAavatar from '../static/da-boi.png';
export const navigation = [ export const navigation = [
{ name: 'Features', href: '#features' }, { name: 'Features', href: '#features' },
- { name: 'Pricing', href: routes.PricingPageRoute.build() }, - { name: 'Pricing', href: routes.PricingPageRoute.build() },
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BlogUrl },
]; ];
export const features = [ export const features = [
{ {
@@ -22,7 +21,7 @@
+ description: + description:
+ 'The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!', + 'The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!',
icon: '🤝', icon: '🤝',
href: DOCS_URL, href: DocsUrl,
}, },
{ {
- name: 'Cool Feature #2', - name: 'Cool Feature #2',
@@ -30,8 +29,8 @@
+ name: 'DIY Auth, Done For You', + name: 'DIY Auth, Done For You',
+ description: 'Pre-configured full-stack Auth that you own. No 3rd-party services or hidden fees.', + description: 'Pre-configured full-stack Auth that you own. No 3rd-party services or hidden fees.',
icon: '🔐', icon: '🔐',
- href: DOCS_URL, - href: DocsUrl,
+ href: DOCS_URL + '/guides/authentication/', + href: DocsUrl + '/guides/authentication/',
}, },
{ {
- name: 'Cool Feature #3', - name: 'Cool Feature #3',
@@ -40,7 +39,7 @@
+ description: + description:
+ 'Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!', + 'Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!',
icon: '🥞', icon: '🥞',
href: DOCS_URL, href: DocsUrl,
}, },
{ {
- name: 'Cool Feature #4', - name: 'Cool Feature #4',
@@ -49,56 +48,56 @@
+ description: + description:
+ "No SaaS is complete without payments. That's why payments and the necessary webhooks are built-in.", + "No SaaS is complete without payments. That's why payments and the necessary webhooks are built-in.",
icon: '💸', icon: '💸',
+ href: DOCS_URL + '/guides/stripe-integration/', + href: DocsUrl + '/guides/stripe-integration/',
+ }, + },
+ { + {
+ name: 'Admin Dashboard', + name: 'Admin Dashboard',
+ description: 'Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooooh.', + description: 'Graphs! Tables! Analytics w/ Plausible or Google! All in one place. Ooooooooooh.',
+ icon: '📈', + icon: '📈',
+ href: DOCS_URL + '/general/admin-dashboard/', + href: DocsUrl + '/general/admin-dashboard/',
+ }, + },
+ { + {
+ name: 'Email Sending', + name: 'Email Sending',
+ description: + description:
+ 'Email sending built-in. Combine it with the cron jobs feature to easily send emails to your customers.', + 'Email sending built-in. Combine it with the cron jobs feature to easily send emails to your customers.',
+ icon: '📧', + icon: '📧',
+ href: DOCS_URL + '/guides/email-sending/', + href: DocsUrl + '/guides/email-sending/',
+ }, + },
+ { + {
+ name: 'OpenAI API Implemented', + name: 'OpenAI API Implemented',
+ description: 'Have a sweet AI-powered app concept? Get your idea shipped to potential customers in days!', + description: 'Have a sweet AI-powered app concept? Get your idea shipped to potential customers in days!',
+ icon: '🤖', + icon: '🤖',
+ href: DOCS_URL, + href: DocsUrl,
+ }, + },
+ { + {
+ name: 'File Uploads with AWS', + name: 'File Uploads with AWS',
+ description: 'File upload examples with AWS S3 presigned URLs are included and fully documented!', + description: 'File upload examples with AWS S3 presigned URLs are included and fully documented!',
+ icon: '📁', + icon: '📁',
+ href: DOCS_URL + '/guides/file-uploading/', + href: DocsUrl + '/guides/file-uploading/',
+ }, + },
+ { + {
+ name: 'Deploy Anywhere. Easily.', + name: 'Deploy Anywhere. Easily.',
+ description: + description:
+ 'No vendor lock-in because you own all your code. Deploy yourself, or let Wasp deploy it for you with a single command.', + 'No vendor lock-in because you own all your code. Deploy yourself, or let Wasp deploy it for you with a single command.',
+ icon: '🚀 ', + icon: '🚀 ',
+ href: DOCS_URL + '/guides/deploying/', + href: DocsUrl + '/guides/deploying/',
+ }, + },
+ { + {
+ name: 'Blog w/ Astro', + name: 'Blog w/ Astro',
+ description: + description:
+ 'Built-in blog with the Astro framework. Write your posts in Markdown, and watch your SEO performance take off.', + 'Built-in blog with the Astro framework. Write your posts in Markdown, and watch your SEO performance take off.',
+ icon: '📝', + icon: '📝',
+ href: DOCS_URL + '/start/guided-tour/', + href: DocsUrl + '/start/guided-tour/',
+ }, + },
+ { + {
+ name: 'Complete Documentation & Support', + name: 'Complete Documentation & Support',
+ description: "We don't leave you hanging. We have detailed docs and a Discord community to help!", + description: "We don't leave you hanging. We have detailed docs and a Discord community to help!",
+ icon: '🫂', + icon: '🫂',
href: DOCS_URL, href: DocsUrl,
}, },
]; ];
export const testimonials = [ export const testimonials = [
- { {
- name: 'Da Boi', - name: 'Da Boi',
- role: 'Wasp Mascot', - role: 'Wasp Mascot',
- avatarSrc: daBoiAvatar, - avatarSrc: daBoiAvatar,
@@ -118,28 +117,12 @@
- avatarSrc: avatarPlaceholder, - avatarSrc: avatarPlaceholder,
- socialUrl: '#', - socialUrl: '#',
- quote: 'My cats love it!', - quote: 'My cats love it!',
+ // {
+ // name: 'Jason Warner',
+ // role: 'former CTO @ GitHub',
+ // avatarSrc: 'https://pbs.twimg.com/profile_images/1538765024021258240/qXJBzw6U_400x400.jpg',
+ // socialUrl: 'https://twitter.com/jasoncwarner',
+ // quote:
+ // "I've actually had a bunch of fun with [Wasp]... I loved Batman.js back in the day and getting some of those vibes.",
+ // },
+ {
+ name: 'Max Khamrovskyi', + name: 'Max Khamrovskyi',
+ role: 'Senior Eng @ Red Hat', + role: 'Senior Eng @ Red Hat',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg', + avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg',
+ socialUrl: 'https://twitter.com/maksim36ua', + socialUrl: 'https://twitter.com/maksim36ua',
+ quote: 'I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!', + quote: 'I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!',
+ }, + },
+ // {
+ // name: 'Da Boi',
+ // role: 'Wasp Mascot',
+ // avatarSrc: daBoiAavatar,
+ // socialUrl: 'https://twitter.com/wasplang',
+ // quote: "I don't even know how to code. I'm just a plushie.",
+ // },
+ { + {
+ name: 'Tim Skaggs', + name: 'Tim Skaggs',
+ role: 'Founder @ Antler US', + role: 'Founder @ Antler US',
@@ -147,13 +130,6 @@
+ socialUrl: 'https://twitter.com/tskaggs', + socialUrl: 'https://twitter.com/tskaggs',
+ quote: 'Nearly done with a MVP in 3 days of part-time work... and deployed on Fly.io in 10 minutes.', + quote: 'Nearly done with a MVP in 3 days of part-time work... and deployed on Fly.io in 10 minutes.',
+ }, + },
+ // {
+ // name: 'Fecony',
+ // role: 'Wasp Expert',
+ // avatarSrc: 'https://pbs.twimg.com/profile_images/1560677466749943810/QIFuQMqU_400x400.jpg',
+ // socialUrl: 'https://twitter.com/webrickony',
+ // quote: 'My cats love it!',
+ // },
+ { + {
+ name: 'Jonathan Cocharan', + name: 'Jonathan Cocharan',
+ role: 'Entrepreneur', + role: 'Entrepreneur',
@@ -183,7 +159,7 @@
]; ];
export const footerNavigation = { export const footerNavigation = {
app: [ app: [
+ { name: 'Github', href: GITHUB_URL }, + { name: 'Github', href: GithubUrl },
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BlogUrl },
], ],

View File

@@ -0,0 +1,6 @@
--- template/app/src/common.ts
+++ opensaas-sh/app/src/common.ts
@@ -1,2 +1,3 @@
export const DocsUrl = 'https://docs.opensaas.sh';
export const BlogUrl = 'https://docs.opensaas.sh/blog';
+export const GithubUrl = 'https://github.com/wasp-lang/open-saas';

View File

@@ -1,9 +1,11 @@
--- template/app/src/server/scripts/dbSeeds.ts --- template/app/src/server/scripts/dbSeeds.ts
+++ opensaas-sh/app/src/server/scripts/dbSeeds.ts +++ opensaas-sh/app/src/server/scripts/dbSeeds.ts
@@ -43,5 +43,6 @@ @@ -42,5 +42,8 @@
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null, datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null, checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null,
subscriptionTier: subscriptionStatus ? faker.helpers.arrayElement([TierIds.HOBBY, TierIds.PRO]) : null, subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds()) : 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, + isMockUser: true,
}; };
} }

View File

@@ -1,7 +0,0 @@
--- template/app/src/shared/constants.ts
+++ opensaas-sh/app/src/shared/constants.ts
@@ -6,3 +6,4 @@
export const DOCS_URL = 'https://docs.opensaas.sh';
export const BLOG_URL = 'https://docs.opensaas.sh/blog';
+export const GITHUB_URL = 'https://github.com/wasp-lang/open-saas';

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -7,7 +7,7 @@ banner:
--- ---
This reference will help you understand how the User entity works in this template. This reference will help you understand how the User entity works in this template.
This includes the user roles, subscription tiers and statuses, and how to authorize access to certain pages and components. This includes the user roles, subscription plans and statuses, and how to authorize access to certain pages and components.
## User Entity ## User Entity
@@ -23,14 +23,15 @@ entity User {=psl
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
stripeId String? stripeId String?
checkoutSessionId String? checkoutSessionId String?
subscriptionTier String? subscriptionPlan String?
subscriptionStatus String? subscriptionStatus String?
sendEmail Boolean @default(false) sendEmail Boolean @default(false)
datePaid DateTime? datePaid DateTime?
credits Int @default(3) credits Int @default(3)
relatedObject RelatedObject[] gptResponses GptResponse[]
externalAuthAssociations SocialLogin[] contactFormMessages ContactFormMessage[]
contactFormMessages ContactFormMessage[] tasks Task[]
files File[]
psl=} psl=}
``` ```
@@ -46,7 +47,7 @@ entity User {=psl
//... //...
stripeId String? stripeId String?
checkoutSessionId String? checkoutSessionId String?
subscriptionTier String? subscriptionPlan String?
subscriptionStatus String? subscriptionStatus String?
datePaid DateTime? datePaid DateTime?
credits Int @default(3) credits Int @default(3)
@@ -56,17 +57,17 @@ psl=}
- `stripeId`: The Stripe customer ID. This is created by Stripe on checkout and used to identify the customer. - `stripeId`: The Stripe customer ID. This is created by Stripe on checkout and used to identify the customer.
- `checkoutSessionId`: The Stripe checkout session ID. This is created by Stripe on checkout and used to identify the checkout session. - `checkoutSessionId`: The Stripe checkout session ID. This is created by Stripe on checkout and used to identify the checkout session.
- `subscriptionTier`: The subscription tier the user is on. This is set by the app and is used to determine what features the user has access to. By default, we have two tiers: `hobby-tier` and `pro-tier`. - `subscriptionPlan`: The subscription plan the user is on. This is set by the app and is used to determine what features the user has access to. By default, we have two plan: `hobby` and `pro`.
- `subscriptionStatus`: The subscription status of the user. This is set by Stripe and is used to determine whether the user has access to the app or not. By default, we have four statuses: `active`, `past_due`, `canceled`, and `deleted`. - `subscriptionStatus`: The subscription status of the user. This is set by Stripe and is used to determine whether the user has access to the app or not. By default, we have four statuses: `active`, `past_due`, `canceled`, and `deleted`.
- `credits` (optional): By default, a user is given 3 credits to trial your product before they have to pay. You can create a one-time purchase product in Stripe to allow users to purchase more credits if they run out. - `credits` (optional): By default, a user is given 3 credits to trial your product before they have to pay. You can create a one-time purchase product in Stripe to allow users to purchase more credits if they run out.
### Subscription Statuses ### Subscription Statuses
In general, we determine if a user has paid for an initial subscription by checking if the `subscriptionStatus` field is set. This field is set by Stripe within your webhook handler and is used to signify more detailed information on the user's current status. By default, the template handles four statuses: `active`, `past_due`, `canceled`, and `deleted`. In general, we determine if a user has paid for an initial subscription by checking if the `subscriptionStatus` field is set. This field is set by Stripe within your webhook handler and is used to signify more detailed information on the user's current status. By default, the template handles four statuses: `active`, `past_due`, `canceled_at_period_end`, and `deleted`.
- When `active` the user has paid for a subscription and has full access to the app. - When `active` the user has paid for a subscription and has full access to the app.
- When `canceled`, the user has canceled their subscription and has access to the app until the end of their billing period. - When `canceled_at_period_end`, the user has canceled their subscription and has access to the app until the end of their billing period.
- When `deleted`, the user has reached the end of their subscription period after canceling and no longer has access to the app. - When `deleted`, the user has reached the end of their subscription period after canceling and no longer has access to the app.
@@ -98,13 +99,13 @@ if (subscription.status === 'past_due') {
See the client-side [authorization section](/guides/authorization) below for more info on how to handle these statuses within your app. See the client-side [authorization section](/guides/authorization) below for more info on how to handle these statuses within your app.
### Subscription Tiers ### Subscription Plans
The `subscriptionTier` field is used to determine what features the user has access to. The `subscriptionPlan` field is used to determine what features the user has access to.
By default, we have two tiers: `hobby-tier` and `pro-tier`. By default, we have two plans: `hobby` and `pro`.
You can add more tiers by adding more products and price IDs to your Stripe product and updating environment variables in your `.env.server` file as well as the relevant code in your app. You can add more plans by adding more products and price IDs to your Stripe product and updating environment variables in your `.env.server` file as well as the relevant code in your app.
See the [Stripe Integration Guide](/guides/stripe-integration) for more info on how to do this. See the [Stripe Integration Guide](/guides/stripe-integration) for more info on how to do this.

View File

@@ -111,8 +111,48 @@ After deploying your server, you need to add the correct redirect URIs to the cr
### Setting up your Stripe Webhook ### Setting up your Stripe Webhook
Now you need to set up your stripe webhook for production use. Now you need to set up your stripe webhook for production use. Below are some important steps and considerations you should take as you prepare to deploy your app to production.
#### Stripe API Versions
When you create your Stripe account, Stripe will automatically assign you to their latest API version at that time. This API version is important because it determines the structure of the responses Stripe sends to your webhook, as well as the structure it expects of the requests you make toward the Stripe API.
Because this template was built with a specific version of the Stripe API in mind, it could be that your Stripe account is set to a different API version.
:::note
```ts title="stripeClient.ts"
export const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: 'YYYY-MM-DD', // e.g. 2023-08-16
});
```
When you specify a specific API version in your Stripe client, the requests you send to Stripe from your server, along with their responses, will match that API version. On the other hand, Stripe will send all other events to your webhook that didn't originate as a request sent from your server, like those made after a user completes a payment on checkout, using the default API version of the API.
This is why it's important to make sure your Stripe client version also matches the API version in your Stripe account, and to thoroughly test any changes you make to your Stripe client before deploying to production.
:::
To make sure your app is consistent with your Stripe account, here are some steps you can follow:
1. You can find your `default` API version in the Stripe dashboard under the [Developers](https://dashboard.stripe.com/developers) section.
2. Check that the API version in your `stripe/stripeClient.ts` file matches the default API version in your dashboard:
```ts title="stripeClient.ts" {2}
export const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: 'YYYY-MM-DD', // e.g. 2023-08-16
});
```
3. If they don't match, you can upgrade/downgrade your Stripe NPM package in `package.json` to match the API version in your dashboard:
- If your default version on the Stripe dashboard is also the latest version of the API, you can simply upgrade your Stripe NPM package to the latest version.
- If your default version on the Stripe dashboard is not the latest version, and you don't want to [upgrade to the latest version](https://docs.stripe.com/upgrades#how-can-i-upgrade-my-api), because e.g. you have other projects that depend on the current version, you can find and install the Stripe NPM package version that matches your default API version by following these steps:
- Find and note the date of your default API version in the [developer dashboard](https://dashboard.stripe.com/developers).
- Go to the [Stripe NPM package](https://www.npmjs.com/package/stripe) page and hover over `Published` date column until you find the package release that matches your version. For example, here we find the NPM version that matches the default API version of `2023-08-16` in our dashboard, which is `13.x.x`.
![stripe-npm-versions](/stripe/npm-version.png)
- Install the correct version of the Stripe NPM package by running, :
```sh
npm install stripe@x.x.x # e.g. npm install stripe@13.11.0
```
4. **Test your app thoroughly** to make sure that the changes you made to your Stripe client are working as expected before deploying to production.
#### Creating Your Production Webhook
1. go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks) 1. go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)
2. click on `+ add endpoint` 2. click on `+ add endpoint`
3. enter your endpoint url, which will be the url of your deployed server + `/stripe-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/stripe-webhook` 3. enter your endpoint url, which will be the url of your deployed server + `/stripe-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/stripe-webhook`

View File

@@ -203,25 +203,25 @@ The logic for creating the Stripe Checkout session is defined in the `src/server
a) define the action in the `main.wasp` file a) define the action in the `main.wasp` file
```js title="main.wasp" ```js title="main.wasp"
action stripePayment { action generateStripeCheckoutSession {
fn: import { stripePayment } from "@src/server/actions.js", fn: import { generateStripeCheckoutSession } from "@src/server/actions.js",
entities: [User] entities: [User]
} }
``` ```
b) implement the action in the `src/server/actions.ts` file b) implement the action in the `src/server/actions.ts` file
```js title="src/server/actions.ts" ```js title="src/server/actions.ts"
export const stripePayment = async (tier, context) => { export const generateStripeCheckoutSession = async (paymentPlanId, context) => {
//... //...
} }
``` ```
c) call the action on the client-side c) call the action on the client-side
```js title="src/client/app/SubscriptionPage.tsx" ```js title="src/client/app/SubscriptionPage.tsx"
import { stripePayment } from "wasp/client/operations"; import { generateStripeCheckoutSession } from "wasp/client/operations";
const handleBuyClick = async (tierId) => { const handleBuyClick = async (paymentPlanId) => {
const stripeResults = await stripePayment(tierId); const stripeCheckoutSession = await generateStripeCheckoutSession(paymentPlanId);
}; };
``` ```

View File

@@ -98,10 +98,10 @@ entity User {=psl
lastActiveTimestamp DateTime @default(now()) lastActiveTimestamp DateTime @default(now())
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
stripeId String? stripeId String? @unique
checkoutSessionId String? checkoutSessionId String?
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted', null subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
subscriptionTier String? // 'hobby-tier', 'pro-tier', null subscriptionPlan String? // 'hobby', 'pro'
sendEmail Boolean @default(false) sendEmail Boolean @default(false)
datePaid DateTime? datePaid DateTime?
credits Int @default(3) credits Int @default(3)
@@ -333,8 +333,8 @@ action updateTask {
entities: [Task] entities: [Task]
} }
action stripePayment { action generateStripeCheckoutSession {
fn: import { stripePayment } from "@src/server/actions.js", fn: import { generateStripeCheckoutSession } from "@src/server/actions.js",
entities: [User] entities: [User]
} }

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const CheckboxOne = () => { const CheckboxOne = () => {
const [isChecked, setIsChecked] = useState<boolean>(false); const [isChecked, setIsChecked] = useState<boolean>(false);

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const CheckboxTwo = () => { const CheckboxTwo = () => {
const [enabled, setEnabled] = useState<boolean>(false); const [enabled, setEnabled] = useState<boolean>(false);

View File

@@ -1,4 +1,4 @@
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
import useColorMode from '../../hooks/useColorMode'; import useColorMode from '../../hooks/useColorMode';
const DarkModeSwitcher = () => { const DarkModeSwitcher = () => {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const DropdownDefault = () => { const DropdownDefault = () => {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);

View File

@@ -2,7 +2,7 @@ import { type AuthUser } from 'wasp/auth/types';
import DarkModeSwitcher from './DarkModeSwitcher'; import DarkModeSwitcher from './DarkModeSwitcher';
import MessageButton from './MessageButton'; import MessageButton from './MessageButton';
import DropdownUser from '../../components/DropdownUser'; import DropdownUser from '../../components/DropdownUser';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const Header = (props: { const Header = (props: {
sidebarOpen: string | boolean | undefined; sidebarOpen: string | boolean | undefined;

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import Logo from '../../static/logo.png'; import Logo from '../../static/logo.png';
import SidebarLinkGroup from './SidebarLinkGroup'; import SidebarLinkGroup from './SidebarLinkGroup';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
interface SidebarProps { interface SidebarProps {
sidebarOpen: boolean; sidebarOpen: boolean;

View File

@@ -1,6 +1,6 @@
import { type User } from 'wasp/entities'; import { type User } from 'wasp/entities';
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const SwitcherOne = ({ user, updateUserById }: { user?: Partial<User>; updateUserById?: any }) => { const SwitcherOne = ({ user, updateUserById }: { user?: Partial<User>; updateUserById?: any }) => {
const [enabled, setEnabled] = useState<boolean>(user?.isAdmin || false); const [enabled, setEnabled] = useState<boolean>(user?.isAdmin || false);

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const SwitcherTwo = () => { const SwitcherTwo = () => {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);

View File

@@ -1,4 +1,4 @@
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
import { UpArrow, DownArrow } from '../images/icon/icons-arrows'; import { UpArrow, DownArrow } from '../images/icon/icons-arrows';
type PageViewsStats = { type PageViewsStats = {

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { UpArrow, DownArrow } from '../images/icon/icons-arrows'; import { UpArrow, DownArrow } from '../images/icon/icons-arrows';
import type { DailyStatsProps } from '../common/types'; import type { DailyStatsProps } from '../common/types';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => { const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => { const isDeltaPositive = useMemo(() => {

View File

@@ -1,4 +1,4 @@
import { useMemo, useEffect } from 'react'; import { useMemo } from 'react';
import { UpArrow, DownArrow } from '../images/icon/icons-arrows'; import { UpArrow, DownArrow } from '../images/icon/icons-arrows';
import type { DailyStatsProps } from '../common/types'; import type { DailyStatsProps } from '../common/types';

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { UpArrow } from '../images/icon/icons-arrows'; import { UpArrow } from '../images/icon/icons-arrows';
import type { DailyStatsProps } from '../common/types'; import type { DailyStatsProps } from '../common/types';
import { cn } from '../../../shared/utils'; import { cn } from '../../cn';
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => { const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
const isDeltaPositive = useMemo(() => { const isDeltaPositive = useMemo(() => {

View File

@@ -3,14 +3,14 @@ import { useState, useEffect } from 'react';
import SwitcherOne from './SwitcherOne'; import SwitcherOne from './SwitcherOne';
import Loader from '../common/Loader'; import Loader from '../common/Loader';
import DropdownEditDelete from './DropdownEditDelete'; import DropdownEditDelete from './DropdownEditDelete';
import { type SubscriptionStatusOptions } from '../../../shared/types'; import { type SubscriptionStatus } from '../../../payment/plans';
const UsersTable = () => { const UsersTable = () => {
const [skip, setskip] = useState(0); const [skip, setskip] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [email, setEmail] = useState<string | undefined>(undefined); const [email, setEmail] = useState<string | undefined>(undefined);
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined); const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
const [statusOptions, setStatusOptions] = useState<SubscriptionStatusOptions[]>([]); const [statusOptions, setStatusOptions] = useState<SubscriptionStatus[]>([]);
const { data, isLoading, error } = useQuery(getPaginatedUsers, { const { data, isLoading, error } = useQuery(getPaginatedUsers, {
skip, skip,
emailContains: email, emailContains: email,
@@ -93,10 +93,10 @@ const UsersTable = () => {
onChange={(e) => { onChange={(e) => {
const targetValue = e.target.value === '' ? null : e.target.value; const targetValue = e.target.value === '' ? null : e.target.value;
setStatusOptions((prevValue) => { setStatusOptions((prevValue) => {
if (prevValue?.includes(targetValue as SubscriptionStatusOptions)) { if (prevValue?.includes(targetValue as SubscriptionStatus)) {
return prevValue?.filter((val) => val !== targetValue); return prevValue?.filter((val) => val !== targetValue);
} else if (!!prevValue) { } else if (!!prevValue) {
return [...prevValue, targetValue as SubscriptionStatusOptions]; return [...prevValue, targetValue as SubscriptionStatus];
} else { } else {
return prevValue; return prevValue;
} }
@@ -108,7 +108,7 @@ const UsersTable = () => {
> >
<option value=''>Select filters</option> <option value=''>Select filters</option>
{['past_due', 'canceled', 'active', 'deleted', null].map((status) => { {['past_due', 'canceled', 'active', 'deleted', null].map((status) => {
if (!statusOptions.includes(status as SubscriptionStatusOptions)) { if (!statusOptions.includes(status as SubscriptionStatus)) {
return <option value={status || ''}>{status ? status : 'has not subscribed'}</option>; return <option value={status || ''}>{status ? status : 'has not subscribed'}</option>;
} }
})} })}

View File

@@ -1,7 +1,8 @@
import type { User } from 'wasp/entities';
import type { SubscriptionStatus } from '../../payment/plans';
import { prettyPaymentPlanName, parsePaymentPlanId } from '../../payment/plans';
import { Link } from 'wasp/client/router'; import { Link } from 'wasp/client/router';
import { type User } from 'wasp/entities';
import { logout } from 'wasp/client/auth'; import { logout } from 'wasp/client/auth';
import { TierIds } from '../../shared/constants';
import { z } from 'zod'; import { z } from 'zod';
export default function AccountPage({ user }: { user: User }) { export default function AccountPage({ user }: { user: User }) {
@@ -27,27 +28,12 @@ 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'> <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> <dt className='text-sm font-medium text-gray-500 dark:text-white'>Your Plan</dt>
{!!user.subscriptionStatus ? ( <UserCurrentPaymentPlan
<> subscriptionStatus={ user.subscriptionStatus as SubscriptionStatus}
{user.subscriptionStatus !== 'past_due' ? ( subscriptionPlan={ user.subscriptionPlan }
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'> datePaid={ user.datePaid }
{user.subscriptionTier === TierIds.HOBBY ? 'Hobby' : 'Pro'} Plan credits={ user.credits }
</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 />
</>
)}
</div> </div>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'> <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> <dt className='text-sm font-medium text-gray-500 dark:text-white'>About</dt>
@@ -70,6 +56,69 @@ export default function AccountPage({ user }: { user: User }) {
); );
} }
type UserCurrentPaymentPlanProps = {
subscriptionPlan: string | null
subscriptionStatus: SubscriptionStatus | null
datePaid: Date | null
credits: number
}
function UserCurrentPaymentPlan({ subscriptionPlan, subscriptionStatus, datePaid, credits }: UserCurrentPaymentPlanProps) {
if (subscriptionStatus && subscriptionPlan && datePaid) {
return (
<>
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}
</dd>
{subscriptionStatus !== 'deleted' ? <CustomerPortalButton /> : <BuyMoreButton />}
</>
);
}
return (
<>
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
Credits remaining: {credits}
</dd>
<BuyMoreButton />
</>
);
}
function getUserSubscriptionStatusDescription({
subscriptionPlan,
subscriptionStatus,
datePaid,
}: {
subscriptionPlan: string
subscriptionStatus: SubscriptionStatus
datePaid: Date;
}) {
const planName = prettyPaymentPlanName(parsePaymentPlanId(subscriptionPlan));
const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(datePaid);
return prettyPrintStatus(planName, subscriptionStatus, endOfBillingPeriod);
}
function prettyPrintStatus(planName: string, subscriptionStatus: SubscriptionStatus, endOfBillingPeriod: string): string {
const statusToMessage: Record<SubscriptionStatus, 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.`,
};
if (Object.keys(statusToMessage).includes(subscriptionStatus)) {
return statusToMessage[subscriptionStatus];
} else {
throw new Error(`Invalid subscriptionStatus: ${subscriptionStatus}`);
}
}
function prettyPrintEndOfBillingPeriod(date: Date) {
const oneMonthFromNow = new Date(date);
oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1);
return ': ' + oneMonthFromNow.toLocaleDateString();
}
function BuyMoreButton() { function BuyMoreButton() {
return ( return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'> <div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
@@ -87,13 +136,16 @@ function CustomerPortalButton() {
const customerPortalUrl = schema.parse(import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL); const customerPortalUrl = schema.parse(import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL);
window.open(customerPortalUrl, '_blank'); window.open(customerPortalUrl, '_blank');
} catch (err) { } catch (err) {
console.error(err) console.error(err);
} }
}; };
return ( return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'> <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 Manage Subscription
</button> </button>
</div> </div>

View File

@@ -12,9 +12,8 @@ import {
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { CgSpinner } from 'react-icons/cg'; import { CgSpinner } from 'react-icons/cg';
import { TiDelete } from 'react-icons/ti'; import { TiDelete } from 'react-icons/ti';
import { type GeneratedSchedule } from '../../shared/types'; import type { GeneratedSchedule, MainTask, SubTask } from '../../gpt/schedule';
import { MainTask, Subtask } from '../../shared/types'; import { cn } from '../cn';
import { cn } from '../../shared/utils';
export default function DemoAppPage() { export default function DemoAppPage() {
return ( return (
@@ -319,7 +318,7 @@ function TaskTable({ schedule }: { schedule: GeneratedSchedule }) {
); );
} }
function MainTaskTable({ mainTask, subtasks }: { mainTask: MainTask; subtasks: Subtask[] }) { function MainTaskTable({ mainTask, subtasks }: { mainTask: MainTask; subtasks: SubTask[] }) {
return ( return (
<> <>
<thead> <thead>

View File

@@ -1,36 +1,41 @@
import { useAuth } from 'wasp/client/auth'; import { useAuth } from 'wasp/client/auth';
import { stripePayment } from 'wasp/client/operations'; import { generateStripeCheckoutSession } from 'wasp/client/operations';
import { TierIds } from '../../shared/constants'; import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } from '../../payment/plans';
import { AiFillCheckCircle } from 'react-icons/ai'; import { AiFillCheckCircle } from 'react-icons/ai';
import { useState } from 'react'; import { useState } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { cn } from '../../shared/utils'; import { cn } from '../cn';
import { z } from 'zod'; import { z } from 'zod';
export const tiers = [ const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro;
{
name: 'Hobby', interface PaymentPlanCard {
id: TierIds.HOBBY, name: string;
price: string;
description: string;
features: string[];
};
export const paymentPlanCards: Record<PaymentPlanId, PaymentPlanCard> = {
[PaymentPlanId.Hobby]: {
name: prettyPaymentPlanName(PaymentPlanId.Hobby),
price: '$9.99', price: '$9.99',
description: 'All you need to get started', description: 'All you need to get started',
features: ['Limited monthly usage', 'Basic support'], features: ['Limited monthly usage', 'Basic support'],
}, },
{ [PaymentPlanId.Pro]: {
name: 'Pro', name: prettyPaymentPlanName(PaymentPlanId.Pro),
id: TierIds.PRO,
price: '$19.99', price: '$19.99',
description: 'Our most popular plan', description: 'Our most popular plan',
features: ['Unlimited monthly usage', 'Priority customer support'], features: ['Unlimited monthly usage', 'Priority customer support'],
bestDeal: true,
}, },
{ [PaymentPlanId.Credits10]: {
name: '10 Credits', name: prettyPaymentPlanName(PaymentPlanId.Credits10),
id: TierIds.CREDITS,
price: '$9.99', price: '$9.99',
description: 'One-time purchase of 10 credits for your account', description: 'One-time purchase of 10 credits for your account',
features: ['Use credits for e.g. OpenAI API calls', 'No expiration date'], features: ['Use credits for e.g. OpenAI API calls', 'No expiration date'],
}, }
]; };
const PricingPage = () => { const PricingPage = () => {
const [isStripePaymentLoading, setIsStripePaymentLoading] = useState<boolean | string>(false); const [isStripePaymentLoading, setIsStripePaymentLoading] = useState<boolean | string>(false);
@@ -39,14 +44,14 @@ const PricingPage = () => {
const history = useHistory(); const history = useHistory();
async function handleBuyNowClick(tierId: string) { async function handleBuyNowClick(paymentPlanId: PaymentPlanId) {
if (!user) { if (!user) {
history.push('/login'); history.push('/login');
return; return;
} }
try { try {
setIsStripePaymentLoading(tierId); setIsStripePaymentLoading(paymentPlanId);
let stripeResults = await stripePayment(tierId); let stripeResults = await generateStripeCheckoutSession(paymentPlanId);
if (stripeResults?.sessionUrl) { if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self'); window.open(stripeResults.sessionUrl, '_self');
@@ -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> <span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
</p> </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'> <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 <div
key={tier.id} key={planId}
className={cn( 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', '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-2': planId === bestDealPaymentPlanId,
'ring-1 lg:mt-8': !tier.bestDeal, '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 top-0 right-0 -z-10 w-full h-full transform-gpu blur-3xl' aria-hidden='true'>
<div <div
className='absolute w-full h-full bg-gradient-to-br from-amber-400 to-purple-300 opacity-30 dark:opacity-50' className='absolute w-full h-full bg-gradient-to-br from-amber-400 to-purple-300 opacity-30 dark:opacity-50'
@@ -109,19 +114,23 @@ const PricingPage = () => {
)} )}
<div className='mb-8'> <div className='mb-8'>
<div className='flex items-center justify-between gap-x-4'> <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'> <h3 id={planId} className='text-gray-900 text-lg font-semibold leading-8 dark:text-white'>
{tier.name} {paymentPlanCards[planId].name}
</h3> </h3>
</div> </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'> <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'> <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> </span>
</p> </p>
<ul role='list' className='mt-8 space-y-3 text-sm leading-6 text-gray-600 dark:text-white'> <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'> <li key={feature} className='flex gap-x-3'>
<AiFillCheckCircle className='h-6 w-5 flex-none text-yellow-500' aria-hidden='true' /> <AiFillCheckCircle className='h-6 w-5 flex-none text-yellow-500' aria-hidden='true' />
{feature} {feature}
@@ -136,8 +145,10 @@ const PricingPage = () => {
className={cn( 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', '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, 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400':
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400': !tier.bestDeal, planId === bestDealPaymentPlanId,
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400':
planId !== bestDealPaymentPlanId,
} }
)} )}
> >
@@ -145,15 +156,17 @@ const PricingPage = () => {
</button> </button>
) : ( ) : (
<button <button
onClick={() => handleBuyNowClick(tier.id)} onClick={() => handleBuyNowClick(planId)}
aria-describedby={tier.id} aria-describedby={planId}
className={cn( className={cn(
{ {
'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400': tier.bestDeal, 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400':
'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400': !tier.bestDeal, 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' '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'
)} )}

View File

@@ -7,7 +7,7 @@ import { AiFillCloseCircle } from 'react-icons/ai';
import { HiBars3 } from 'react-icons/hi2'; import { HiBars3 } from 'react-icons/hi2';
import logo from '../static/logo.png'; import logo from '../static/logo.png';
import DropdownUser from './DropdownUser'; import DropdownUser from './DropdownUser';
import { DOCS_URL, BLOG_URL } from '../../shared/constants'; import { DocsUrl, BlogUrl } from '../../common';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher'; import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
import { UserMenuItems } from '../components/UserMenuItems'; import { UserMenuItems } from '../components/UserMenuItems';
@@ -15,8 +15,8 @@ const navigation = [
{ name: 'AI Scheduler (Demo App)', href: routes.DemoAppRoute.build() }, { name: 'AI Scheduler (Demo App)', href: routes.DemoAppRoute.build() },
{ name: 'File Upload (AWS S3)', href: routes.FileUploadRoute.build() }, { name: 'File Upload (AWS S3)', href: routes.FileUploadRoute.build() },
{ name: 'Pricing', href: routes.PricingPageRoute.build() }, { name: 'Pricing', href: routes.PricingPageRoute.build() },
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BlogUrl },
]; ];
const NavLogo = () => <img className='h-8 w-8' src={logo} alt='Your SaaS App' />; const NavLogo = () => <img className='h-8 w-8' src={logo} alt='Your SaaS App' />;

View File

@@ -2,7 +2,7 @@ import { type User } from 'wasp/entities';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { CgProfile } from 'react-icons/cg'; import { CgProfile } from 'react-icons/cg';
import { UserMenuItems } from './UserMenuItems'; import { UserMenuItems } from './UserMenuItems';
import { cn } from '../../shared/utils'; import { cn } from '../cn';
const DropdownUser = ({ user }: { user: Partial<User> }) => { const DropdownUser = ({ user }: { user: Partial<User> }) => {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);

View File

@@ -3,7 +3,7 @@ import { type User } from 'wasp/entities';
import { logout } from 'wasp/client/auth'; import { logout } from 'wasp/client/auth';
import { MdOutlineSpaceDashboard } from 'react-icons/md'; import { MdOutlineSpaceDashboard } from 'react-icons/md';
import { TfiDashboard } from 'react-icons/tfi'; import { TfiDashboard } from 'react-icons/tfi';
import { cn } from '../../shared/utils'; import { cn } from '../cn';
export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial<User>; setMobileMenuOpen?: any }) => { export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial<User>; setMobileMenuOpen?: any }) => {
const path = window.location.pathname; const path = window.location.pathname;

View File

@@ -9,7 +9,7 @@ import logo from '../static/logo.png';
import openSaasBanner from '../static/open-saas-banner.png'; import openSaasBanner from '../static/open-saas-banner.png';
import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections'; import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
import DropdownUser from '../components/DropdownUser'; import DropdownUser from '../components/DropdownUser';
import { DOCS_URL } from '../../shared/constants'; import { DocsUrl } from '../../common';
import { UserMenuItems } from '../components/UserMenuItems'; import { UserMenuItems } from '../components/UserMenuItems';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher'; import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
@@ -160,7 +160,7 @@ export default function LandingPage() {
</p> </p>
<div className='mt-10 flex items-center justify-center gap-x-6'> <div className='mt-10 flex items-center justify-center gap-x-6'>
<a <a
href={DOCS_URL} href={DocsUrl}
className='rounded-md px-3.5 py-2.5 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white' className='rounded-md px-3.5 py-2.5 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 hover:ring-2 hover:ring-yellow-300 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-white'
> >
Get Started <span aria-hidden='true'></span> Get Started <span aria-hidden='true'></span>

View File

@@ -1,4 +1,4 @@
import { DOCS_URL, BLOG_URL } from '../../shared/constants'; import { DocsUrl, BlogUrl } from '../../common';
import daBoiAvatar from '../static/da-boi.png'; import daBoiAvatar from '../static/da-boi.png';
import avatarPlaceholder from '../static/avatar-placeholder.png'; import avatarPlaceholder from '../static/avatar-placeholder.png';
import { routes } from 'wasp/client/router'; import { routes } from 'wasp/client/router';
@@ -6,33 +6,33 @@ import { routes } from 'wasp/client/router';
export const navigation = [ export const navigation = [
{ name: 'Features', href: '#features' }, { name: 'Features', href: '#features' },
{ name: 'Pricing', href: routes.PricingPageRoute.build() }, { name: 'Pricing', href: routes.PricingPageRoute.build() },
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BlogUrl },
]; ];
export const features = [ export const features = [
{ {
name: 'Cool Feature #1', name: 'Cool Feature #1',
description: 'Describe your cool feature here.', description: 'Describe your cool feature here.',
icon: '🤝', icon: '🤝',
href: DOCS_URL, href: DocsUrl,
}, },
{ {
name: 'Cool Feature #2', name: 'Cool Feature #2',
description: 'Describe your cool feature here.', description: 'Describe your cool feature here.',
icon: '🔐', icon: '🔐',
href: DOCS_URL, href: DocsUrl,
}, },
{ {
name: 'Cool Feature #3', name: 'Cool Feature #3',
description: 'Describe your cool feature here.', description: 'Describe your cool feature here.',
icon: '🥞', icon: '🥞',
href: DOCS_URL, href: DocsUrl,
}, },
{ {
name: 'Cool Feature #4', name: 'Cool Feature #4',
description: 'Describe your cool feature here.', description: 'Describe your cool feature here.',
icon: '💸', icon: '💸',
href: DOCS_URL, href: DocsUrl,
}, },
]; ];
export const testimonials = [ export const testimonials = [
@@ -69,8 +69,8 @@ export const faqs = [
]; ];
export const footerNavigation = { export const footerNavigation = {
app: [ app: [
{ name: 'Documentation', href: DOCS_URL }, { name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BLOG_URL }, { name: 'Blog', href: BlogUrl },
], ],
company: [ company: [
{ name: 'About', href: 'https://wasp-lang.dev' }, { name: 'About', href: 'https://wasp-lang.dev' },

View File

@@ -0,0 +1,2 @@
export const DocsUrl = 'https://docs.opensaas.sh';
export const BlogUrl = 'https://docs.opensaas.sh/blog';

View File

@@ -1,7 +1,7 @@
import { createFile, useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; import { createFile, useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
import axios from 'axios'; import axios from 'axios';
import { useState, useEffect, FormEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import { cn } from '../shared/utils'; import { cn } from '../client/cn';
export default function FileUploadPage() { export default function FileUploadPage() {
const [fileToDownload, setFileToDownload] = useState<string>(''); const [fileToDownload, setFileToDownload] = useState<string>('');

View File

@@ -0,0 +1,15 @@
export type GeneratedSchedule = {
mainTasks: MainTask[]; // Main tasks provided by user, ordered by priority
subtasks: SubTask[];
};
export type MainTask = {
name: string;
priority: 'low' | 'medium' | 'high';
};
export type SubTask = {
description: string;
time: number; // total time it takes to complete given main task in hours, e.g. 2.75
mainTaskName: string; // name of main task related to subtask
};

View File

@@ -0,0 +1,52 @@
import { requireNodeEnvVar } from '../server/utils';
export type SubscriptionStatus = 'past_due' | 'cancel_at_period_end' | 'active' | 'deleted';
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 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 },
},
};
export function prettyPaymentPlanName(planId: PaymentPlanId): string {
const planToName: Record<PaymentPlanId, string> = {
[PaymentPlanId.Hobby]: 'Hobby',
[PaymentPlanId.Pro]: 'Pro',
[PaymentPlanId.Credits10]: '10 Credits',
};
return planToName[planId];
}
export function parsePaymentPlanId(planId: string): PaymentPlanId {
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');
}

View File

@@ -2,17 +2,16 @@ import { type User, type Task } from 'wasp/entities';
import { HttpError } from 'wasp/server'; import { HttpError } from 'wasp/server';
import { import {
type GenerateGptResponse, type GenerateGptResponse,
type StripePayment, type GenerateStripeCheckoutSession,
type UpdateCurrentUser, type UpdateCurrentUser,
type UpdateUserById, type UpdateUserById,
type CreateTask, type CreateTask,
type DeleteTask, type DeleteTask,
type UpdateTask, type UpdateTask,
} from 'wasp/server/operations'; } from 'wasp/server/operations';
import Stripe from 'stripe'; import { GeneratedSchedule } from '../gpt/schedule';
import type { GeneratedSchedule, StripePaymentResult } from '../shared/types'; import { PaymentPlanId, paymentPlans, type PaymentPlanEffect } from '../payment/plans';
import { fetchStripeCustomer, createStripeCheckoutSession } from './payments/stripeUtils.js'; import { fetchStripeCustomer, createStripeCheckoutSession, type StripeMode } from './stripe/checkoutUtils.js';
import { TierIds } from '../shared/constants.js';
import OpenAI from 'openai'; import OpenAI from 'openai';
const openai = setupOpenAI(); const openai = setupOpenAI();
@@ -23,7 +22,15 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
} }
export const stripePayment: StripePayment<string, StripePaymentResult> = async (tier, context) => { export type StripeCheckoutSession = {
sessionUrl: string | null;
sessionId: string;
};
export const generateStripeCheckoutSession: GenerateStripeCheckoutSession<PaymentPlanId, StripeCheckoutSession> = async (
paymentPlanId,
context
) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
@@ -35,39 +42,15 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
); );
} }
let priceId; const paymentPlan = paymentPlans[paymentPlanId];
if (tier === TierIds.HOBBY) { const customer = await fetchStripeCustomer(userEmail);
priceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!; const session = await createStripeCheckoutSession({
} else if (tier === TierIds.PRO) { priceId: paymentPlan.getStripePriceId(),
priceId = process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID!; customerId: customer.id,
} else if (tier === TierIds.CREDITS) { mode: paymentPlanEffectToStripeMode(paymentPlan.effect),
priceId = process.env.STRIPE_CREDITS_PRICE_ID!; });
} else {
throw new HttpError(404, 'Invalid tier');
}
let customer: Stripe.Customer | undefined; await context.entities.User.update({
let session: Stripe.Checkout.Session | undefined;
try {
customer = await fetchStripeCustomer(userEmail);
if (!customer) {
throw new HttpError(500, 'Error fetching customer');
}
session = await createStripeCheckoutSession({
priceId,
customerId: customer.id,
mode: tier === TierIds.CREDITS ? 'payment' : 'subscription',
});
if (!session) {
throw new HttpError(500, 'Error creating session');
}
} catch (error: any) {
const statusCode = error.statusCode || 500;
const errorMessage = error.message || 'Internal server error';
throw new HttpError(statusCode, errorMessage);
}
const updatedUser = await context.entities.User.update({
where: { where: {
id: context.user.id, id: context.user.id,
}, },
@@ -83,6 +66,14 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
}; };
}; };
function paymentPlanEffectToStripeMode (planEffect: PaymentPlanEffect): StripeMode {
const effectToMode: Record<PaymentPlanEffect['kind'], StripeMode> = {
'subscription': 'subscription',
'credits': 'payment'
};
return effectToMode[planEffect.kind];
}
type GptPayload = { type GptPayload = {
hours: string; hours: string;
}; };
@@ -126,7 +117,7 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
} }
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc. model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
messages: [ messages: [
{ {
role: 'system', role: 'system',

View File

@@ -6,7 +6,7 @@ import {
type GetPaginatedUsers, type GetPaginatedUsers,
type GetAllTasksByUser, type GetAllTasksByUser,
} from 'wasp/server/operations'; } from 'wasp/server/operations';
import { type SubscriptionStatusOptions } from '../shared/types.js'; import { type SubscriptionStatus } from '../payment/plans';
type DailyStatsWithSources = DailyStats & { type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[]; sources: PageViewSource[];
@@ -77,7 +77,7 @@ type GetPaginatedUsersInput = {
cursor?: number | undefined; cursor?: number | undefined;
emailContains?: string; emailContains?: string;
isAdmin?: boolean; isAdmin?: boolean;
subscriptionStatus?: SubscriptionStatusOptions[]; subscriptionStatus?: SubscriptionStatus[];
}; };
type GetPaginatedUsersOutput = { type GetPaginatedUsersOutput = {
users: Pick< users: Pick<

View File

@@ -1,8 +1,7 @@
import { type User } from 'wasp/entities'; import { type User } from 'wasp/entities';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { PrismaClient } from '@prisma/client'; import type { PrismaClient } from '@prisma/client';
import { TierIds } from '../../shared/constants.js'; import { getSubscriptionPaymentPlanIds, type SubscriptionStatus } from '../../payment/plans';
import { type SubscriptionStatusOptions } from '../../shared/types.js';
type MockUserData = Omit<User, 'id'>; type MockUserData = Omit<User, 'id'>;
@@ -24,7 +23,7 @@ function generateMockUsersData(numOfUsers: number): MockUserData[] {
function generateMockUserData(): MockUserData { function generateMockUserData(): MockUserData {
const firstName = faker.person.firstName(); const firstName = faker.person.firstName();
const lastName = faker.person.lastName(); const lastName = faker.person.lastName();
const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatusOptions>(['active', 'canceled', 'past_due', 'deleted', null]); const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatus | null>(['active', 'cancel_at_period_end', 'past_due', 'deleted', null]);
const now = new Date(); const now = new Date();
const createdAt = faker.date.past({ refDate: now }); const createdAt = faker.date.past({ refDate: now });
const lastActiveTimestamp = faker.date.between({ from: createdAt, to: now }); const lastActiveTimestamp = faker.date.between({ from: createdAt, to: now });
@@ -42,6 +41,6 @@ function generateMockUserData(): MockUserData {
stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null, stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null, datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null, checkoutSessionId: hasUserPaidOnStripe ? `cs_test_${faker.string.uuid()}` : null,
subscriptionTier: subscriptionStatus ? faker.helpers.arrayElement([TierIds.HOBBY, TierIds.PRO]) : null, subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds()) : null,
}; };
} }

View File

@@ -1,9 +1,5 @@
import Stripe from 'stripe'; import Stripe from 'stripe';
import { HttpError } from 'wasp/server'; import { stripe } from './stripeClient';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying // 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'; const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
@@ -24,12 +20,14 @@ export async function fetchStripeCustomer(customerEmail: string) {
customer = stripeCustomers.data[0]; customer = stripeCustomers.data[0];
} }
return customer; return customer;
} catch (error: any) { } catch (error) {
console.error(error.message); console.error(error);
throw error; throw error;
} }
} }
export type StripeMode = 'subscription' | 'payment';
export async function createStripeCheckoutSession({ export async function createStripeCheckoutSession({
priceId, priceId,
customerId, customerId,
@@ -37,7 +35,7 @@ export async function createStripeCheckoutSession({
}: { }: {
priceId: string; priceId: string;
customerId: string; customerId: string;
mode: 'subscription' | 'payment'; mode: StripeMode;
}) { }) {
try { try {
return await stripe.checkout.sessions.create({ return await stripe.checkout.sessions.create({
@@ -56,8 +54,8 @@ export async function createStripeCheckoutSession({
}, },
customer: customerId, customer: customerId,
}); });
} catch (error: any) { } catch (error) {
console.error(error.message); console.error(error);
throw error; throw error;
} }
} }

View File

@@ -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',
});

View File

@@ -0,0 +1,8 @@
export function requireNodeEnvVar(name: string): string {
const value = process.env[name];
if (value === undefined) {
throw new Error(`Env var ${name} is undefined`);
} else {
return value;
}
}

View File

@@ -1,189 +1,54 @@
import { emailSender } from 'wasp/server/email'; import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
import { type MiddlewareConfigFn } from 'wasp/server';
import { type StripeWebhook } from 'wasp/server/api'; import { type StripeWebhook } from 'wasp/server/api';
import { type PrismaClient } from '@prisma/client';
import express from 'express'; import express from 'express';
import { TierIds } from '../../shared/constants.js'; import { Stripe } from 'stripe';
import { stripe } from '../stripe/stripeClient';
import Stripe from 'stripe'; import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../../payment/plans';
import { updateUserStripePaymentDetails } from './stripePaymentDetails';
// make sure the api version matches the version in the Stripe dashboard import { emailSender } from 'wasp/server/email';
const stripe = new Stripe(process.env.STRIPE_KEY!, { import { assertUnreachable } from '../../utils';
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document import { requireNodeEnvVar } from '../utils';
}); import { z } from 'zod';
export const stripeWebhook: StripeWebhook = async (request, response, context) => { export const stripeWebhook: StripeWebhook = async (request, response, context) => {
const sig = request.headers['stripe-signature'] as string; const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET');
const sig = request.headers['stripe-signature'];
if (!sig) {
throw new HttpError(400, 'Stripe Webhook Signature Not Provided');
}
let event: Stripe.Event; let event: Stripe.Event;
try { try {
event = stripe.webhooks.constructEvent(request.body, sig, process.env.STRIPE_WEBHOOK_SECRET!); event = stripe.webhooks.constructEvent(request.body, sig, secret);
// console.table({sig: 'stripe webhook signature verified', type: event.type}) } catch (err) {
} catch (err: any) { throw new HttpError(400, 'Error Constructing Stripe Webhook Event');
console.log(err.message);
return response.status(400).send(`Webhook Error: ${err.message}`);
} }
const prismaUserDelegate = context.entities.User;
try { switch (event.type) {
if (event.type === 'checkout.session.completed') { case 'checkout.session.completed':
console.log('Checkout session completed');
const session = event.data.object as Stripe.Checkout.Session; const session = event.data.object as Stripe.Checkout.Session;
const userStripeId = session.customer as string; await handleCheckoutSessionCompleted(session, prismaUserDelegate);
if (!userStripeId) { break;
console.log('No userStripeId in session'); case 'invoice.paid':
return response.status(400).send(`Webhook Error: No userStripeId in session`);
}
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
/**
* here are your products, both subscriptions and one-time payments.
* make sure to configure them in the Stripe dashboard first!
* see: https://docs.opensaas.sh/guides/stripe-integration/
*/
if (line_items?.data[0]?.price?.id === process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID) {
console.log('Hobby subscription purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: new Date(),
subscriptionTier: TierIds.HOBBY,
},
});
} else if (line_items?.data[0]?.price?.id === process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID) {
console.log('Pro subscription purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: new Date(),
subscriptionTier: TierIds.PRO,
},
});
} else if (line_items?.data[0]?.price?.id === process.env.STRIPE_CREDITS_PRICE_ID) {
console.log('Credits purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
credits: {
increment: 10,
},
datePaid: new Date(),
},
});
} else {
response.status(404).send('Invalid product');
}
} else if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice; const invoice = event.data.object as Stripe.Invoice;
const userStripeId = invoice.customer as string; await handleInvoicePaid(invoice, prismaUserDelegate);
const periodStart = new Date(invoice.period_start * 1000); break;
await context.entities.User.updateMany({ case 'customer.subscription.updated':
where: { const updatedSubscription = event.data.object as Stripe.Subscription;
stripeId: userStripeId, await handleCustomerSubscriptionUpdated(updatedSubscription, prismaUserDelegate);
}, break;
data: { case 'customer.subscription.deleted':
datePaid: periodStart, const deletedSubscription = event.data.object as Stripe.Subscription;
}, await handleCustomerSubscriptionDeleted(deletedSubscription, prismaUserDelegate);
}); break;
} else if (event.type === 'customer.subscription.updated') { default:
const subscription = event.data.object as Stripe.Subscription; // If you'd like to handle more events, you can add more cases above.
const userStripeId = subscription.customer as string; // When deploying your app, you configure your webhook in the Stripe dashboard to only send the events that you're
if (subscription.status === 'active') { // handling above and that are necessary for the functioning of your app. See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook
console.log('Subscription active ', userStripeId); // In development, it is likely that you will receive other events that you are not handling, and that's fine. These can be ignored without any issues.
await context.entities.User.updateMany({ console.error('Unhandled event type: ', event.type);
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'active',
},
});
}
/**
* you'll want to make a check on the front end to see if the subscription is past due
* and then prompt the user to update their payment method
* this is useful if the user's card expires or is canceled and automatic subscription renewal fails
*/
if (subscription.status === 'past_due') {
console.log('Subscription past due for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'past_due',
},
});
}
/**
* Stripe will send a subscription.updated event when a subscription is canceled
* but the subscription is still active until the end of the period.
* So we check if cancel_at_period_end is true and send an email to the customer.
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end for user: ', userStripeId);
let customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
},
select: {
id: true,
email: true,
},
});
if (customer) {
await context.entities.User.update({
where: {
id: customer.id,
},
data: {
subscriptionStatus: 'canceled',
},
});
if (customer.email) {
await emailSender.send({
to: customer.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
}
} else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
const userStripeId = subscription.customer as string;
/**
* Stripe will send then finally send a subscription.deleted event when subscription period ends
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
console.log('Subscription deleted/ended for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'deleted',
},
});
} else {
console.log(`Unhandled event type ${event.type}`);
}
response.json({ received: true });
} catch (err: any) {
response.status(400).send(`Webhook Error: ${err?.message}`);
} }
response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook
}; };
// This allows us to override Wasp's defaults and parse the raw body of the request from Stripe to verify the signature // This allows us to override Wasp's defaults and parse the raw body of the request from Stripe to verify the signature
@@ -192,3 +57,107 @@ export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
return middlewareConfig; return middlewareConfig;
}; };
const LineItemsPriceSchema = z.object({
data: z.array(
z.object({
price: z.object({
id: z.string(),
}),
})
),
});
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
prismaUserDelegate: PrismaClient["user"]
) {
const userStripeId = validateUserStripeIdOrThrow(session.customer);
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
const result = LineItemsPriceSchema.safeParse(line_items);
if (!result.success) {
throw new HttpError(400, 'No price id in line item');
}
if (result.data.data.length > 1) {
throw new HttpError(400, 'More than one line item in session');
}
const lineItemPriceId = result.data.data[0].price.id;
const planId = Object.values(PaymentPlanId).find(
(planId) => paymentPlans[planId].getStripePriceId() === lineItemPriceId
);
if (!planId) {
throw new Error(`No plan with stripe price id ${lineItemPriceId}`);
}
const plan = paymentPlans[planId];
let subscriptionPlan: PaymentPlanId | undefined;
let numOfCreditsPurchased: number | undefined;
switch (plan.effect.kind) {
case 'subscription':
subscriptionPlan = planId;
break;
case 'credits':
numOfCreditsPurchased = plan.effect.amount;
break;
default:
assertUnreachable(plan.effect);
}
return updateUserStripePaymentDetails(
{ userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
prismaUserDelegate
);
}
export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaClient["user"]) {
const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
const datePaid = new Date(invoice.period_start * 1000);
return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
}
export async function handleCustomerSubscriptionUpdated(
subscription: Stripe.Subscription,
prismaUserDelegate: PrismaClient["user"]
) {
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
let subscriptionStatus: SubscriptionStatus | undefined;
// There are other subscription statuses, such as `trialing` that we are not handling and simply ignore
// If you'd like to handle more statuses, you can add more cases above. Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well
if (subscription.status === 'active') {
subscriptionStatus = subscription.cancel_at_period_end ? 'cancel_at_period_end' : 'active';
} else if (subscription.status === 'past_due') {
subscriptionStatus = 'past_due';
}
if (subscriptionStatus) {
const user = await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus }, prismaUserDelegate);
if (subscription.cancel_at_period_end) {
if (user.email) {
await emailSender.send({
to: user.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
return user;
}
}
export async function handleCustomerSubscriptionDeleted(
subscription: Stripe.Subscription,
prismaUserDelegate: PrismaClient["user"]
) {
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
return updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
}
function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['customer']): string {
if (!userStripeId) throw new HttpError(400, 'No customer id');
if (typeof userStripeId !== 'string') throw new HttpError(400, 'Customer id is not a string');
return userStripeId;
}

View File

@@ -0,0 +1,28 @@
import type { SubscriptionStatus } from '../../payment/plans';
import { PaymentPlanId } from '../../payment/plans';
import { PrismaClient } from '@prisma/client';
type UserStripePaymentDetails = {
userStripeId: string;
subscriptionPlan?: PaymentPlanId;
subscriptionStatus?: SubscriptionStatus;
numOfCreditsPurchased?: number;
datePaid?: Date;
};
export const updateUserStripePaymentDetails = (
{ userStripeId, subscriptionPlan, subscriptionStatus, datePaid, numOfCreditsPurchased }: UserStripePaymentDetails,
userDelegate: PrismaClient['user']
) => {
return userDelegate.update({
where: {
stripeId: userStripeId,
},
data: {
subscriptionPlan,
subscriptionStatus,
datePaid,
credits: numOfCreditsPurchased !== undefined ? { increment: numOfCreditsPurchased } : undefined,
},
});
};

View File

@@ -1,12 +1,9 @@
import { type DailyStatsJob } from 'wasp/server/jobs'; import { type DailyStatsJob } from 'wasp/server/jobs';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js'; import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js';
// import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js'; // import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => { export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
const nowUTC = new Date(Date.now()); const nowUTC = new Date(Date.now());
nowUTC.setUTCHours(0, 0, 0, 0); nowUTC.setUTCHours(0, 0, 0, 0);

View File

@@ -1,8 +0,0 @@
export enum TierIds {
HOBBY = 'hobby-tier',
PRO = 'pro-tier',
CREDITS = 'credits',
}
export const DOCS_URL = 'https://docs.opensaas.sh';
export const BLOG_URL = 'https://docs.opensaas.sh/blog';

View File

@@ -1,26 +0,0 @@
export type StripePaymentResult = {
sessionUrl: string | null;
sessionId: string;
};
export type SubscriptionStatusOptions = 'past_due' | 'canceled' | 'active' | 'deleted' | null;
export type Subtask = {
description: string; // detailed breakdown and description of sub-task
time: number; // total time it takes to complete given main task in hours, e.g. 2.75
mainTaskName: string; // name of main task related to subtask
};
export type MainTask = {
name: string;
priority: 'low' | 'medium' | 'high';
};
export type GeneratedSchedule = {
mainTasks: MainTask[]; // Main tasks provided by user, ordered by priority
subtasks: Subtask[]; // Array of subtasks
};
export type FunctionCallResponse = {
schedule: GeneratedSchedule[];
};

View File

@@ -0,0 +1,8 @@
/**
* Used purely to help compiler check for exhaustiveness in switch statements,
* will never execute. See https://stackoverflow.com/a/39419171.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function assertUnreachable(x: never): never {
throw Error('This code should be unreachable');
}