diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0e47c3e36..596b307f9 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/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/analytics/stats.ts.diff b/opensaas-sh/app_diff/src/analytics/stats.ts.diff index 58361dfbe..c4e19c4ee 100644 --- a/opensaas-sh/app_diff/src/analytics/stats.ts.diff +++ b/opensaas-sh/app_diff/src/analytics/stats.ts.diff @@ -6,7 +6,7 @@ import { type DailyStats } from "wasp/entities"; import { type DailyStatsJob } from "wasp/server/jobs"; +import { SubscriptionStatus } from "../payment/plans"; - import { stripe } from "../payment/stripe/stripeClient"; + import { stripeClient } from "../payment/stripe/stripeClient"; import { getDailyPageViews, getSources, @@ -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/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/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 b9dd15868..000000000 --- a/opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff +++ /dev/null @@ -1,15 +0,0 @@ ---- template/app/src/payment/stripe/paymentDetails.ts -+++ opensaas-sh/app/src/payment/stripe/paymentDetails.ts -@@ -20,10 +20,10 @@ - ) => { - return userDelegate.update({ - where: { -- paymentProcessorUserId: userStripeId, -+ stripeId: userStripeId, - }, - data: { -- paymentProcessorUserId: userStripeId, -+ stripeId: userStripeId, - subscriptionPlan, - subscriptionStatus, - datePaid, 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 bd3d9ae41..125100019 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,48 @@ --- template/app/src/payment/stripe/paymentProcessor.ts +++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts -@@ -32,7 +32,7 @@ - id: userId, - }, - data: { +@@ -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, + stripeId: customer.id, }, - }); - if (!stripeSession.url) + prismaUserDelegate, + ); +@@ -59,18 +59,18 @@ + prismaUserDelegate, + userId, + }: FetchCustomerPortalUrlArgs) => { +- const paymentProcessorUserId = await fetchUserPaymentProcessorUserId( ++ const stripeId = await fetchUserStripeId( + userId, + prismaUserDelegate, + ); + +- if (!paymentProcessorUserId) { ++ if (!stripeId) { + return null; + } + + const billingPortalSession = + await stripeClient.billingPortal.sessions.create({ +- customer: paymentProcessorUserId, ++ customer: stripeId, + return_url: `${config.frontendUrl}/account`, + }); + 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..e351ef207 --- /dev/null +++ b/opensaas-sh/app_diff/src/payment/stripe/user.ts.diff @@ -0,0 +1,65 @@ +--- 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, + }, + select: { +- paymentProcessorUserId: true, ++ stripeId: true, + }, + }); + +- return user.paymentProcessorUserId; ++ return user.stripeId; + } + +-interface UpdateUserPaymentProcessorUserIdArgs { ++interface UpdateUserStripeIdArgs { + userId: User["id"]; +- paymentProcessorUserId: NonNullable; ++ stripeId: NonNullable; + } + +-export function updateUserPaymentProcessorUserId( +- { userId, paymentProcessorUserId }: UpdateUserPaymentProcessorUserIdArgs, ++export function updateUserStripeId( ++ { userId, stripeId }: UpdateUserStripeIdArgs, + 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, diff --git a/opensaas-sh/blog/README.md b/opensaas-sh/blog/README.md index 46eda1e33..706787a7f 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](https://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/guides/deploying.mdx b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx index 257db56e1..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 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 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: 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..bdcb740c7 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 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, 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 -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 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`. +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 87705c79d..73f6dd281 100644 --- a/template/app/src/analytics/stats.ts +++ b/template/app/src/analytics/stats.ts @@ -2,7 +2,7 @@ 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 "../payment/stripe/stripeClient"; +import { stripeClient } from "../payment/stripe/stripeClient"; import { getDailyPageViews, getSources, @@ -156,7 +156,8 @@ 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/paymentProcessor.ts b/template/app/src/payment/paymentProcessor.ts index 498eb952f..123779ba7 100644 --- a/template/app/src/payment/paymentProcessor.ts +++ b/template/app/src/payment/paymentProcessor.ts @@ -1,17 +1,19 @@ 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"; 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 +33,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/plans.ts b/template/app/src/payment/plans.ts index b0ac3bb62..d2c3935d5 100644 --- a/template/app/src/payment/plans.ts +++ b/template/app/src/payment/plans.ts @@ -14,8 +14,11 @@ 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; } @@ -24,7 +27,7 @@ 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"), @@ -40,7 +43,7 @@ export const paymentPlans: Record = { 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 adc6475b3..d7b9d99a6 100644 --- a/template/app/src/payment/stripe/checkoutUtils.ts +++ b/template/app/src/payment/stripe/checkoutUtils.ts @@ -1,64 +1,70 @@ -import type { StripeMode } from "./paymentProcessor"; - import Stripe from "stripe"; -import { stripe } from "./stripeClient"; +import { User } from "wasp/entities"; +import { config } from "wasp/server"; +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"; +/** + * 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: NonNullable, +): Promise { + const stripeCustomers = await stripeClient.customers.list({ + email: userEmail, + }); -export async function fetchStripeCustomer(customerEmail: string) { - let customer: Stripe.Customer; - try { - const stripeCustomers = await stripe.customers.list({ - email: customerEmail, + if (stripeCustomers.data.length === 0) { + return stripeClient.customers.create({ + email: userEmail, }); - if (!stripeCustomers.data.length) { - console.log("creating customer"); - customer = await stripe.customers.create({ - email: customerEmail, - }); - } else { - console.log("using existing customer"); - customer = stripeCustomers.data[0]; - } - return customer; - } catch (error) { - console.error(error); - throw error; + } else { + return stripeCustomers.data[0]; } } interface CreateStripeCheckoutSessionParams { - priceId: string; - customerId: string; - mode: StripeMode; + priceId: Stripe.Price["id"]; + customerId: Stripe.Customer["id"]; + mode: Stripe.Checkout.Session.Mode; } -export async function createStripeCheckoutSession({ +export function createStripeCheckoutSession({ priceId, customerId, mode, -}: CreateStripeCheckoutSessionParams) { - try { - return await stripe.checkout.sessions.create({ - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: mode, - success_url: `${DOMAIN}/checkout?status=success`, - cancel_url: `${DOMAIN}/checkout?status=canceled`, - automatic_tax: { enabled: true }, - allow_promotion_codes: true, - customer_update: { - address: "auto", +}: CreateStripeCheckoutSessionParams): Promise { + return stripeClient.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, }, - customer: customerId, - }); - } catch (error) { - console.error(error); - throw error; - } + ], + mode, + success_url: `${config.frontendUrl}/checkout?status=success`, + cancel_url: `${config.frontendUrl}/checkout?status=canceled`, + automatic_tax: { enabled: true }, + allow_promotion_codes: true, + customer_update: { + address: "auto", + }, + 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/paymentDetails.ts b/template/app/src/payment/stripe/paymentDetails.ts deleted file mode 100644 index 3d67941a2..000000000 --- a/template/app/src/payment/stripe/paymentDetails.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import type { SubscriptionStatus } from "../plans"; -import { PaymentPlanId } from "../plans"; - -export const updateUserStripePaymentDetails = async ( - { - userStripeId, - subscriptionPlan, - subscriptionStatus, - datePaid, - numOfCreditsPurchased, - }: { - userStripeId: string; - subscriptionPlan?: PaymentPlanId; - subscriptionStatus?: SubscriptionStatus; - numOfCreditsPurchased?: number; - datePaid?: Date; - }, - userDelegate: PrismaClient["user"], -) => { - return userDelegate.update({ - where: { - paymentProcessorUserId: userStripeId, - }, - data: { - paymentProcessorUserId: userStripeId, - subscriptionPlan, - 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 e52a00934..681a2c039 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -1,4 +1,6 @@ -import { requireNodeEnvVar } from "../../server/utils"; +import Stripe from "stripe"; +import { config } from "wasp/server"; +import { assertUnreachable } from "../../shared/utils"; import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, @@ -7,12 +9,15 @@ import type { import type { PaymentPlanEffect } from "../plans"; import { createStripeCheckoutSession, - fetchStripeCustomer, + ensureStripeCustomer, } from "./checkoutUtils"; +import { stripeClient } from "./stripeClient"; +import { + fetchUserPaymentProcessorUserId, + updateUserPaymentProcessorUserId, +} from "./user"; import { stripeMiddlewareConfigFn, stripeWebhook } from "./webhook"; -export type StripeMode = "subscription" | "payment"; - export const stripePaymentProcessor: PaymentProcessor = { id: "stripe", createCheckoutSession: async ({ @@ -21,40 +26,69 @@ export const stripePaymentProcessor: PaymentProcessor = { paymentPlan, prismaUserDelegate, }: CreateCheckoutSessionArgs) => { - const customer = await fetchStripeCustomer(userEmail); + const customer = await ensureStripeCustomer(userEmail); + + await updateUserPaymentProcessorUserId( + { + userId, + paymentProcessorUserId: customer.id, + }, + prismaUserDelegate, + ); + const stripeSession = await createStripeCheckoutSession({ - priceId: paymentPlan.getPaymentProcessorPlanId(), customerId: customer.id, - mode: paymentPlanEffectToStripeMode(paymentPlan.effect), + priceId: paymentPlan.getPaymentProcessorPlanId(), + mode: paymentPlanEffectToStripeCheckoutSessionMode(paymentPlan.effect), }); - await prismaUserDelegate.update({ - where: { - id: userId, - }, - data: { - paymentProcessorUserId: customer.id, + + 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, }, - }); - if (!stripeSession.url) - throw new Error("Error creating Stripe Checkout Session"); - const session = { - url: stripeSession.url, - id: stripeSession.id, }; - return { session }; }, - fetchCustomerPortalUrl: async (_args: FetchCustomerPortalUrlArgs) => - requireNodeEnvVar("STRIPE_CUSTOMER_PORTAL_URL"), + fetchCustomerPortalUrl: async ({ + prismaUserDelegate, + userId, + }: FetchCustomerPortalUrlArgs) => { + const paymentProcessorUserId = await fetchUserPaymentProcessorUserId( + userId, + prismaUserDelegate, + ); + + if (!paymentProcessorUserId) { + return null; + } + + const billingPortalSession = + await stripeClient.billingPortal.sessions.create({ + customer: paymentProcessorUserId, + return_url: `${config.frontendUrl}/account`, + }); + + return billingPortalSession.url; + }, webhook: stripeWebhook, webhookMiddlewareConfigFn: stripeMiddlewareConfigFn, }; -function paymentPlanEffectToStripeMode( - planEffect: PaymentPlanEffect, -): StripeMode { - const effectToMode: Record = { - 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); + } } diff --git a/template/app/src/payment/stripe/stripeClient.ts b/template/app/src/payment/stripe/stripeClient.ts index 3dd2a4178..e4f593d78 100644 --- a/template/app/src/payment/stripe/stripeClient.ts +++ b/template/app/src/payment/stripe/stripeClient.ts @@ -1,12 +1,30 @@ import Stripe from "stripe"; import { requireNodeEnvVar } from "../../server/utils"; -export const stripe = 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` - // npm package to the API version that matches your Stripe dashboard's one. - // For more details and alternative setups check - // https://docs.stripe.com/api/versioning . - apiVersion: "2025-04-30.basil", +/** + * The Stripe client API version. + * + * By default, Stripe uses the API version set in your Dashboard. + * + * You can override this by setting `apiVersion` when creating the Stripe client. + * This is useful for testing new API versions before updating the + * default version in your Dashboard. + * + * The Stripe Node SDK works with multiple API versions and follows semantic versioning. + * Major SDK versions typically correspond to Stripe's biannual releases (like 'basil'). + * Each SDK version uses the API version that was current when it was released. + * + * Note: '2025-04-30.basil' follows Stripe's newer versioning format where: + * - The date represents the release date + * - The suffix ('basil') indicates the major release name + * + * Monthly API updates use the same suffix as the last major release but with updated dates. + * + * @see https://docs.stripe.com/api/versioning + * @see https://docs.stripe.com/sdks/versioning + */ +const STRIPE_API_VERSION = "2025-04-30.basil"; + +export const stripeClient = new Stripe(requireNodeEnvVar("STRIPE_API_KEY"), { + apiVersion: STRIPE_API_VERSION, }); diff --git a/template/app/src/payment/stripe/user.ts b/template/app/src/payment/stripe/user.ts new file mode 100644 index 000000000..e232c6b3d --- /dev/null +++ b/template/app/src/payment/stripe/user.ts @@ -0,0 +1,93 @@ +import { PrismaClient } from "@prisma/client"; +import Stripe from "stripe"; +import { User } from "wasp/entities"; +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 fed32220d..44d1924d6 100644 --- a/template/app/src/payment/stripe/webhook.ts +++ b/template/app/src/payment/stripe/webhook.ts @@ -1,67 +1,85 @@ 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 { stripeClient } from "./stripeClient"; import { - PaymentPlanId, - paymentPlans, - SubscriptionStatus, - type PaymentPlanEffect, -} from "../plans"; -import { updateUserStripePaymentDetails } from "./paymentDetails"; -import { stripe } from "./stripeClient"; -import { - parseWebhookPayload, - type InvoicePaidData, - type SessionCompletedData, - type SubscriptionDeletedData, - type SubscriptionUpdatedData, -} from "./webhookPayload"; + updateUserOneTimePaymentDetails, + updateUserSubscriptionDetails, +} from "./user"; + +/** + * Stripe requires a raw request to construct events successfully. + */ +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, ) => { + 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); + + // 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(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); + throw new UnhandledWebhookEventError(stripeEvent.type); } - return response.json({ received: true }); // Stripe expects a 200 response to acknowledge receipt of the webhook - } catch (err) { - if (err instanceof UnhandledWebhookEventError) { - console.error(err.message); - return response.status(422).json({ error: err.message }); + return 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. + // 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("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) @@ -71,210 +89,175 @@ export const stripeWebhook: PaymentsWebhook = async ( }; function constructStripeEvent(request: express.Request): Stripe.Event { - try { - const secret = requireNodeEnvVar("STRIPE_WEBHOOK_SECRET"); - const sig = request.headers["stripe-signature"]; - if (!sig) { - throw new HttpError(400, "Stripe webhook signature not provided"); - } - return stripe.webhooks.constructEvent(request.body, sig, secret); - } catch (err) { - throw new HttpError(500, "Error constructing Stripe webhook event"); + const stripeWebhookSecret = requireNodeEnvVar("STRIPE_WEBHOOK_SECRET"); + const stripeSignature = request.headers["stripe-signature"]; + if (!stripeSignature) { + throw new Error("Stripe webhook signature not provided"); } -} -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 stripeClient.webhooks.constructEvent( + request.body, + stripeSignature, + stripeWebhookSecret, ); - 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, +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 invoicePaidAtDate = getInvoicePaidAtDate(invoice); + const paymentPlanId = getPaymentPlanIdByPriceId(getInvoicePriceId(invoice)); + + switch (paymentPlanId) { + case PaymentPlanId.Credits10: + await updateUserOneTimePaymentDetails( + { + customerId, + datePaid: invoicePaidAtDate, + numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount, + }, + prismaUserDelegate, + ); + break; + case PaymentPlanId.Pro: + case PaymentPlanId.Hobby: + await updateUserSubscriptionDetails( + { + customerId, + datePaid: invoicePaidAtDate, + 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, - ); -} +function getInvoicePriceId(invoice: Stripe.Invoice): Stripe.Price["id"] { + const invoiceLineItems = invoice.lines.data; + // 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"); + } -// 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); + const priceId = invoiceLineItems[0].pricing?.price_details?.price; + if (!priceId) { + throw new Error("Unable to extract price id from items"); + } + + return priceId; } -async function saveActiveSubscription( - invoice: InvoicePaidData, +async function handleCustomerSubscriptionUpdated( + event: Stripe.CustomerSubscriptionUpdatedEvent, 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, - }, +): Promise { + const subscription = event.data.object; + + // There are other subscription statuses, such as `trialing` that we are not handling. + const subscriptionStatus = getOpenSaasSubscriptionStatus(subscription); + if (!subscriptionStatus) { + return; + } + + const customerId = getCustomerId(subscription.customer); + const paymentPlanId = getPaymentPlanIdByPriceId( + getSubscriptionPriceId(subscription), + ); + + const user = await updateUserSubscriptionDetails( + { customerId, paymentPlanId, subscriptionStatus }, prismaUserDelegate, ); -} -async function handleCustomerSubscriptionUpdated( - subscription: SubscriptionUpdatedData, - prismaUserDelegate: PrismaClient["user"], -) { - const userStripeId = subscription.customer; - let subscriptionStatus: SubscriptionStatus | undefined; - const priceId = extractPriceId(subscription.items); - const subscriptionPlan = getPlanIdByPriceId(priceId); + 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...", + }); + } +} - // 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. +function getOpenSaasSubscriptionStatus( + subscription: Stripe.Subscription, +): SubscriptionStatus | undefined { if (subscription.status === SubscriptionStatus.Active) { - subscriptionStatus = subscription.cancel_at_period_end - ? SubscriptionStatus.CancelAtPeriodEnd - : SubscriptionStatus.Active; + if (subscription.cancel_at_period_end) { + return SubscriptionStatus.CancelAtPeriodEnd; + } + return SubscriptionStatus.Active; } else if (subscription.status === SubscriptionStatus.PastDue) { - subscriptionStatus = SubscriptionStatus.PastDue; + return SubscriptionStatus.PastDue; } - if (subscriptionStatus) { - const user = await updateUserStripePaymentDetails( - { userStripeId, subscriptionPlan, subscriptionStatus }, - prismaUserDelegate, +} + +function getSubscriptionPriceId( + subscription: Stripe.Subscription, +): Stripe.Price["id"] { + const subscriptionItems = subscription.items.data; + // 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", ); - 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; } + + return subscriptionItems[0].price.id; } 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); + + await updateUserSubscriptionDetails( + { 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) { - throw new HttpError(400, "No items in stripe event object"); +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; } - if (items.data.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. - if ("price" in item && item.price?.id) { - return item.price.id; - } - - // The 'pricing' property is found on 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"); +function getInvoicePaidAtDate(invoice: Stripe.Invoice): Date { + if (!invoice.status_transitions.paid_at) { + throw new Error("Invoice has not been paid yet"); } - return line_items; + + // 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); } -function getPlanIdByPriceId(priceId: string): PaymentPlanId { - const planId = Object.values(PaymentPlanId).find( - (planId) => paymentPlans[planId].getPaymentProcessorPlanId() === priceId, +function getPaymentPlanIdByPriceId(priceId: string): PaymentPlanId { + const paymentPlanId = Object.values(PaymentPlanId).find( + (paymentPlanId) => + paymentPlans[paymentPlanId].getPaymentProcessorPlanId() === priceId, ); - if (!planId) { - throw new Error(`No plan with Stripe price id ${priceId}`); + if (!paymentPlanId) { + 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); - } + return paymentPlanId; } diff --git a/template/app/src/payment/stripe/webhookPayload.ts b/template/app/src/payment/stripe/webhookPayload.ts deleted file mode 100644 index 3448a7d73..000000000 --- a/template/app/src/payment/stripe/webhookPayload.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Stripe } from "stripe"; -import { HttpError } from "wasp/server"; -import * as z from "zod"; -import { UnhandledWebhookEventError } from "../errors"; - -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< - typeof subscriptionUpdatedDataSchema ->; - -export type SubscriptionDeletedData = z.infer< - typeof subscriptionDeletedDataSchema ->; diff --git a/template/app/src/shared/utils.ts b/template/app/src/shared/utils.ts index c7cd68494..d403fafed 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"); } 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": { 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 ```