Merge branch 'main' into filip-transactions

This commit is contained in:
Filip Sodić 2025-04-28 20:43:50 +02:00
commit b381af389d
48 changed files with 3511 additions and 2204 deletions

View File

@ -10,7 +10,7 @@ on:
env:
WASP_TELEMETRY_DISABLE: 1
WASP_VERSION: 0.16.0
WASP_VERSION: 0.16.3
jobs:
test:
@ -30,7 +30,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Install Wasp
run: curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s -- -v ${{ env.WASP_VERSION }}
run: curl -sSL https://get.wasp.sh/installer.sh | sh -s -- -v ${{ env.WASP_VERSION }}
- name: Cache global node modules
uses: actions/cache@v4

View File

@ -46,7 +46,7 @@ You also get access to Wasp's diverse, helpful community if you get stuck or nee
First, to install the latest version of [Wasp](https://wasp.sh/) on macOS, Linux, or Windows with WSL, run the following command:
```bash
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
curl -sSL https://get.wasp.sh/installer.sh | sh
```
Then, create a new SaaS app with the following command:

View File

@ -1,6 +1,6 @@
--- template/app/main.wasp
+++ opensaas-sh/app/main.wasp
@@ -3,30 +3,29 @@
@@ -3,30 +3,30 @@
version: "^0.16.0"
},
@ -44,10 +44,11 @@
- "<script defer data-domain='<your-site-id>' src='https://plausible.io/js/script.js'></script>", // for production
- "<script defer data-domain='<your-site-id>' src='https://plausible.io/js/script.local.js'></script>", // for development
+ "<script defer data-domain='opensaas.sh' data-api='/waspara/wasp/event' src='/waspara/wasp/script.js'></script>",
+ "<script defer src='/piggy.js'></script>",
],
// 🔐 Auth out of the box! https://wasp.sh/docs/auth/overview
@@ -38,7 +37,7 @@
@@ -38,7 +38,7 @@
email: {
fromField: {
name: "Open SaaS App",
@ -56,7 +57,7 @@
},
emailVerification: {
clientRoute: EmailVerificationRoute,
@@ -50,21 +49,18 @@
@@ -50,21 +50,18 @@
},
userSignupFields: import { getEmailUserFields } from "@src/auth/userSignupFields",
},
@ -88,9 +89,9 @@
+ configFn: import { getDiscordAuthConfig } from "@src/auth/userSignupFields"
+ }
},
onAfterSignup: import { onAfterSignup } from "@src/auth/hooks",
onAuthFailedRedirectTo: "/login",
@@ -87,11 +83,11 @@
onAuthSucceededRedirectTo: "/demo-app",
@@ -86,11 +83,11 @@
// NOTE: "Dummy" provider is just for local development purposes.
// Make sure to check the server logs for the email confirmation url (it will not be sent to an address)!
// Once you are ready for production, switch to e.g. "SendGrid" or "Mailgun" providers. Check out https://docs.opensaas.sh/guides/email-sending/ .
@ -104,7 +105,7 @@
},
},
}
@@ -207,9 +203,9 @@
@@ -206,9 +203,9 @@
}
api paymentsWebhook {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
--- template/app/package.json
+++ opensaas-sh/app/package.json
@@ -1,13 +1,17 @@
@@ -1,6 +1,11 @@
{
"name": "opensaas",
"type": "module",
@ -11,7 +11,8 @@
+ },
"dependencies": {
"@aws-sdk/client-s3": "^3.523.0",
"@aws-sdk/s3-request-presigner": "^3.523.0",
"@aws-sdk/s3-presigned-post": "^3.750.0",
@@ -8,7 +13,6 @@
"@faker-js/faker": "8.3.1",
"@google-analytics/data": "4.1.0",
"@headlessui/react": "1.7.13",

View File

@ -0,0 +1,9 @@
--- template/app/public/piggy.js
+++ opensaas-sh/app/public/piggy.js
@@ -0,0 +1,5 @@
+!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
+posthog.init('CdDd2A0jKTI2vFAsrI9JWm3MqpOcgHz1bMyogAcwsE4', {
+ api_host: 'https://us.i.posthog.com',
+ person_profiles: 'identified_only',
+})
\ No newline at end of file

View File

@ -15,4 +15,4 @@
+ stripeId String? @unique
subscriptionStatus String? // 'active', 'cancel_at_period_end', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
sendNewsletter Boolean @default(false)
datePaid DateTime?

View File

@ -1,46 +1,64 @@
--- template/app/src/auth/userSignupFields.ts
+++ opensaas-sh/app/src/auth/userSignupFields.ts
@@ -1,11 +1,8 @@
@@ -1,8 +1,6 @@
import { z } from 'zod';
import { defineUserSignupFields } from 'wasp/auth/providers/types';
-const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
-
export const getEmailUserFields = defineUserSignupFields({
username: (data: any) => data.email,
- isAdmin: (data: any) => adminEmails.includes(data.email),
email: (data: any) => data.email,
const emailDataSchema = z.object({
email: z.string(),
});
@@ -16,10 +14,6 @@
const emailData = emailDataSchema.parse(data);
return emailData.email;
},
- isAdmin: (data) => {
- const emailData = emailDataSchema.parse(data);
- return adminEmails.includes(emailData.email);
- },
});
@@ -29,10 +26,6 @@
const githubDataSchema = z.object({
@@ -45,14 +39,6 @@
const githubData = githubDataSchema.parse(data);
return githubData.profile.login;
},
- isAdmin: (data) => {
- const githubData = githubDataSchema.parse(data);
- return adminEmails.includes(githubData.profile.emails[0].email);
- const emailInfo = getGithubEmailInfo(githubData);
- if (!emailInfo.verified) {
- return false;
- }
- return adminEmails.includes(emailInfo.email);
- },
});
// NOTE: if we don't want to access users' emails, we can use scope ["user:read"]
@@ -58,10 +51,6 @@
// We are using the first email from the list of emails returned by GitHub.
@@ -85,13 +71,6 @@
const googleData = googleDataSchema.parse(data);
return googleData.profile.email;
},
- isAdmin: (data) => {
- const googleData = googleDataSchema.parse(data);
- if (!googleData.profile.email_verified) {
- return false;
- }
- return adminEmails.includes(googleData.profile.email);
- },
});
export function getGoogleAuthConfig() {
@@ -86,10 +75,6 @@
@@ -121,13 +100,6 @@
const discordData = discordDataSchema.parse(data);
return discordData.profile.username;
},
- isAdmin: (data) => {
- const email = discordDataSchema.parse(data).profile.email;
- return !!email && adminEmails.includes(email);
- const discordData = discordDataSchema.parse(data);
- if (!discordData.profile.email || !discordData.profile.verified) {
- return false;
- }
- return adminEmails.includes(discordData.profile.email);
- },
});

