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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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.
+ isMockUser Boolean @default(false)
stripeId String?
stripeId String? @unique
checkoutSessionId String?

View File

@ -3,7 +3,7 @@
@@ -11,6 +11,7 @@
const [email, setEmail] = useState<string | 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 { data, isLoading, error } = useQuery(getPaginatedUsers, {
skip,

View File

@ -21,8 +21,8 @@
+ testimonials,
+} from './contentSections';
import DropdownUser from '../components/DropdownUser';
-import { DOCS_URL } from '../../shared/constants';
+import { DOCS_URL, GITHUB_URL } from '../../shared/constants';
-import { DocsUrl } from '../../common';
+import { DocsUrl, GithubUrl } from '../../common';
import { UserMenuItems } from '../components/UserMenuItems';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
@ -137,14 +137,14 @@
</p>
<div className='mt-10 flex items-center justify-center gap-x-6'>
<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-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>
</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'
+ >
+ {/* <AiFillGithub size='1.25rem' className='mr-2' /> */}

View File

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

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 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
@ -23,14 +23,15 @@ entity User {=psl
isAdmin Boolean @default(false)
stripeId String?
checkoutSessionId String?
subscriptionTier String?
subscriptionPlan String?
subscriptionStatus String?
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
relatedObject RelatedObject[]
externalAuthAssociations SocialLogin[]
contactFormMessages ContactFormMessage[]
gptResponses GptResponse[]
contactFormMessages ContactFormMessage[]
tasks Task[]
files File[]
psl=}
```
@ -46,7 +47,7 @@ entity User {=psl
//...
stripeId String?
checkoutSessionId String?
subscriptionTier String?
subscriptionPlan String?
subscriptionStatus String?
datePaid DateTime?
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.
- `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`.
- `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
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 `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.
@ -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.
### 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.

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
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)
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`

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
```js title="main.wasp"
action stripePayment {
fn: import { stripePayment } from "@src/server/actions.js",
action generateStripeCheckoutSession {
fn: import { generateStripeCheckoutSession } from "@src/server/actions.js",
entities: [User]
}
```
b) implement the action in the `src/server/actions.ts` file
```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
```js title="src/client/app/SubscriptionPage.tsx"
import { stripePayment } from "wasp/client/operations";
import { generateStripeCheckoutSession } from "wasp/client/operations";
const handleBuyClick = async (tierId) => {
const stripeResults = await stripePayment(tierId);
const handleBuyClick = async (paymentPlanId) => {
const stripeCheckoutSession = await generateStripeCheckoutSession(paymentPlanId);
};
```

View File

@ -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
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
@ -333,8 +333,8 @@ action updateTask {
entities: [Task]
}
action stripePayment {
fn: import { stripePayment } from "@src/server/actions.js",
action generateStripeCheckoutSession {
fn: import { generateStripeCheckoutSession } from "@src/server/actions.js",
entities: [User]
}

View File

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

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { cn } from '../../../shared/utils';
import { cn } from '../../cn';
const CheckboxTwo = () => {
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';
const DarkModeSwitcher = () => {

View File

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

View File

@ -2,7 +2,7 @@ import { type AuthUser } from 'wasp/auth/types';
import DarkModeSwitcher from './DarkModeSwitcher';
import MessageButton from './MessageButton';
import DropdownUser from '../../components/DropdownUser';
import { cn } from '../../../shared/utils';
import { cn } from '../../cn';
const Header = (props: {
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 Logo from '../../static/logo.png';
import SidebarLinkGroup from './SidebarLinkGroup';
import { cn } from '../../../shared/utils';
import { cn } from '../../cn';
interface SidebarProps {
sidebarOpen: boolean;

View File

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

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { cn } from '../../../shared/utils';
import { cn } from '../../cn';
const SwitcherTwo = () => {
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';
type PageViewsStats = {

View File

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

View File

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

View File

@ -3,14 +3,14 @@ import { useState, useEffect } from 'react';
import SwitcherOne from './SwitcherOne';
import Loader from '../common/Loader';
import DropdownEditDelete from './DropdownEditDelete';
import { type SubscriptionStatusOptions } from '../../../shared/types';
import { type SubscriptionStatus } from '../../../payment/plans';
const UsersTable = () => {
const [skip, setskip] = useState(0);
const [page, setPage] = useState(1);
const [email, setEmail] = useState<string | 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, {
skip,
emailContains: email,
@ -93,10 +93,10 @@ const UsersTable = () => {
onChange={(e) => {
const targetValue = e.target.value === '' ? null : e.target.value;
setStatusOptions((prevValue) => {
if (prevValue?.includes(targetValue as SubscriptionStatusOptions)) {
if (prevValue?.includes(targetValue as SubscriptionStatus)) {
return prevValue?.filter((val) => val !== targetValue);
} else if (!!prevValue) {
return [...prevValue, targetValue as SubscriptionStatusOptions];
return [...prevValue, targetValue as SubscriptionStatus];
} else {
return prevValue;
}
@ -108,7 +108,7 @@ const UsersTable = () => {
>
<option value=''>Select filters</option>
{['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>;
}
})}

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 { 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 }) {
@ -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'>
<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 />
</>
)}
<UserCurrentPaymentPlan
subscriptionStatus={ user.subscriptionStatus as SubscriptionStatus}
subscriptionPlan={ user.subscriptionPlan }
datePaid={ user.datePaid }
credits={ user.credits }
/>
</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>
@ -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() {
return (
<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);
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>

View File

@ -12,9 +12,8 @@ import {
import { useState, useMemo } from 'react';
import { CgSpinner } from 'react-icons/cg';
import { TiDelete } from 'react-icons/ti';
import { type GeneratedSchedule } from '../../shared/types';
import { MainTask, Subtask } from '../../shared/types';
import { cn } from '../../shared/utils';
import type { GeneratedSchedule, MainTask, SubTask } from '../../gpt/schedule';
import { cn } from '../cn';
export default function DemoAppPage() {
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 (
<>
<thead>

View File

@ -1,36 +1,41 @@
import { useAuth } from 'wasp/client/auth';
import { stripePayment } from 'wasp/client/operations';
import { TierIds } from '../../shared/constants';
import { generateStripeCheckoutSession } from 'wasp/client/operations';
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } 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 { cn } from '../cn';
import { z } from 'zod';
export const tiers = [
{
name: 'Hobby',
id: TierIds.HOBBY,
const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro;
interface PaymentPlanCard {
name: string;
price: string;
description: string;
features: string[];
};
export const paymentPlanCards: Record<PaymentPlanId, PaymentPlanCard> = {
[PaymentPlanId.Hobby]: {
name: prettyPaymentPlanName(PaymentPlanId.Hobby),
price: '$9.99',
description: 'All you need to get started',
features: ['Limited monthly usage', 'Basic support'],
},
{
name: 'Pro',
id: TierIds.PRO,
[PaymentPlanId.Pro]: {
name: prettyPaymentPlanName(PaymentPlanId.Pro),
price: '$19.99',
description: 'Our most popular plan',
features: ['Unlimited monthly usage', 'Priority customer support'],
bestDeal: true,
},
{
name: '10 Credits',
id: TierIds.CREDITS,
[PaymentPlanId.Credits10]: {
name: prettyPaymentPlanName(PaymentPlanId.Credits10),
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);
@ -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 generateStripeCheckoutSession(paymentPlanId);
if (stripeResults?.sessionUrl) {
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>
</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'
@ -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}
@ -136,8 +145,10 @@ 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,
}
)}
>
@ -145,15 +156,17 @@ const PricingPage = () => {
</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'
)}

View File

@ -7,7 +7,7 @@ import { AiFillCloseCircle } from 'react-icons/ai';
import { HiBars3 } from 'react-icons/hi2';
import logo from '../static/logo.png';
import DropdownUser from './DropdownUser';
import { DOCS_URL, BLOG_URL } from '../../shared/constants';
import { DocsUrl, BlogUrl } from '../../common';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
import { UserMenuItems } from '../components/UserMenuItems';
@ -15,8 +15,8 @@ const navigation = [
{ name: 'AI Scheduler (Demo App)', href: routes.DemoAppRoute.build() },
{ name: 'File Upload (AWS S3)', href: routes.FileUploadRoute.build() },
{ name: 'Pricing', href: routes.PricingPageRoute.build() },
{ name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL },
{ name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BlogUrl },
];
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 { CgProfile } from 'react-icons/cg';
import { UserMenuItems } from './UserMenuItems';
import { cn } from '../../shared/utils';
import { cn } from '../cn';
const DropdownUser = ({ user }: { user: Partial<User> }) => {
const [dropdownOpen, setDropdownOpen] = useState(false);

View File

@ -3,7 +3,7 @@ import { type User } from 'wasp/entities';
import { logout } from 'wasp/client/auth';
import { MdOutlineSpaceDashboard } from 'react-icons/md';
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 }) => {
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 { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
import DropdownUser from '../components/DropdownUser';
import { DOCS_URL } from '../../shared/constants';
import { DocsUrl } from '../../common';
import { UserMenuItems } from '../components/UserMenuItems';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
@ -160,7 +160,7 @@ export default function LandingPage() {
</p>
<div className='mt-10 flex items-center justify-center gap-x-6'>
<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'
>
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 avatarPlaceholder from '../static/avatar-placeholder.png';
import { routes } from 'wasp/client/router';
@ -6,33 +6,33 @@ import { routes } from 'wasp/client/router';
export const navigation = [
{ name: 'Features', href: '#features' },
{ name: 'Pricing', href: routes.PricingPageRoute.build() },
{ name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL },
{ name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BlogUrl },
];
export const features = [
{
name: 'Cool Feature #1',
description: 'Describe your cool feature here.',
icon: '🤝',
href: DOCS_URL,
href: DocsUrl,
},
{
name: 'Cool Feature #2',
description: 'Describe your cool feature here.',
icon: '🔐',
href: DOCS_URL,
href: DocsUrl,
},
{
name: 'Cool Feature #3',
description: 'Describe your cool feature here.',
icon: '🥞',
href: DOCS_URL,
href: DocsUrl,
},
{
name: 'Cool Feature #4',
description: 'Describe your cool feature here.',
icon: '💸',
href: DOCS_URL,
href: DocsUrl,
},
];
export const testimonials = [
@ -69,8 +69,8 @@ export const faqs = [
];
export const footerNavigation = {
app: [
{ name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL },
{ name: 'Documentation', href: DocsUrl },
{ name: 'Blog', href: BlogUrl },
],
company: [
{ 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 axios from 'axios';
import { useState, useEffect, FormEvent } from 'react';
import { cn } from '../shared/utils';
import { cn } from '../client/cn';
export default function FileUploadPage() {
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 {
type GenerateGptResponse,
type StripePayment,
type GenerateStripeCheckoutSession,
type UpdateCurrentUser,
type UpdateUserById,
type CreateTask,
type DeleteTask,
type UpdateTask,
} from 'wasp/server/operations';
import Stripe from 'stripe';
import type { GeneratedSchedule, StripePaymentResult } from '../shared/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './payments/stripeUtils.js';
import { TierIds } from '../shared/constants.js';
import { GeneratedSchedule } from '../gpt/schedule';
import { PaymentPlanId, paymentPlans, type PaymentPlanEffect } from '../payment/plans';
import { fetchStripeCustomer, createStripeCheckoutSession, type StripeMode } from './stripe/checkoutUtils.js';
import OpenAI from 'openai';
const openai = setupOpenAI();
@ -23,7 +22,15 @@ function setupOpenAI() {
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) {
throw new HttpError(401);
}
@ -35,39 +42,15 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
);
}
let priceId;
if (tier === TierIds.HOBBY) {
priceId = process.env.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.PRO) {
priceId = process.env.STRIPE_PRO_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.CREDITS) {
priceId = process.env.STRIPE_CREDITS_PRICE_ID!;
} else {
throw new HttpError(404, 'Invalid tier');
}
const paymentPlan = paymentPlans[paymentPlanId];
const customer = await fetchStripeCustomer(userEmail);
const session = await createStripeCheckoutSession({
priceId: paymentPlan.getStripePriceId(),
customerId: customer.id,
mode: paymentPlanEffectToStripeMode(paymentPlan.effect),
});
let customer: Stripe.Customer | undefined;
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({
await context.entities.User.update({
where: {
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 = {
hours: string;
};
@ -126,7 +117,7 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
}
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: [
{
role: 'system',

View File

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

View File

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

View File

@ -1,9 +1,5 @@
import Stripe from 'stripe';
import { HttpError } from 'wasp/server';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
import { stripe } from './stripeClient';
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
@ -24,12 +20,14 @@ export async function fetchStripeCustomer(customerEmail: string) {
customer = stripeCustomers.data[0];
}
return customer;
} catch (error: any) {
console.error(error.message);
} catch (error) {
console.error(error);
throw error;
}
}
export type StripeMode = 'subscription' | 'payment';
export async function createStripeCheckoutSession({
priceId,
customerId,
@ -37,7 +35,7 @@ export async function createStripeCheckoutSession({
}: {
priceId: string;
customerId: string;
mode: 'subscription' | 'payment';
mode: StripeMode;
}) {
try {
return await stripe.checkout.sessions.create({
@ -56,8 +54,8 @@ export async function createStripeCheckoutSession({
},
customer: customerId,
});
} catch (error: any) {
console.error(error.message);
} catch (error) {
console.error(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 } from 'wasp/server';
import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
import { type StripeWebhook } from 'wasp/server/api';
import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { TierIds } from '../../shared/constants.js';
import Stripe from 'stripe';
// make sure the api version matches the version in the Stripe dashboard
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
import { Stripe } from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../../payment/plans';
import { updateUserStripePaymentDetails } from './stripePaymentDetails';
import { emailSender } from 'wasp/server/email';
import { assertUnreachable } from '../../utils';
import { requireNodeEnvVar } from '../utils';
import { z } from 'zod';
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;
try {
event = stripe.webhooks.constructEvent(request.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
// console.table({sig: 'stripe webhook signature verified', type: event.type})
} catch (err: any) {
console.log(err.message);
return response.status(400).send(`Webhook Error: ${err.message}`);
event = stripe.webhooks.constructEvent(request.body, sig, secret);
} catch (err) {
throw new HttpError(400, 'Error Constructing Stripe Webhook Event');
}
try {
if (event.type === 'checkout.session.completed') {
console.log('Checkout session completed');
const prismaUserDelegate = context.entities.User;
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
const userStripeId = session.customer as string;
if (!userStripeId) {
console.log('No userStripeId in session');
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') {
await handleCheckoutSessionCompleted(session, prismaUserDelegate);
break;
case 'invoice.paid':
const invoice = event.data.object as Stripe.Invoice;
const userStripeId = invoice.customer as string;
const periodStart = new Date(invoice.period_start * 1000);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: periodStart,
},
});
} else if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
const userStripeId = subscription.customer as string;
if (subscription.status === 'active') {
console.log('Subscription active ', userStripeId);
await context.entities.User.updateMany({
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}`);
await handleInvoicePaid(invoice, prismaUserDelegate);
break;
case 'customer.subscription.updated':
const updatedSubscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionUpdated(updatedSubscription, prismaUserDelegate);
break;
case 'customer.subscription.deleted':
const deletedSubscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionDeleted(deletedSubscription, prismaUserDelegate);
break;
default:
// If you'd like to handle more events, you can add more cases above.
// When deploying your app, you configure your webhook in the Stripe dashboard to only send the events that you're
// handling above and that are necessary for the functioning of your app. See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook
// 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.
console.error('Unhandled event type: ', event.type);
}
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
@ -192,3 +57,107 @@ export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
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 Stripe from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.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) => {
const nowUTC = new Date(Date.now());
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');
}