From 809e982147c21e70d1ec4c773e0d930eac0dbce2 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 17 Sep 2025 10:15:40 +0200 Subject: [PATCH 01/44] Stripe only changes --- .github/workflows/e2e-tests.yml | 1 - .../app_diff/src/analytics/stats.ts.diff | 15 +- .../src/payment/stripe/paymentDetails.ts.diff | 21 +- .../payment/stripe/paymentProcessor.ts.diff | 14 +- opensaas-sh/blog/public/llms-full.txt | 75 +++-- opensaas-sh/blog/public/llms.txt | 1 + .../docs/guides/payments-integration.mdx | 19 +- template/app/.env.server.example | 2 - template/app/src/analytics/stats.ts | 8 +- template/app/src/payment/plans.ts | 11 +- .../app/src/payment/stripe/checkoutUtils.ts | 52 +-- .../app/src/payment/stripe/paymentDetails.ts | 62 +++- .../src/payment/stripe/paymentProcessor.ts | 89 ++++-- .../app/src/payment/stripe/stripeClient.ts | 2 +- template/app/src/payment/stripe/webhook.ts | 300 +++++++++--------- template/app/src/shared/utils.ts | 3 +- 16 files changed, 391 insertions(+), 284 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index cb5875751..83562540c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -108,7 +108,6 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} STRIPE_API_KEY: ${{ secrets.STRIPE_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - STRIPE_CUSTOMER_PORTAL_URL: https://billing.stripe.com/p/login/test_8wM8x17JN7DT4zC000 PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID: ${{ secrets.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID }} PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID: ${{ secrets.STRIPE_PRO_SUBSCRIPTION_PRICE_ID }} PAYMENTS_CREDITS_10_PLAN_ID: ${{ secrets.STRIPE_CREDITS_PRICE_ID }} diff --git a/opensaas-sh/app_diff/src/analytics/stats.ts.diff b/opensaas-sh/app_diff/src/analytics/stats.ts.diff index 736bdca6d..9ccbe7ca2 100644 --- a/opensaas-sh/app_diff/src/analytics/stats.ts.diff +++ b/opensaas-sh/app_diff/src/analytics/stats.ts.diff @@ -1,15 +1,16 @@ --- template/app/src/analytics/stats.ts +++ opensaas-sh/app/src/analytics/stats.ts -@@ -2,11 +2,9 @@ - import { type DailyStatsJob } from 'wasp/server/jobs'; - import Stripe from 'stripe'; - import { stripe } from '../payment/stripe/stripeClient'; +@@ -1,12 +1,10 @@ -import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; + import Stripe from 'stripe'; + import { type DailyStats } from 'wasp/entities'; + import { type DailyStatsJob } from 'wasp/server/jobs'; ++import { SubscriptionStatus } from '../payment/plans'; + import { stripeClient } from '../payment/stripe/stripeClient'; import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils'; --// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; + // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; -import { paymentProcessor } from '../payment/paymentProcessor'; - import { SubscriptionStatus } from '../payment/plans'; -+// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; +-import { SubscriptionStatus } from '../payment/plans'; export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean }; diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff index b87e266d7..b0e1c181c 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff @@ -1,15 +1,20 @@ --- template/app/src/payment/stripe/paymentDetails.ts +++ opensaas-sh/app/src/payment/stripe/paymentDetails.ts -@@ -14,10 +14,10 @@ - ) => { +@@ -26,7 +26,7 @@ + ) { return userDelegate.update({ where: { -- paymentProcessorUserId: userStripeId -+ stripeId: userStripeId +- paymentProcessorUserId: customerId, ++ stripeId: customerId, }, data: { -- paymentProcessorUserId: userStripeId, -+ stripeId: userStripeId, - subscriptionPlan, - subscriptionStatus, datePaid, +@@ -48,7 +48,7 @@ + ) { + return userDelegate.update({ + where: { +- paymentProcessorUserId: customerId, ++ stripeId: customerId, + }, + data: { + subscriptionPlan: paymentPlanId, diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index c5443612a..f0b6510ba 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -1,11 +1,11 @@ --- template/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts -@@ -20,7 +20,7 @@ - id: userId +@@ -24,7 +24,7 @@ + id: userId, }, data: { -- paymentProcessorUserId: customer.id -+ stripeId: customer.id - } - }) - if (!stripeSession.url) throw new Error('Error creating Stripe Checkout Session'); +- paymentProcessorUserId: customer.id, ++ stripeId: customer.id, + }, + }); + diff --git a/opensaas-sh/blog/public/llms-full.txt b/opensaas-sh/blog/public/llms-full.txt index f4200ed69..1b0f357c7 100644 --- a/opensaas-sh/blog/public/llms-full.txt +++ b/opensaas-sh/blog/public/llms-full.txt @@ -23,7 +23,8 @@ If you find this template useful, consider giving us [a star on GitHub](https:// ## What's inside? The template itself is built on top of some very powerful tools and frameworks, including: - - [Wasp](https://wasp.sh) - a full-stack React, NodeJS, Prisma framework with superpowers- [Astro](https://starlight.astro.build/) - Astro's lightweight "Starlight" template for documentation and blog + - [Wasp](https://wasp.sh) - a full-stack React, NodeJS, Prisma framework with superpowers + - [Astro](https://starlight.astro.build/) - Astro's lightweight "Starlight" template for documentation and blog - [Stripe](https://stripe.com) or [Lemon Squeezy](https://lemonsqueezy.com/) - for products and payments - [Plausible](https://plausible.io) or [Google](https://analytics.google.com/) Analytics - [OpenAI](https://openai.com) - OpenAI API integrated into the app or [Replicate](https://replicate.com/) (coming soon ) @@ -64,7 +65,7 @@ If you prefer video tutorials, you can watch this walkthrough below which will g ### Pre-requisites You must have Node.js (and NPM) installed on your machine and available in `PATH` to use Wasp. -Your version of Node.js must be >= 20. +Your version of Node.js must be >= 22.12. To switch easily between Node.js versions, we recommend using [nvm](https://github.com/nvm-sh/nvm). @@ -128,7 +129,7 @@ Once Rosetta is installed, you should be able to run Wasp without any issues. ### Windows -In order to use Wasp on Windows, you need to install WSL2 (Windows Subsystem for Linux) and a Linux distribution of your choice. We recommend using Ubuntu. +In order to use Wasp on Windows, you need to install WSL2 (Windows Subsystem for Linux) and a Linux distribution of your choice. We recommend using Ubuntu. **You can refer to this [article](https://wasp.sh/blog/2023/11/21/guide-windows-development-wasp-wsl) for a step by step guide to using Wasp in the WSL environment.** If you need further help, reach out to us on [Discord](https://discord.gg/rzdnErX). @@ -173,7 +174,7 @@ curl -sSL https://get.wasp.sh/installer.sh | sh If you are using WSL2, make sure that your Wasp project is not on the Windows file system, but instead on the Linux file system. Otherwise, Wasp won't be able to detect file changes, due to this issue in WSL2. -::: +::: ### Finalize Installation @@ -874,7 +875,7 @@ To create a Google OAuth app and get your Google API keys, follow the instructio To create a GitHub OAuth app and get your GitHub API keys, follow the instructions in [Wasp's GitHub Auth docs](https://wasp.sh/docs/auth/social-auth/github#3-creating-a-github-oauth-app). -To create a Discord OAuth app and get your Discord API keys, follow the instructions in [Wasp's Discord Auth docs](docs/auth/social-auth/google#3-creating-a-google-oauth-app) +To create a Discord OAuth app and get your Discord API keys, follow the instructions in [Wasp's Discord Auth docs](https://wasp.sh/docs/auth/social-auth/discord#3-creating-a-discord-app) Again, Wasp will take care of the rest and update your AuthUI components accordingly. @@ -1287,13 +1288,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, { 2. click on `+ add endpoint` 3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook` -4. select the events you want to listen to. These should be the same events you're consuming in your webhook. For example, if you haven't added any additional events to the webhook and are using the defaults that came with this template, then you'll need to add: -
- `account.updated` -
- `checkout.session.completed` -
- `customer.subscription.deleted` -
- `customer.subscription.updated` -
- `invoice.paid` -
- `payment_intent.succeeded` +4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts): 5. after that, go to the webhook you just created and `reveal` the new signing secret. 6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable.
If you've deployed to Fly.io, you can do that easily with the following command: @@ -1620,7 +1615,7 @@ export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; At this point, you can delete: - the unused payment processor code within the `/src/payment/` directory, - any unused environment variables from `.env.server` (they will be prefixed with the name of the provider your are not using): - - e.g. `STRIPE_API_KEY`, `STRIPE_CUSTOMER_PORTAL_URL`, `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_WEBHOOK_SECRET` + - e.g. `STRIPE_API_KEY`, `LEMONSQUEEZY_API_KEY` - Make sure to also uninstall the unused dependencies: - `npm uninstall @lemonsqueezy/lemonsqueezy.js` - or @@ -1666,22 +1661,21 @@ To create a test product, go to the test products url [https://dashboard.stripe. ### Create a Test Customer -To create a test customer, go to the test customers url [https://dashboard.stripe.com/test/customers](https://dashboard.stripe.com/test/customers). +You can create a test customer directly in the [Stripe Dashboard](https://dashboard.stripe.com/test/customers). - Click on the `Add a customer` button and fill in the relevant information for your test customer. -:::note - When filling in the test customer email address, use an address you have access to and will use when logging into your SaaS app. This is important because the email address is used to identify the customer when creating a subscription and allows you to manage your test user's payments/subscriptions via the test customer portal -::: + +Alternatively, OpenSasS will automatically create a test customer the first time a user starts a checkout session. +This customer is linked to the email address associated with your app's user. ### Set up the Customer Portal -Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Dashboard and activate and copy the `Customer portal link`. Paste it in your `.env.server` file: +You can set up your customer portal in your [Stripe Dashboard](https://dashboard.stripe.com/test/settings/billing/portal). -```ts title=".env.server" -STRIPE_CUSTOMER_PORTAL_URL= -``` +By default, OpenSaas generates a unique customer portal link for each user on the back end. +If you'd rather provide a permanent link to the customer portal, activate and copy the `Customer portal link`. -If you'd like to give users the ability to switch between different plans, e.g. upgrade from a hobby to a pro subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`. +If you'd like to give users the ability to switch between different plans, e.g., upgrade from a "Hobby" to a "Pro" subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`. Then select the products you'd like them to be able to switch between. @@ -2015,6 +2009,43 @@ The method you choose is up to you and will largely depend on the complexity of --- +# Vibe Coding with Open SaaS + +If you're looking to use AI to help build (or "vibe code") your SaaS app, this guide is for you. + +## Coding with AI, Open SaaS, & Wasp + +Wasp is particularly well suited to coding with AI due to its central config file which gives LLMs context about the entire full-stack app, and its ability to manage boilerplate code so AI doesn't have to. + +Regardless, there are still some shortcomings to using AI to code with Wasp, as well as a learning curve to using it effectively. + +Luckily, we did the work for you and put together a bunch of resources to help you use Wasp & Open SaaS with AI as effectively as possible. + +### AI Resources in the Template + +The template comes with: +- A full set of rules files, `app/.cursor/rules`, to be used with Cursor or adapted to your coding tool of choice (Windsurf, Claude Code, etc.). +- A set of example prompts, `app/.cursor/example-prompts.md`, to help you get started. + +### LLM-Friendly Documentation + +We've also created a bunch of LLM-friendly documentation: +- [Open SaaS Docs - LLMs.txt](https://docs.opensaas.sh/llms.txt) - Links to the raw text docs. +- **[Open SaaS Docs - LLMs-full.txt](https://docs.opensaas.sh/llms-full.txt) - Complete docs as one text file.** +- Coming Soon! ~~[Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt)~~ - Links to the raw text docs. +- Coming Soon! ~~[Wasp Docs - LLMs-full.txt](https://wasp.sh/llms-full.txt)~~ - Complete docs as one text file. + +Add these to your AI-assisted IDE settings so you can easily reference them in your chat sessions with the LLM. +**In most cases, you'll want to pass the `llms-full.txt` to the LLM and ask it to help you with a specific task.** + +### More AI-assisted Coding Learning Resources + +Here's a list of articles and tutorials we've made: +- [3hr YouTube tutorial: Vibe Coding a Personal Finance App w/ Wasp & Cursor](https://www.youtube.com/watch?v=WYzEROo7reY) +- [Article: A Structured Workflow for "Vibe Coding" Full-Stack Apps](https://dev.to/wasp/a-structured-workflow-for-vibe-coding-full-stack-apps-352l) + +--- + # Admin Dashboard This is a reference on how the Admin dashboard, available at `/admin`, is set up. diff --git a/opensaas-sh/blog/public/llms.txt b/opensaas-sh/blog/public/llms.txt index 3afd4c58a..eb3fffea3 100644 --- a/opensaas-sh/blog/public/llms.txt +++ b/opensaas-sh/blog/public/llms.txt @@ -20,5 +20,6 @@ - [SEO](https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/src/content/docs/guides/seo.mdx) - [Tests](https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/src/content/docs/guides/tests.md) - [How (Not) to Update Your Open SaaS App](https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/src/content/docs/guides/updating-opensaas.md) +- [Vibe Coding with Open SaaS](https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx) - [Admin Dashboard](https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/src/content/docs/general/admin-dashboard.mdx) - [User Overview](https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/src/content/docs/general/user-overview.md) \ No newline at end of file diff --git a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx index df9c0c7a6..be7926f07 100644 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx @@ -43,7 +43,7 @@ export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; At this point, you can delete: - the unused payment processor code within the `/src/payment/` directory, - any unused environment variables from `.env.server` (they will be prefixed with the name of the provider your are not using): - - e.g. `STRIPE_API_KEY`, `STRIPE_CUSTOMER_PORTAL_URL`, `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_WEBHOOK_SECRET` + - e.g. `STRIPE_API_KEY`, `LEMONSQUEEZY_API_KEY` - Make sure to also uninstall the unused dependencies: - `npm uninstall @lemonsqueezy/lemonsqueezy.js` - or @@ -95,22 +95,21 @@ To create a test product, go to the test products url [https://dashboard.stripe. ### Create a Test Customer -To create a test customer, go to the test customers url [https://dashboard.stripe.com/test/customers](https://dashboard.stripe.com/test/customers). +You can create a test customer directly in the [Stripe Dashboard](https://dashboard.stripe.com/test/customers). - Click on the `Add a customer` button and fill in the relevant information for your test customer. -:::note - When filling in the test customer email address, use an address you have access to and will use when logging into your SaaS app. This is important because the email address is used to identify the customer when creating a subscription and allows you to manage your test user's payments/subscriptions via the test customer portal -::: + +Alternatively, OpenSasS will automatically create a test customer the first time a user starts a checkout session. +This customer is linked to the email address associated with your app's user. ### Set up the Customer Portal -Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Dashboard and activate and copy the `Customer portal link`. Paste it in your `.env.server` file: +You can set up your customer portal in your [Stripe Dashboard](https://dashboard.stripe.com/test/settings/billing/portal). -```ts title=".env.server" -STRIPE_CUSTOMER_PORTAL_URL= -``` +By default, OpenSaas generates a unique customer portal link for each user on the back end. +If you'd rather provide a permanent link to the customer portal, activate and copy the `Customer portal link`. -If you'd like to give users the ability to switch between different plans, e.g. upgrade from a hobby to a pro subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`. +If you'd like to give users the ability to switch between different plans, e.g., upgrade from a "Hobby" to a "Pro" subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`. switch plans diff --git a/template/app/.env.server.example b/template/app/.env.server.example index 65d02b9e1..b602ea362 100644 --- a/template/app/.env.server.example +++ b/template/app/.env.server.example @@ -7,8 +7,6 @@ STRIPE_API_KEY=sk_test_... # After downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/payments-webhook` it will output your signing secret STRIPE_WEBHOOK_SECRET=whsec_... -# You can find your Stripe customer portal URL in the Stripe Dashboard under the 'Customer Portal' settings. -STRIPE_CUSTOMER_PORTAL_URL=https://billing.stripe.com/... # For testing, create a new store in test mode on https://lemonsqueezy.com LEMONSQUEEZY_API_KEY=eyJ... diff --git a/template/app/src/analytics/stats.ts b/template/app/src/analytics/stats.ts index 57e73986b..0a00b6b25 100644 --- a/template/app/src/analytics/stats.ts +++ b/template/app/src/analytics/stats.ts @@ -1,8 +1,8 @@ +import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; +import Stripe from 'stripe'; import { type DailyStats } from 'wasp/entities'; import { type DailyStatsJob } from 'wasp/server/jobs'; -import Stripe from 'stripe'; -import { stripe } from '../payment/stripe/stripeClient'; -import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; +import { stripeClient } from '../payment/stripe/stripeClient'; import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils'; // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; import { paymentProcessor } from '../payment/paymentProcessor'; @@ -144,7 +144,7 @@ async function fetchTotalStripeRevenue() { let hasMore = true; while (hasMore) { - const balanceTransactions = await stripe.balanceTransactions.list(params); + const balanceTransactions = await stripeClient.balanceTransactions.list(params); for (const transaction of balanceTransactions.data) { if (transaction.type === 'charge') { diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts index 85d6a1ecc..3df1719e6 100644 --- a/template/app/src/payment/plans.ts +++ b/template/app/src/payment/plans.ts @@ -14,15 +14,18 @@ export enum PaymentPlanId { } export interface PaymentPlan { - // Returns the id under which this payment plan is identified on your payment processor. - // E.g. this might be price id on Stripe, or variant id on LemonSqueezy. + /** + * Returns the id under which this payment plan is identified on your payment processor. + * + * E.g. price id on Stripe, or variant id on LemonSqueezy. + */ getPaymentProcessorPlanId: () => string; effect: PaymentPlanEffect; } export type PaymentPlanEffect = { kind: 'subscription' } | { kind: 'credits'; amount: number }; -export const paymentPlans: Record = { +export const paymentPlans = { [PaymentPlanId.Hobby]: { getPaymentProcessorPlanId: () => requireNodeEnvVar('PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID'), effect: { kind: 'subscription' }, @@ -35,7 +38,7 @@ export const paymentPlans: Record = { getPaymentProcessorPlanId: () => requireNodeEnvVar('PAYMENTS_CREDITS_10_PLAN_ID'), effect: { kind: 'credits', amount: 10 }, }, -}; +} as const satisfies Record; export function prettyPaymentPlanName(planId: PaymentPlanId): string { const planToName: Record = { diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 0ab9ad54e..2b9d11a5b 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,27 +1,28 @@ -import type { StripeMode } from './paymentProcessor'; - import Stripe from 'stripe'; -import { stripe } from './stripeClient'; +import { stripeClient } from './stripeClient'; // WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying -const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000'; +const CLIENT_BASE_URL = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000'; -export async function fetchStripeCustomer(customerEmail: string) { - let customer: Stripe.Customer; +/** + * Returns a Stripe customer for the given User email, creating a customer if none exist. + * Implements email uniqueness logic since Stripe doesn't enforce unique emails. + */ +export async function ensureStripeCustomer(userEmail: string): Promise { try { - const stripeCustomers = await stripe.customers.list({ - email: customerEmail, + const stripeCustomers = await stripeClient.customers.list({ + email: userEmail, }); - if (!stripeCustomers.data.length) { - console.log('creating customer'); - customer = await stripe.customers.create({ - email: customerEmail, + + if (stripeCustomers.data.length === 0) { + console.log('Creating a new Stripe customer'); + return stripeClient.customers.create({ + email: userEmail, }); } else { - console.log('using existing customer'); - customer = stripeCustomers.data[0]; + console.log('Using an existing Stripe customer'); + return stripeCustomers.data[0]; } - return customer; } catch (error) { console.error(error); throw error; @@ -31,16 +32,17 @@ export async function fetchStripeCustomer(customerEmail: string) { interface CreateStripeCheckoutSessionParams { priceId: string; customerId: string; - mode: StripeMode; + mode: Stripe.Checkout.Session.Mode; } export async function createStripeCheckoutSession({ priceId, customerId, mode, -}: CreateStripeCheckoutSessionParams) { +}: CreateStripeCheckoutSessionParams): Promise { try { - return await stripe.checkout.sessions.create({ + return await stripeClient.checkout.sessions.create({ + customer: customerId, line_items: [ { price: priceId, @@ -48,14 +50,22 @@ export async function createStripeCheckoutSession({ }, ], mode: mode, - success_url: `${DOMAIN}/checkout?success=true`, - cancel_url: `${DOMAIN}/checkout?canceled=true`, + success_url: `${CLIENT_BASE_URL}/checkout?success=true`, + cancel_url: `${CLIENT_BASE_URL}/checkout?canceled=true`, automatic_tax: { enabled: true }, allow_promotion_codes: true, customer_update: { address: 'auto', }, - customer: customerId, + // Stripe automatically creates invoices for subscriptions. + // For one-time payments, we must enable them manually. + // Enabling invoices for subscriptions will cause an error. + invoice_creation: + mode === 'payment' + ? { + enabled: true, + } + : undefined, }); } catch (error) { console.error(error); diff --git a/template/app/src/payment/stripe/paymentDetails.ts b/template/app/src/payment/stripe/paymentDetails.ts index 96325ff02..489dcbb48 100644 --- a/template/app/src/payment/stripe/paymentDetails.ts +++ b/template/app/src/payment/stripe/paymentDetails.ts @@ -1,27 +1,59 @@ +import { PrismaClient } from '@prisma/client'; +import Stripe from 'stripe'; import type { SubscriptionStatus } from '../plans'; import { PaymentPlanId } from '../plans'; -import { PrismaClient } from '@prisma/client'; -export const updateUserStripePaymentDetails = async ( - { userStripeId, subscriptionPlan, subscriptionStatus, datePaid, numOfCreditsPurchased }: { - userStripeId: string; - subscriptionPlan?: PaymentPlanId; - subscriptionStatus?: SubscriptionStatus; - numOfCreditsPurchased?: number; - datePaid?: Date; - }, +export async function updateUserStripePaymentDetails( + paymentDetails: UpdateUserStripeOneTimePaymentDetails | UpdateUserStripeSubscriptionDetails, + userDelegate: PrismaClient['user'] +) { + if ('numOfCreditsPurchased' in paymentDetails) { + return updateUserStripeOneTimePaymentDetails(paymentDetails, userDelegate); + } else { + return updateUserStripeSubscriptionDetails(paymentDetails, userDelegate); + } +} + +interface UpdateUserStripeOneTimePaymentDetails { + customerId: Stripe.Customer['id']; + datePaid: Date; + numOfCreditsPurchased: number; +} + +function updateUserStripeOneTimePaymentDetails( + { customerId, datePaid, numOfCreditsPurchased }: UpdateUserStripeOneTimePaymentDetails, + userDelegate: PrismaClient['user'] +) { + return userDelegate.update({ + where: { + paymentProcessorUserId: customerId, + }, + data: { + datePaid, + credits: { increment: numOfCreditsPurchased }, + }, + }); +} + +interface UpdateUserStripeSubscriptionDetails { + customerId: Stripe.Customer['id']; + subscriptionStatus: SubscriptionStatus; + paymentPlanId?: PaymentPlanId; + datePaid?: Date; +} + +function updateUserStripeSubscriptionDetails( + { customerId, paymentPlanId, subscriptionStatus, datePaid }: UpdateUserStripeSubscriptionDetails, userDelegate: PrismaClient['user'] -) => { +) { return userDelegate.update({ where: { - paymentProcessorUserId: userStripeId + paymentProcessorUserId: customerId, }, data: { - paymentProcessorUserId: userStripeId, - subscriptionPlan, + subscriptionPlan: paymentPlanId, subscriptionStatus, datePaid, - credits: numOfCreditsPurchased !== undefined ? { increment: numOfCreditsPurchased } : undefined, }, }); -}; +} diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 4055d8827..8828d071e 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,43 +1,80 @@ +import Stripe from 'stripe'; +import type { + CreateCheckoutSessionArgs, + FetchCustomerPortalUrlArgs, + PaymentProcessor, +} from '../paymentProcessor'; import type { PaymentPlanEffect } from '../plans'; -import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, PaymentProcessor } from '../paymentProcessor' -import { fetchStripeCustomer, createStripeCheckoutSession } from './checkoutUtils'; -import { requireNodeEnvVar } from '../../server/utils'; -import { stripeWebhook, stripeMiddlewareConfigFn } from './webhook'; - -export type StripeMode = 'subscription' | 'payment'; +import { createStripeCheckoutSession, ensureStripeCustomer } from './checkoutUtils'; +import { stripeClient } from './stripeClient'; +import { stripeMiddlewareConfigFn, stripeWebhook } from './webhook'; export const stripePaymentProcessor: PaymentProcessor = { id: 'stripe', - createCheckoutSession: async ({ userId, userEmail, paymentPlan, prismaUserDelegate }: CreateCheckoutSessionArgs) => { - const customer = await fetchStripeCustomer(userEmail); - const stripeSession = await createStripeCheckoutSession({ - priceId: paymentPlan.getPaymentProcessorPlanId(), - customerId: customer.id, - mode: paymentPlanEffectToStripeMode(paymentPlan.effect), - }); + createCheckoutSession: async ({ + userId, + userEmail, + paymentPlan, + prismaUserDelegate, + }: CreateCheckoutSessionArgs) => { + const customer = await ensureStripeCustomer(userEmail); + await prismaUserDelegate.update({ where: { - id: userId + id: userId, }, data: { - paymentProcessorUserId: customer.id - } - }) - if (!stripeSession.url) throw new Error('Error creating Stripe Checkout Session'); - const session = { - url: stripeSession.url, - id: stripeSession.id, + paymentProcessorUserId: customer.id, + }, + }); + + const stripeSession = await createStripeCheckoutSession({ + customerId: customer.id, + priceId: paymentPlan.getPaymentProcessorPlanId(), + mode: paymentPlanEffectToStripeCheckoutSessionMode(paymentPlan.effect), + }); + + if (!stripeSession.url) { + throw new Error('Stripe checkout session URL is missing. Checkout session might not be active.'); + } + + return { + session: { + url: stripeSession.url, + id: stripeSession.id, + }, }; - return { session }; }, - fetchCustomerPortalUrl: async (_args: FetchCustomerPortalUrlArgs) => - requireNodeEnvVar('STRIPE_CUSTOMER_PORTAL_URL'), + fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { + const user = await args.prismaUserDelegate.findUniqueOrThrow({ + where: { + id: args.userId, + }, + select: { + paymentProcessorUserId: true, + }, + }); + + if (!user.paymentProcessorUserId) { + return null; + } + + const CLIENT_BASE_URL = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000'; + const session = await stripeClient.billingPortal.sessions.create({ + customer: user.paymentProcessorUserId, + return_url: `${CLIENT_BASE_URL}/account`, + }); + + return session.url; + }, webhook: stripeWebhook, webhookMiddlewareConfigFn: stripeMiddlewareConfigFn, }; -function paymentPlanEffectToStripeMode(planEffect: PaymentPlanEffect): StripeMode { - const effectToMode: Record = { +function paymentPlanEffectToStripeCheckoutSessionMode( + planEffect: PaymentPlanEffect +): Stripe.Checkout.Session.Mode { + const effectToMode: Record = { subscription: 'subscription', credits: 'payment', }; diff --git a/template/app/src/payment/stripe/stripeClient.ts b/template/app/src/payment/stripe/stripeClient.ts index 1c1acaf2e..318fc10fa 100644 --- a/template/app/src/payment/stripe/stripeClient.ts +++ b/template/app/src/payment/stripe/stripeClient.ts @@ -1,7 +1,7 @@ import Stripe from 'stripe'; import { requireNodeEnvVar } from '../../server/utils'; -export const stripe = new Stripe(requireNodeEnvVar('STRIPE_API_KEY'), { +export const stripeClient = new Stripe(requireNodeEnvVar('STRIPE_API_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` diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 638c45041..afbf638ee 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -1,56 +1,50 @@ -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 type { Stripe } from 'stripe'; -import { stripe } from './stripeClient'; -import { paymentPlans, PaymentPlanId, SubscriptionStatus, type PaymentPlanEffect } from '../plans'; -import { updateUserStripePaymentDetails } from './paymentDetails'; +import { HttpError, type MiddlewareConfigFn } from 'wasp/server'; +import { type PaymentsWebhook } from 'wasp/server/api'; import { emailSender } from 'wasp/server/email'; -import { assertUnreachable } from '../../shared/utils'; import { requireNodeEnvVar } from '../../server/utils'; -import { - parseWebhookPayload, - type InvoicePaidData, - type SessionCompletedData, - type SubscriptionDeletedData, - type SubscriptionUpdatedData, -} from './webhookPayload'; +import { assertUnreachable } from '../../shared/utils'; import { UnhandledWebhookEventError } from '../errors'; +import { PaymentPlanId, paymentPlans, SubscriptionStatus } from '../plans'; +import { updateUserStripePaymentDetails } from './paymentDetails'; +import { stripeClient } from './stripeClient'; export const stripeWebhook: PaymentsWebhook = async (request, response, context) => { + const prismaUserDelegate = context.entities.User; try { - 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; + const stripeEvent = constructStripeEvent(request); + + switch (stripeEvent.type) { case 'invoice.paid': - await handleInvoicePaid(data, prismaUserDelegate); + await handleInvoicePaid(stripeEvent, prismaUserDelegate); break; case 'customer.subscription.updated': - await handleCustomerSubscriptionUpdated(data, prismaUserDelegate); + await handleCustomerSubscriptionUpdated(stripeEvent, prismaUserDelegate); break; case 'customer.subscription.deleted': - await handleCustomerSubscriptionDeleted(data, prismaUserDelegate); + await handleCustomerSubscriptionDeleted(stripeEvent, 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); + // When deploying your app, you configure your webhook in the Stripe dashboard + // to only send the events that you're handling above. + // 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. + // These can be ignored without any issues. + if (process.env.NODE_ENV === 'production') { + throw new UnhandledWebhookEventError(stripeEvent.type); + } } - return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook + return response.status(204).send(); // any 2xx HTTP response is fine } catch (err) { if (err instanceof UnhandledWebhookEventError) { - console.error(err.message); + console.error('Unhandled Stripe webhook event: ', err.message); return response.status(422).json({ error: err.message }); } - console.error('Webhook error:', err); + console.error('Stripe webhook error:', err); if (err instanceof HttpError) { return response.status(err.statusCode).json({ error: err.message }); } else { @@ -60,184 +54,182 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) }; function constructStripeEvent(request: express.Request): Stripe.Event { + const stripeWebhookSecret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET'); + const stripeSignature = request.headers['stripe-signature']; + if (!stripeSignature) { + throw new HttpError(400, 'Stripe webhook signature not provided'); + } + 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); + return stripeClient.webhooks.constructEvent(request.body, stripeSignature, stripeWebhookSecret); } catch (err) { - throw new HttpError(500, 'Error constructing Stripe webhook event'); + throw new HttpError(400, 'Error constructing Stripe webhook event'); } } +/** + * Stripe requires the raw request to construct the event successfully. + * That is we we delete the Wasp's default 'express.json' middleware + * and replace it with 'express.raw' middleware. + */ 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. middlewareConfig.delete('express.json'); middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); return middlewareConfig; }; -// Here we only update the user's payment details, and confirm credits because Stripe does not send invoices for one-time payments. -// NOTE: If you're accepting async payment methods like bank transfers or SEPA and not just card payments -// which are synchronous, checkout session completed could potentially result in a pending payment. -// If so, use the checkout.session.async_payment_succeeded event to confirm the payment. -async function handleCheckoutSessionCompleted( - session: SessionCompletedData, +/** + * We create invoice both for subscriptions and one-time payments. + * So we can handle all payment scenarios in a unified way. + * + * Also provides one-time payment invoices inside of the customer portal. + */ +async function handleInvoicePaid( + event: Stripe.InvoicePaidEvent, prismaUserDelegate: PrismaClient['user'] -) { - const isSuccessfulOneTimePayment = session.mode === 'payment' && session.payment_status === 'paid'; - if (isSuccessfulOneTimePayment) { - await saveSuccessfulOneTimePayment(session, prismaUserDelegate); +): Promise { + const invoice = event.data.object; + const customerId = getCustomerId(invoice.customer); + const datePaid = getInvoicePaidAtDate(invoice); + const priceId = getItemsPriceId(invoice.lines.data); + const paymentPlanId = getPaymentPlanIdByPriceId(priceId); + + switch (paymentPlanId) { + case PaymentPlanId.Credits10: + updateUserStripePaymentDetails( + { customerId, numOfCreditsPurchased: paymentPlans.credits10.effect.amount, datePaid }, + prismaUserDelegate + ); + break; + case PaymentPlanId.Pro: + case PaymentPlanId.Hobby: + updateUserStripePaymentDetails( + { + customerId, + datePaid, + paymentPlanId, + subscriptionStatus: SubscriptionStatus.Active, + }, + prismaUserDelegate + ); + break; + default: + assertUnreachable(paymentPlanId); } } -async function saveSuccessfulOneTimePayment( - session: SessionCompletedData, - prismaUserDelegate: PrismaClient['user'] -) { - const userStripeId = session.customer; - const lineItems = await getCheckoutLineItemsBySessionId(session.id); - const lineItemPriceId = extractPriceId(lineItems); - const planId = getPlanIdByPriceId(lineItemPriceId); - const plan = paymentPlans[planId]; - const { numOfCreditsPurchased } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect }); - return updateUserStripePaymentDetails( - { userStripeId, numOfCreditsPurchased, datePaid: new Date() }, - prismaUserDelegate - ); -} - -// This is called when a subscription is successfully purchased or renewed and payment succeeds. -// Invoices are not created for one-time payments, so we handle them above. -async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) { - await saveActiveSubscription(invoice, prismaUserDelegate); -} - -async function saveActiveSubscription(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) { - const userStripeId = invoice.customer; - const datePaid = new Date(invoice.period_start * 1000); - const priceId = extractPriceId(invoice.lines); - const subscriptionPlan = getPlanIdByPriceId(priceId); - return updateUserStripePaymentDetails( - { userStripeId, datePaid, subscriptionPlan, subscriptionStatus: SubscriptionStatus.Active }, - prismaUserDelegate - ); -} - async function handleCustomerSubscriptionUpdated( - subscription: SubscriptionUpdatedData, + event: Stripe.CustomerSubscriptionUpdatedEvent, prismaUserDelegate: PrismaClient['user'] -) { - const userStripeId = subscription.customer; - let subscriptionStatus: SubscriptionStatus | undefined; - const priceId = extractPriceId(subscription.items); - const subscriptionPlan = getPlanIdByPriceId(priceId); +): Promise { + const subscription = event.data.object; - // 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. + // There are other subscription statuses, such as `trialing` that we are not handling. + // If you'd like to handle more statuses, you can add more cases below. + // Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well. + let subscriptionStatus: SubscriptionStatus | undefined; if (subscription.status === SubscriptionStatus.Active) { - subscriptionStatus = subscription.cancel_at_period_end - ? SubscriptionStatus.CancelAtPeriodEnd - : SubscriptionStatus.Active; + subscriptionStatus = SubscriptionStatus.Active; + if (subscription.cancel_at_period_end) { + subscriptionStatus = SubscriptionStatus.CancelAtPeriodEnd; + } } else if (subscription.status === SubscriptionStatus.PastDue) { subscriptionStatus = SubscriptionStatus.PastDue; + } else { + return; } - if (subscriptionStatus) { - const user = await updateUserStripePaymentDetails( - { userStripeId, subscriptionPlan, 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...', - }); - } + + const customerId = getCustomerId(subscription.customer); + const priceId = getItemsPriceId(subscription.items.data); + const paymentPlanId = getPaymentPlanIdByPriceId(priceId); + + const user = await updateUserStripePaymentDetails( + { customerId, paymentPlanId, 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; } } async function handleCustomerSubscriptionDeleted( - subscription: SubscriptionDeletedData, + event: Stripe.CustomerSubscriptionDeletedEvent, prismaUserDelegate: PrismaClient['user'] -) { - const userStripeId = subscription.customer; - return updateUserStripePaymentDetails( - { userStripeId, subscriptionStatus: SubscriptionStatus.Deleted }, +): Promise { + const subscription = event.data.object; + const customerId = getCustomerId(subscription.customer); + + updateUserStripePaymentDetails( + { customerId, subscriptionStatus: SubscriptionStatus.Deleted }, prismaUserDelegate ); } -// We only expect one line item, but if you set up a product with multiple prices, you should change this function to handle them. -function extractPriceId( - items: Stripe.ApiList | SubscriptionUpdatedData['items'] | InvoicePaidData['lines'] -): string { - if (items.data.length === 0) { +function getCustomerId( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null +): Stripe.Customer['id'] { + if (!customer) { + throw new Error('Customer is missing'); + } else if (typeof customer === 'string') { + return customer; + } else { + return customer.id; + } +} + +function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { + if (!invoice.status_transitions.paid_at) { + throw new Error('Invoice has not been paid yet'); + } + + // Stripe returns timestamps in seconds (Unix time), + // so we multiply by 1000 to convert to milliseconds. + return new Date(invoice.status_transitions.paid_at * 1000); +} + +/** + * We only expect one line item, but if you set up a product with multiple prices, you should change this function to handle them. + */ +function getItemsPriceId(items: Stripe.InvoiceLineItem[] | Stripe.SubscriptionItem[]): Stripe.Price['id'] { + if (items.length === 0) { throw new HttpError(400, 'No items in stripe event object'); } - if (items.data.length > 1) { + if (items.length > 1) { throw new HttpError(400, 'More than one item in stripe event object'); } - const item = items.data[0]; - // The 'price' property is found on SubscriptionItem and LineItem. + const item = items[0]; + + // SubscriptionItem if ('price' in item && item.price?.id) { return item.price.id; } - // The 'pricing' property is found on InvoiceLineItem. + // InvoiceLineItem if ('pricing' in item) { const priceId = item.pricing?.price_details?.price; if (priceId) { return priceId; } } - throw new HttpError(400, 'Unable to extract price id from item'); -} -async function getCheckoutLineItemsBySessionId(sessionId: string) { - const { line_items } = await stripe.checkout.sessions.retrieve(sessionId, { - expand: ['line_items'], - }); - if (!line_items) { - throw new HttpError(400, 'No line items found in checkout session'); - } - return line_items; + throw new Error('Unable to extract price id from items'); } -function getPlanIdByPriceId(priceId: string): PaymentPlanId { +function getPaymentPlanIdByPriceId(priceId: string): PaymentPlanId { const planId = Object.values(PaymentPlanId).find( (planId) => paymentPlans[planId].getPaymentProcessorPlanId() === priceId ); if (!planId) { - throw new Error(`No plan with Stripe price id ${priceId}`); + throw new Error(`No payment plan with Stripe price id ${priceId}`); } return planId; } - -function getPlanEffectPaymentDetails({ - planId, - planEffect, -}: { - planId: PaymentPlanId; - planEffect: PaymentPlanEffect; -}): { - subscriptionPlan: PaymentPlanId | undefined; - numOfCreditsPurchased: number | undefined; -} { - switch (planEffect.kind) { - case 'subscription': - return { subscriptionPlan: planId, numOfCreditsPurchased: undefined }; - case 'credits': - return { subscriptionPlan: undefined, numOfCreditsPurchased: planEffect.amount }; - default: - assertUnreachable(planEffect); - } -} diff --git a/template/app/src/shared/utils.ts b/template/app/src/shared/utils.ts index 7a5180e50..f25554ac2 100644 --- a/template/app/src/shared/utils.ts +++ b/template/app/src/shared/utils.ts @@ -2,8 +2,7 @@ * 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 { +export function assertUnreachable(_: never): never { throw Error('This code should be unreachable'); } From 3df17a293e501b454a4917dd05d39621a6334cf0 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 17 Sep 2025 16:58:04 +0200 Subject: [PATCH 02/44] changes --- .../app/src/payment/stripe/checkoutUtils.ts | 4 +- .../app/src/payment/stripe/stripeClient.ts | 32 +++-- template/app/src/payment/stripe/webhook.ts | 117 ++++++++---------- .../app/src/payment/stripe/webhookPayload.ts | 108 ---------------- 4 files changed, 80 insertions(+), 181 deletions(-) delete mode 100644 template/app/src/payment/stripe/webhookPayload.ts diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 2b9d11a5b..e67a09637 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -30,8 +30,8 @@ export async function ensureStripeCustomer(userEmail: string): Promise { + middlewareConfig.delete('express.json'); + middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); + return middlewareConfig; +}; + export const stripeWebhook: PaymentsWebhook = async (request, response, context) => { const prismaUserDelegate = context.entities.User; try { @@ -37,11 +46,11 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) throw new UnhandledWebhookEventError(stripeEvent.type); } } - return response.status(204).send(); // any 2xx HTTP response is fine + return response.status(204).send(); } catch (err) { if (err instanceof UnhandledWebhookEventError) { - console.error('Unhandled Stripe webhook event: ', err.message); - return response.status(422).json({ error: err.message }); + response.status(204).send(); + throw err; } console.error('Stripe webhook error:', err); @@ -60,44 +69,26 @@ function constructStripeEvent(request: express.Request): Stripe.Event { throw new HttpError(400, 'Stripe webhook signature not provided'); } - try { - return stripeClient.webhooks.constructEvent(request.body, stripeSignature, stripeWebhookSecret); - } catch (err) { - throw new HttpError(400, 'Error constructing Stripe webhook event'); - } + return stripeClient.webhooks.constructEvent(request.body, stripeSignature, stripeWebhookSecret); } -/** - * Stripe requires the raw request to construct the event successfully. - * That is we we delete the Wasp's default 'express.json' middleware - * and replace it with 'express.raw' middleware. - */ -export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => { - middlewareConfig.delete('express.json'); - middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); - return middlewareConfig; -}; - -/** - * We create invoice both for subscriptions and one-time payments. - * So we can handle all payment scenarios in a unified way. - * - * Also provides one-time payment invoices inside of the customer portal. - */ async function handleInvoicePaid( event: Stripe.InvoicePaidEvent, prismaUserDelegate: PrismaClient['user'] ): Promise { const invoice = event.data.object; const customerId = getCustomerId(invoice.customer); - const datePaid = getInvoicePaidAtDate(invoice); - const priceId = getItemsPriceId(invoice.lines.data); - const paymentPlanId = getPaymentPlanIdByPriceId(priceId); + const invoicePaidAtDate = getInvoicePaidAtDate(invoice); + const paymentPlanId = getPaymentPlanIdByPriceId(getInvoicePriceId(invoice)); switch (paymentPlanId) { case PaymentPlanId.Credits10: updateUserStripePaymentDetails( - { customerId, numOfCreditsPurchased: paymentPlans.credits10.effect.amount, datePaid }, + { + customerId, + datePaid: invoicePaidAtDate, + numOfCreditsPurchased: paymentPlans.credits10.effect.amount, + }, prismaUserDelegate ); break; @@ -106,7 +97,7 @@ async function handleInvoicePaid( updateUserStripePaymentDetails( { customerId, - datePaid, + datePaid: invoicePaidAtDate, paymentPlanId, subscriptionStatus: SubscriptionStatus.Active, }, @@ -118,6 +109,23 @@ async function handleInvoicePaid( } } +/** + * We only expect one line item, if your workflow expected more, you should change this function to handle them. + */ +function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price['id'] { + const invoiceLineItems = invoice.lines.data; + if (invoiceLineItems.length === 0 || invoiceLineItems.length > 1) { + throw new HttpError(400, 'There should be exactly one line item in Stripe invoice'); + } + + const priceId = invoiceLineItems[0].pricing?.price_details?.price; + if (!priceId) { + throw new Error('Unable to extract price id from items'); + } + + return priceId; +} + async function handleCustomerSubscriptionUpdated( event: Stripe.CustomerSubscriptionUpdatedEvent, prismaUserDelegate: PrismaClient['user'] @@ -125,8 +133,6 @@ async function handleCustomerSubscriptionUpdated( const subscription = event.data.object; // There are other subscription statuses, such as `trialing` that we are not handling. - // If you'd like to handle more statuses, you can add more cases below. - // Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well. let subscriptionStatus: SubscriptionStatus | undefined; if (subscription.status === SubscriptionStatus.Active) { subscriptionStatus = SubscriptionStatus.Active; @@ -140,8 +146,7 @@ async function handleCustomerSubscriptionUpdated( } const customerId = getCustomerId(subscription.customer); - const priceId = getItemsPriceId(subscription.items.data); - const paymentPlanId = getPaymentPlanIdByPriceId(priceId); + const paymentPlanId = getPaymentPlanIdByPriceId(getSubscriptionPriceId(subscription)); const user = await updateUserStripePaymentDetails( { customerId, paymentPlanId, subscriptionStatus }, @@ -160,6 +165,18 @@ async function handleCustomerSubscriptionUpdated( } } +/** + * We only expect one subscription item, if your workflow expected more, you should change this function to handle them. + */ +function getSubscriptionPriceId(subscription: Stripe.Subscription): Stripe.Price['id'] { + const subscriptionItems = subscription.items.data; + if (subscriptionItems.length === 0 || subscriptionItems.length > 1) { + throw new HttpError(400, 'There should be exactly one subscription item in Stripe subscription'); + } + + return subscriptionItems[0].price.id; +} + async function handleCustomerSubscriptionDeleted( event: Stripe.CustomerSubscriptionDeletedEvent, prismaUserDelegate: PrismaClient['user'] @@ -195,41 +212,13 @@ function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { return new Date(invoice.status_transitions.paid_at * 1000); } -/** - * We only expect one line item, but if you set up a product with multiple prices, you should change this function to handle them. - */ -function getItemsPriceId(items: Stripe.InvoiceLineItem[] | Stripe.SubscriptionItem[]): Stripe.Price['id'] { - if (items.length === 0) { - throw new HttpError(400, 'No items in stripe event object'); - } - if (items.length > 1) { - throw new HttpError(400, 'More than one item in stripe event object'); - } - - const item = items[0]; - - // SubscriptionItem - if ('price' in item && item.price?.id) { - return item.price.id; - } - - // InvoiceLineItem - if ('pricing' in item) { - const priceId = item.pricing?.price_details?.price; - if (priceId) { - return priceId; - } - } - - throw new Error('Unable to extract price id from items'); -} - function getPaymentPlanIdByPriceId(priceId: string): PaymentPlanId { const planId = Object.values(PaymentPlanId).find( - (planId) => paymentPlans[planId].getPaymentProcessorPlanId() === priceId + (paymentPlanId) => paymentPlans[paymentPlanId].getPaymentProcessorPlanId() === priceId ); if (!planId) { throw new Error(`No payment plan with Stripe price id ${priceId}`); } + return planId; } diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts deleted file mode 100644 index 02257781e..000000000 --- a/template/app/src/payment/stripe/webhookPayload.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 '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(), - payment_status: z.enum(['paid', 'unpaid', 'no_payment_required']), - mode: z.enum(['payment', 'subscription']), -}); - -/** - * This is a subtype of - * @type import('stripe').Stripe.Invoice - */ -const invoicePaidDataSchema = z.object({ - id: z.string(), - customer: z.string(), - period_start: z.number(), - lines: z.object({ - data: z.array( - z.object({ - pricing: z.object({ price_details: z.object({ price: 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; - -export type InvoicePaidData = z.infer; - -export type SubscriptionUpdatedData = z.infer; - -export type SubscriptionDeletedData = z.infer; From d0a2e157645c4b18873980d2552b53c38f8991e9 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 17 Sep 2025 17:03:43 +0200 Subject: [PATCH 03/44] further improvements --- .../app/src/payment/stripe/checkoutUtils.ts | 55 +++++++++---------- .../src/payment/stripe/paymentProcessor.ts | 5 +- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index e67a09637..62fb50267 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -40,35 +40,30 @@ export async function createStripeCheckoutSession({ customerId, mode, }: CreateStripeCheckoutSessionParams): Promise { - try { - return await stripeClient.checkout.sessions.create({ - customer: customerId, - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: mode, - success_url: `${CLIENT_BASE_URL}/checkout?success=true`, - cancel_url: `${CLIENT_BASE_URL}/checkout?canceled=true`, - automatic_tax: { enabled: true }, - allow_promotion_codes: true, - customer_update: { - address: 'auto', + return await stripeClient.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, }, - // Stripe automatically creates invoices for subscriptions. - // For one-time payments, we must enable them manually. - // Enabling invoices for subscriptions will cause an error. - invoice_creation: - mode === 'payment' - ? { - enabled: true, - } - : undefined, - }); - } catch (error) { - console.error(error); - throw error; - } + ], + mode, + success_url: `${CLIENT_BASE_URL}/checkout?success=true`, + cancel_url: `${CLIENT_BASE_URL}/checkout?canceled=true`, + automatic_tax: { enabled: true }, + allow_promotion_codes: true, + customer_update: { + address: 'auto', + }, + // Stripe automatically creates invoices for subscriptions. + // For one-time payments, we must enable them manually. + // However, enabling invoices for subscriptions will throw an error. + invoice_creation: + mode === 'payment' + ? { + enabled: true, + } + : undefined, + }); } diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 8828d071e..21561dfe8 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,4 +1,5 @@ import Stripe from 'stripe'; +import { requireNodeEnvVar } from '../../server/utils'; import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, @@ -59,10 +60,10 @@ export const stripePaymentProcessor: PaymentProcessor = { return null; } - const CLIENT_BASE_URL = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000'; + const webClientUrl = requireNodeEnvVar('WASP_WEB_CLIENT_URL'); const session = await stripeClient.billingPortal.sessions.create({ customer: user.paymentProcessorUserId, - return_url: `${CLIENT_BASE_URL}/account`, + return_url: `${webClientUrl}/account`, }); return session.url; From 156083384c4c7b673efeec0bf6f18400eba8d679 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 09:33:09 +0200 Subject: [PATCH 04/44] remove typ catch --- .../app/src/payment/stripe/checkoutUtils.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 62fb50267..d28f8990d 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -9,23 +9,18 @@ const CLIENT_BASE_URL = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:300 * Implements email uniqueness logic since Stripe doesn't enforce unique emails. */ export async function ensureStripeCustomer(userEmail: string): Promise { - try { - const stripeCustomers = await stripeClient.customers.list({ + const stripeCustomers = await stripeClient.customers.list({ + email: userEmail, + }); + + if (stripeCustomers.data.length === 0) { + console.log('Creating a new Stripe customer'); + return stripeClient.customers.create({ email: userEmail, }); - - if (stripeCustomers.data.length === 0) { - console.log('Creating a new Stripe customer'); - return stripeClient.customers.create({ - email: userEmail, - }); - } else { - console.log('Using an existing Stripe customer'); - return stripeCustomers.data[0]; - } - } catch (error) { - console.error(error); - throw error; + } else { + console.log('Using an existing Stripe customer'); + return stripeCustomers.data[0]; } } From fec25ab2fd7d5caf814f6bb76cd984105da722a6 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 16:07:48 +0200 Subject: [PATCH 05/44] split payment details funcs --- .../app/src/payment/stripe/paymentDetails.ts | 17 +++-------------- template/app/src/payment/stripe/webhook.ts | 10 +++++----- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/template/app/src/payment/stripe/paymentDetails.ts b/template/app/src/payment/stripe/paymentDetails.ts index 489dcbb48..73e3b37f1 100644 --- a/template/app/src/payment/stripe/paymentDetails.ts +++ b/template/app/src/payment/stripe/paymentDetails.ts @@ -3,24 +3,13 @@ import Stripe from 'stripe'; import type { SubscriptionStatus } from '../plans'; import { PaymentPlanId } from '../plans'; -export async function updateUserStripePaymentDetails( - paymentDetails: UpdateUserStripeOneTimePaymentDetails | UpdateUserStripeSubscriptionDetails, - userDelegate: PrismaClient['user'] -) { - if ('numOfCreditsPurchased' in paymentDetails) { - return updateUserStripeOneTimePaymentDetails(paymentDetails, userDelegate); - } else { - return updateUserStripeSubscriptionDetails(paymentDetails, userDelegate); - } -} - interface UpdateUserStripeOneTimePaymentDetails { customerId: Stripe.Customer['id']; datePaid: Date; numOfCreditsPurchased: number; } -function updateUserStripeOneTimePaymentDetails( +export function updateUserStripeOneTimePaymentDetails( { customerId, datePaid, numOfCreditsPurchased }: UpdateUserStripeOneTimePaymentDetails, userDelegate: PrismaClient['user'] ) { @@ -37,12 +26,12 @@ function updateUserStripeOneTimePaymentDetails( interface UpdateUserStripeSubscriptionDetails { customerId: Stripe.Customer['id']; + datePaid?: Date; subscriptionStatus: SubscriptionStatus; paymentPlanId?: PaymentPlanId; - datePaid?: Date; } -function updateUserStripeSubscriptionDetails( +export function updateUserStripeSubscriptionDetails( { customerId, paymentPlanId, subscriptionStatus, datePaid }: UpdateUserStripeSubscriptionDetails, userDelegate: PrismaClient['user'] ) { diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index bfc627cb5..69cd124a4 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -8,7 +8,7 @@ import { requireNodeEnvVar } from '../../server/utils'; import { assertUnreachable } from '../../shared/utils'; import { UnhandledWebhookEventError } from '../errors'; import { PaymentPlanId, paymentPlans, SubscriptionStatus } from '../plans'; -import { updateUserStripePaymentDetails } from './paymentDetails'; +import { updateUserStripeOneTimePaymentDetails, updateUserStripeSubscriptionDetails } from './paymentDetails'; import { stripeClient } from './stripeClient'; /** @@ -83,7 +83,7 @@ async function handleInvoicePaid( switch (paymentPlanId) { case PaymentPlanId.Credits10: - updateUserStripePaymentDetails( + updateUserStripeOneTimePaymentDetails( { customerId, datePaid: invoicePaidAtDate, @@ -94,7 +94,7 @@ async function handleInvoicePaid( break; case PaymentPlanId.Pro: case PaymentPlanId.Hobby: - updateUserStripePaymentDetails( + updateUserStripeSubscriptionDetails( { customerId, datePaid: invoicePaidAtDate, @@ -148,7 +148,7 @@ async function handleCustomerSubscriptionUpdated( const customerId = getCustomerId(subscription.customer); const paymentPlanId = getPaymentPlanIdByPriceId(getSubscriptionPriceId(subscription)); - const user = await updateUserStripePaymentDetails( + const user = await updateUserStripeSubscriptionDetails( { customerId, paymentPlanId, subscriptionStatus }, prismaUserDelegate ); @@ -184,7 +184,7 @@ async function handleCustomerSubscriptionDeleted( const subscription = event.data.object; const customerId = getCustomerId(subscription.customer); - updateUserStripePaymentDetails( + updateUserStripeSubscriptionDetails( { customerId, subscriptionStatus: SubscriptionStatus.Deleted }, prismaUserDelegate ); From 05b0fad2d18f44df7f2cf58e6f4f3d592cafccd9 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 16:33:23 +0200 Subject: [PATCH 06/44] add awaits --- template/app/src/payment/stripe/webhook.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 69cd124a4..caa122efd 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -83,7 +83,7 @@ async function handleInvoicePaid( switch (paymentPlanId) { case PaymentPlanId.Credits10: - updateUserStripeOneTimePaymentDetails( + await updateUserStripeOneTimePaymentDetails( { customerId, datePaid: invoicePaidAtDate, @@ -94,7 +94,7 @@ async function handleInvoicePaid( break; case PaymentPlanId.Pro: case PaymentPlanId.Hobby: - updateUserStripeSubscriptionDetails( + await updateUserStripeSubscriptionDetails( { customerId, datePaid: invoicePaidAtDate, @@ -184,7 +184,7 @@ async function handleCustomerSubscriptionDeleted( const subscription = event.data.object; const customerId = getCustomerId(subscription.customer); - updateUserStripeSubscriptionDetails( + await updateUserStripeSubscriptionDetails( { customerId, subscriptionStatus: SubscriptionStatus.Deleted }, prismaUserDelegate ); From 91e2f0d0ebf98081fffe9510e5317b3a3c33252d Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 16:40:31 +0200 Subject: [PATCH 07/44] name --- opensaas-sh/blog/README.md | 2 +- ...0-turboreel-os-ai-video-generator-built-with-open-saas.mdx | 4 ++-- opensaas-sh/blog/src/content/docs/guides/deploying.mdx | 2 +- .../blog/src/content/docs/guides/payments-integration.mdx | 2 +- opensaas-sh/blog/src/content/docs/start/getting-started.mdx | 2 +- opensaas-sh/blog/src/content/docs/start/guided-tour.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/opensaas-sh/blog/README.md b/opensaas-sh/blog/README.md index 510735c92..cc643d03a 100644 --- a/opensaas-sh/blog/README.md +++ b/opensaas-sh/blog/README.md @@ -1,4 +1,4 @@ -# OpenSaaS Docs and Blog +# Open SaaS Docs and Blog This is the docs and blog for the OpenSaaS.sh website, [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) diff --git a/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx b/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx index 059944196..f1054572f 100644 --- a/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx +++ b/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx @@ -38,7 +38,7 @@ Here's a video presenting Open SaaS, generated with TurboReel 🐝 ## TurboReel's Tech Stack -TurboReel lets users generate short explainer videos with minimal effort. Starting with a single text prompt describing the video's purpose (e.g. “Create a video on building your SaaS with OpenSaaS”), you can produce professional grade TikTok and YT shorts without needing any video editing skills. +TurboReel lets users generate short explainer videos with minimal effort. Starting with a single text prompt describing the video's purpose (e.g. “Create a video on building your SaaS with OpenS aaS”), you can produce professional grade TikTok and YT shorts without needing any video editing skills. @@ -116,7 +116,7 @@ app myApp { ### Out-of-the-box Stripe integration -Another significant advantage for Peter was how Open SaaS handled third-party integrations. Setting up services like [**Stripe for payments**](https://docs.opensaas.sh/guides/payments-integration/) often requires a lot of effort, but Wasp's OpenSaaS streamlined the process - you just need to add your API key and you're good to go. +Another significant advantage for Peter was how Open SaaS handled third-party integrations. Setting up services like [**Stripe for payments**](https://docs.opensaas.sh/guides/payments-integration/) often requires a lot of effort, but Wasp's Open SaaS streamlined the process - you just need to add your API key and you're good to go. > *"Payments are usually a huge headache, but Open SaaS made it so smooth. I didn't have to spend weeks integrating Stripe—it just worked. That gave me more time to focus on TurboReel's core functionality.*" diff --git a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx index 257db56e1..3f5cd9ca6 100644 --- a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx @@ -175,7 +175,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, { 2. click on `+ add endpoint` 3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook` listen events -4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts): +4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find handled in [`src/payment/stripe/webhook.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhook.ts): signing secret 5. after that, go to the webhook you just created and `reveal` the new signing secret. 6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable.
If you've deployed to Fly.io, you can do that easily with the following command: diff --git a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx index be7926f07..a9f9664fa 100644 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx @@ -99,7 +99,7 @@ You can create a test customer directly in the [Stripe Dashboard](https://dashbo - Click on the `Add a customer` button and fill in the relevant information for your test customer. -Alternatively, OpenSasS will automatically create a test customer the first time a user starts a checkout session. +Alternatively, Open SasS will automatically create a test customer the first time a user starts a checkout session. This customer is linked to the email address associated with your app's user. ### Set up the Customer Portal diff --git a/opensaas-sh/blog/src/content/docs/start/getting-started.mdx b/opensaas-sh/blog/src/content/docs/start/getting-started.mdx index 7bbc717c4..df04c4fb0 100644 --- a/opensaas-sh/blog/src/content/docs/start/getting-started.mdx +++ b/opensaas-sh/blog/src/content/docs/start/getting-started.mdx @@ -145,7 +145,7 @@ You can install the Wasp VSCode extension by searching for "Wasp" in the Extensi ## Setting up your SaaS app -### Cloning the OpenSaaS template +### Cloning the Open SaaS template From the directory where you'd like to create your new project run: ```sh diff --git a/opensaas-sh/blog/src/content/docs/start/guided-tour.md b/opensaas-sh/blog/src/content/docs/start/guided-tour.md index 7b22cf681..34facc663 100644 --- a/opensaas-sh/blog/src/content/docs/start/guided-tour.md +++ b/opensaas-sh/blog/src/content/docs/start/guided-tour.md @@ -53,7 +53,7 @@ We've structured this full-stack app template vertically (by feature). That mean Let's check out what's in the `app` folder in more detail: :::caution[v0.13 and below] -If you are using an older version of the OpenSaaS template with Wasp `v0.13.x` or below, you may see a slightly different file structure. But don't worry, the vast majority of the code and features are the same! 😅 +If you are using an older version of the Open SaaS template with Wasp `v0.13.x` or below, you may see a slightly different file structure. But don't worry, the vast majority of the code and features are the same! 😅 ::: ```sh From 0cbe24c642618a90a7e3db7dc344ac0e1d5fbe34 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 16:50:37 +0200 Subject: [PATCH 08/44] formatting --- template/app/src/payment/plans.ts | 45 +++--- .../app/src/payment/stripe/checkoutUtils.ts | 4 +- .../app/src/payment/stripe/paymentDetails.ts | 29 ++-- .../src/payment/stripe/paymentProcessor.ts | 33 +++-- .../app/src/payment/stripe/stripeClient.ts | 8 +- template/app/src/payment/stripe/webhook.ts | 135 +++++++++++------- 6 files changed, 156 insertions(+), 98 deletions(-) diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts index 3df1719e6..d2c3935d5 100644 --- a/template/app/src/payment/plans.ts +++ b/template/app/src/payment/plans.ts @@ -1,16 +1,16 @@ -import { requireNodeEnvVar } from '../server/utils'; +import { requireNodeEnvVar } from "../server/utils"; export enum SubscriptionStatus { - PastDue = 'past_due', - CancelAtPeriodEnd = 'cancel_at_period_end', - Active = 'active', - Deleted = 'deleted', + PastDue = "past_due", + CancelAtPeriodEnd = "cancel_at_period_end", + Active = "active", + Deleted = "deleted", } export enum PaymentPlanId { - Hobby = 'hobby', - Pro = 'pro', - Credits10 = 'credits10', + Hobby = "hobby", + Pro = "pro", + Credits10 = "credits10", } export interface PaymentPlan { @@ -23,28 +23,33 @@ export interface PaymentPlan { effect: PaymentPlanEffect; } -export type PaymentPlanEffect = { kind: 'subscription' } | { kind: 'credits'; amount: number }; +export type PaymentPlanEffect = + | { kind: "subscription" } + | { kind: "credits"; amount: number }; export const paymentPlans = { [PaymentPlanId.Hobby]: { - getPaymentProcessorPlanId: () => requireNodeEnvVar('PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID'), - effect: { kind: 'subscription' }, + getPaymentProcessorPlanId: () => + requireNodeEnvVar("PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID"), + effect: { kind: "subscription" }, }, [PaymentPlanId.Pro]: { - getPaymentProcessorPlanId: () => requireNodeEnvVar('PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID'), - effect: { kind: 'subscription' }, + getPaymentProcessorPlanId: () => + requireNodeEnvVar("PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID"), + effect: { kind: "subscription" }, }, [PaymentPlanId.Credits10]: { - getPaymentProcessorPlanId: () => requireNodeEnvVar('PAYMENTS_CREDITS_10_PLAN_ID'), - effect: { kind: 'credits', amount: 10 }, + getPaymentProcessorPlanId: () => + requireNodeEnvVar("PAYMENTS_CREDITS_10_PLAN_ID"), + effect: { kind: "credits", amount: 10 }, }, } as const satisfies Record; export function prettyPaymentPlanName(planId: PaymentPlanId): string { const planToName: Record = { - [PaymentPlanId.Hobby]: 'Hobby', - [PaymentPlanId.Pro]: 'Pro', - [PaymentPlanId.Credits10]: '10 Credits', + [PaymentPlanId.Hobby]: "Hobby", + [PaymentPlanId.Pro]: "Pro", + [PaymentPlanId.Credits10]: "10 Credits", }; return planToName[planId]; } @@ -58,5 +63,7 @@ export function parsePaymentPlanId(planId: string): PaymentPlanId { } export function getSubscriptionPaymentPlanIds(): PaymentPlanId[] { - return Object.values(PaymentPlanId).filter((planId) => paymentPlans[planId].effect.kind === 'subscription'); + return Object.values(PaymentPlanId).filter( + (planId) => paymentPlans[planId].effect.kind === "subscription", + ); } diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index c998272b9..495de3c99 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -9,7 +9,9 @@ const CLIENT_BASE_URL = * Returns a Stripe customer for the given User email, creating a customer if none exist. * Implements email uniqueness logic since Stripe doesn't enforce unique emails. */ -export async function ensureStripeCustomer(userEmail: string): Promise { +export async function ensureStripeCustomer( + userEmail: string, +): Promise { const stripeCustomers = await stripeClient.customers.list({ email: userEmail, }); diff --git a/template/app/src/payment/stripe/paymentDetails.ts b/template/app/src/payment/stripe/paymentDetails.ts index 73e3b37f1..96deb9c78 100644 --- a/template/app/src/payment/stripe/paymentDetails.ts +++ b/template/app/src/payment/stripe/paymentDetails.ts @@ -1,17 +1,21 @@ -import { PrismaClient } from '@prisma/client'; -import Stripe from 'stripe'; -import type { SubscriptionStatus } from '../plans'; -import { PaymentPlanId } from '../plans'; +import { PrismaClient } from "@prisma/client"; +import Stripe from "stripe"; +import type { SubscriptionStatus } from "../plans"; +import { PaymentPlanId } from "../plans"; interface UpdateUserStripeOneTimePaymentDetails { - customerId: Stripe.Customer['id']; + customerId: Stripe.Customer["id"]; datePaid: Date; numOfCreditsPurchased: number; } export function updateUserStripeOneTimePaymentDetails( - { customerId, datePaid, numOfCreditsPurchased }: UpdateUserStripeOneTimePaymentDetails, - userDelegate: PrismaClient['user'] + { + customerId, + datePaid, + numOfCreditsPurchased, + }: UpdateUserStripeOneTimePaymentDetails, + userDelegate: PrismaClient["user"], ) { return userDelegate.update({ where: { @@ -25,15 +29,20 @@ export function updateUserStripeOneTimePaymentDetails( } interface UpdateUserStripeSubscriptionDetails { - customerId: Stripe.Customer['id']; + customerId: Stripe.Customer["id"]; datePaid?: Date; subscriptionStatus: SubscriptionStatus; paymentPlanId?: PaymentPlanId; } export function updateUserStripeSubscriptionDetails( - { customerId, paymentPlanId, subscriptionStatus, datePaid }: UpdateUserStripeSubscriptionDetails, - userDelegate: PrismaClient['user'] + { + customerId, + paymentPlanId, + subscriptionStatus, + datePaid, + }: UpdateUserStripeSubscriptionDetails, + userDelegate: PrismaClient["user"], ) { return userDelegate.update({ where: { diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 21561dfe8..a43a56070 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,17 +1,20 @@ -import Stripe from 'stripe'; -import { requireNodeEnvVar } from '../../server/utils'; +import Stripe from "stripe"; +import { requireNodeEnvVar } from "../../server/utils"; import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, PaymentProcessor, -} from '../paymentProcessor'; -import type { PaymentPlanEffect } from '../plans'; -import { createStripeCheckoutSession, ensureStripeCustomer } from './checkoutUtils'; -import { stripeClient } from './stripeClient'; -import { stripeMiddlewareConfigFn, stripeWebhook } from './webhook'; +} from "../paymentProcessor"; +import type { PaymentPlanEffect } from "../plans"; +import { + createStripeCheckoutSession, + ensureStripeCustomer, +} from "./checkoutUtils"; +import { stripeClient } from "./stripeClient"; +import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; export const stripePaymentProcessor: PaymentProcessor = { - id: 'stripe', + id: "stripe", createCheckoutSession: async ({ userId, userEmail, @@ -36,7 +39,9 @@ export const stripePaymentProcessor: PaymentProcessor = { }); if (!stripeSession.url) { - throw new Error('Stripe checkout session URL is missing. Checkout session might not be active.'); + throw new Error( + "Stripe checkout session URL is missing. Checkout session might not be active.", + ); } return { @@ -60,7 +65,7 @@ export const stripePaymentProcessor: PaymentProcessor = { return null; } - const webClientUrl = requireNodeEnvVar('WASP_WEB_CLIENT_URL'); + const webClientUrl = requireNodeEnvVar("WASP_WEB_CLIENT_URL"); const session = await stripeClient.billingPortal.sessions.create({ customer: user.paymentProcessorUserId, return_url: `${webClientUrl}/account`, @@ -73,11 +78,11 @@ export const stripePaymentProcessor: PaymentProcessor = { }; function paymentPlanEffectToStripeCheckoutSessionMode( - planEffect: PaymentPlanEffect + planEffect: PaymentPlanEffect, ): Stripe.Checkout.Session.Mode { - const effectToMode: Record = { - subscription: 'subscription', - credits: 'payment', + const effectToMode: Record = { + subscription: "subscription", + credits: "payment", }; return effectToMode[planEffect.kind]; } diff --git a/template/app/src/payment/stripe/stripeClient.ts b/template/app/src/payment/stripe/stripeClient.ts index 2bc23a16a..105c05cdf 100644 --- a/template/app/src/payment/stripe/stripeClient.ts +++ b/template/app/src/payment/stripe/stripeClient.ts @@ -1,5 +1,5 @@ -import Stripe from 'stripe'; -import { requireNodeEnvVar } from '../../server/utils'; +import Stripe from "stripe"; +import { requireNodeEnvVar } from "../../server/utils"; /** * Stripe API version to use for this client. @@ -23,8 +23,8 @@ import { requireNodeEnvVar } from '../../server/utils'; * @see https://docs.stripe.com/api/versioning * @see https://docs.stripe.com/sdks/versioning */ -const STRIPE_API_VERSION = '2025-04-30.basil'; +const STRIPE_API_VERSION = "2025-04-30.basil"; -export const stripeClient = new Stripe(requireNodeEnvVar('STRIPE_API_KEY'), { +export const stripeClient = new Stripe(requireNodeEnvVar("STRIPE_API_KEY"), { apiVersion: STRIPE_API_VERSION, }); diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index caa122efd..6e39a24e4 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -1,39 +1,57 @@ -import { type PrismaClient } from '@prisma/client'; -import express from 'express'; -import type { Stripe } from 'stripe'; -import { HttpError, type MiddlewareConfigFn } from 'wasp/server'; -import { type PaymentsWebhook } from 'wasp/server/api'; -import { emailSender } from 'wasp/server/email'; -import { requireNodeEnvVar } from '../../server/utils'; -import { assertUnreachable } from '../../shared/utils'; -import { UnhandledWebhookEventError } from '../errors'; -import { PaymentPlanId, paymentPlans, SubscriptionStatus } from '../plans'; -import { updateUserStripeOneTimePaymentDetails, updateUserStripeSubscriptionDetails } from './paymentDetails'; -import { stripeClient } from './stripeClient'; +import { type PrismaClient } from "@prisma/client"; +import express from "express"; +import type { Stripe } from "stripe"; +import { HttpError, type MiddlewareConfigFn } from "wasp/server"; +import { type PaymentsWebhook } from "wasp/server/api"; +import { emailSender } from "wasp/server/email"; +import { requireNodeEnvVar } from "../../server/utils"; +import { assertUnreachable } from "../../shared/utils"; +import { UnhandledWebhookEventError } from "../errors"; +import { PaymentPlanId, paymentPlans, SubscriptionStatus } from "../plans"; +import { + updateUserStripeOneTimePaymentDetails, + updateUserStripeSubscriptionDetails, +} from "./paymentDetails"; +import { stripeClient } from "./stripeClient"; /** * Stripe requires the raw request to construct the event successfully. */ -export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => { - middlewareConfig.delete('express.json'); - middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); +export const stripeMiddlewareConfigFn: MiddlewareConfigFn = ( + middlewareConfig, +) => { + middlewareConfig.delete("express.json"); + middlewareConfig.set( + "express.raw", + express.raw({ type: "application/json" }), + ); return middlewareConfig; }; -export const stripeWebhook: PaymentsWebhook = async (request, response, context) => { +export const stripeWebhook: PaymentsWebhook = async ( + request, + response, + context, +) => { const prismaUserDelegate = context.entities.User; try { const stripeEvent = constructStripeEvent(request); switch (stripeEvent.type) { - case 'invoice.paid': + case "invoice.paid": await handleInvoicePaid(stripeEvent, prismaUserDelegate); break; - case 'customer.subscription.updated': - await handleCustomerSubscriptionUpdated(stripeEvent, prismaUserDelegate); + case "customer.subscription.updated": + await handleCustomerSubscriptionUpdated( + stripeEvent, + prismaUserDelegate, + ); break; - case 'customer.subscription.deleted': - await handleCustomerSubscriptionDeleted(stripeEvent, prismaUserDelegate); + case "customer.subscription.deleted": + await handleCustomerSubscriptionDeleted( + stripeEvent, + prismaUserDelegate, + ); break; default: // If you'd like to handle more events, you can add more cases above. @@ -42,7 +60,7 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) // 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. // These can be ignored without any issues. - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV === "production") { throw new UnhandledWebhookEventError(stripeEvent.type); } } @@ -53,28 +71,34 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context) throw err; } - console.error('Stripe webhook error:', err); + console.error("Stripe 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' }); + return response + .status(400) + .json({ error: "Error processing Stripe webhook event" }); } } }; function constructStripeEvent(request: express.Request): Stripe.Event { - const stripeWebhookSecret = requireNodeEnvVar('STRIPE_WEBHOOK_SECRET'); - const stripeSignature = request.headers['stripe-signature']; + const stripeWebhookSecret = requireNodeEnvVar("STRIPE_WEBHOOK_SECRET"); + const stripeSignature = request.headers["stripe-signature"]; if (!stripeSignature) { - throw new HttpError(400, 'Stripe webhook signature not provided'); + throw new HttpError(400, "Stripe webhook signature not provided"); } - return stripeClient.webhooks.constructEvent(request.body, stripeSignature, stripeWebhookSecret); + return stripeClient.webhooks.constructEvent( + request.body, + stripeSignature, + stripeWebhookSecret, + ); } async function handleInvoicePaid( event: Stripe.InvoicePaidEvent, - prismaUserDelegate: PrismaClient['user'] + prismaUserDelegate: PrismaClient["user"], ): Promise { const invoice = event.data.object; const customerId = getCustomerId(invoice.customer); @@ -89,7 +113,7 @@ async function handleInvoicePaid( datePaid: invoicePaidAtDate, numOfCreditsPurchased: paymentPlans.credits10.effect.amount, }, - prismaUserDelegate + prismaUserDelegate, ); break; case PaymentPlanId.Pro: @@ -101,7 +125,7 @@ async function handleInvoicePaid( paymentPlanId, subscriptionStatus: SubscriptionStatus.Active, }, - prismaUserDelegate + prismaUserDelegate, ); break; default: @@ -112,15 +136,18 @@ async function handleInvoicePaid( /** * We only expect one line item, if your workflow expected more, you should change this function to handle them. */ -function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price['id'] { +function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price["id"] { const invoiceLineItems = invoice.lines.data; if (invoiceLineItems.length === 0 || invoiceLineItems.length > 1) { - throw new HttpError(400, 'There should be exactly one line item in Stripe invoice'); + throw new HttpError( + 400, + "There should be exactly one line item in Stripe invoice", + ); } const priceId = invoiceLineItems[0].pricing?.price_details?.price; if (!priceId) { - throw new Error('Unable to extract price id from items'); + throw new Error("Unable to extract price id from items"); } return priceId; @@ -128,7 +155,7 @@ function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price['id'] { async function handleCustomerSubscriptionUpdated( event: Stripe.CustomerSubscriptionUpdatedEvent, - prismaUserDelegate: PrismaClient['user'] + prismaUserDelegate: PrismaClient["user"], ): Promise { const subscription = event.data.object; @@ -146,20 +173,22 @@ async function handleCustomerSubscriptionUpdated( } const customerId = getCustomerId(subscription.customer); - const paymentPlanId = getPaymentPlanIdByPriceId(getSubscriptionPriceId(subscription)); + const paymentPlanId = getPaymentPlanIdByPriceId( + getSubscriptionPriceId(subscription), + ); const user = await updateUserStripeSubscriptionDetails( { customerId, paymentPlanId, subscriptionStatus }, - prismaUserDelegate + 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...', + 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...", }); } } @@ -168,10 +197,15 @@ async function handleCustomerSubscriptionUpdated( /** * We only expect one subscription item, if your workflow expected more, you should change this function to handle them. */ -function getSubscriptionPriceId(subscription: Stripe.Subscription): Stripe.Price['id'] { +function getSubscriptionPriceId( + subscription: Stripe.Subscription, +): Stripe.Price["id"] { const subscriptionItems = subscription.items.data; if (subscriptionItems.length === 0 || subscriptionItems.length > 1) { - throw new HttpError(400, 'There should be exactly one subscription item in Stripe subscription'); + throw new HttpError( + 400, + "There should be exactly one subscription item in Stripe subscription", + ); } return subscriptionItems[0].price.id; @@ -179,23 +213,23 @@ function getSubscriptionPriceId(subscription: Stripe.Subscription): Stripe.Price async function handleCustomerSubscriptionDeleted( event: Stripe.CustomerSubscriptionDeletedEvent, - prismaUserDelegate: PrismaClient['user'] + prismaUserDelegate: PrismaClient["user"], ): Promise { const subscription = event.data.object; const customerId = getCustomerId(subscription.customer); await updateUserStripeSubscriptionDetails( { customerId, subscriptionStatus: SubscriptionStatus.Deleted }, - prismaUserDelegate + prismaUserDelegate, ); } function getCustomerId( - customer: string | Stripe.Customer | Stripe.DeletedCustomer | null -): Stripe.Customer['id'] { + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null, +): Stripe.Customer["id"] { if (!customer) { - throw new Error('Customer is missing'); - } else if (typeof customer === 'string') { + throw new Error("Customer is missing"); + } else if (typeof customer === "string") { return customer; } else { return customer.id; @@ -204,7 +238,7 @@ function getCustomerId( function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { if (!invoice.status_transitions.paid_at) { - throw new Error('Invoice has not been paid yet'); + throw new Error("Invoice has not been paid yet"); } // Stripe returns timestamps in seconds (Unix time), @@ -214,7 +248,8 @@ function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { function getPaymentPlanIdByPriceId(priceId: string): PaymentPlanId { const planId = Object.values(PaymentPlanId).find( - (paymentPlanId) => paymentPlans[paymentPlanId].getPaymentProcessorPlanId() === priceId + (paymentPlanId) => + paymentPlans[paymentPlanId].getPaymentProcessorPlanId() === priceId, ); if (!planId) { throw new Error(`No payment plan with Stripe price id ${priceId}`); From 5dce0da9a05dec0671c3e12e77a07e873df5acaa Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 16:53:07 +0200 Subject: [PATCH 09/44] fix format --- template/app/src/payment/stripe/paymentProcessor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index a43a56070..b3d613452 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -80,7 +80,10 @@ export const stripePaymentProcessor: PaymentProcessor = { function paymentPlanEffectToStripeCheckoutSessionMode( planEffect: PaymentPlanEffect, ): Stripe.Checkout.Session.Mode { - const effectToMode: Record = { + const effectToMode: Record< + PaymentPlanEffect["kind"], + Stripe.Checkout.Session.Mode + > = { subscription: "subscription", credits: "payment", }; From 4d6a64f0c77736eca03e4c8ab9d320e81635cfd3 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 16:54:38 +0200 Subject: [PATCH 10/44] update --- template/app/src/payment/stripe/webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 6e39a24e4..31c52e0e1 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -15,7 +15,7 @@ import { import { stripeClient } from "./stripeClient"; /** - * Stripe requires the raw request to construct the event successfully. + * Stripe requires a raw request to construct events successfully. */ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = ( middlewareConfig, From 1541759427371847b55f2ca0226f6a743e91dc61 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 17:38:26 +0200 Subject: [PATCH 11/44] map to switch --- .../src/payment/stripe/paymentProcessor.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index b3d613452..dda8525fe 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,5 +1,6 @@ import Stripe from "stripe"; import { requireNodeEnvVar } from "../../server/utils"; +import { assertUnreachable } from "../../shared/utils"; import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, @@ -77,15 +78,15 @@ export const stripePaymentProcessor: PaymentProcessor = { webhookMiddlewareConfigFn: stripeMiddlewareConfigFn, }; -function paymentPlanEffectToStripeCheckoutSessionMode( - planEffect: PaymentPlanEffect, -): Stripe.Checkout.Session.Mode { - const effectToMode: Record< - PaymentPlanEffect["kind"], - Stripe.Checkout.Session.Mode - > = { - subscription: "subscription", - credits: "payment", - }; - return effectToMode[planEffect.kind]; +function paymentPlanEffectToStripeCheckoutSessionMode({ + kind, +}: PaymentPlanEffect): Stripe.Checkout.Session.Mode { + switch (kind) { + case "subscription": + return "subscription"; + case "credits": + return "payment"; + default: + assertUnreachable(kind); + } } From dddc12809ea6c72e0fc7165747fcd2a08f3f1525 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sun, 21 Sep 2025 17:38:34 +0200 Subject: [PATCH 12/44] wording --- template/app/src/payment/stripe/webhook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 31c52e0e1..89aab4e63 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -134,7 +134,7 @@ async function handleInvoicePaid( } /** - * We only expect one line item, if your workflow expected more, you should change this function to handle them. + * We only expect one line item, if your workflow expects more, you should change this function to handle them. */ function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price["id"] { const invoiceLineItems = invoice.lines.data; @@ -195,7 +195,7 @@ async function handleCustomerSubscriptionUpdated( } /** - * We only expect one subscription item, if your workflow expected more, you should change this function to handle them. + * We only expect one subscription item, if your workflow expects more, you should change this function to handle them. */ function getSubscriptionPriceId( subscription: Stripe.Subscription, From d7b5f8fd314a6f0d012b0e7be277c42fe1d7fbb1 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Mon, 22 Sep 2025 10:23:56 +0200 Subject: [PATCH 13/44] fix typo --- ...-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx b/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx index f1054572f..5ddf9ac8f 100644 --- a/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx +++ b/opensaas-sh/blog/src/content/docs/blog/2024-12-10-turboreel-os-ai-video-generator-built-with-open-saas.mdx @@ -38,7 +38,7 @@ Here's a video presenting Open SaaS, generated with TurboReel 🐝 ## TurboReel's Tech Stack -TurboReel lets users generate short explainer videos with minimal effort. Starting with a single text prompt describing the video's purpose (e.g. “Create a video on building your SaaS with OpenS aaS”), you can produce professional grade TikTok and YT shorts without needing any video editing skills. +TurboReel lets users generate short explainer videos with minimal effort. Starting with a single text prompt describing the video's purpose (e.g. “Create a video on building your SaaS with OpenS SaaS”), you can produce professional grade TikTok and YT shorts without needing any video editing skills. From d6a608a500445f484b4c9d5559483d42265d8860 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 24 Sep 2025 13:55:14 +0200 Subject: [PATCH 14/44] format --- template/app/src/analytics/stats.ts | 18 +++++++++++------- template/app/src/shared/utils.ts | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/template/app/src/analytics/stats.ts b/template/app/src/analytics/stats.ts index 3eae85ece..73f6dd281 100644 --- a/template/app/src/analytics/stats.ts +++ b/template/app/src/analytics/stats.ts @@ -1,9 +1,12 @@ -import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; -import Stripe from 'stripe'; -import { type DailyStats } from 'wasp/entities'; -import { type DailyStatsJob } from 'wasp/server/jobs'; -import { stripeClient } from '../payment/stripe/stripeClient'; -import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils'; +import { listOrders } from "@lemonsqueezy/lemonsqueezy.js"; +import Stripe from "stripe"; +import { type DailyStats } from "wasp/entities"; +import { type DailyStatsJob } from "wasp/server/jobs"; +import { stripeClient } from "../payment/stripe/stripeClient"; +import { + getDailyPageViews, + getSources, +} from "./providers/plausibleAnalyticsUtils"; // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; import { paymentProcessor } from "../payment/paymentProcessor"; import { SubscriptionStatus } from "../payment/plans"; @@ -153,7 +156,8 @@ async function fetchTotalStripeRevenue() { let hasMore = true; while (hasMore) { - const balanceTransactions = await stripeClient.balanceTransactions.list(params); + const balanceTransactions = + await stripeClient.balanceTransactions.list(params); for (const transaction of balanceTransactions.data) { if (transaction.type === "charge") { diff --git a/template/app/src/shared/utils.ts b/template/app/src/shared/utils.ts index d4cc71072..d403fafed 100644 --- a/template/app/src/shared/utils.ts +++ b/template/app/src/shared/utils.ts @@ -3,7 +3,7 @@ * will never execute. See https://stackoverflow.com/a/39419171. */ export function assertUnreachable(_: never): never { - throw Error('This code should be unreachable'); + throw Error("This code should be unreachable"); } /** From 346da0f3313e121b9325b605388af8dd35ca33af Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 24 Sep 2025 14:01:13 +0200 Subject: [PATCH 15/44] fix diffs? --- opensaas-sh/app_diff/src/analytics/stats.ts.diff | 2 +- .../app_diff/src/payment/stripe/paymentDetails.ts.diff | 6 +++--- .../app_diff/src/payment/stripe/paymentProcessor.ts.diff | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/opensaas-sh/app_diff/src/analytics/stats.ts.diff b/opensaas-sh/app_diff/src/analytics/stats.ts.diff index 66cf21779..c4e19c4ee 100644 --- a/opensaas-sh/app_diff/src/analytics/stats.ts.diff +++ b/opensaas-sh/app_diff/src/analytics/stats.ts.diff @@ -38,7 +38,7 @@ const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews(); -@@ -176,38 +162,3 @@ +@@ -177,38 +163,3 @@ // Revenue is in cents so we convert to dollars (or your main currency unit) return totalRevenue / 100; } diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff index f2d38d97f..e6e8160c4 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff @@ -1,7 +1,7 @@ --- template/app/src/payment/stripe/paymentDetails.ts +++ opensaas-sh/app/src/payment/stripe/paymentDetails.ts -@@ -20,10 +20,10 @@ - ) => { +@@ -19,7 +19,7 @@ + ) { return userDelegate.update({ where: { - paymentProcessorUserId: customerId, @@ -9,7 +9,7 @@ }, data: { datePaid, -@@ -48,7 +48,7 @@ +@@ -46,7 +46,7 @@ ) { return userDelegate.update({ where: { diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index b885ecf81..a3d785b2e 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -1,6 +1,6 @@ --- template/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts -@@ -32,7 +32,7 @@ +@@ -29,7 +29,7 @@ id: userId, }, data: { From 8371bd499105eda99b90106e78977ba58989a55d Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 24 Sep 2025 17:18:34 +0200 Subject: [PATCH 16/44] docs: update LLM files after documentation changes --- opensaas-sh/blog/public/llms-full.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opensaas-sh/blog/public/llms-full.txt b/opensaas-sh/blog/public/llms-full.txt index 779f63764..0602c26fe 100644 --- a/opensaas-sh/blog/public/llms-full.txt +++ b/opensaas-sh/blog/public/llms-full.txt @@ -1288,7 +1288,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, { 2. click on `+ add endpoint` 3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook` -4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts): +4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find handled in [`src/payment/stripe/webhook.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhook.ts): 5. after that, go to the webhook you just created and `reveal` the new signing secret. 6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable.
If you've deployed to Fly.io, you can do that easily with the following command: @@ -1665,7 +1665,7 @@ You can create a test customer directly in the [Stripe Dashboard](https://dashbo - Click on the `Add a customer` button and fill in the relevant information for your test customer. -Alternatively, OpenSasS will automatically create a test customer the first time a user starts a checkout session. +Alternatively, Open SasS will automatically create a test customer the first time a user starts a checkout session. This customer is linked to the email address associated with your app's user. ### Set up the Customer Portal From e5caba806d424ac84cb759e84875e8c24d578c10 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 1 Oct 2025 11:23:50 +0200 Subject: [PATCH 17/44] comments --- template/app/src/payment/stripe/webhook.ts | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 89aab4e63..6356501b1 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -37,6 +37,10 @@ export const stripeWebhook: PaymentsWebhook = async ( try { const stripeEvent = constructStripeEvent(request); + // If you'd like to handle more events, you can add more cases below. + // When deploying your app, you configure your webhook in the Stripe dashboard + // to only send the events that you're handling above. + // See: https://docs.opensaas.sh/guides/deploying/#setting-up-your-stripe-webhook switch (stripeEvent.type) { case "invoice.paid": await handleInvoicePaid(stripeEvent, prismaUserDelegate); @@ -54,21 +58,20 @@ export const stripeWebhook: PaymentsWebhook = async ( ); 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. - // 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. - // These can be ignored without any issues. - if (process.env.NODE_ENV === "production") { - throw new UnhandledWebhookEventError(stripeEvent.type); - } + throw new UnhandledWebhookEventError(stripeEvent.type); } return response.status(204).send(); } catch (err) { if (err instanceof UnhandledWebhookEventError) { - response.status(204).send(); - throw err; + // We must return a 2XX status code, otherwise Stripe will keep retrying the webhook. + response.status(202).send(); + + // In development, it is likely that you will receive other events that you are not handling. + // E.g. via `stripe trigger` command. + // These can be ignored without any issues. + if (process.env.NODE_ENV === "production") { + throw err; + } } console.error("Stripe webhook error:", err); From 2e5ea07b5a18b712c93c58d5c67ba97cddf8a0fa Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 1 Oct 2025 11:25:47 +0200 Subject: [PATCH 18/44] wording --- template/app/src/payment/stripe/webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 6356501b1..e2f092194 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -63,7 +63,7 @@ export const stripeWebhook: PaymentsWebhook = async ( return response.status(204).send(); } catch (err) { if (err instanceof UnhandledWebhookEventError) { - // We must return a 2XX status code, otherwise Stripe will keep retrying the webhook. + // We must return a 2XX status code, otherwise Stripe will keep retrying the event. response.status(202).send(); // In development, it is likely that you will receive other events that you are not handling. From f09865f2b4e86340e3e045afd43cbd8ec7f3540d Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 1 Oct 2025 11:27:20 +0200 Subject: [PATCH 19/44] wording --- template/app/src/payment/stripe/webhook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index e2f092194..293b36396 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -66,8 +66,8 @@ export const stripeWebhook: PaymentsWebhook = async ( // We must return a 2XX status code, otherwise Stripe will keep retrying the event. response.status(202).send(); - // In development, it is likely that you will receive other events that you are not handling. - // E.g. via `stripe trigger` command. + // In development, it is likely that we will receive events that we are not handling. + // E.g. via the `stripe trigger` command. // These can be ignored without any issues. if (process.env.NODE_ENV === "production") { throw err; From b4f80d37c61d28a97ae8a71cd31b985a0dcceaff Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 1 Oct 2025 11:31:26 +0200 Subject: [PATCH 20/44] wording --- template/app/src/payment/stripe/stripeClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/app/src/payment/stripe/stripeClient.ts b/template/app/src/payment/stripe/stripeClient.ts index 105c05cdf..e4f593d78 100644 --- a/template/app/src/payment/stripe/stripeClient.ts +++ b/template/app/src/payment/stripe/stripeClient.ts @@ -2,7 +2,7 @@ import Stripe from "stripe"; import { requireNodeEnvVar } from "../../server/utils"; /** - * Stripe API version to use for this client. + * The Stripe client API version. * * By default, Stripe uses the API version set in your Dashboard. * From 34209aeeda868387ddf8ba526cd6a44cc3a0cbee Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 1 Oct 2025 11:48:58 +0200 Subject: [PATCH 21/44] fix typo --- template/app/src/payment/stripe/checkoutUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 495de3c99..cb9af7b55 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -48,7 +48,7 @@ export async function createStripeCheckoutSession({ ], mode, success_url: `${CLIENT_BASE_URL}/checkout?status=success`, - cancel_url: `${CLIENT_BASE_URL}/checkout?status=cancel`, + cancel_url: `${CLIENT_BASE_URL}/checkout?status=canceled`, automatic_tax: { enabled: true }, allow_promotion_codes: true, customer_update: { From f288f9b16302785adcbeaa4086e4f3758775983d Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 1 Oct 2025 13:09:48 +0200 Subject: [PATCH 22/44] to 204 --- template/app/src/payment/stripe/webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 293b36396..684afe27f 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -64,7 +64,7 @@ export const stripeWebhook: PaymentsWebhook = async ( } catch (err) { if (err instanceof UnhandledWebhookEventError) { // We must return a 2XX status code, otherwise Stripe will keep retrying the event. - response.status(202).send(); + response.status(204).send(); // In development, it is likely that we will receive events that we are not handling. // E.g. via the `stripe trigger` command. From 4673ec6724b268c326a9f49843150cc488121152 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Sat, 11 Oct 2025 15:44:30 +0200 Subject: [PATCH 23/44] update env --- template/app/src/payment/stripe/paymentProcessor.ts | 8 +++++--- template/e2e-tests/package-lock.json | 8 ++++---- template/e2e-tests/package.json | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index dda8525fe..1caf00c73 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,5 +1,4 @@ import Stripe from "stripe"; -import { requireNodeEnvVar } from "../../server/utils"; import { assertUnreachable } from "../../shared/utils"; import type { CreateCheckoutSessionArgs, @@ -14,6 +13,10 @@ import { import { stripeClient } from "./stripeClient"; import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; +// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying +const CLIENT_BASE_URL = + process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; + export const stripePaymentProcessor: PaymentProcessor = { id: "stripe", createCheckoutSession: async ({ @@ -66,10 +69,9 @@ export const stripePaymentProcessor: PaymentProcessor = { return null; } - const webClientUrl = requireNodeEnvVar("WASP_WEB_CLIENT_URL"); const session = await stripeClient.billingPortal.sessions.create({ customer: user.paymentProcessorUserId, - return_url: `${webClientUrl}/account`, + return_url: `${CLIENT_BASE_URL}/account`, }); return session.url; diff --git a/template/e2e-tests/package-lock.json b/template/e2e-tests/package-lock.json index efedffca3..cbb222348 100644 --- a/template/e2e-tests/package-lock.json +++ b/template/e2e-tests/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@playwright/test": "^1.42.1", "@prisma/client": "5.19.1", - "@wasp.sh/wasp-app-runner": "0.0.7", + "@wasp.sh/wasp-app-runner": "0.0.9", "prisma": "5.19.1" }, "devDependencies": { @@ -115,9 +115,9 @@ } }, "node_modules/@wasp.sh/wasp-app-runner": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@wasp.sh/wasp-app-runner/-/wasp-app-runner-0.0.7.tgz", - "integrity": "sha512-sN7b7DuMZMBrnnPoZDQuqPofxW9VyYudmdfbj8e5A+1sLV2WfSFGO7BBP/qcLYDIosU6c4TMiEnM0R3M0gy+uw==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@wasp.sh/wasp-app-runner/-/wasp-app-runner-0.0.9.tgz", + "integrity": "sha512-FkT8l4eDaRBO7mAeHBmlsYVinp9Ftf4FEThiAQ9w6joociRgPj7XoekFu9jt9OVest4qAu4g4xPPqYkuwDqG+w==", "license": "MIT", "dependencies": { "@commander-js/extra-typings": "^13.1.0", diff --git a/template/e2e-tests/package.json b/template/e2e-tests/package.json index c35337401..0365ad919 100644 --- a/template/e2e-tests/package.json +++ b/template/e2e-tests/package.json @@ -12,14 +12,14 @@ "_comment-on-local:e2e:cleanup-stripe": "NOTE: because we are running the stripe webhook listener in the background, we want to make sure we kill the previous processes before starting a new one.", "e2e:playwright": "DEBUG=pw:webserver npx playwright test", "local:e2e:cleanup-stripe": "PID=$(ps -ef | grep 'stripe listen' | grep -v grep | awk '{print $2}') || true && kill -9 $PID || true", - "local:e2e:playwright:ui": "npx playwright test --ui", + "local:e2e:playwright:ui": "SKIP_EMAIL_VERIFICATION_IN_DEV=true npx playwright test --ui", "local:e2e:start": "npm run local:e2e:cleanup-stripe && npm run local:e2e:start-stripe && npm run local:e2e:playwright:ui && npm run local:e2e:cleanup-stripe", "local:e2e:start-stripe": "stripe listen --forward-to localhost:3001/payments-webhook &" }, "dependencies": { "@playwright/test": "^1.42.1", "@prisma/client": "5.19.1", - "@wasp.sh/wasp-app-runner": "0.0.7", + "@wasp.sh/wasp-app-runner": "0.0.9", "prisma": "5.19.1" }, "devDependencies": { From fb0ff9414c401aedd8a966e3ba04b2f4bfb6a6cc Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Mon, 13 Oct 2025 10:12:53 +0200 Subject: [PATCH 24/44] formatting --- template/app/src/payment/stripe/checkoutUtils.ts | 8 ++++---- template/app/src/payment/stripe/paymentProcessor.ts | 8 ++++---- tools/README.md | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index cb9af7b55..e396fa050 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,10 +1,6 @@ import Stripe from "stripe"; import { stripeClient } from "./stripeClient"; -// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying -const CLIENT_BASE_URL = - process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; - /** * Returns a Stripe customer for the given User email, creating a customer if none exist. * Implements email uniqueness logic since Stripe doesn't enforce unique emails. @@ -38,6 +34,10 @@ export async function createStripeCheckoutSession({ customerId, mode, }: CreateStripeCheckoutSessionParams): Promise { + // WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying + const CLIENT_BASE_URL = + process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; + return await stripeClient.checkout.sessions.create({ customer: customerId, line_items: [ diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 1caf00c73..80ad1c254 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -13,10 +13,6 @@ import { import { stripeClient } from "./stripeClient"; import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; -// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying -const CLIENT_BASE_URL = - process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; - export const stripePaymentProcessor: PaymentProcessor = { id: "stripe", createCheckoutSession: async ({ @@ -56,6 +52,10 @@ export const stripePaymentProcessor: PaymentProcessor = { }; }, fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { + // WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying + const CLIENT_BASE_URL = + process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; + const user = await args.prismaUserDelegate.findUniqueOrThrow({ where: { id: args.userId, diff --git a/tools/README.md b/tools/README.md index 274a23235..8cd7f857c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -36,6 +36,7 @@ and `.copy` files to copy files directly from the diff directory to the derived The typical workflow is: 1. Run `dope.sh` with the `patch` action to generate `app/` from `../template/` and `app_diff/`: + ```bash ./dope.sh ../template app patch ``` @@ -58,7 +59,7 @@ If you're running the `dope.sh` script on macOS, install: - `diffutils` ```sh -brew install coreutils # contains grealpath +brew install coreutils # contains grealpath brew install gpatch brew install diffutils ``` From cd0988e9351959c02c26800192ef7f4350286915 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Tue, 4 Nov 2025 17:30:13 +0100 Subject: [PATCH 25/44] work --- .../app/src/payment/stripe/checkoutUtils.ts | 38 +++++---- .../src/payment/stripe/paymentProcessor.ts | 7 +- template/app/src/payment/stripe/webhook.ts | 78 +++++++++++-------- 3 files changed, 68 insertions(+), 55 deletions(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index e396fa050..640bc19a8 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,4 +1,5 @@ import Stripe from "stripe"; +import { env } from "wasp/server"; import { stripeClient } from "./stripeClient"; /** @@ -29,16 +30,12 @@ interface CreateStripeCheckoutSessionParams { mode: Stripe.Checkout.Session.Mode; } -export async function createStripeCheckoutSession({ +export function createStripeCheckoutSession({ priceId, customerId, mode, }: CreateStripeCheckoutSessionParams): Promise { - // WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying - const CLIENT_BASE_URL = - process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; - - return await stripeClient.checkout.sessions.create({ + return stripeClient.checkout.sessions.create({ customer: customerId, line_items: [ { @@ -47,21 +44,28 @@ export async function createStripeCheckoutSession({ }, ], mode, - success_url: `${CLIENT_BASE_URL}/checkout?status=success`, - cancel_url: `${CLIENT_BASE_URL}/checkout?status=canceled`, + success_url: `${env.WASP_WEB_CLIENT_URL}/checkout?status=success`, + cancel_url: `${env.WASP_WEB_CLIENT_URL}/checkout?status=canceled`, automatic_tax: { enabled: true }, allow_promotion_codes: true, customer_update: { address: "auto", }, - // Stripe automatically creates invoices for subscriptions. - // For one-time payments, we must enable them manually. - // However, enabling invoices for subscriptions will throw an error. - invoice_creation: - mode === "payment" - ? { - enabled: true, - } - : undefined, + invoice_creation: getInvoiceCreationConfig(mode), }); } + +/** + * Stripe automatically creates invoices for subscriptions. + * For one-time payments, we must enable them manually. + * However, enabling invoices for subscriptions will throw an error. + */ +function getInvoiceCreationConfig( + mode: Stripe.Checkout.Session.Mode, +): Stripe.Checkout.SessionCreateParams["invoice_creation"] { + return mode === "payment" + ? { + enabled: true, + } + : undefined; +} diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 80ad1c254..7404fabb0 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,4 +1,5 @@ import Stripe from "stripe"; +import { env } from "wasp/server"; import { assertUnreachable } from "../../shared/utils"; import type { CreateCheckoutSessionArgs, @@ -52,10 +53,6 @@ export const stripePaymentProcessor: PaymentProcessor = { }; }, fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { - // WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying - const CLIENT_BASE_URL = - process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000"; - const user = await args.prismaUserDelegate.findUniqueOrThrow({ where: { id: args.userId, @@ -71,7 +68,7 @@ export const stripePaymentProcessor: PaymentProcessor = { const session = await stripeClient.billingPortal.sessions.create({ customer: user.paymentProcessorUserId, - return_url: `${CLIENT_BASE_URL}/account`, + return_url: `${env.WASP_WEB_CLIENT_URL}/account`, }); return session.url; diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 684afe27f..b0788ad3d 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -1,13 +1,17 @@ import { type PrismaClient } from "@prisma/client"; import express from "express"; import type { Stripe } from "stripe"; -import { HttpError, type MiddlewareConfigFn } from "wasp/server"; +import { type MiddlewareConfigFn } from "wasp/server"; import { type PaymentsWebhook } from "wasp/server/api"; import { emailSender } from "wasp/server/email"; import { requireNodeEnvVar } from "../../server/utils"; import { assertUnreachable } from "../../shared/utils"; import { UnhandledWebhookEventError } from "../errors"; -import { PaymentPlanId, paymentPlans, SubscriptionStatus } from "../plans"; +import { + PaymentPlanId, + paymentPlans, + SubscriptionStatus +} from "../plans"; import { updateUserStripeOneTimePaymentDetails, updateUserStripeSubscriptionDetails, @@ -61,22 +65,25 @@ export const stripeWebhook: PaymentsWebhook = async ( throw new UnhandledWebhookEventError(stripeEvent.type); } return response.status(204).send(); - } catch (err) { - if (err instanceof UnhandledWebhookEventError) { - // We must return a 2XX status code, otherwise Stripe will keep retrying the event. - response.status(204).send(); - + } catch (error) { + if (error instanceof UnhandledWebhookEventError) { // In development, it is likely that we will receive events that we are not handling. // E.g. via the `stripe trigger` command. - // These can be ignored without any issues. - if (process.env.NODE_ENV === "production") { - throw err; + // While these can be ignored safely in development, it's good to be aware of them. + // For production we shouldn't have any extra webhook events. + if (process.env.NODE_ENV === "development") { + console.info("Unhandled Stripe webhook event in development: ", error); + } else if (process.env.NODE_ENV === "production") { + console.error("Unhandled Stripe webhook event in production: ", error); } + + // We must return a 2XX status code, otherwise Stripe will keep retrying the event. + return response.status(204).send(); } - console.error("Stripe webhook error:", err); - if (err instanceof HttpError) { - return response.status(err.statusCode).json({ error: err.message }); + console.error("Stripe webhook error:", error); + if (error instanceof Error) { + return response.status(400).json({ error: error.message }); } else { return response .status(400) @@ -89,7 +96,7 @@ function constructStripeEvent(request: express.Request): Stripe.Event { const stripeWebhookSecret = requireNodeEnvVar("STRIPE_WEBHOOK_SECRET"); const stripeSignature = request.headers["stripe-signature"]; if (!stripeSignature) { - throw new HttpError(400, "Stripe webhook signature not provided"); + throw new Error("Stripe webhook signature not provided"); } return stripeClient.webhooks.constructEvent( @@ -114,7 +121,7 @@ async function handleInvoicePaid( { customerId, datePaid: invoicePaidAtDate, - numOfCreditsPurchased: paymentPlans.credits10.effect.amount, + numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount, }, prismaUserDelegate, ); @@ -142,10 +149,7 @@ async function handleInvoicePaid( function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price["id"] { const invoiceLineItems = invoice.lines.data; if (invoiceLineItems.length === 0 || invoiceLineItems.length > 1) { - throw new HttpError( - 400, - "There should be exactly one line item in Stripe invoice", - ); + throw new Error("There should be exactly one line item in Stripe invoice"); } const priceId = invoiceLineItems[0].pricing?.price_details?.price; @@ -163,15 +167,8 @@ async function handleCustomerSubscriptionUpdated( const subscription = event.data.object; // There are other subscription statuses, such as `trialing` that we are not handling. - let subscriptionStatus: SubscriptionStatus | undefined; - if (subscription.status === SubscriptionStatus.Active) { - subscriptionStatus = SubscriptionStatus.Active; - if (subscription.cancel_at_period_end) { - subscriptionStatus = SubscriptionStatus.CancelAtPeriodEnd; - } - } else if (subscription.status === SubscriptionStatus.PastDue) { - subscriptionStatus = SubscriptionStatus.PastDue; - } else { + const subscriptionStatus = getOpenSaasSubscriptionStatus(subscription); + if (!subscriptionStatus) { return; } @@ -197,6 +194,22 @@ async function handleCustomerSubscriptionUpdated( } } +function getOpenSaasSubscriptionStatus( + subscription: Stripe.Subscription, +): SubscriptionStatus | undefined { + let subscriptionStatus: SubscriptionStatus | undefined; + if (subscription.status === SubscriptionStatus.Active) { + subscriptionStatus = SubscriptionStatus.Active; + if (subscription.cancel_at_period_end) { + subscriptionStatus = SubscriptionStatus.CancelAtPeriodEnd; + } + } else if (subscription.status === SubscriptionStatus.PastDue) { + subscriptionStatus = SubscriptionStatus.PastDue; + } + + return subscriptionStatus; +} + /** * We only expect one subscription item, if your workflow expects more, you should change this function to handle them. */ @@ -205,8 +218,7 @@ function getSubscriptionPriceId( ): Stripe.Price["id"] { const subscriptionItems = subscription.items.data; if (subscriptionItems.length === 0 || subscriptionItems.length > 1) { - throw new HttpError( - 400, + throw new Error( "There should be exactly one subscription item in Stripe subscription", ); } @@ -250,13 +262,13 @@ function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { } function getPaymentPlanIdByPriceId(priceId: string): PaymentPlanId { - const planId = Object.values(PaymentPlanId).find( + const paymentPlanId = Object.values(PaymentPlanId).find( (paymentPlanId) => paymentPlans[paymentPlanId].getPaymentProcessorPlanId() === priceId, ); - if (!planId) { + if (!paymentPlanId) { throw new Error(`No payment plan with Stripe price id ${priceId}`); } - return planId; + return paymentPlanId; } From a86021df4b8958f8ab1420004d20cddcceaccc90 Mon Sep 17 00:00:00 2001 From: Franjo Mindek <84568328+FranjoMindek@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:26:52 +0100 Subject: [PATCH 26/44] Update opensaas-sh/blog/src/content/docs/guides/deploying.mdx Co-authored-by: Mihovil Ilakovac --- opensaas-sh/blog/src/content/docs/guides/deploying.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx index 3f5cd9ca6..4100a3dbd 100644 --- a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx @@ -175,7 +175,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, { 2. click on `+ add endpoint` 3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook` listen events -4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find handled in [`src/payment/stripe/webhook.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhook.ts): +4. select the events you want to listen to. These should be the same events you're consuming in your webhook which are handled in [`src/payment/stripe/webhook.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhook.ts): signing secret 5. after that, go to the webhook you just created and `reveal` the new signing secret. 6. add this secret to your deployed server's `STRIPE_WEBHOOK_SECRET=` environment variable.
If you've deployed to Fly.io, you can do that easily with the following command: From 68220cc857ba2542ae92246b30576f2621803f68 Mon Sep 17 00:00:00 2001 From: Franjo Mindek <84568328+FranjoMindek@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:27:51 +0100 Subject: [PATCH 27/44] Apply suggestion from @infomiho Co-authored-by: Mihovil Ilakovac --- .../blog/src/content/docs/guides/payments-integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx index a9f9664fa..38147a7b3 100644 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx @@ -95,7 +95,7 @@ To create a test product, go to the test products url [https://dashboard.stripe. ### Create a Test Customer -You can create a test customer directly in the [Stripe Dashboard](https://dashboard.stripe.com/test/customers). +You can create a test customer in the [Stripe Dashboard](https://dashboard.stripe.com/test/customers). - Click on the `Add a customer` button and fill in the relevant information for your test customer. From ddbe28e53b9e8caa3fb49e0140ea6dddc51710bf Mon Sep 17 00:00:00 2001 From: Franjo Mindek <84568328+FranjoMindek@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:28:22 +0100 Subject: [PATCH 28/44] Apply suggestion from @infomiho Co-authored-by: Mihovil Ilakovac --- .../blog/src/content/docs/guides/payments-integration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx index 38147a7b3..5d22df0c0 100644 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx @@ -99,8 +99,8 @@ You can create a test customer in the [Stripe Dashboard](https://dashboard.strip - Click on the `Add a customer` button and fill in the relevant information for your test customer. -Alternatively, Open SasS will automatically create a test customer the first time a user starts a checkout session. -This customer is linked to the email address associated with your app's user. +Alternatively, Open SasS automatically creates a test customer the first time a user starts a checkout session. +This customer is linked to the email address associated with the user in your app. ### Set up the Customer Portal From 9a4ea8606c7db8b26832fed95f3fd5848f35da57 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Tue, 4 Nov 2025 20:37:36 +0100 Subject: [PATCH 29/44] link change --- .../blog/src/content/docs/guides/payments-integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx index a9f9664fa..c4b3bbf34 100644 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx @@ -107,7 +107,7 @@ This customer is linked to the email address associated with your app's user. You can set up your customer portal in your [Stripe Dashboard](https://dashboard.stripe.com/test/settings/billing/portal). By default, OpenSaas generates a unique customer portal link for each user on the back end. -If you'd rather provide a permanent link to the customer portal, activate and copy the `Customer portal link`. +If you'd rather provide a permanent link to the customer portal, activate it and copy the `portal link`. If you'd like to give users the ability to switch between different plans, e.g., upgrade from a "Hobby" to a "Pro" subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`. From 5441ffc0201b34a753672bcff2680dd6e7172dc9 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Tue, 4 Nov 2025 20:43:12 +0100 Subject: [PATCH 30/44] rename --- template/app/src/payment/stripe/paymentProcessor.ts | 11 ++++++----- template/app/src/payment/stripe/webhook.ts | 6 +----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 7404fabb0..aa594d1d1 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -66,12 +66,13 @@ export const stripePaymentProcessor: PaymentProcessor = { return null; } - const session = await stripeClient.billingPortal.sessions.create({ - customer: user.paymentProcessorUserId, - return_url: `${env.WASP_WEB_CLIENT_URL}/account`, - }); + const billingPortalSession = + await stripeClient.billingPortal.sessions.create({ + customer: user.paymentProcessorUserId, + return_url: `${env.WASP_WEB_CLIENT_URL}/account`, + }); - return session.url; + return billingPortalSession.url; }, webhook: stripeWebhook, webhookMiddlewareConfigFn: stripeMiddlewareConfigFn, diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index b0788ad3d..e99dae61b 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -7,11 +7,7 @@ import { emailSender } from "wasp/server/email"; import { requireNodeEnvVar } from "../../server/utils"; import { assertUnreachable } from "../../shared/utils"; import { UnhandledWebhookEventError } from "../errors"; -import { - PaymentPlanId, - paymentPlans, - SubscriptionStatus -} from "../plans"; +import { PaymentPlanId, paymentPlans, SubscriptionStatus } from "../plans"; import { updateUserStripeOneTimePaymentDetails, updateUserStripeSubscriptionDetails, From 4088bcad9cb23ec99f723d194071b8c53e1c72f1 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Tue, 4 Nov 2025 20:53:53 +0100 Subject: [PATCH 31/44] changes --- opensaas-sh/app_diff/README.md.diff | 8 ++-- opensaas-sh/app_diff/deletions | 1 + opensaas-sh/app_diff/package-lock.json.diff | 2 +- opensaas-sh/app_diff/src/client/Main.css.diff | 16 ++++---- .../src/file-upload/operations.ts.diff | 7 ++-- .../src/landing-page/contentSections.tsx.diff | 40 ++++++++++--------- .../payment/stripe/paymentProcessor.ts.diff | 21 ++++++++++ 7 files changed, 59 insertions(+), 36 deletions(-) diff --git a/opensaas-sh/app_diff/README.md.diff b/opensaas-sh/app_diff/README.md.diff index 97027f9cf..fe343f709 100644 --- a/opensaas-sh/app_diff/README.md.diff +++ b/opensaas-sh/app_diff/README.md.diff @@ -1,17 +1,17 @@ --- template/app/README.md +++ opensaas-sh/app/README.md -@@ -1,16 +1,31 @@ +@@ -1,6 +1,8 @@ -# +# opensaas.sh (demo) app -Built with [Wasp](https://wasp.sh), based on the [Open Saas](https://opensaas.sh) template. +This is a Wasp app based on Open Saas template with minimal modifications that make it into a demo app that showcases Open Saas's abilities. - -+It is deployed to https://opensaas.sh and serves both as a landing page for Open Saas and as a demo app. + ++It is deployed to https://opensaas.sh and serves both as a landing page for Open Saas and as a demo app. + ## UI Components - This template includes [ShadCN UI](https://ui.shadcn.com/) v2 for beautiful, accessible React components. See [SHADCN_SETUP.md](./SHADCN_SETUP.md) for details on how to use ShadCN components in your app. +@@ -8,9 +10,22 @@ ## Development diff --git a/opensaas-sh/app_diff/deletions b/opensaas-sh/app_diff/deletions index 5f72c050c..9399c08d4 100644 --- a/opensaas-sh/app_diff/deletions +++ b/opensaas-sh/app_diff/deletions @@ -5,4 +5,5 @@ src/payment/lemonSqueezy/checkoutUtils.ts src/payment/lemonSqueezy/paymentDetails.ts src/payment/lemonSqueezy/paymentProcessor.ts src/payment/lemonSqueezy/webhook.ts +src/payment/lemonSqueezy/webhookPayload.ts src/payment/webhook.ts diff --git a/opensaas-sh/app_diff/package-lock.json.diff b/opensaas-sh/app_diff/package-lock.json.diff index f9431f64e..2c9374ae6 100644 --- a/opensaas-sh/app_diff/package-lock.json.diff +++ b/opensaas-sh/app_diff/package-lock.json.diff @@ -98,7 +98,7 @@ + "@types/express-serve-static-core": "^5.0.0" + }, + "peerDependencies": { -+ "@tanstack/react-query": "^4.39.1" ++ "@tanstack/react-query": "~4.41.0" + } + }, + "node_modules/@adobe/css-tools": { diff --git a/opensaas-sh/app_diff/src/client/Main.css.diff b/opensaas-sh/app_diff/src/client/Main.css.diff index 83261b140..774270a25 100644 --- a/opensaas-sh/app_diff/src/client/Main.css.diff +++ b/opensaas-sh/app_diff/src/client/Main.css.diff @@ -1,6 +1,6 @@ --- template/app/src/client/Main.css +++ opensaas-sh/app/src/client/Main.css -@@ -56,8 +56,66 @@ +@@ -56,6 +56,64 @@ .border-gradient-primary > * { background: hsl(var(--background)); } @@ -21,8 +21,8 @@ + hsl(var(--card)) 100% + ); + } - } - ++} ++ +/* Satoshi Font Family */ +@font-face { + font-family: "Satoshi"; @@ -62,11 +62,9 @@ + font-weight: 900; + font-style: normal; + font-display: swap; -+} -+ - /* third-party libraries CSS */ + } - .tableCheckbox:checked ~ div span { + /* third-party libraries CSS */ @@ -176,4 +234,22 @@ body { @apply bg-background text-foreground; @@ -85,8 +83,8 @@ + p { + @apply font-mono text-base leading-relaxed; + } - } ++} + +.navbar-maxwidth-transition { + transition: max-width 300ms cubic-bezier(0.4, 0, 0.2, 1); -+} + } diff --git a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff index facf44bcd..d34ceb568 100644 --- a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff +++ b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff @@ -10,7 +10,7 @@ import * as z from "zod"; import { ensureArgsSchemaOrThrowHttpError } from "../server/validation"; -@@ -37,11 +38,16 @@ +@@ -37,10 +38,15 @@ throw new HttpError(401); } @@ -25,9 +25,8 @@ + if (userFileLimitReached) { + throw new HttpError(403, 'This demo only allows 2 file uploads per user.'); + } - -+ const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs); + ++ const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs); + return await getUploadFileSignedURLFromS3({ fileType, - fileName, diff --git a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff index 4c21d3ee3..8be0d2796 100644 --- a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff +++ b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff @@ -160,6 +160,20 @@ - avatarSrc: daBoiAvatar, - socialUrl: "https://twitter.com/wasplang", - quote: "I don't even know how to code. I'm just a plushie.", +- }, +- { +- name: "Mr. Foobar", +- role: "Founder @ Cool Startup", +- avatarSrc: daBoiAvatar, +- socialUrl: "", +- quote: "This product makes me cooler than I already am.", +- }, +- { +- name: "Jamie", +- role: "Happy Customer", +- avatarSrc: daBoiAvatar, +- socialUrl: "#", +- quote: "My cats love it!", + name: "Max Khamrovskyi", + role: "Senior Eng @ Red Hat", + avatarSrc: @@ -167,13 +181,8 @@ + 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: "Mr. Foobar", -- role: "Founder @ Cool Startup", -- avatarSrc: daBoiAvatar, -- socialUrl: "", -- quote: "This product makes me cooler than I already am.", ++ }, ++ { + name: "Jonathan Cocharan", + role: "Entrepreneur", + avatarSrc: @@ -181,13 +190,8 @@ + socialUrl: "https://twitter.com/JonathanCochran", + quote: + "In just 6 nights... my SaaS app is live 🎉! Huge thanks to the amazing @wasplang community 🙌 for their guidance along the way. These tools are incredibly efficient 🤯!", - }, - { -- name: "Jamie", -- role: "Happy Customer", -- avatarSrc: daBoiAvatar, -- socialUrl: "#", -- quote: "My cats love it!", ++ }, ++ { + name: "Billy Howell", + role: "Entrepreneur", + avatarSrc: @@ -195,7 +199,7 @@ + socialUrl: "https://twitter.com/billyjhowell", + quote: + "Congrats! I am loving Wasp & Open SaaS. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.", - }, ++ }, + { + name: "Tim Skaggs", + role: "Founder @ Antler US", @@ -249,7 +253,7 @@ + "https://dev.to/wasp/our-web-framework-reached-9000-stars-on-github-9000-jij#comment-2dech", + quote: + "This is exactly the framework I've been dreaming of ever since I've been waiting to fully venture into the JS Backend Dev world. I believe Wasp will go above 50k stars this year. The documentation alone gives me the confidence that this is my permanent Nodejs framework and I'm staying with Wasp. Phenomenal work by the team... Please keep up your amazing spirits. Thank you", -+ }, + }, ]; - export const faqs = [ @@ -261,14 +265,14 @@ + question: "Why is this SaaS Template free and open-source?", + answer: + "We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.", - }, ++ }, + { + id: 2, + question: "What's Wasp?", + href: "https://wasp-lang.dev", + answer: + "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.", -+ }, + }, ]; - export const footerNavigation = { diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index a3d785b2e..7b481ca26 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -9,3 +9,24 @@ }, }); +@@ -58,17 +58,17 @@ + id: args.userId, + }, + select: { +- paymentProcessorUserId: true, ++ stripeId: true, + }, + }); + +- if (!user.paymentProcessorUserId) { ++ if (!user.stripeId) { + return null; + } + + const billingPortalSession = + await stripeClient.billingPortal.sessions.create({ +- customer: user.paymentProcessorUserId, ++ customer: user.stripeId, + return_url: `${env.WASP_WEB_CLIENT_URL}/account`, + }); + From 2262a2c90e0df01cc7b373f6feaf28f09dd4602f Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 5 Nov 2025 14:11:11 +0100 Subject: [PATCH 32/44] capitalize --- .../blog/src/content/docs/guides/payments-integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx index 994f94256..bdcb740c7 100644 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx @@ -107,7 +107,7 @@ This customer is linked to the email address associated with the user in your ap You can set up your customer portal in your [Stripe Dashboard](https://dashboard.stripe.com/test/settings/billing/portal). By default, OpenSaas generates a unique customer portal link for each user on the back end. -If you'd rather provide a permanent link to the customer portal, activate it and copy the `portal link`. +If you'd rather provide a permanent link to the customer portal, activate it and copy the `Portal link`. If you'd like to give users the ability to switch between different plans, e.g., upgrade from a "Hobby" to a "Pro" subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`. From 5fab9a3be9a7e7196fe5a76e94c990ab54a4cf6b Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 5 Nov 2025 14:47:17 +0100 Subject: [PATCH 33/44] fix trailing slash bug, formatting --- .../app/src/payment/stripe/checkoutUtils.ts | 4 ++-- .../app/src/payment/stripe/paymentDetails.ts | 2 +- .../src/payment/stripe/paymentProcessor.ts | 23 ++++++++++--------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 640bc19a8..0df2f26e1 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -44,8 +44,8 @@ export function createStripeCheckoutSession({ }, ], mode, - success_url: `${env.WASP_WEB_CLIENT_URL}/checkout?status=success`, - cancel_url: `${env.WASP_WEB_CLIENT_URL}/checkout?status=canceled`, + success_url: `${env.WASP_WEB_CLIENT_URL}checkout?status=success`, + cancel_url: `${env.WASP_WEB_CLIENT_URL}checkout?status=canceled`, automatic_tax: { enabled: true }, allow_promotion_codes: true, customer_update: { diff --git a/template/app/src/payment/stripe/paymentDetails.ts b/template/app/src/payment/stripe/paymentDetails.ts index 96deb9c78..ad7d94205 100644 --- a/template/app/src/payment/stripe/paymentDetails.ts +++ b/template/app/src/payment/stripe/paymentDetails.ts @@ -30,8 +30,8 @@ export function updateUserStripeOneTimePaymentDetails( interface UpdateUserStripeSubscriptionDetails { customerId: Stripe.Customer["id"]; - datePaid?: Date; subscriptionStatus: SubscriptionStatus; + datePaid?: Date; paymentPlanId?: PaymentPlanId; } diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index aa594d1d1..b29d866b0 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -53,23 +53,24 @@ export const stripePaymentProcessor: PaymentProcessor = { }; }, fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { - const user = await args.prismaUserDelegate.findUniqueOrThrow({ - where: { - id: args.userId, - }, - select: { - paymentProcessorUserId: true, - }, - }); + const { paymentProcessorUserId } = + await args.prismaUserDelegate.findUniqueOrThrow({ + where: { + id: args.userId, + }, + select: { + paymentProcessorUserId: true, + }, + }); - if (!user.paymentProcessorUserId) { + if (!paymentProcessorUserId) { return null; } const billingPortalSession = await stripeClient.billingPortal.sessions.create({ - customer: user.paymentProcessorUserId, - return_url: `${env.WASP_WEB_CLIENT_URL}/account`, + customer: paymentProcessorUserId, + return_url: `${env.WASP_WEB_CLIENT_URL}account`, }); return billingPortalSession.url; From ae1b4581cf5a765e193b3da893bc2eb09c954b66 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 5 Nov 2025 14:55:54 +0100 Subject: [PATCH 34/44] update diff --- .../payment/stripe/paymentProcessor.ts.diff | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index 7b481ca26..9cd612c30 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -9,24 +9,35 @@ }, }); -@@ -58,17 +58,17 @@ - id: args.userId, - }, - select: { -- paymentProcessorUserId: true, -+ stripeId: true, - }, - }); +@@ -53,24 +53,24 @@ + }; + }, + fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { +- const { paymentProcessorUserId } = ++ const { stripeId } = + await args.prismaUserDelegate.findUniqueOrThrow({ + where: { + id: args.userId, + }, + select: { +- paymentProcessorUserId: true, +- }, +- }); ++ stripeId: true, ++ }, ++ }); -- if (!user.paymentProcessorUserId) { -+ if (!user.stripeId) { +- if (!paymentProcessorUserId) { ++ if (!stripeId) { return null; } const billingPortalSession = await stripeClient.billingPortal.sessions.create({ -- customer: user.paymentProcessorUserId, -+ customer: user.stripeId, - return_url: `${env.WASP_WEB_CLIENT_URL}/account`, +- customer: paymentProcessorUserId, +- return_url: `${env.WASP_WEB_CLIENT_URL}account`, ++ customer: stripeId, ++ return_url: `${env.WASP_WEB_CLIENT_URL}/account`, }); + return billingPortalSession.url; From 8b649e06473ec986140c84d16c33698a36d3ab03 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 6 Nov 2025 12:21:00 +0100 Subject: [PATCH 35/44] abstract more user stuff, move to user.ts, fixes --- template/app/src/payment/paymentProcessor.ts | 11 ++- .../app/src/payment/stripe/checkoutUtils.ts | 3 +- .../app/src/payment/stripe/paymentDetails.ts | 57 ------------ .../src/payment/stripe/paymentProcessor.ts | 33 +++---- template/app/src/payment/stripe/user.ts | 92 +++++++++++++++++++ template/app/src/payment/stripe/webhook.ts | 16 ++-- 6 files changed, 125 insertions(+), 87 deletions(-) delete mode 100644 template/app/src/payment/stripe/paymentDetails.ts create mode 100644 template/app/src/payment/stripe/user.ts diff --git a/template/app/src/payment/paymentProcessor.ts b/template/app/src/payment/paymentProcessor.ts index 498eb952f..221db24b3 100644 --- a/template/app/src/payment/paymentProcessor.ts +++ b/template/app/src/payment/paymentProcessor.ts @@ -1,17 +1,18 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, User } from "@prisma/client"; import type { MiddlewareConfigFn } from "wasp/server"; import type { PaymentsWebhook } from "wasp/server/api"; import type { PaymentPlan } from "./plans"; import { stripePaymentProcessor } from "./stripe/paymentProcessor"; export interface CreateCheckoutSessionArgs { - userId: string; - userEmail: string; + userId: User["id"]; + userEmail: NonNullable; paymentPlan: PaymentPlan; prismaUserDelegate: PrismaClient["user"]; } + export interface FetchCustomerPortalUrlArgs { - userId: string; + userId: User["id"]; prismaUserDelegate: PrismaClient["user"]; } @@ -31,5 +32,5 @@ export interface PaymentProcessor { * Choose which payment processor you'd like to use, then delete the * other payment processor code that you're not using from `/src/payment` */ -// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; +// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 0df2f26e1..4e6828699 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,3 +1,4 @@ +import { User } from "@prisma/client"; import Stripe from "stripe"; import { env } from "wasp/server"; import { stripeClient } from "./stripeClient"; @@ -7,7 +8,7 @@ import { stripeClient } from "./stripeClient"; * Implements email uniqueness logic since Stripe doesn't enforce unique emails. */ export async function ensureStripeCustomer( - userEmail: string, + userEmail: NonNullable, ): Promise { const stripeCustomers = await stripeClient.customers.list({ email: userEmail, diff --git a/template/app/src/payment/stripe/paymentDetails.ts b/template/app/src/payment/stripe/paymentDetails.ts deleted file mode 100644 index ad7d94205..000000000 --- a/template/app/src/payment/stripe/paymentDetails.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import Stripe from "stripe"; -import type { SubscriptionStatus } from "../plans"; -import { PaymentPlanId } from "../plans"; - -interface UpdateUserStripeOneTimePaymentDetails { - customerId: Stripe.Customer["id"]; - datePaid: Date; - numOfCreditsPurchased: number; -} - -export function updateUserStripeOneTimePaymentDetails( - { - customerId, - datePaid, - numOfCreditsPurchased, - }: UpdateUserStripeOneTimePaymentDetails, - userDelegate: PrismaClient["user"], -) { - return userDelegate.update({ - where: { - paymentProcessorUserId: customerId, - }, - data: { - datePaid, - credits: { increment: numOfCreditsPurchased }, - }, - }); -} - -interface UpdateUserStripeSubscriptionDetails { - customerId: Stripe.Customer["id"]; - subscriptionStatus: SubscriptionStatus; - datePaid?: Date; - paymentPlanId?: PaymentPlanId; -} - -export function updateUserStripeSubscriptionDetails( - { - customerId, - paymentPlanId, - subscriptionStatus, - datePaid, - }: UpdateUserStripeSubscriptionDetails, - userDelegate: PrismaClient["user"], -) { - return userDelegate.update({ - where: { - paymentProcessorUserId: customerId, - }, - data: { - subscriptionPlan: paymentPlanId, - subscriptionStatus, - datePaid, - }, - }); -} diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index b29d866b0..55a355329 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -12,6 +12,10 @@ import { ensureStripeCustomer, } from "./checkoutUtils"; import { stripeClient } from "./stripeClient"; +import { + fetchUserPaymentProcessorUserId, + updateUserPaymentProcessorUserId, +} from "./user"; import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; export const stripePaymentProcessor: PaymentProcessor = { @@ -24,14 +28,13 @@ export const stripePaymentProcessor: PaymentProcessor = { }: CreateCheckoutSessionArgs) => { const customer = await ensureStripeCustomer(userEmail); - await prismaUserDelegate.update({ - where: { - id: userId, - }, - data: { + await updateUserPaymentProcessorUserId( + { + userId, paymentProcessorUserId: customer.id, }, - }); + prismaUserDelegate, + ); const stripeSession = await createStripeCheckoutSession({ customerId: customer.id, @@ -52,16 +55,14 @@ export const stripePaymentProcessor: PaymentProcessor = { }, }; }, - fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { - const { paymentProcessorUserId } = - await args.prismaUserDelegate.findUniqueOrThrow({ - where: { - id: args.userId, - }, - select: { - paymentProcessorUserId: true, - }, - }); + fetchCustomerPortalUrl: async ({ + prismaUserDelegate, + userId, + }: FetchCustomerPortalUrlArgs) => { + const paymentProcessorUserId = await fetchUserPaymentProcessorUserId( + userId, + prismaUserDelegate, + ); if (!paymentProcessorUserId) { return null; diff --git a/template/app/src/payment/stripe/user.ts b/template/app/src/payment/stripe/user.ts new file mode 100644 index 000000000..f0eb37ee7 --- /dev/null +++ b/template/app/src/payment/stripe/user.ts @@ -0,0 +1,92 @@ +import { PrismaClient, User } from "@prisma/client"; +import Stripe from "stripe"; +import type { SubscriptionStatus } from "../plans"; +import { PaymentPlanId } from "../plans"; + +export async function fetchUserPaymentProcessorUserId( + userId: User["id"], + prismaUserDelegate: PrismaClient["user"], +): Promise { + const user = await prismaUserDelegate.findUniqueOrThrow({ + where: { + id: userId, + }, + select: { + paymentProcessorUserId: true, + }, + }); + + return user.paymentProcessorUserId; +} + +interface UpdateUserPaymentProcessorUserIdArgs { + userId: User["id"]; + paymentProcessorUserId: NonNullable; +} + +export function updateUserPaymentProcessorUserId( + { userId, paymentProcessorUserId }: UpdateUserPaymentProcessorUserIdArgs, + prismaUserDelegate: PrismaClient["user"], +): Promise { + return prismaUserDelegate.update({ + where: { + id: userId, + }, + data: { + paymentProcessorUserId, + }, + }); +} + +interface UpdateUserOneTimePaymentDetailsArgs { + customerId: Stripe.Customer["id"]; + datePaid: Date; + numOfCreditsPurchased: number; +} + +export function updateUserOneTimePaymentDetails( + { + customerId, + datePaid, + numOfCreditsPurchased, + }: UpdateUserOneTimePaymentDetailsArgs, + userDelegate: PrismaClient["user"], +): Promise { + return userDelegate.update({ + where: { + paymentProcessorUserId: customerId, + }, + data: { + datePaid, + credits: { increment: numOfCreditsPurchased }, + }, + }); +} + +interface UpdateUserSubscriptionDetailsArgs { + customerId: Stripe.Customer["id"]; + subscriptionStatus: SubscriptionStatus; + datePaid?: Date; + paymentPlanId?: PaymentPlanId; +} + +export function updateUserSubscriptionDetails( + { + customerId, + paymentPlanId, + subscriptionStatus, + datePaid, + }: UpdateUserSubscriptionDetailsArgs, + userDelegate: PrismaClient["user"], +): Promise { + return userDelegate.update({ + where: { + paymentProcessorUserId: customerId, + }, + data: { + subscriptionPlan: paymentPlanId, + subscriptionStatus, + datePaid, + }, + }); +} diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index e99dae61b..d4fc054be 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -8,11 +8,11 @@ import { requireNodeEnvVar } from "../../server/utils"; import { assertUnreachable } from "../../shared/utils"; import { UnhandledWebhookEventError } from "../errors"; import { PaymentPlanId, paymentPlans, SubscriptionStatus } from "../plans"; -import { - updateUserStripeOneTimePaymentDetails, - updateUserStripeSubscriptionDetails, -} from "./paymentDetails"; import { stripeClient } from "./stripeClient"; +import { + updateUserOneTimePaymentDetails, + updateUserSubscriptionDetails, +} from "./user"; /** * Stripe requires a raw request to construct events successfully. @@ -113,7 +113,7 @@ async function handleInvoicePaid( switch (paymentPlanId) { case PaymentPlanId.Credits10: - await updateUserStripeOneTimePaymentDetails( + await updateUserOneTimePaymentDetails( { customerId, datePaid: invoicePaidAtDate, @@ -124,7 +124,7 @@ async function handleInvoicePaid( break; case PaymentPlanId.Pro: case PaymentPlanId.Hobby: - await updateUserStripeSubscriptionDetails( + await updateUserSubscriptionDetails( { customerId, datePaid: invoicePaidAtDate, @@ -173,7 +173,7 @@ async function handleCustomerSubscriptionUpdated( getSubscriptionPriceId(subscription), ); - const user = await updateUserStripeSubscriptionDetails( + const user = await updateUserSubscriptionDetails( { customerId, paymentPlanId, subscriptionStatus }, prismaUserDelegate, ); @@ -229,7 +229,7 @@ async function handleCustomerSubscriptionDeleted( const subscription = event.data.object; const customerId = getCustomerId(subscription.customer); - await updateUserStripeSubscriptionDetails( + await updateUserSubscriptionDetails( { customerId, subscriptionStatus: SubscriptionStatus.Deleted }, prismaUserDelegate, ); From 17586a7fa13dde8874cfe78bfce3e3fe2c4b03af Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 6 Nov 2025 12:25:51 +0100 Subject: [PATCH 36/44] diff --- .../src/payment/paymentProcessor.ts.diff | 4 +- .../src/payment/stripe/paymentDetails.ts.diff | 20 ------- .../payment/stripe/paymentProcessor.ts.diff | 38 ++++++------- .../app_diff/src/payment/stripe/user.ts.diff | 54 +++++++++++++++++++ 4 files changed, 71 insertions(+), 45 deletions(-) delete mode 100644 opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff create mode 100644 opensaas-sh/app_diff/src/payment/stripe/user.ts.diff diff --git a/opensaas-sh/app_diff/src/payment/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/paymentProcessor.ts.diff index 979d0136f..2fd85bc27 100644 --- a/opensaas-sh/app_diff/src/payment/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/paymentProcessor.ts.diff @@ -1,6 +1,6 @@ --- template/app/src/payment/paymentProcessor.ts +++ opensaas-sh/app/src/payment/paymentProcessor.ts -@@ -27,9 +27,4 @@ +@@ -28,9 +28,4 @@ webhookMiddlewareConfigFn: MiddlewareConfigFn; } @@ -8,5 +8,5 @@ - * Choose which payment processor you'd like to use, then delete the - * other payment processor code that you're not using from `/src/payment` - */ --// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; +-// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff deleted file mode 100644 index e6e8160c4..000000000 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff +++ /dev/null @@ -1,20 +0,0 @@ ---- template/app/src/payment/stripe/paymentDetails.ts -+++ opensaas-sh/app/src/payment/stripe/paymentDetails.ts -@@ -19,7 +19,7 @@ - ) { - return userDelegate.update({ - where: { -- paymentProcessorUserId: customerId, -+ stripeId: customerId, - }, - data: { - datePaid, -@@ -46,7 +46,7 @@ - ) { - return userDelegate.update({ - where: { -- paymentProcessorUserId: customerId, -+ stripeId: customerId, - }, - data: { - subscriptionPlan: paymentPlanId, diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index 9cd612c30..b355b8e80 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -1,31 +1,23 @@ --- template/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts -@@ -29,7 +29,7 @@ - id: userId, - }, - data: { +@@ -31,7 +31,7 @@ + await updateUserPaymentProcessorUserId( + { + userId, - paymentProcessorUserId: customer.id, + stripeId: customer.id, }, - }); - -@@ -53,24 +53,24 @@ - }; - }, - fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { -- const { paymentProcessorUserId } = -+ const { stripeId } = - await args.prismaUserDelegate.findUniqueOrThrow({ - where: { - id: args.userId, - }, - select: { -- paymentProcessorUserId: true, -- }, -- }); -+ stripeId: true, -+ }, -+ }); + prismaUserDelegate, + ); +@@ -59,19 +59,19 @@ + prismaUserDelegate, + userId, + }: FetchCustomerPortalUrlArgs) => { +- const paymentProcessorUserId = await fetchUserPaymentProcessorUserId( ++ const stripeId = await fetchUserPaymentProcessorUserId( + userId, + prismaUserDelegate, + ); - if (!paymentProcessorUserId) { + if (!stripeId) { diff --git a/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff new file mode 100644 index 000000000..b28759045 --- /dev/null +++ b/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff @@ -0,0 +1,54 @@ +--- template/app/src/payment/stripe/user.ts ++++ opensaas-sh/app/src/payment/stripe/user.ts +@@ -12,20 +12,20 @@ + id: userId, + }, + select: { +- paymentProcessorUserId: true, ++ stripeId: true, + }, + }); + +- return user.paymentProcessorUserId; ++ return user.stripeId; + } + + interface UpdateUserPaymentProcessorUserIdArgs { + userId: User["id"]; +- paymentProcessorUserId: NonNullable; ++ stripeId: NonNullable; + } + + export function updateUserPaymentProcessorUserId( +- { userId, paymentProcessorUserId }: UpdateUserPaymentProcessorUserIdArgs, ++ { userId, stripeId }: UpdateUserPaymentProcessorUserIdArgs, + prismaUserDelegate: PrismaClient["user"], + ): Promise { + return prismaUserDelegate.update({ +@@ -33,7 +33,7 @@ + id: userId, + }, + data: { +- paymentProcessorUserId, ++ stripeId + }, + }); + } +@@ -54,7 +54,7 @@ + ): Promise { + return userDelegate.update({ + where: { +- paymentProcessorUserId: customerId, ++ stripeId: customerId, + }, + data: { + datePaid, +@@ -81,7 +81,7 @@ + ): Promise { + return userDelegate.update({ + where: { +- paymentProcessorUserId: customerId, ++ stripeId: customerId, + }, + data: { + subscriptionPlan: paymentPlanId, From 631f237bf621e6e4c929f4d7617538cc2b6f7204 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 6 Nov 2025 12:28:43 +0100 Subject: [PATCH 37/44] fix diffs --- .../payment/stripe/paymentProcessor.ts.diff | 21 ++++++++++++++++--- .../app_diff/src/payment/stripe/user.ts.diff | 19 +++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index b355b8e80..fb1bb2acf 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -1,7 +1,22 @@ --- template/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts -@@ -31,7 +31,7 @@ - await updateUserPaymentProcessorUserId( +@@ -13,8 +13,8 @@ + } from "./checkoutUtils"; + import { stripeClient } from "./stripeClient"; + import { +- fetchUserPaymentProcessorUserId, +- updateUserPaymentProcessorUserId, ++ fetchUserStripeId, ++ updateUserStripeId + } from "./user"; + import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; + +@@ -28,10 +28,10 @@ + }: CreateCheckoutSessionArgs) => { + const customer = await ensureStripeCustomer(userEmail); + +- await updateUserPaymentProcessorUserId( ++ await updateUserStripeId( { userId, - paymentProcessorUserId: customer.id, @@ -14,7 +29,7 @@ userId, }: FetchCustomerPortalUrlArgs) => { - const paymentProcessorUserId = await fetchUserPaymentProcessorUserId( -+ const stripeId = await fetchUserPaymentProcessorUserId( ++ const stripeId = await fetchUserStripeId( userId, prismaUserDelegate, ); diff --git a/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff index b28759045..e351ef207 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff @@ -1,5 +1,14 @@ --- template/app/src/payment/stripe/user.ts +++ opensaas-sh/app/src/payment/stripe/user.ts +@@ -3,7 +3,7 @@ + import type { SubscriptionStatus } from "../plans"; + import { PaymentPlanId } from "../plans"; + +-export async function fetchUserPaymentProcessorUserId( ++export async function fetchUserStripeId( + userId: User["id"], + prismaUserDelegate: PrismaClient["user"], + ): Promise { @@ -12,20 +12,20 @@ id: userId, }, @@ -13,15 +22,17 @@ + return user.stripeId; } - interface UpdateUserPaymentProcessorUserIdArgs { +-interface UpdateUserPaymentProcessorUserIdArgs { ++interface UpdateUserStripeIdArgs { userId: User["id"]; - paymentProcessorUserId: NonNullable; -+ stripeId: NonNullable; ++ stripeId: NonNullable; } - export function updateUserPaymentProcessorUserId( +-export function updateUserPaymentProcessorUserId( - { userId, paymentProcessorUserId }: UpdateUserPaymentProcessorUserIdArgs, -+ { userId, stripeId }: UpdateUserPaymentProcessorUserIdArgs, ++export function updateUserStripeId( ++ { userId, stripeId }: UpdateUserStripeIdArgs, prismaUserDelegate: PrismaClient["user"], ): Promise { return prismaUserDelegate.update({ From b9c830428f46dfe373a977283f82fa0fc13d5f49 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 6 Nov 2025 12:57:03 +0100 Subject: [PATCH 38/44] env to config --- .../app_diff/src/payment/stripe/paymentProcessor.ts.diff | 6 ++---- template/app/src/payment/stripe/checkoutUtils.ts | 6 +++--- template/app/src/payment/stripe/paymentProcessor.ts | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff index fb1bb2acf..125100019 100644 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff +++ b/opensaas-sh/app_diff/src/payment/stripe/paymentProcessor.ts.diff @@ -24,7 +24,7 @@ }, prismaUserDelegate, ); -@@ -59,19 +59,19 @@ +@@ -59,18 +59,18 @@ prismaUserDelegate, userId, }: FetchCustomerPortalUrlArgs) => { @@ -42,9 +42,7 @@ const billingPortalSession = await stripeClient.billingPortal.sessions.create({ - customer: paymentProcessorUserId, -- return_url: `${env.WASP_WEB_CLIENT_URL}account`, + customer: stripeId, -+ return_url: `${env.WASP_WEB_CLIENT_URL}/account`, + return_url: `${config.frontendUrl}/account`, }); - return billingPortalSession.url; diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 4e6828699..598c5eb02 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,6 +1,6 @@ import { User } from "@prisma/client"; import Stripe from "stripe"; -import { env } from "wasp/server"; +import { config } from "wasp/server"; import { stripeClient } from "./stripeClient"; /** @@ -45,8 +45,8 @@ export function createStripeCheckoutSession({ }, ], mode, - success_url: `${env.WASP_WEB_CLIENT_URL}checkout?status=success`, - cancel_url: `${env.WASP_WEB_CLIENT_URL}checkout?status=canceled`, + success_url: `${config.frontendUrl}/checkout?status=success`, + cancel_url: `${config.frontendUrl}/checkout?status=canceled`, automatic_tax: { enabled: true }, allow_promotion_codes: true, customer_update: { diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 55a355329..681a2c039 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,5 +1,5 @@ import Stripe from "stripe"; -import { env } from "wasp/server"; +import { config } from "wasp/server"; import { assertUnreachable } from "../../shared/utils"; import type { CreateCheckoutSessionArgs, @@ -71,7 +71,7 @@ export const stripePaymentProcessor: PaymentProcessor = { const billingPortalSession = await stripeClient.billingPortal.sessions.create({ customer: paymentProcessorUserId, - return_url: `${env.WASP_WEB_CLIENT_URL}account`, + return_url: `${config.frontendUrl}/account`, }); return billingPortalSession.url; From 39381df050defe0101aaa856cd192c20d5b7a3f8 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Mon, 10 Nov 2025 22:08:48 +0100 Subject: [PATCH 39/44] user import --- template/app/src/payment/paymentProcessor.ts | 3 ++- template/app/src/payment/stripe/checkoutUtils.ts | 2 +- template/app/src/payment/stripe/user.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/template/app/src/payment/paymentProcessor.ts b/template/app/src/payment/paymentProcessor.ts index 221db24b3..123779ba7 100644 --- a/template/app/src/payment/paymentProcessor.ts +++ b/template/app/src/payment/paymentProcessor.ts @@ -1,4 +1,5 @@ -import { PrismaClient, User } from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; +import { User } from "wasp/entities"; import type { MiddlewareConfigFn } from "wasp/server"; import type { PaymentsWebhook } from "wasp/server/api"; import type { PaymentPlan } from "./plans"; diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index 598c5eb02..bd38cdaf5 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,5 +1,5 @@ -import { User } from "@prisma/client"; import Stripe from "stripe"; +import { User } from "wasp/entities"; import { config } from "wasp/server"; import { stripeClient } from "./stripeClient"; diff --git a/template/app/src/payment/stripe/user.ts b/template/app/src/payment/stripe/user.ts index f0eb37ee7..e232c6b3d 100644 --- a/template/app/src/payment/stripe/user.ts +++ b/template/app/src/payment/stripe/user.ts @@ -1,5 +1,6 @@ -import { PrismaClient, User } from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; import Stripe from "stripe"; +import { User } from "wasp/entities"; import type { SubscriptionStatus } from "../plans"; import { PaymentPlanId } from "../plans"; From 99f01adb5b28ecf581d04a6da0d0c211bd8eaa30 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Mon, 10 Nov 2025 22:10:44 +0100 Subject: [PATCH 40/44] move jsdoc to comment + simplify logic --- template/app/src/payment/stripe/webhook.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index d4fc054be..236b6af14 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -206,14 +206,13 @@ function getOpenSaasSubscriptionStatus( return subscriptionStatus; } -/** - * We only expect one subscription item, if your workflow expects more, you should change this function to handle them. - */ function getSubscriptionPriceId( subscription: Stripe.Subscription, ): Stripe.Price["id"] { const subscriptionItems = subscription.items.data; - if (subscriptionItems.length === 0 || subscriptionItems.length > 1) { + // We only expect one subscription item. + // If your workflow expects more, you should change this function to handle them. + if (subscriptionItems.length !== 1) { throw new Error( "There should be exactly one subscription item in Stripe subscription", ); From 0e17428179ad8e1f66d07468138512c16abd8292 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Mon, 10 Nov 2025 22:11:23 +0100 Subject: [PATCH 41/44] simplify --- template/app/src/payment/stripe/webhook.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 236b6af14..69db389d6 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -178,15 +178,13 @@ async function handleCustomerSubscriptionUpdated( 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...", - }); - } + if (subscription.cancel_at_period_end && 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...", + }); } } From 6928c60e9dc85fcba4fadbb137c869da95e0003e Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Mon, 10 Nov 2025 22:21:59 +0100 Subject: [PATCH 42/44] improve opensaas map --- template/app/src/payment/stripe/webhook.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 69db389d6..6e42252fb 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -191,17 +191,14 @@ async function handleCustomerSubscriptionUpdated( function getOpenSaasSubscriptionStatus( subscription: Stripe.Subscription, ): SubscriptionStatus | undefined { - let subscriptionStatus: SubscriptionStatus | undefined; if (subscription.status === SubscriptionStatus.Active) { - subscriptionStatus = SubscriptionStatus.Active; if (subscription.cancel_at_period_end) { - subscriptionStatus = SubscriptionStatus.CancelAtPeriodEnd; + return SubscriptionStatus.CancelAtPeriodEnd; } + return SubscriptionStatus.Active; } else if (subscription.status === SubscriptionStatus.PastDue) { - subscriptionStatus = SubscriptionStatus.PastDue; + return SubscriptionStatus.PastDue; } - - return subscriptionStatus; } function getSubscriptionPriceId( From abb546ce874457d7efc1bebe45c15f9e461b476a Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Tue, 11 Nov 2025 14:59:37 +0100 Subject: [PATCH 43/44] fix comment --- template/app/src/payment/stripe/webhook.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/template/app/src/payment/stripe/webhook.ts b/template/app/src/payment/stripe/webhook.ts index 6e42252fb..44d1924d6 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -139,12 +139,11 @@ async function handleInvoicePaid( } } -/** - * We only expect one line item, if your workflow expects more, you should change this function to handle them. - */ function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price["id"] { const invoiceLineItems = invoice.lines.data; - if (invoiceLineItems.length === 0 || invoiceLineItems.length > 1) { + // We only expect one line item. + // If your workflow expects more, you should change this function to handle them. + if (invoiceLineItems.length !== 1) { throw new Error("There should be exactly one line item in Stripe invoice"); } From 5f894c0a813274e5fed879bac17c918574498137 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Tue, 11 Nov 2025 15:20:47 +0100 Subject: [PATCH 44/44] remove logs --- template/app/src/payment/stripe/checkoutUtils.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/template/app/src/payment/stripe/checkoutUtils.ts b/template/app/src/payment/stripe/checkoutUtils.ts index bd38cdaf5..d7b9d99a6 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -15,12 +15,10 @@ export async function ensureStripeCustomer( }); if (stripeCustomers.data.length === 0) { - console.log("Creating a new Stripe customer"); return stripeClient.customers.create({ email: userEmail, }); } else { - console.log("Using an existing Stripe customer"); return stripeCustomers.data[0]; } }