View File

@ -1,8 +1,8 @@
--- template/app/src/file-upload/operations.ts
+++ opensaas-sh/app/src/file-upload/operations.ts
@@ -25,6 +25,18 @@
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
@@ -37,6 +37,18 @@
userId: context.user.id,
});
+ const numberOfFilesByUser = await context.entities.File.count({
+ where: {
@ -16,6 +16,6 @@
+ throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.');
+ }
+
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
fileType,
fileName,
await context.entities.File.create({
data: {
name: fileName,

View File

@ -8,16 +8,7 @@
interface PaymentPlanCard {
name: string;
@@ -82,7 +83,7 @@
}
if (!customerPortalUrl) {
- throw new Error(`Customer Portal does not exist for user ${user.id}`)
+ throw new Error(`Customer Portal does not exist for user ${user.id}`);
}
window.open(customerPortalUrl, '_blank');
@@ -96,11 +97,18 @@
@@ -105,16 +106,24 @@
Pick your <span className='text-yellow-500'>pricing</span>
</h2>
</div>
@ -37,11 +28,17 @@
+ <span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
+ </p>
+ </div>
+
{errorMessage && (
<div className='mt-8 p-4 bg-red-100 text-red-600 rounded-md dark:bg-red-200 dark:text-red-800'>
{errorMessage}
</div>
)}
+
<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'>
{Object.values(PaymentPlanId).map((planId) => (
<div
@@ -187,7 +195,7 @@
@@ -201,7 +210,7 @@
)}
disabled={isPaymentLoading}
>

View File

@ -1,7 +1,7 @@
--- template/app/src/server/scripts/dbSeeds.ts
+++ opensaas-sh/app/src/server/scripts/dbSeeds.ts
@@ -38,9 +38,11 @@
sendNewsletter: false,
@@ -37,9 +37,11 @@
isAdmin: false,
credits,
subscriptionStatus,
- lemonSqueezyCustomerPortalUrl: null,

View File

@ -30,19 +30,10 @@ export default defineConfig({
{
tag: 'script',
attrs: {
src: 'https://www.googletagmanager.com/gtag/js?id=G-8QGM76GR3Q',
defer: true,
src: '/piggy.js',
},
},
{
tag: 'script',
content: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-8QGM76GR3Q');
`,
},
],
editLink: {
baseUrl: 'https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog',

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,5 @@
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('CdDd2A0jKTI2vFAsrI9JWm3MqpOcgHz1bMyogAcwsE4', {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only',
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -34,8 +34,17 @@ Plausible Analytics is an easy to use, lightweight, open source and privacy-frie
We have been working on Plausible for more than 6 years now, have more than 14,000 active subscribers at this point and have counted more than 136 billion pageviews so far.
<div className="flex justify-center">
<img src="https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExYnJ0MzBudXNsd2Nnb2VtYWs3dWQ3ZXJjNTd1amh3bjMwaGhxbmwybiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3oKIPEqDGUULpEU0aQ/giphy.gif" alt="Data is everywhere!" />
Here's an interactive demo of Plausible Analytics:
<div style={{ position: 'relative', paddingBottom: 'calc(53.11430527036276% + 41px)', height: 0, width: '100%' }}>
<iframe
src="https://demo.arcade.software/4kph6Di7Pv5wlVhhsmEw?embed"
title="Plausible Analytics: Live Demo"
frameBorder="0"
loading="lazy"
allowFullScreen
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', colorScheme: 'light' }}
></iframe>
</div>
## Plausible is bootstrapped and open-source—what made you choose this path instead of taking the more common VC route?

View File

@ -0,0 +1,113 @@
---
title: "Going from an Idea to MVP in Weeks: PromptPanda's Launch(es)"
date: 2025-03-12
tags:
- webdev
- saas
- sideproject
- indiehackers
authors: milica
---
import { Image } from 'astro:assets';
import StarOpenSaaSCTA from '../../../components/StarOpenSaaSCTA.astro';
import plausibleCommunity from '../../../assets/plausible/plausible-community.png';
import interfaceImg from '../../../assets/promptpanda/interface.png';
import meme1 from '../../../assets/promptpanda/meme1.jpg';
import meme2 from '../../../assets/promptpanda/meme2.jpg';
import ph1 from '../../../assets/promptpanda/ph1.png';
import ph2 from '../../../assets/promptpanda/ph2.png';
import listImg from '../../../assets/promptpanda/list.png';
Did you know that most co-founders meet each other through work? **[Lander Willem](https://x.com/WWWillems)** met his friend and co-founder **[Bram Billiet](https://x.com/brambilicious)** while they were working at the local venture fund. They both shared the love towards LLMs and got the idea to kickstart their SaaS after experiencing the same pain points with managing and versioning prompts.
In this post, you'll learn how they:
- Shipped their SaaS from idea to MVP in weeks, using modern AI stack
- Launched and got trending on Product Hunt with 100+ upvotes
- Successfully onboarded first users
## The problem: Managing prompts is messy
Right after OpenAI released their first LLM models, Lander and Bram started exchanging tips on how to get optimal results from prompts. Soon, they learned that managing AI prompts is often chaotic.
People who share prompts usually do so through messaging apps such as Slack, Microsoft Teams or in better cases, shared Google Docs documents. Some of the people they talked to even confessed they were sharing their favorite prompts using screenshots 😅. Although a Google Doc might work initially, people quickly bump into issues regarding versioning and granular access management.
This is how they got the idea to create [PromptPanda](https://www.promptpanda.io/) - a SaaS that allows people to exchange prompts in an easy way. Here's an interactive demo you can click through to see what they've built:
<div style={{ position: 'relative', paddingBottom: 'calc(53.11430527036276% + 41px)', height: 0, width: '100%' }}>
<iframe
src="https://demo.arcade.software/JiVvKE3oDWzbar0DKUDX?embed"
title="PromptPanda: Live Demo"
frameBorder="0"
loading="lazy"
allowFullScreen
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', colorScheme: 'light' }}
></iframe>
</div>
## The opportunity: Everyone uses prompts, not just devs
Other AI prompt tools are primarily designed with developers in mind, which leaves out non-technical teams. Those less technical users depend heavily on collaboration, efficiency, and consistency to complete their tasks. This is the market PromptPanda decided to go after.
<div className="flex justify-center">
<Image src={meme1} alt="Make apps for everyone" loading="lazy" />
</div>
The tool is designed specifically to help teams centralize their prompts and ensures consistent output quality. Collaboration is painless because of an intuitive web app that also has [a Chrome extension](https://chromewebstore.google.com/detail/promptpanda/hpgfoodclhmbloolkenjjofklhalfblc).
PromptPanda integrates with major AI providers such as OpenAI, Anthropic, Google, Perplexity, and DeepSeek. Coupled with its built-in Prompt Improver, these integrations allow users to quickly test, iterate, and enhance their prompts, while not imposing any limitations for the end-users.
<div className="flex justify-center">
<Image src={interfaceImg} alt="PromptPanda interface" loading="lazy" />
</div>
With this approach they covered a market that other companies overlooked, non-technical users who rely on the biggest LLM providers for their daily tasks.
<StarOpenSaaSCTA />
## Launching is unpredictable: Product Hunt hits and flops
As soon as the app was somewhat stable and usable, Lander and Bram decided to launch on ProductHunt.
<div className="flex justify-center">
<Image src={ph1} alt="PromptPanda on Product Hunt" loading="lazy" />
</div>
[Their first ProductHunt launch](https://www.producthunt.com/products/promptpanda#promptpanda) was great in terms of visibility. They were featured by the ProductHunt team which got them a bunch of upvotes and comments. **Although there was quite a lot of engagement with the launch, it didn't really end up in sticky, paying customers.**
<div className="flex justify-center">
<Image src={ph2} alt="PromptPanda on Product Hunt" loading="lazy" />
</div>
A short while later they relaunched on ProductHunt after processing the feedback from their first launch. Both their product and launch campaign were much better prepared. Weirdly enough, the launch mostly failed as they got almost no upvotes or conversions.
<div className="flex justify-center">
<Image src={meme2} alt="Trying again" loading="lazy" />
</div>
**Although their second launch was mostly a flop, it did manage to get them mentioned in a Superhuman (the email app) newsletter. Their user base doubled overnight.**
Ever since then they have an active stream of users and new signups coming in.
> ”My main takeaway is to never stop shipping, and always share your work!”
>
> Lander Willem
Most of their users today have found PromptPanda through organic SEO. They started writing articles about [AI Prompt Management](https://www.promptpanda.io/ai-prompt-management/) which have quickly found traction in search engine algorithms.
## Choosing the right stack for developing your SaaS app
PromptPanda's team chose [Open SaaS](https://opensaas.sh/) because it significantly streamlined their product development by simplifying backend setup, database management, and built-in authentication. This was crucial as they needed an efficient solution that could save time due to their busy schedules. [Wasp](https://wasp.sh/)'s default integration with Fly also enabled rapid deployment, allowing them to quickly validate their product idea without getting bogged down in infrastructure complexities.
Here's a full overview of their tech stack alongside all the tools they rely on to run their SaaS:
<div className="flex justify-center">
<Image src={listImg} alt="PromptPanda tech stack" loading="lazy" />
</div>
## Are you ready to ship your SaaS now?
PromptPanda's story proves the best SaaS ideas come from solving your own pain points. Lander and Bram also learned launching isn't predictable—success can come from unexpected places, even failed launches. The takeaway? Keep building, keep shipping, and always share your progress openly.
If you enjoyed this post please make sure to [give Open SaaS a star on GitHub](https://github.com/wasp-lang/open-saas), this keeps us going forward and supports our work!

View File

@ -11,7 +11,6 @@ import defaultSettings from '@assets/file-uploads/default-settings.png';
import newBucket from '@assets/file-uploads/new-bucket.png';
import permissions from '@assets/file-uploads/permissions.png';
import cors from '@assets/file-uploads/cors.png';
import corsExample from '@assets/file-uploads/cors-example.png';
import username from '@assets/file-uploads/username.png';
import keys from '@assets/file-uploads/keys.png';
@ -94,7 +93,7 @@ Now we need to change some permissions on the bucket to allow for file uploads f
"*"
],
"AllowedMethods": [
"PUT",
"POST",
"GET"
],
"AllowedOrigins": [
@ -105,8 +104,6 @@ Now we need to change some permissions on the bucket to allow for file uploads f
}
]
```
As an example, here are the CORS permissions for this site - https://opensaas.sh:
<Image src={corsExample} alt="cors-example" loading="lazy" />
### Get your AWS S3 credentials

View File

@ -61,7 +61,7 @@ To switch easily between Node.js versions, we recommend using [nvm](https://gith
Open your terminal and run:
```shell
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
curl -sSL https://get.wasp.sh/installer.sh | sh
```
:::caution[Bad CPU type in executable]
@ -117,7 +117,7 @@ su -s $USER
Once in WSL2, run the following command in your **WSL2 environment**:
```sh
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
curl -sSL https://get.wasp.sh/installer.sh | sh
```
:::caution[WSL2 and file system issues]

View File

@ -70,7 +70,6 @@ If you are using an older version of the OpenSaaS template with Wasp `v0.13.x` o
│   ├── file-upload/ # Logic for uploading files to S3.
│   ├── landing-page # Landing page related code
│   ├── messages # Logic for app user messages.
│   ├── newsletter/ # Logic for scheduled recurring newsletter sending.
│   ├── payment/ # Logic for handling payments and webhooks.
│   ├── server/ # Scripts, shared server utils, and other server-specific code (NodeJS).
│   ├── shared/ # Shared constants and util functions.

View File

@ -66,7 +66,6 @@ app OpenSaaS {
// configFn: import { getDiscordAuthConfig } from "@src/auth/userSignupFields"
// }
},
onAfterSignup: import { onAfterSignup } from "@src/auth/hooks",
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/demo-app",
},
@ -327,16 +326,3 @@ page AdminMessagesPage {
component: import AdminMessages from "@src/messages/MessagesPage"
}
//#endregion
//#region Newsletter
job sendNewsletter {
executor: PgBoss,
perform: {
fn: import { checkAndQueueNewsletterEmails } from "@src/newsletter/sendNewsletter"
},
schedule: {
cron: "0 7 * * 1" // at 7:00 am every Monday
},
entities: [User]
}
//#endregion

View File

@ -3,6 +3,7 @@
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.523.0",
"@aws-sdk/s3-presigned-post": "^3.750.0",
"@aws-sdk/s3-request-presigner": "^3.523.0",
"@faker-js/faker": "8.3.1",
"@google-analytics/data": "4.1.0",
@ -18,17 +19,17 @@
"prettier": "3.1.1",
"prettier-plugin-tailwindcss": "0.5.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.2",
"react-apexcharts": "1.4.1",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "4.11.0",
"react-router-dom": "^6.26.2",
"stripe": "11.15.0",
"tailwind-merge": "^2.2.1",
"tailwindcss": "^3.2.7",
"vanilla-cookieconsent": "^3.0.1",
"wasp": "file:.wasp/out/sdk/wasp",
"zod": "^3.23.8",
"tailwindcss": "^3.2.7"
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.13",

View File

@ -19,7 +19,6 @@ model User {
lemonSqueezyCustomerPortalUrl String? // You can delete this if you're not using Lemon Squeezy as your payments processor.
subscriptionStatus String? // 'active', 'cancel_at_period_end', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
sendNewsletter Boolean @default(false)
datePaid DateTime?
credits Int @default(3)

View File

@ -1,16 +0,0 @@
import { HttpError } from 'wasp/server';
import type { OnAfterSignupHook } from 'wasp/server/auth';
export const onAfterSignup: OnAfterSignupHook = async ({ providerId, user, prisma }) => {
// For Stripe to function correctly, we need a valid email associated with the user.
// Discord allows an email address to be optional. If this is the case, we delete the user
// from our DB and throw an error.
if (providerId.providerName === 'discord' && !user.email) {
await prisma.user.delete({
where: {
id: user.id,
},
});
throw new HttpError(403, 'Discord user needs a valid email to sign up');
}
};

View File

@ -3,19 +3,35 @@ import { defineUserSignupFields } from 'wasp/auth/providers/types';
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
const emailDataSchema = z.object({
email: z.string(),
});
export const getEmailUserFields = defineUserSignupFields({
username: (data: any) => data.email,
isAdmin: (data: any) => adminEmails.includes(data.email),
email: (data: any) => data.email,
email: (data) => {
const emailData = emailDataSchema.parse(data);
return emailData.email;
},
username: (data) => {
const emailData = emailDataSchema.parse(data);
return emailData.email;
},
isAdmin: (data) => {
const emailData = emailDataSchema.parse(data);
return adminEmails.includes(emailData.email);
},
});
const githubDataSchema = z.object({
profile: z.object({
emails: z.array(
z.object({
email: z.string(),
})
),
emails: z
.array(
z.object({
email: z.string(),
verified: z.boolean(),
})
)
.min(1, 'You need to have an email address associated with your GitHub account to sign up.'),
login: z.string(),
}),
});
@ -23,7 +39,7 @@ const githubDataSchema = z.object({
export const getGitHubUserFields = defineUserSignupFields({
email: (data) => {
const githubData = githubDataSchema.parse(data);
return githubData.profile.emails[0].email;
return getGithubEmailInfo(githubData).email;
},
username: (data) => {
const githubData = githubDataSchema.parse(data);
@ -31,10 +47,20 @@ export const getGitHubUserFields = defineUserSignupFields({
},
isAdmin: (data) => {
const githubData = githubDataSchema.parse(data);
return adminEmails.includes(githubData.profile.emails[0].email);
const emailInfo = getGithubEmailInfo(githubData);
if (!emailInfo.verified) {
return false;
}
return adminEmails.includes(emailInfo.email);
},
});
// We are using the first email from the list of emails returned by GitHub.
// If you want to use a different email, you can modify this function.
function getGithubEmailInfo(githubData: z.infer<typeof githubDataSchema>) {
return githubData.profile.emails[0];
}
// NOTE: if we don't want to access users' emails, we can use scope ["user:read"]
// instead of ["user"] and access args.profile.username instead
export function getGitHubAuthConfig() {
@ -46,6 +72,7 @@ export function getGitHubAuthConfig() {
const googleDataSchema = z.object({
profile: z.object({
email: z.string(),
email_verified: z.boolean(),
}),
});
@ -60,6 +87,9 @@ export const getGoogleUserFields = defineUserSignupFields({
},
isAdmin: (data) => {
const googleData = googleDataSchema.parse(data);
if (!googleData.profile.email_verified) {
return false;
}
return adminEmails.includes(googleData.profile.email);
},
});
@ -74,12 +104,17 @@ const discordDataSchema = z.object({
profile: z.object({
username: z.string(),
email: z.string().email().nullable(),
verified: z.boolean().nullable(),
}),
});
export const getDiscordUserFields = defineUserSignupFields({
email: (data) => {
const discordData = discordDataSchema.parse(data);
// Users need to have an email for payment processing.
if (!discordData.profile.email) {
throw new Error('You need to have an email address associated with your Discord account to sign up.');
}
return discordData.profile.email;
},
username: (data) => {
@ -87,8 +122,11 @@ export const getDiscordUserFields = defineUserSignupFields({
return discordData.profile.username;
},
isAdmin: (data) => {
const email = discordDataSchema.parse(data).profile.email;
return !!email && adminEmails.includes(email);
const discordData = discordDataSchema.parse(data);
if (!discordData.profile.email || !discordData.profile.verified) {
return false;
}
return adminEmails.includes(discordData.profile.email);
},
});

View File

@ -24,7 +24,6 @@ function getOpenAi(): OpenAI | null {
}
//#region Actions
const generateGptResponseInputSchema = z.object({
hours: z.string().regex(/^\d+(\.\d+)?$/, 'Hours must be a number'),
});
@ -136,6 +135,9 @@ export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (rawArgs, con
const task = await context.entities.Task.update({
where: {
id,
user: {
id: context.user.id,
},
},
data: {
isDone,
@ -162,6 +164,9 @@ export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, con
const task = await context.entities.Task.delete({
where: {
id,
user: {
id: context.user.id,
},
},
});

View File

@ -1,21 +1,20 @@
import { Dispatch, SetStateAction } from 'react';
import { createFile } from 'wasp/client/operations';
import axios from 'axios';
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from './validation';
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
export type FileWithValidType = Omit<File, 'type'> & { type: AllowedFileType };
type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
interface FileUploadProgress {
file: FileWithValidType;
setUploadProgressPercent: Dispatch<SetStateAction<number>>;
setUploadProgressPercent: (percentage: number) => void;
}
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
const { uploadUrl } = await createFile({ fileType: file.type, fileName: file.name });
return axios.put(uploadUrl, file, {
headers: {
'Content-Type': file.type,
},
const { s3UploadUrl, s3UploadFields } = await createFile({ fileType: file.type, fileName: file.name });
const formData = getFileUploadFormData(file, s3UploadFields);
return axios.post(s3UploadUrl, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
@ -25,15 +24,24 @@ export async function uploadFileWithProgress({ file, setUploadProgressPercent }:
});
}
function getFileUploadFormData(file: File, s3UploadFields: Record<string, string>) {
const formData = new FormData();
Object.entries(s3UploadFields).forEach(([key, value]) => {
formData.append(key, value);
});
formData.append('file', file);
return formData;
}
export interface FileUploadError {
message: string;
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
}
export function validateFile(file: File) {
if (file.size > MAX_FILE_SIZE) {
if (file.size > MAX_FILE_SIZE_BYTES) {
return {
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
message: `File size exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit.`,
code: 'FILE_TOO_LARGE' as const,
};
}

View File

@ -18,28 +18,39 @@ const createFileInputSchema = z.object({
type CreateFileInput = z.infer<typeof createFileInputSchema>;
export const createFile: CreateFile<CreateFileInput, File> = async (rawArgs, context) => {
export const createFile: CreateFile<
CreateFileInput,
{
s3UploadUrl: string;
s3UploadFields: Record<string, string>;
}
> = async (rawArgs, context) => {
if (!context.user) {
throw new HttpError(401);
}
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({
fileType,
fileName,
userId: context.user.id,
});
return await context.entities.File.create({
await context.entities.File.create({
data: {
name: fileName,
key,
uploadUrl,
uploadUrl: s3UploadUrl,
type: fileType,
user: { connect: { id: context.user.id } },
},
});
return {
s3UploadUrl,
s3UploadFields,
};
};
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args, context) => {

View File

@ -1,8 +1,9 @@
import * as path from 'path';
import { randomUUID } from 'crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { MAX_FILE_SIZE_BYTES } from './validation';
const s3Client = new S3Client({
region: process.env.AWS_S3_REGION,
@ -20,13 +21,18 @@ type S3Upload = {
export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId }: S3Upload) => {
const key = getS3Key(fileName, userId);
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_FILES_BUCKET,
const { url: s3UploadUrl, fields: s3UploadFields } = await createPresignedPost(s3Client, {
Bucket: process.env.AWS_S3_FILES_BUCKET!,
Key: key,
ContentType: fileType,
Conditions: [['content-length-range', 0, MAX_FILE_SIZE_BYTES]],
Fields: {
'Content-Type': fileType,
},
Expires: 3600,
});
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return { uploadUrl, key };
return { s3UploadUrl, key, s3UploadFields };
};
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {

View File

@ -1,5 +1,5 @@
// Set this to the max file size you want to allow (currently 5MB).
export const MAX_FILE_SIZE = 5 * 1024 * 1024;
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
export const ALLOWED_FILE_TYPES = [
'image/jpeg',
'image/png',

View File

@ -1,54 +0,0 @@
import { type SendNewsletter } from 'wasp/server/jobs';
import { type User } from 'wasp/entities';
import { emailSender } from 'wasp/server/email';
import { type Email } from 'wasp/server/email/core/types'; // TODO fix after it gets fixed in wasp :)
const emailToSend: Email = {
to: '',
subject: 'The SaaS App Newsletter',
text: 'Hey There! \n\nThis is just a newsletter that sends automatically via cron jobs',
html: `<html lang="en">
<head>
<meta charset="UTF-8">
<title>SaaS App Newsletter</title>
</head>
<body>
<p>Hey There!</p>
<p>This is just a newsletter that sends automatically via cron jobs</p>
</body>
</html>`,
};
// you could use this function to send newsletters, expiration notices, etc.
export const checkAndQueueNewsletterEmails: SendNewsletter<never, void> = async (_args, context) => {
// e.g. you could send an offer email 2 weeks before their subscription expires
const currentDate = new Date();
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000);
const users = (await context.entities.User.findMany({
where: {
datePaid: {
equals: twoWeeksFromNow,
},
sendNewsletter: true,
},
})) as User[];
if (users.length === 0) {
return;
}
await Promise.allSettled(
users.map(async (user) => {
if (user.email) {
try {
emailToSend.to = user.email;
await emailSender.send(emailToSend);
} catch (error) {
console.error('Error sending notice to user: ', user.id, error);
}
}
})
);
};

View File

@ -38,9 +38,11 @@ export const paymentPlanCards: Record<PaymentPlanId, PaymentPlanCard> = {
const PricingPage = () => {
const [isPaymentLoading, setIsPaymentLoading] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { data: user } = useAuth();
const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== SubscriptionStatus.Deleted;
const isUserSubscribed =
!!user && !!user.subscriptionStatus && user.subscriptionStatus !== SubscriptionStatus.Deleted;
const {
data: customerPortalUrl,
@ -65,8 +67,13 @@ const PricingPage = () => {
} else {
throw new Error('Error generating checkout session URL');
}
} catch (error) {
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
setErrorMessage(error.message);
} else {
setErrorMessage('Error processing payment. Please try again later.');
}
setIsPaymentLoading(false); // We only set this to false here and not in the try block because we redirect to the checkout url within the same window
}
}
@ -78,11 +85,13 @@ const PricingPage = () => {
}
if (customerPortalUrlError) {
console.error('Error fetching customer portal url');
setErrorMessage('Error fetching Customer Portal URL');
return;
}
if (!customerPortalUrl) {
throw new Error(`Customer Portal does not exist for user ${user.id}`)
setErrorMessage(`Customer Portal does not exist for user ${user.id}`);
return;
}
window.open(customerPortalUrl, '_blank');
@ -101,6 +110,11 @@ const PricingPage = () => {
out below with test credit card number <br />
<span className='px-2 py-1 bg-gray-100 rounded-md text-gray-500'>4242 4242 4242 4242 4242</span>
</p>
{errorMessage && (
<div className='mt-8 p-4 bg-red-100 text-red-600 rounded-md dark:bg-red-200 dark:text-red-800'>
{errorMessage}
</div>
)}
<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'>
{Object.values(PaymentPlanId).map((planId) => (
<div

View File

@ -0,0 +1,6 @@
export class UnhandledWebhookEventError extends Error {
constructor(eventType: string) {
super(`Unhandled event type: ${eventType}`);
this.name = 'UnhandledWebhookEventError';
}
}

View File

@ -4,60 +4,76 @@ import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails';
import { type Order, type Subscription, getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import crypto from 'crypto';
import { requireNodeEnvVar } from '../../server/utils';
import { parseWebhookPayload, type OrderData, type SubscriptionData } from './webhookPayload';
import { assertUnreachable } from '../../shared/utils';
import { UnhandledWebhookEventError } from '../errors';
export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => {
try {
const rawBody = request.body.toString('utf8');
const signature = request.get('X-Signature');
if (!signature) {
throw new HttpError(400, 'Lemon Squeezy Webhook Signature Not Provided');
}
const rawRequestBody = parseRequestBody(request);
const secret = requireNodeEnvVar('LEMONSQUEEZY_WEBHOOK_SECRET');
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
if (!crypto.timingSafeEqual(Buffer.from(signature, 'utf8'), digest)) {
throw new HttpError(400, 'Invalid signature');
}
const event = JSON.parse(rawBody);
const userId = event.meta.custom_data.user_id;
const { eventName, meta, data } = await parseWebhookPayload(rawRequestBody);
const userId = meta.custom_data.user_id;
const prismaUserDelegate = context.entities.User;
switch (event.meta.event_name) {
switch (eventName) {
case 'order_created':
await handleOrderCreated(event as Order, userId, prismaUserDelegate);
await handleOrderCreated(data, userId, prismaUserDelegate);
break;
case 'subscription_created':
await handleSubscriptionCreated(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionCreated(data, userId, prismaUserDelegate);
break;
case 'subscription_updated':
await handleSubscriptionUpdated(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionUpdated(data, userId, prismaUserDelegate);
break;
case 'subscription_cancelled':
await handleSubscriptionCancelled(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionCancelled(data, userId, prismaUserDelegate);
break;
case 'subscription_expired':
await handleSubscriptionExpired(event as Subscription, userId, prismaUserDelegate);
await handleSubscriptionExpired(data, userId, prismaUserDelegate);
break;
default:
console.error('Unhandled event type: ', event.meta.event_name);
// If you'd like to handle more events, you can add more cases above.
assertUnreachable(eventName);
}
response.status(200).json({ received: true });
return response.status(200).json({ received: true });
} catch (err) {
if (err instanceof UnhandledWebhookEventError) {
console.error(err.message);
return response.status(422).json({ error: err.message });
}
console.error('Webhook error:', err);
if (err instanceof HttpError) {
response.status(err.statusCode).json({ error: err.message });
return response.status(err.statusCode).json({ error: err.message });
} else {
response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' });
return response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' });
}
}
};
function parseRequestBody(request: express.Request): string {
const requestBody = request.body.toString('utf8');
const signature = request.get('X-Signature');
if (!signature) {
throw new HttpError(400, 'Lemon Squeezy webhook signature not provided');
}
const secret = requireNodeEnvVar('LEMONSQUEEZY_WEBHOOK_SECRET');
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(requestBody).digest('hex'), 'utf8');
if (!crypto.timingSafeEqual(Buffer.from(signature, 'utf8'), digest)) {
throw new HttpError(400, 'Invalid signature');
}
return requestBody;
}
export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => {
// We need to delete the default 'express.json' middleware and replace it with 'express.raw' middleware
// because webhook data in the body of the request as raw JSON, not as JSON in the body of the request.
@ -69,8 +85,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders,
// as well as to save the customer portal URL and customer id for the user.
async function handleOrderCreated(data: Order, userId: string, prismaUserDelegate: PrismaClient['user']) {
const { customer_id, status, first_order_item, order_number } = data.data.attributes;
async function handleOrderCreated(data: OrderData, userId: string, prismaUserDelegate: PrismaClient['user']) {
const { customer_id, status, first_order_item, order_number } = data.attributes;
const lemonSqueezyId = customer_id.toString();
const planId = getPlanIdByVariantId(first_order_item.variant_id.toString());
@ -94,11 +110,11 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
}
async function handleSubscriptionCreated(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id, status, variant_id } = data.data.attributes;
const { customer_id, status, variant_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();
const planId = getPlanIdByVariantId(variant_id.toString());
@ -123,11 +139,11 @@ async function handleSubscriptionCreated(
// NOTE: LemonSqueezy's 'subscription_updated' event is sent as a catch-all and fires even after 'subscription_created' & 'order_created'.
async function handleSubscriptionUpdated(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id, status, variant_id } = data.data.attributes;
const { customer_id, status, variant_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();
const planId = getPlanIdByVariantId(variant_id.toString());
@ -153,11 +169,11 @@ async function handleSubscriptionUpdated(
}
async function handleSubscriptionCancelled(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id } = data.data.attributes;
const { customer_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();
await updateUserLemonSqueezyPaymentDetails(
@ -174,11 +190,11 @@ async function handleSubscriptionCancelled(
}
async function handleSubscriptionExpired(
data: Subscription,
data: SubscriptionData,
userId: string,
prismaUserDelegate: PrismaClient['user']
) {
const { customer_id } = data.data.attributes;
const { customer_id } = data.attributes;
const lemonSqueezyId = customer_id.toString();
await updateUserLemonSqueezyPaymentDetails(
@ -217,4 +233,3 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
}
return planId;
}

View File

@ -0,0 +1,77 @@
import * as z from 'zod';
import { UnhandledWebhookEventError } from '../errors';
import { HttpError } from 'wasp/server';
export async function parseWebhookPayload(rawPayload: string) {
try {
const rawEvent: unknown = JSON.parse(rawPayload);
const { meta, data } = await genericEventSchema.parseAsync(rawEvent);
switch (meta.event_name) {
case 'order_created':
const orderData = await orderDataSchema.parseAsync(data);
return { eventName: meta.event_name, meta, data: orderData };
case 'subscription_created':
case 'subscription_updated':
case 'subscription_cancelled':
case 'subscription_expired':
const subscriptionData = await subscriptionDataSchema.parseAsync(data);
return { eventName: meta.event_name, meta, data: subscriptionData };
default:
// If you'd like to handle more events, you can add more cases above.
throw new UnhandledWebhookEventError(meta.event_name);
}
} catch (e: unknown) {
if (e instanceof UnhandledWebhookEventError) {
throw e;
} else {
console.error(e);
throw new HttpError(400, 'Error parsing Lemon Squeezy webhook payload');
}
}
}
export type SubscriptionData = z.infer<typeof subscriptionDataSchema>;
export type OrderData = z.infer<typeof orderDataSchema>;
/**
* This schema is based on LemonSqueezyResponse type
*/
const genericEventSchema = z.object({
meta: z.object({
event_name: z.string(),
custom_data: z.object({
user_id: z.string(),
}),
}),
data: z.unknown(),
});
/**
* This schema is based on
* @type import('@lemonsqueezy/lemonsqueezy.js').Order
* specifically Order['data'].
*/
const orderDataSchema = z.object({
attributes: z.object({
customer_id: z.number(),
status: z.string(),
first_order_item: z.object({
variant_id: z.number(),
}),
order_number: z.number(),
}),
});
/**
* This schema is based on
* @type import('@lemonsqueezy/lemonsqueezy.js').Subscription
* specifically Subscription['data'].
*/
const subscriptionDataSchema = z.object({
attributes: z.object({
customer_id: z.number(),
status: z.string(),
variant_id: z.number(),
}),
});

View File

@ -26,10 +26,8 @@ export const generateCheckoutSession: GenerateCheckoutSession<
const userId = context.user.id;
const userEmail = context.user.email;
if (!userEmail) {
throw new HttpError(
403,
'User needs an email to make a payment. If using the usernameAndPassword Auth method, switch to an Auth method that provides an email.'
);
// If using the usernameAndPassword Auth method, switch to an Auth method that provides an email.
throw new HttpError(403, 'User needs an email to make a payment.');
}
const paymentPlan = paymentPlans[paymentPlanId];

View File

@ -2,59 +2,81 @@ import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
import { type PaymentsWebhook } from 'wasp/server/api';
import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { Stripe } from 'stripe';
import type { Stripe } from 'stripe';
import { stripe } from './stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus, PaymentPlanEffect, PaymentPlan } from '../plans';
import { paymentPlans, PaymentPlanId, SubscriptionStatus, type PaymentPlanEffect } from '../plans';
import { updateUserStripePaymentDetails } from './paymentDetails';
import { emailSender } from 'wasp/server/email';
import { assertUnreachable } from '../../shared/utils';
import { requireNodeEnvVar } from '../../server/utils';
import { z } from 'zod';
import {
parseWebhookPayload,
type InvoicePaidData,
type PaymentIntentSucceededData,
type SessionCompletedData,
type SubscriptionDeletedData,
type SubscriptionUpdatedData,
} from './webhookPayload';
import { UnhandledWebhookEventError } from '../errors';
export const stripeWebhook: PaymentsWebhook = async (request, response, context) => {
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, secret);
const rawStripeEvent = constructStripeEvent(request);
const { eventName, data } = await parseWebhookPayload(rawStripeEvent);
const prismaUserDelegate = context.entities.User;
switch (eventName) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(data, prismaUserDelegate);
break;
case 'invoice.paid':
await handleInvoicePaid(data, prismaUserDelegate);
break;
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(data, prismaUserDelegate);
break;
case 'customer.subscription.updated':
await handleCustomerSubscriptionUpdated(data, prismaUserDelegate);
break;
case 'customer.subscription.deleted':
await handleCustomerSubscriptionDeleted(data, 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.
assertUnreachable(eventName);
}
return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook
} catch (err) {
throw new HttpError(400, 'Error Constructing Stripe Webhook Event');
if (err instanceof UnhandledWebhookEventError) {
console.error(err.message);
return response.status(422).json({ error: err.message });
}
console.error('Webhook error:', err);
if (err instanceof HttpError) {
return response.status(err.statusCode).json({ error: err.message });
} else {
return response.status(400).json({ error: 'Error processing Stripe webhook event' });
}
}
const prismaUserDelegate = context.entities.User;
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutSessionCompleted(session, prismaUserDelegate);
break;
case 'invoice.paid':
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice, prismaUserDelegate);
break;
case 'payment_intent.succeeded':
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentIntentSucceeded(paymentIntent, 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
};
function constructStripeEvent(request: express.Request): Stripe.Event {
try {
const secret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET');
const sig = request.headers['stripe-signature'];
if (!sig) {
throw new HttpError(400, 'Stripe webhook signature not provided');
}
return stripe.webhooks.constructEvent(request.body, sig, secret);
} catch (err) {
throw new HttpError(500, 'Error constructing Stripe webhook event');
}
}
export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => {
// We need to delete the default 'express.json' middleware and replace it with 'express.raw' middleware
// because webhook data in the body of the request as raw JSON, not as JSON in the body of the request.
@ -67,14 +89,14 @@ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) =
// we can update the user's payment details here, but confirm credits or a subscription
// if the payment succeeds in other, more specific, webhooks.
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
session: SessionCompletedData,
prismaUserDelegate: PrismaClient['user']
) {
const userStripeId = validateUserStripeIdOrThrow(session.customer);
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
const lineItemPriceId = extractPriceId(line_items);
const userStripeId = session.customer;
const lineItems = await getSubscriptionLineItemsBySessionId(session.id);
const lineItemPriceId = extractPriceId(lineItems);
const planId = getPlanIdByPriceId(lineItemPriceId);
const plan = paymentPlans[planId];
if (plan.effect.kind === 'credits') {
@ -87,14 +109,14 @@ export async function handleCheckoutSessionCompleted(
// This is called when a subscription is purchased or renewed and payment succeeds.
// Invoices are not created for one-time payments, so we handle them in the payment_intent.succeeded webhook.
export async function handleInvoicePaid(invoice: Stripe.Invoice, prismaUserDelegate: PrismaClient['user']) {
const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
export async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) {
const userStripeId = invoice.customer;
const datePaid = new Date(invoice.period_start * 1000);
return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
}
export async function handlePaymentIntentSucceeded(
paymentIntent: Stripe.PaymentIntent,
paymentIntent: PaymentIntentSucceededData,
prismaUserDelegate: PrismaClient['user']
) {
// We handle invoices in the invoice.paid webhook. Invoices exist for subscription payments,
@ -103,7 +125,7 @@ export async function handlePaymentIntentSucceeded(
return;
}
const userStripeId = validateUserStripeIdOrThrow(paymentIntent.customer);
const userStripeId = paymentIntent.customer;
const datePaid = new Date(paymentIntent.created * 1000);
// We capture the price id from the payment intent metadata
@ -129,10 +151,10 @@ export async function handlePaymentIntentSucceeded(
}
export async function handleCustomerSubscriptionUpdated(
subscription: Stripe.Subscription,
subscription: SubscriptionUpdatedData,
prismaUserDelegate: PrismaClient['user']
) {
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
const userStripeId = subscription.customer;
let subscriptionStatus: SubscriptionStatus | undefined;
const priceId = extractPriceId(subscription.items);
@ -167,23 +189,19 @@ export async function handleCustomerSubscriptionUpdated(
}
export async function handleCustomerSubscriptionDeleted(
subscription: Stripe.Subscription,
subscription: SubscriptionDeletedData,
prismaUserDelegate: PrismaClient['user']
) {
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
const userStripeId = subscription.customer;
return updateUserStripePaymentDetails(
{ userStripeId, subscriptionStatus: 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;
}
type SubscsriptionItems = z.infer<typeof subscriptionItemsSchema>;
const LineItemsPriceSchema = z.object({
const subscriptionItemsSchema = z.object({
data: z.array(
z.object({
price: z.object({
@ -193,15 +211,28 @@ const LineItemsPriceSchema = z.object({
),
});
function extractPriceId(items: Stripe.Checkout.Session['line_items'] | Stripe.Subscription['items']) {
const result = LineItemsPriceSchema.safeParse(items);
if (!result.success) {
throw new HttpError(400, 'No price id in stripe event object');
function extractPriceId(items: SubscsriptionItems): string {
if (items.data.length === 0) {
throw new HttpError(400, 'No items in stripe event object');
}
if (result.data.data.length > 1) {
if (items.data.length > 1) {
throw new HttpError(400, 'More than one item in stripe event object');
}
return result.data.data[0].price.id;
return items.data[0].price.id;
}
async function getSubscriptionLineItemsBySessionId(sessionId: string) {
try {
const { line_items: lineItemsRaw } = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
const lineItems = await subscriptionItemsSchema.parseAsync(lineItemsRaw);
return lineItems;
} catch (e: unknown) {
throw new HttpError(500, 'Error parsing Stripe line items');
}
}
function getPlanIdByPriceId(priceId: string): PaymentPlanId {

View File

@ -0,0 +1,116 @@
import * as z from 'zod';
import { Stripe } from 'stripe';
import { UnhandledWebhookEventError } from '../errors';
import { HttpError } from 'wasp/server';
export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) {
try {
const event = await genericStripeEventSchema.parseAsync(rawStripeEvent);
switch (event.type) {
case 'checkout.session.completed':
const session = await sessionCompletedDataSchema.parseAsync(event.data.object);
return { eventName: event.type, data: session };
case 'invoice.paid':
const invoice = await invoicePaidDataSchema.parseAsync(event.data.object);
return { eventName: event.type, data: invoice };
case 'payment_intent.succeeded':
const paymentIntent = await paymentIntentSucceededDataSchema.parseAsync(event.data.object);
return { eventName: event.type, data: paymentIntent };
case 'customer.subscription.updated':
const updatedSubscription = await subscriptionUpdatedDataSchema.parseAsync(event.data.object);
return { eventName: event.type, data: updatedSubscription };
case 'customer.subscription.deleted':
const deletedSubscription = await subscriptionDeletedDataSchema.parseAsync(event.data.object);
return { eventName: event.type, data: deletedSubscription };
default:
// If you'd like to handle more events, you can add more cases above.
throw new UnhandledWebhookEventError(event.type);
}
} catch (e: unknown) {
if (e instanceof UnhandledWebhookEventError) {
throw e;
} else {
console.error(e);
throw new HttpError(400, 'Error parsing Stripe event object');
}
}
}
/**
* This is a subtype of
* @type import('stripe').Stripe.Event
*/
const genericStripeEventSchema = z.object({
type: z.string(),
data: z.object({
object: z.unknown(),
}),
});
/**
* This is a subtype of
* @type import('stripe').Stripe.Checkout.Session
*/
const sessionCompletedDataSchema = z.object({
id: z.string(),
customer: z.string(),
});
/**
* This is a subtype of
* @type import('stripe').Stripe.Invoice
*/
const invoicePaidDataSchema = z.object({
customer: z.string(),
period_start: z.number(),
});
/**
* This is a subtype of
* @type import('stripe').Stripe.PaymentIntent
*/
const paymentIntentSucceededDataSchema = z.object({
invoice: z.unknown().optional(),
created: z.number(),
metadata: z.object({
priceId: z.string().optional(),
}),
customer: z.string(),
});
/**
* This is a subtype of
* @type import('stripe').Stripe.Subscription
*/
const subscriptionUpdatedDataSchema = z.object({
customer: z.string(),
status: z.string(),
cancel_at_period_end: z.boolean(),
items: z.object({
data: z.array(
z.object({
price: z.object({
id: z.string(),
}),
})
),
}),
});
/**
* This is a subtype of
* @type import('stripe').Stripe.Subscription
*/
const subscriptionDeletedDataSchema = z.object({
customer: z.string(),
});
export type SessionCompletedData = z.infer<typeof sessionCompletedDataSchema>;
export type InvoicePaidData = z.infer<typeof invoicePaidDataSchema>;
export type PaymentIntentSucceededData = z.infer<typeof paymentIntentSucceededDataSchema>;
export type SubscriptionUpdatedData = z.infer<typeof subscriptionUpdatedDataSchema>;
export type SubscriptionDeletedData = z.infer<typeof subscriptionDeletedDataSchema>;

View File

@ -35,7 +35,6 @@ function generateMockUserData(): MockUserData {
username: faker.internet.userName({ firstName, lastName }),
createdAt,
isAdmin: false,
sendNewsletter: false,
credits,
subscriptionStatus,
lemonSqueezyCustomerPortalUrl: null,

View File

@ -18,13 +18,28 @@ Start your Wasp DB and leave it running:
cd ../app && wasp db start
```
### Skipping Email Verification in e2e Tests
Open another terminal and start the Wasp app with the environment variable set to skip email verification in development mode:
```shell
> [!IMPORTANT]
> When using the email auth method, a verification link is typically sent when a user registers. If you're using the default Dummy provider, this link is logged in the console.
>
> **However, during e2e tests, this manual step will cause the tests to hang and fail** because the link is never clicked. To prevent this, set the following environment variable when starting your app:
```bash
cd app && SKIP_EMAIL_VERIFICATION_IN_DEV=true wasp start
```
> [!IMPORTANT]
> When using the email auth method a verification link is sent when the user registers, or logged to the console if you're using the default Dummy provider. You must click this link to complete registration. Setting SKIP_EMAIL_VERIFICATION_IN_DEV to "true" skips this verification step, allowing you to automatically log in. This step must be skipped when running tests, otherwise the tests will hang and fail as the verification link is never clicked!
#### What this step will do:
- **Automated Testing:** Skipping email verification ensures e2e tests run uninterrupted.
- **Consistent Behavior:** It guarantees login flows wont break during automated test runs.
- **CI/CD Pipelines:** This variable should also be set in CI pipelines to avoid test failures.
```yaml
env:
SKIP_EMAIL_VERIFICATION_IN_DEV: "true"
```
In another terminal, run the local e2e tests:
```shell