diff --git a/opensaas-sh/app_diff/deletions b/opensaas-sh/app_diff/deletions index 9399c08d4..870bd9c1f 100644 --- a/opensaas-sh/app_diff/deletions +++ b/opensaas-sh/app_diff/deletions @@ -6,4 +6,8 @@ src/payment/lemonSqueezy/paymentDetails.ts src/payment/lemonSqueezy/paymentProcessor.ts src/payment/lemonSqueezy/webhook.ts src/payment/lemonSqueezy/webhookPayload.ts +src/payment/polar/checkoutUtils.ts +src/payment/polar/paymentProcessor.ts +src/payment/polar/polarClient.ts +src/payment/polar/webhook.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 2c9374ae6..8a4fc098f 100644 --- a/opensaas-sh/app_diff/package-lock.json.diff +++ b/opensaas-sh/app_diff/package-lock.json.diff @@ -1,6 +1,6 @@ --- template/app/package-lock.json +++ opensaas-sh/app/package-lock.json -@@ -0,0 +1,14527 @@ +@@ -0,0 +1,14517 @@ +{ + "name": "opensaas", + "lockfileVersion": 3, @@ -15,7 +15,6 @@ + "@google-analytics/data": "4.1.0", + "@headlessui/react": "1.7.13", + "@hookform/resolvers": "^5.1.1", -+ "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", @@ -101,6 +100,69 @@ + "@tanstack/react-query": "~4.41.0" + } + }, ++ ".wasp/out/sdk/wasp/node_modules/express": { ++ "version": "5.1.0", ++ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", ++ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", ++ "license": "MIT", ++ "dependencies": { ++ "accepts": "^2.0.0", ++ "body-parser": "^2.2.0", ++ "content-disposition": "^1.0.0", ++ "content-type": "^1.0.5", ++ "cookie": "^0.7.1", ++ "cookie-signature": "^1.2.1", ++ "debug": "^4.4.0", ++ "encodeurl": "^2.0.0", ++ "escape-html": "^1.0.3", ++ "etag": "^1.8.1", ++ "finalhandler": "^2.1.0", ++ "fresh": "^2.0.0", ++ "http-errors": "^2.0.0", ++ "merge-descriptors": "^2.0.0", ++ "mime-types": "^3.0.0", ++ "on-finished": "^2.4.1", ++ "once": "^1.4.0", ++ "parseurl": "^1.3.3", ++ "proxy-addr": "^2.0.7", ++ "qs": "^6.14.0", ++ "range-parser": "^1.2.1", ++ "router": "^2.2.0", ++ "send": "^1.1.0", ++ "serve-static": "^2.2.0", ++ "statuses": "^2.0.1", ++ "type-is": "^2.0.1", ++ "vary": "^1.1.2" ++ }, ++ "engines": { ++ "node": ">= 18" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/express" ++ } ++ }, ++ ".wasp/out/sdk/wasp/node_modules/mime-db": { ++ "version": "1.54.0", ++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", ++ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.6" ++ } ++ }, ++ ".wasp/out/sdk/wasp/node_modules/mime-types": { ++ "version": "3.0.1", ++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", ++ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", ++ "license": "MIT", ++ "dependencies": { ++ "mime-db": "^1.54.0" ++ }, ++ "engines": { ++ "node": ">= 0.6" ++ } ++ }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -1801,15 +1863,6 @@ + "url": "https://opencollective.com/js-sdsl" + } + }, -+ "node_modules/@lemonsqueezy/lemonsqueezy.js": { -+ "version": "3.3.1", -+ "resolved": "https://registry.npmjs.org/@lemonsqueezy/lemonsqueezy.js/-/lemonsqueezy.js-3.3.1.tgz", -+ "integrity": "sha512-gM/FdNsK3BlrD6JRrhmiyqBXQsCpzSUdKSoZwJMQfXqfqcK321og+uMssc6HYcygUMrGvPnNJyJ1RqZPFDrgtg==", -+ "license": "MIT", -+ "engines": { -+ "node": ">=20" -+ } -+ }, + "node_modules/@lucia-auth/adapter-prisma": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@lucia-auth/adapter-prisma/-/adapter-prisma-4.0.1.tgz", @@ -7850,69 +7903,6 @@ + "url": "https://github.com/sponsors/sindresorhus" + } + }, -+ "node_modules/express": { -+ "version": "5.1.0", -+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", -+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", -+ "license": "MIT", -+ "dependencies": { -+ "accepts": "^2.0.0", -+ "body-parser": "^2.2.0", -+ "content-disposition": "^1.0.0", -+ "content-type": "^1.0.5", -+ "cookie": "^0.7.1", -+ "cookie-signature": "^1.2.1", -+ "debug": "^4.4.0", -+ "encodeurl": "^2.0.0", -+ "escape-html": "^1.0.3", -+ "etag": "^1.8.1", -+ "finalhandler": "^2.1.0", -+ "fresh": "^2.0.0", -+ "http-errors": "^2.0.0", -+ "merge-descriptors": "^2.0.0", -+ "mime-types": "^3.0.0", -+ "on-finished": "^2.4.1", -+ "once": "^1.4.0", -+ "parseurl": "^1.3.3", -+ "proxy-addr": "^2.0.7", -+ "qs": "^6.14.0", -+ "range-parser": "^1.2.1", -+ "router": "^2.2.0", -+ "send": "^1.1.0", -+ "serve-static": "^2.2.0", -+ "statuses": "^2.0.1", -+ "type-is": "^2.0.1", -+ "vary": "^1.1.2" -+ }, -+ "engines": { -+ "node": ">= 18" -+ }, -+ "funding": { -+ "type": "opencollective", -+ "url": "https://opencollective.com/express" -+ } -+ }, -+ "node_modules/express/node_modules/mime-db": { -+ "version": "1.54.0", -+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", -+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", -+ "license": "MIT", -+ "engines": { -+ "node": ">= 0.6" -+ } -+ }, -+ "node_modules/express/node_modules/mime-types": { -+ "version": "3.0.1", -+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", -+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", -+ "license": "MIT", -+ "dependencies": { -+ "mime-db": "^1.54.0" -+ }, -+ "engines": { -+ "node": ">= 0.6" -+ } -+ }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/opensaas-sh/app_diff/package.json.diff b/opensaas-sh/app_diff/package.json.diff index 7428bce2e..463d3ca6f 100644 --- a/opensaas-sh/app_diff/package.json.diff +++ b/opensaas-sh/app_diff/package.json.diff @@ -12,7 +12,16 @@ "dependencies": { "@aws-sdk/client-s3": "^3.523.0", "@aws-sdk/s3-presigned-post": "^3.750.0", -@@ -40,6 +45,7 @@ +@@ -12,8 +17,6 @@ + "@google-analytics/data": "4.1.0", + "@headlessui/react": "1.7.13", + "@hookform/resolvers": "^5.1.1", +- "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", +- "@polar-sh/sdk": "^0.34.3", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", +@@ -41,6 +44,7 @@ "react-apexcharts": "1.4.1", "react-dom": "^18.2.0", "react-hook-form": "^7.60.0", diff --git a/opensaas-sh/app_diff/src/analytics/stats.ts.diff b/opensaas-sh/app_diff/src/analytics/stats.ts.diff index c4e19c4ee..60d76725f 100644 --- a/opensaas-sh/app_diff/src/analytics/stats.ts.diff +++ b/opensaas-sh/app_diff/src/analytics/stats.ts.diff @@ -1,6 +1,6 @@ --- template/app/src/analytics/stats.ts +++ opensaas-sh/app/src/analytics/stats.ts -@@ -1,15 +1,13 @@ +@@ -1,18 +1,13 @@ -import { listOrders } from "@lemonsqueezy/lemonsqueezy.js"; import Stripe from "stripe"; import { type DailyStats } from "wasp/entities"; @@ -12,12 +12,15 @@ getSources, } from "./providers/plausibleAnalyticsUtils"; // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; +-import { OrderStatus } from "@polar-sh/sdk/models/components/orderstatus.js"; -import { paymentProcessor } from "../payment/paymentProcessor"; -import { SubscriptionStatus } from "../payment/plans"; +-import { polarClient } from "../payment/polar/polarClient"; +-import { assertUnreachable } from "../shared/utils"; export type DailyStatsProps = { dailyStats?: DailyStats; -@@ -52,19 +50,7 @@ +@@ -55,20 +50,7 @@ paidUserDelta -= yesterdaysStats.paidUserCount; } @@ -29,16 +32,17 @@ - case "lemonsqueezy": - totalRevenue = await fetchTotalLemonSqueezyRevenue(); - break; +- case "polar": +- totalRevenue = await fetchTotalPolarRevenue(); +- break; - default: -- throw new Error( -- `Unsupported payment processor: ${paymentProcessor.id}`, -- ); +- assertUnreachable(paymentProcessor.id); - } + let totalRevenue = await fetchTotalStripeRevenue(); const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews(); -@@ -177,38 +163,3 @@ +@@ -181,59 +163,3 @@ // Revenue is in cents so we convert to dollars (or your main currency unit) return totalRevenue / 100; } @@ -77,3 +81,24 @@ - throw error; - } -} +- +-async function fetchTotalPolarRevenue(): Promise { +- let totalRevenue = 0; +- +- const result = await polarClient.orders.list({ +- limit: 100, +- }); +- +- for await (const page of result) { +- const orders = page.result.items || []; +- +- for (const order of orders) { +- if (order.status === OrderStatus.Paid && order.totalAmount > 0) { +- totalRevenue += order.totalAmount; +- } +- } +- } +- +- // Revenue is in cents so we convert to dollars +- return totalRevenue / 100; +-} diff --git a/opensaas-sh/app_diff/src/payment/PricingPage.tsx.diff b/opensaas-sh/app_diff/src/payment/PricingPage.tsx.diff index 208d8d192..82abaf928 100644 --- a/opensaas-sh/app_diff/src/payment/PricingPage.tsx.diff +++ b/opensaas-sh/app_diff/src/payment/PricingPage.tsx.diff @@ -4,27 +4,7 @@ } from "./plans"; const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro; -+const PaymentsDocsURL = "https://docs.opensaas.sh/guides/payments-integration/"; ++const PaymentsDocsURL = "https://docs.opensaas.sh/guides/payment-integrations/"; interface PaymentPlanCard { name: string; -@@ -125,9 +126,16 @@ - - -

-- Choose between Stripe and LemonSqueezy as your payment provider. Just -- add your Product IDs! Try it out below with test credit card number{" "} --
-+ Choose between{" "} -+ -+ Stripe -+ {" "} -+ and{" "} -+ -+ LemonSqueezy -+ {" "} -+ as your payment provider. Just add your Product IDs! Try it out below -+ with test credit card number
- - 4242 4242 4242 4242 4242 - diff --git a/opensaas-sh/app_diff/src/payment/paymentProcessor.ts.diff b/opensaas-sh/app_diff/src/payment/paymentProcessor.ts.diff index dc2265a38..83bf39345 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 -@@ -29,9 +29,4 @@ +@@ -29,10 +29,4 @@ webhookMiddlewareConfigFn: MiddlewareConfigFn; } @@ -10,3 +10,4 @@ - */ export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; -// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; +-// export const paymentProcessor: PaymentProcessor = polarPaymentProcessor; diff --git a/opensaas-sh/blog/astro.config.mjs b/opensaas-sh/blog/astro.config.mjs index b404b1f9b..655025f60 100644 --- a/opensaas-sh/blog/astro.config.mjs +++ b/opensaas-sh/blog/astro.config.mjs @@ -62,7 +62,28 @@ export default defineConfig({ }, { label: "Guides", - autogenerate: { directory: "/guides/" }, + items: [ + { label: 'Analytics', link: "/guides/analytics/" }, + { label: 'Authentication', link: "/guides/authentication/" }, + { label: 'Authorization', link: "/guides/authorization/" }, + { label: 'Cookie Consent Modal', link: "/guides/cookie-consent/" }, + { + label: "Payment Integrations", + items: [ + { label: "Overview", link: "/guides/payment-integrations/" }, + { label: "Stripe", link: "/guides/payment-integrations/stripe/" }, + { label: "Lemon Squeezy", link: "/guides/payment-integrations/lemon-squeezy/" }, + { label: "Polar", link: "/guides/payment-integrations/polar/" }, + ] + }, + { label: 'Deploying', link: "/guides/deploying/" }, + { label: 'SEO', link: "/guides/seo/" }, + { label: 'Email Sending', link: "/guides/email-sending/" }, + { label: 'File Uploading', link: "/guides/file-uploading/" }, + { label: 'Tests', link: "/guides/tests/" }, + { label: 'How (Not) to Update Your Open SaaS App', link: "/guides/updating-opensaas/" }, + { label: 'Vibe Coding with Open SaaS', link: "/guides/vibe-coding/" }, + ] }, { label: "General", diff --git a/opensaas-sh/blog/src/assets/lemon-squeezy/ngrok.png b/opensaas-sh/blog/src/assets/ngrok.png similarity index 100% rename from opensaas-sh/blog/src/assets/lemon-squeezy/ngrok.png rename to opensaas-sh/blog/src/assets/ngrok.png diff --git a/opensaas-sh/blog/src/assets/polar/user-table.png b/opensaas-sh/blog/src/assets/polar/user-table.png new file mode 100644 index 000000000..5e2c9277c Binary files /dev/null and b/opensaas-sh/blog/src/assets/polar/user-table.png differ diff --git a/opensaas-sh/blog/src/assets/polar/webhook-log.png b/opensaas-sh/blog/src/assets/polar/webhook-log.png new file mode 100644 index 000000000..39a560370 Binary files /dev/null and b/opensaas-sh/blog/src/assets/polar/webhook-log.png differ 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 6c0660664..cd85bbb55 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 @@ -8,13 +8,13 @@ tags: - indiehackers authors: milica --- -import VideoPlayer from '../../../components/VideoPlayer.astro'; import { Image } from 'astro:assets'; import landing from '../../../assets/turboreel/landing.webp'; -import studioInterface from '../../../assets/turboreel/studio-interface.mp4'; import opensaas from '../../../assets/turboreel/opensaas.mp4'; -import reddit100Users from '../../../assets/turboreel/reddit-100-users.webp' -import reddit200Upvotes from '../../../assets/turboreel/reddit-200-upvotes.webp' +import reddit100Users from '../../../assets/turboreel/reddit-100-users.webp'; +import reddit200Upvotes from '../../../assets/turboreel/reddit-200-upvotes.webp'; +import studioInterface from '../../../assets/turboreel/studio-interface.mp4'; +import VideoPlayer from '../../../components/VideoPlayer.astro'; Peter is the creator of [**TurboReel**](https://turboreelgpt.tech/), an open-source platform with a paid SaaS layer, that transforms how creators generate short-form video content. With just a prompt, users can produce polished TikToks and YouTube Shorts in moments. @@ -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 Open SaaS 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/payment-integrations/) 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/general/admin-dashboard.mdx b/opensaas-sh/blog/src/content/docs/general/admin-dashboard.mdx index f1195d55d..bdf062238 100644 --- a/opensaas-sh/blog/src/content/docs/general/admin-dashboard.mdx +++ b/opensaas-sh/blog/src/content/docs/general/admin-dashboard.mdx @@ -4,9 +4,9 @@ banner: content: | Have an Open SaaS app in production? We'll send you some swag! πŸ‘• --- -import { Image } from 'astro:assets'; -import dbStudio from '@assets/stripe/db-studio.png'; import adminDashboard from '@assets/admin/admin-dashboard.png'; +import dbStudio from '@assets/stripe/db-studio.png'; +import { Image } from 'astro:assets'; This is a reference on how the Admin dashboard, available at `/admin`, is set up. @@ -55,7 +55,7 @@ If you're finding this template and its guides useful, consider giving us [a sta ### Analytics Dashboard The Admin analytics dashboard is a single place for you to view your most important metrics and perform some admin tasks. At the moment, it pulls data from: -- [Payments Processor](/guides/payments-integration/): +- [Payment Processor](/guides/payment-integrations/): - total revenue - revenue for each day of the past week - [Google or Plausible](/guides/analytics/): @@ -85,7 +85,7 @@ job dailyStatsJob { ``` For more info on Wasp's recurring background jobs, check out the [Wasp Jobs docs](https://wasp.sh/docs/advanced/jobs). -For a guide on how to integrate these services so that you can view your analytics via the dashboard, check out the [Payments Integration](/guides/payments-integration/) and [Analytics guide](/guides/analytics/) of the docs. +For a guide on how to integrate these services so that you can view your analytics via the dashboard, check out the [Payment Integrations](/guides/payment-integrations/) and [Analytics guide](/guides/analytics/) of the docs. :::note[Help us improve] We're always looking to improve the Admin dashboard. If you feel something is missing or could be improved, consider [opening an issue](https://github.com/wasp-lang/open-saas/issues) or [submitting a pull request](https://github.com/wasp-lang/open-saas/pulls) diff --git a/opensaas-sh/blog/src/content/docs/general/user-overview.md b/opensaas-sh/blog/src/content/docs/general/user-overview.md index faea88e58..90ffeb163 100644 --- a/opensaas-sh/blog/src/content/docs/general/user-overview.md +++ b/opensaas-sh/blog/src/content/docs/general/user-overview.md @@ -103,7 +103,7 @@ By default, we have three plans: `hobby` and `pro` subscription plans, as well a You can add more plans by adding more products and price IDs to your Stripe product and updating environment variables in your `.env.server` file as well as the relevant code in your app. -See the [Payments Integration Guide](/guides/payments-integration/) for more info on how to do this. +See the [Payment Integrations Guide](/guides/payment-integrations/) for more info on how to do this. ## User Roles diff --git a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx index 4100a3dbd..01697c130 100644 --- a/opensaas-sh/blog/src/content/docs/guides/deploying.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/deploying.mdx @@ -4,65 +4,64 @@ banner: content: | Have an Open SaaS app in production? We'll send you some swag! πŸ‘• --- -import { Image } from 'astro:assets'; -import npmVersion from '@assets/stripe/npm-version.png'; import stripeListenEvents from '@assets/stripe/listen-to-stripe-events.png'; +import npmVersion from '@assets/stripe/npm-version.png'; import stripeSigningSecret from '@assets/stripe/stripe-webhook-signing-secret.png'; +import { Image } from 'astro:assets'; -Because this SaaS app is a React/NodeJS/Postgres app built on top of [Wasp](https://wasp.sh), Open SaaS can take advantage of Wasp's easy, one-command deploy to Fly.io or manual deploy to any provider of your choice. +Because this SaaS app is a React/NodeJS/Postgres app built on top of [Wasp](https://wasp.sh), Open SaaS can take advantage of Wasp's easy, one-command deploy or manual deploy to any provider of your choice. -The simplest and quickest option is to take advantage of Wasp's one-command deploy to Fly.io. +The simplest and quickest option is to take advantage of Wasp's one-command deploy to Fly.io or Railway. -Or if you prefer to deploy to a different provider, or your frontend and backend separately, you can follow the Deploying Manually section below. +Or if you prefer to deploy to a different provider, or your frontend and backend separately, you can follow the [Deploying Manually](#deploying-manually) section below. -## Deploying your App -### Steps for Deploying +## Steps for Deploying These are the steps necessary for you to deploy your app. We recommend you follow these steps in order. -- [ ] Get your [production API keys and environment variables](#prerequisites) -- [ ] Deploy your app easily to [Fly.io](#deploying-to-flyio) or [manually](#deploying-manually--to-other-providers) to any provider. -- [ ] Add the correct [redirect URL's to your social auth credentials](#adding-server-redirect-urls-to-social-auth) -- [ ] Set up your [production webhooks for either [Stripe](#setting-up-your-production-stripe-webhook) or [Lemon Squeezy](#setting-up-your-production-lemon-squeezy-webhook) -- [ ] Set your [production environment variables](#other-vars) on your deployed apps -- [ ] (Optional) [Deploy your blog](#deploying-your-blog) +- [ ] Get your [production API keys and environment variables](#prerequisites). +- [ ] Set your [production environment variables](#other-vars) on your deployed apps. +- [ ] Add the correct [redirect URL's to your social auth credentials](#adding-server-redirect-urls-to-social-auth). +- [ ] Set your [production settings for your payment provider](#payment-providers-production-settings). +- [ ] [Deploy your App](#deploying-your-app) through Wasp Deploy commands or [manually](#deploying-manually--to-other-providers) to any provider. +- [ ] (Optional) [Deploy your blog](#deploying-your-blog). Each of these steps is covered in more detail below. -### Prerequisites -#### AWS S3 CORS configuration +## Prerequisites +### AWS S3 CORS configuration If you're storing files in AWS S3, ensure you've listed your production domain in the bucket's CORS configuration under `AllowedOrigins`. Check the [File uploading guide](/guides/file-uploading/#change-the-cors-settings) for details. -#### Env Vars +### Env Vars Make sure you've got all your API keys and environment variables set up before you deploy. -##### Payment Processor Vars -In the [Payments Processor integration guide](/guides/payments-integration/), you set up your API keys using test keys and test product ids. You'll need to get the live/production versions of those keys. To get these, repeat the instructions in the [Integration Guide](/guides/payments-integration/) without being in test mode. Add the new keys to your deployed environment secrets. +#### Payment Processor Vars +In the [Payments Processor integration guide](/guides/payment-integrations/), you set up your API keys using test keys and test product ids. You'll need to get the live/production versions of those keys. To get these, repeat the instructions in the [Integration Guide](/guides/payment-integrations/) without being in test mode. Add the new keys to your deployed environment secrets. -##### Other Vars +#### Other Vars Many of your other environment variables will probably be the same as in development, but you should double-check that they are set correctly for production. Here are a list of all of them (some of which you may not be using, e.g. Analytics, Social Auth) in case you need to check: -###### General Vars +##### General Vars - [ ] `DATABASE_URL` - [ ] `JWT_SECRET` - [ ] `WASP_WEB_CLIENT_URL` - [ ] `WASP_SERVER_URL` -###### Open AI API Key +##### Open AI API Key - [ ] `OPENAI_API_KEY` -###### Sendgrid API Key +##### Sendgrid API Key - [ ] `SENDGRID_API_KEY` -###### Social Auth Vars +##### Social Auth Vars - [ ] `GOOGLE_CLIENT_ID` - [ ] `GOOGLE_CLIENT_SECRET` - [ ] `GITHUB_CLIENT_ID` - [ ] `GITHUB_CLIENT_SECRET` -###### Analytics Vars +##### Analytics Vars - [ ] `REACT_APP_PLAUSIBLE_ANALYTICS_ID` (for client-side) - [ ] `PLAUSIBLE_API_KEY` - [ ] `PLAUSIBLE_SITE_ID` @@ -73,7 +72,7 @@ Here are a list of all of them (some of which you may not be using, e.g. Analyti - [ ] `GOOGLE_ANALYTICS_PRIVATE_KEY` (Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. See the [Analytics docs](/guides/analytics/#google-analytics) for more info) -###### AWS S3 Vars +##### AWS S3 Vars - [ ] `AWS_S3_IAM_ACCESS_KEY` - [ ] `AWS_S3_IAM_SECRET_KEY` - [ ] `AWS_S3_FILES_BUCKET` @@ -85,49 +84,13 @@ Do you have an Open SaaS app running in production? If yes, we'd love to send so ::: -### Deploying to Fly.io +## Adding Server Redirect URL's to Social Auth -[Fly.io](https://fly.io) is a platform for running your apps globally. It's a great choice for deploying your SaaS app because it's free to get started, can host your entire full-stack app in one place, scales well, and has one-command deploy integration with Wasp. +After deploying your server, you need to add the correct redirect URIs to the credential settings. For this, refer to [the guides from the Wasp Docs](https://wasp.sh/docs/auth/social-auth/overview). -**Wasp provides the handy `wasp deploy` command to deploy your entire full-stack app (DB, server, and client) in one command.** +## Payment Providers Production Settings -To learn how, please follow the detailed guide for [deploying to Fly via the Wasp CLI](https://wasp.sh/docs/deployment/deployment-methods/cli) from the Wasp documentation. We suggest you follow this guide carefully to get your app deployed. - -:::caution[Setting Environment Variables] -Remember, because we've set certain client-side env variables, make sure to pass them to the `wasp deploy` commands so that they can be included in the build: -```sh -REACT_APP_CLIENT_ENV_VAR_1=<...> REACT_APP_CLIENT_ENV_VAR_2=<...> wasp deploy -``` - -The `wasp deploy` command will also take care of setting the following server-side environment variables for you so you don't have to: -- `DATABASE_URL` -- `PORT` -- `JWT_SECRET` -- `WASP_WEB_CLIENT_URL` -- `WASP_SERVER_URL` - -For setting the remaining server-side environment variables, please refer to the [Deploying with the Wasp CLI Guide](https://wasp.sh/docs/deployment/deployment-methods/cli#launch). -::: - -### Deploying Manually / to Other Providers - -If you prefer to deploy manually, your frontend and backend separately, or just prefer using your favorite provider you can follow [Wasp's Manual Deployment Guide](https://wasp.sh/docs/deployment/deployment-methods/paas). - -:::caution[Client-side Environment Variables] -Remember to always set additional client-side environment variables, such as `REACT_APP_STRIPE_CUSTOMER_PORTAL` by appending them to the build command, e.g. -```sh -REACT_APP_CLIENT_ENV_VAR_1=<...> npm run build -``` -::: - -### Adding Server Redirect URL's to Social Auth - -After deploying your server, you need to add the correct redirect URIs to the credential settings. For this, refer to the following guides from the Wasp Docs: - -- [Google Auth](https://wasp.sh/docs/auth/social-auth/google#3-creating-a-google-oauth-app:~:text=Under%20Authorized%20redirect%20URIs) -- [Github Auth](https://wasp.sh/docs/auth/social-auth/github#3-creating-a-github-oauth-app:~:text=Authorization%20callback%20URL) - -### Setting up your Production Stripe Webhook +### Stripe Now you need to set up your stripe webhook for production use. Below are some important steps and considerations you should take as you prepare to deploy your app to production. @@ -139,8 +102,10 @@ Because this template was built with a specific version of the Stripe API in min :::note ```ts title="stripeClient.ts" -export const stripe = new Stripe(process.env.STRIPE_API_KEY!, { - apiVersion: 'YYYY-MM-DD', // e.g. 2023-08-16 +const STRIPE_API_VERSION = "2025-04-30.basil"; // or e.g. 2023-08-16 if using older format + +export const stripeClient = new Stripe(requireNodeEnvVar("STRIPE_API_KEY"), { + apiVersion: STRIPE_API_VERSION, }); ``` When you specify a specific API version in your Stripe client, the requests you send to Stripe from your server, along with their responses, will match that API version. On the other hand, Stripe will send all other events to your webhook that didn't originate as a request sent from your server, like those made after a user completes a payment on checkout, using the default API version of the API. @@ -152,61 +117,126 @@ To make sure your app is consistent with your Stripe account, here are some step 1. You can find your `default` API version in the Stripe dashboard under the [Developers](https://dashboard.stripe.com/developers) section. 2. Check that the API version in your `/src/payment/stripe/stripeClient.ts` file matches the default API version in your dashboard: -```ts title="stripeClient.ts" {2} -export const stripe = new Stripe(process.env.STRIPE_KEY!, { - apiVersion: 'YYYY-MM-DD', // e.g. 2023-08-16 -}); -``` + ```ts title="stripeClient.ts" {1} + const STRIPE_API_VERSION = "2025-04-30.basil"; // or e.g. 2023-08-16 if using older format + + export const stripeClient = new Stripe(requireNodeEnvVar("STRIPE_API_KEY"), { + apiVersion: STRIPE_API_VERSION, + }); + ``` 3. If they don't match, you can upgrade/downgrade your Stripe NPM package in `package.json` to match the API version in your dashboard: - If your default version on the Stripe dashboard is also the latest version of the API, you can simply upgrade your Stripe NPM package to the latest version. - If your default version on the Stripe dashboard is not the latest version, and you don't want to [upgrade to the latest version](https://docs.stripe.com/upgrades#how-can-i-upgrade-my-api), because e.g. you have other projects that depend on the current version, you can find and install the Stripe NPM package version that matches your default API version by following these steps: - Find and note the date of your default API version in the [developer dashboard](https://dashboard.stripe.com/developers). - - Go to the [Stripe NPM package](https://www.npmjs.com/package/stripe) page and hover over `Published` date column until you find the package release that matches your version. For example, here we find the NPM version that matches the default API version of `2023-08-16` in our dashboard, which is `13.x.x`. - npm version - - Install the correct version of the Stripe NPM package by running, : - ```sh - npm install stripe@x.x.x # e.g. npm install stripe@13.11.0 - ``` + - Go to the [Stripe NPM package](https://www.npmjs.com/package/stripe) page and hover over `Published` date column until you find the package release that matches your version. For example, here we find the NPM version `13.x.x` matches the API version of `2023-08-16`. + npm version + - Install the correct version of the Stripe NPM package by running: + ```sh + npm install stripe@x.x.x # e.g. npm install stripe@13.11.0 + ``` 4. **Test your app thoroughly** to make sure that the changes you made to your Stripe client are working as expected before deploying to production. #### Creating Your Production Webhook -1. go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks) -2. click on `+ add endpoint` -3. enter your endpoint url, which will be the url of your deployed server + `/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 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: -```sh -wasp deploy fly cmd --context server secrets set STRIPE_WEBHOOK_SECRET=whsec_... -``` +1. Go to [https://dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks) +2. Click on `+ add endpoint` +3. Enter your endpoint url, which will be the url of your deployed server + `/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 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: + ```sh + wasp deploy fly cmd --context server secrets set STRIPE_WEBHOOK_SECRET=whsec_... + ``` + +### Lemon Squeezy -### Setting up your Production Lemon Squeezy Webhook +#### Creating Your Production Webhook To set up your Lemon Squeezy webhook, you'll need the URL of you newly deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`. With the webhook url ready, go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks): -- click the `+` button. -- add the webhook forwarding url to the `Callback URL` section. -- give your webhook a signing secret (a long, random string). -- add this signing secret to your server's production environment variables under `LEMONSQUEEZY_WEBHOOK_SECRET=` -- make sure to select at least the following updates to be sent: - - order_created - - subscription_created - - subscription_updated - - subscription_cancelled -- click `save` +1. Click the `+` button. +2. Add the webhook forwarding url to the `Callback URL` section. +3. Give your webhook a signing secret (a long, random string). +4. Add this signing secret to your server's production environment variables under `LEMONSQUEEZY_WEBHOOK_SECRET`. +5. Make sure to select at least the following updates to be sent: + - `order_created` + - `subscription_created` + - `subscription_updated` + - `subscription_cancelled` +6. Click `save`. + +### Polar + +#### Turning off the Sandbox mode + +Make sure to turn off the sandbox mode in your server's production environemnt: + +```ini title=".env.server" +POLAR_SANDBOX_MODE=false +``` + +#### Creating Your Production Webhook + +To set up your Polar webhook, you'll need the URL of your newly deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`. + +With the webhook URL ready, in your [Polar Dashboard](https://polar.sh/dashboard): +1. Navigate to `Settings > Webhooks`. +2. Click the `Add Endpoint` button. +3. Paste the webhook forwarding URL in the `URL` field. +4. Set the `Format` to `"Raw"`. +5. Select at least the following events to be sent: + - `order.paid` + - `subscription.updated` +6. Click `Save`. +7. Copy the generated webhook secret and set it as the `POLAR_WEBHOOK_SECRET` environment variable in production. + +## Deploying your App +### Deploying to Fly.io + +[Fly.io](https://fly.io) is a platform for running your apps globally. It's a great choice for deploying your SaaS app because it's free to get started, can host your entire full-stack app in one place, scales well, and has one-command deploy integration with Wasp. + +**Wasp provides the handy `wasp deploy` command to deploy your entire full-stack app (DB, server, and client) in one command.** + +To learn how, please follow the detailed guide for [deploying to Fly via the Wasp CLI](https://wasp.sh/docs/deployment/deployment-methods/cli) from the Wasp documentation. We suggest you follow this guide carefully to get your app deployed. + +:::caution[Setting Environment Variables] +Remember, because we've set certain client-side env variables, make sure to pass them to the `wasp deploy` commands so that they can be included in the build: +```sh +REACT_APP_CLIENT_ENV_VAR_1=<...> REACT_APP_CLIENT_ENV_VAR_2=<...> wasp deploy +``` + +The `wasp deploy` command will also take care of setting the following server-side environment variables for you so you don't have to: +- `DATABASE_URL` +- `PORT` +- `JWT_SECRET` +- `WASP_WEB_CLIENT_URL` +- `WASP_SERVER_URL` + +For setting the remaining server-side environment variables, please refer to the [Deploying with the Wasp CLI Guide](https://wasp.sh/docs/deployment/deployment-methods/cli#launch). +::: + +### Deploying Manually + +If you prefer to deploy manually, your frontend and backend separately, or just prefer using your favorite provider you can follow [Wasp's Manual Deployment Guide](https://wasp.sh/docs/deployment/deployment-methods/paas). + +:::caution[Client-side Environment Variables] +Remember to always set additional client-side environment variables, such as `REACT_APP_STRIPE_CUSTOMER_PORTAL` by appending them to the build command, e.g. +```sh +REACT_APP_CLIENT_ENV_VAR_1=<...> npm run build +``` +::: ## Deploying your Blog Deploying your Astro Starlight blog is a bit different than deploying your SaaS app. As an example, we will show you how to deploy your blog for free to Netlify. You will need a Netlify account and [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed to follow these instructions. -Make sure you are logged in with Netlify CLI. -- You can check if you are logged in with `netlify status`, -- you can log in with `netlify login`. +Make sure you are logged in with Netlify CLI: +- You can check if you are logged in with `netlify status`. +- You can log in with `netlify login`. Position yourself in the `blog` directory and run the following command: diff --git a/opensaas-sh/blog/src/content/docs/guides/email-sending.mdx b/opensaas-sh/blog/src/content/docs/guides/email-sending.mdx index e10c60e41..d6f0f3bec 100644 --- a/opensaas-sh/blog/src/content/docs/guides/email-sending.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/email-sending.mdx @@ -4,7 +4,7 @@ banner: content: | Have an Open SaaS app in production? We'll send you some swag! πŸ‘• --- -import { Tabs, TabItem } from '@astrojs/starlight/components'; +import { TabItem, Tabs } from '@astrojs/starlight/components'; This guide explains how to use the integrated email sender and how you can integrate your own account in this template. @@ -87,9 +87,9 @@ To set up your email sender, you first need an account with one of the supported - Go to [Mailgun](https://mailgun.com) and create an account. - Go to [API Keys](https://app.mailgun.com/settings/api_security/api_keys?onboardingTask=api-key) and create a new API key. - - Copy the API key and add it to your .env.server file under the `MAILGUN_API_KEY=` variable. + - Copy the API key and add it to your .env.server file under the `MAILGUN_API_KEY` variable. - Go to [Domains](https://app.mailgun.com/mg/sending/new-domain?onboardingTask=add-verify-domain) and create a new domain. - - Copy the domain and add it to your .env.server file as `MAILGUN_DOMAIN=`. + - Copy the domain and add it to your .env.server file as `MAILGUN_DOMAIN`. Make sure to change the `defaultFrom` email address in the `main.wasp` file to use the same email address that you configured your account to send out emails with! diff --git a/opensaas-sh/blog/src/content/docs/guides/payment-integrations/index.mdx b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/index.mdx new file mode 100644 index 000000000..7148ee35b --- /dev/null +++ b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/index.mdx @@ -0,0 +1,100 @@ +--- +title: Overview +banner: + content: | + Have an Open SaaS app in production? We'll send you some swag! πŸ‘• +--- +import { TabItem, Tabs } from '@astrojs/starlight/components'; + +This guide will show you how to set up payments for testing and local development with the following payment processors: +- Stripe +- Lemon Squeezy +- Polar + +:::note[Which should I choose?] +Stripe is the industry standard, is more configurable, and has cheaper fees. +Lemon Squeezy acts a [Merchant of Record](https://www.lemonsqueezy.com/reporting/merchant-of-record). This means they take care of paying taxes in multiple countries for you, but charge higher fees per transaction. +Polar is an open-source [Merchant of Record](https://docs.polar.sh/merchant-of-record/introduction#merchant-of-record) designed specifically for developers. +::: + +## Important First Steps + +First, go to `/src/payment/paymentProcessor.ts` and choose which payment processor you'd like to use: + + + + ```ts title="src/payment/paymentProcessor.ts" del={6, 7} + import { stripePaymentProcessor } from './stripe/paymentProcessor'; + + // ... + + export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; + // export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; + // export const paymentProcessor: PaymentProcessor = polarPaymentProcessor; + ``` + + + ```ts title="src/payment/paymentProcessor.ts" del={1, 6, 7, 8} ins={2, 9} + import { stripePaymentProcessor } from './stripe/paymentProcessor'; + import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor'; + + // ... + + export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; + // export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; + // export const paymentProcessor: PaymentProcessor = polarPaymentProcessor; + export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; + ``` + + + ```ts title="src/payment/paymentProcessor.ts" del={1, 6, 7, 8} ins={2, 9} + import { stripePaymentProcessor } from './stripe/paymentProcessor'; + import { polarPaymentProcessor } from './polar/paymentProcessor'; + + // ... + + export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; + // export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; + // export const paymentProcessor: PaymentProcessor = polarPaymentProcessor; + export const paymentProcessor: PaymentProcessor = polarPaymentProcessor; + ``` + + + +Then you should delete: +- The unused payment processor code within the `/src/payment/` directories. +- 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`, `LEMONSQUEEZY_API_KEY`, `POLAR_ORGANIZATION_ACCESS_TOKEN`. +- Make sure to also uninstall the unused dependencies: + + + ```sh + npm uninstall @lemonsqueezy/lemonsqueezy.js @polar-sh/sdk + ``` + + + ```sh + npm uninstall stripe @polar-sh/sdk + ``` + + + ```sh + npm uninstall @lemonsqueezy/lemonsqueezy.js stripe + ``` + + +- Remove any unused fields from the `User` model in the `schema.prisma` file if they exist: + - E.g. `lemonSqueezyCustomerPortalUrl`. + +Now your code is ready to go with your preferred payment processor and it's time to configure your payment processor's API keys, products, and other settings. + +Follow the steps for your selected processor: + +- [Stripe](stripe/) +- [Lemon Squeezy](lemon-squeezy/) +- [Polar](polar/) + + +## Deploying + +Once you're ready to deploy your app, follow the steps from the [deploying guide](/guides/deploying/) to set up the production settings for your prayment provider. diff --git a/opensaas-sh/blog/src/content/docs/guides/payment-integrations/lemon-squeezy.mdx b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/lemon-squeezy.mdx new file mode 100644 index 000000000..6ae80de6c --- /dev/null +++ b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/lemon-squeezy.mdx @@ -0,0 +1,92 @@ +--- +title: Lemon Squeezy +banner: + content: | + Have an Open SaaS app in production? We'll send you some swag! πŸ‘• +--- +import addProduct from '@assets/lemon-squeezy/add-product.png'; +import addVariant from '@assets/lemon-squeezy/add-variant.png'; +import storeId from '@assets/lemon-squeezy/store-id.png'; +import subscriptionVariantIds from '@assets/lemon-squeezy/subscription-variant-ids.png'; +import variantId from '@assets/lemon-squeezy/variant-id.png'; +import ngrok from '@assets/ngrok.png'; +import { Image } from 'astro:assets'; + +First, make sure you've defined your payment processor in `src/payment/paymentProcessor.ts`, as described in the [important first steps](/guides/payment-integrations/). + +Next, you'll need to create a Lemon Squeezy account in test mode. You can do that [here](https://lemonsqueezy.com). + +:::tip[Star our Repo on GitHub! 🌟] +We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! + +If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) +::: + +### Get your test Lemon Squeezy API Keys + +Once you've created your account, you'll need to get your test API keys. You can do that by navigating to [https://app.lemonsqueezy.com/settings/api](https://app.lemonsqueezy.com/settings/api) and creating a new API key: +1. Click on the `+` button. +2. Give your API key a name. +3. Copy and paste it in your `.env.server` file under `LEMONSQUEEZY_API_KEY`. + +### Get your Lemon Squeezy Store ID + +To get your store ID, go to the [Lemon Squeezy Dashboard](https://app.lemonsqueezy.com/settings/stores) and copy the `Store ID` from the top right corner. + +store id + +Copy and paste this number in your `.env.server` file under `LEMONSQUEEZY_STORE_ID`. + +### Create Test Products + +To create a test product, go to the test products url [https://app.lemonsqueezy.com/products](https://app.lemonsqueezy.com/products): + +1. Click on the `+ New Product` button and fill in the relevant information for your product. +2. Fill in the general information. +3. For pricing, select the type of product you'd like to create, e.g. `Subscription` for a recurring monthly payment product or `Single Payment` for credits-based product. + add product +4. Make sure you select `Software as a service (SaaS)` as the Tax category type. +5. If you want to add different price tiers for `Subscription` products, click on `add variant` under the `variants` tab. Here you can input the name of the variant (e.g. "Hobby", "Pro"), and that variant's price. + add variant +6. For a product with no variants, on the product page, click the `...` menu button and select `Copy variant ID` + variant id +7. For a product with variants, on the product page, click on the product, go to the variants tab and select `Copy ID` for each variant. + subscription variant ids +8. Paste these IDs in the `.env.server` file: + - We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID`. + - As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS_10_PLAN_ID`. + +Note that if you change the names of the these environment variables, you'll need to update your app code to match these names as well. + +### Create and Use the Lemon Squeezy Webhook in Local Development + +Lemon Squeezy notifies your Wasp app about events through a webhook (for example, when a payment succeeds). During development, you need to expose your locally running Wasp server (started with `wasp start`) to the internet. Wasp server runs on port 3001 by default, so you can, for example, run `ngrok` on port 3001 to expose your server to the internet. `ngrok` will generate a public URL that you can give to Lemon Squeezy. + +First make sure you have installed [ngrok](https://ngrok.com/docs/getting-started/). + +Once installed, and with your wasp app running, run: +```sh +ngrok http 3001 +``` + +ngrok + +Ngrok will output a forwarding address for you. Copy and paste this address and add `/payments-webhook` to the end (this URL path has been configured for you already in `main.wasp` under the `api paymentsWebhook` definition). It should look something like this: + +```sh title="Callback URL" +https://89e5-2003-c7-153c-72a5-f837.ngrok-free.app/payments-webhook +``` + +Now go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks): +1. Click the `+` button. +2. Add the newly created webhook forwarding url to the `Callback URL` section. +3. Give your webhook a signing secret (a long, random string). +4. Copy and paste this same signing secret into your `.env.server` file under `LEMONSQUEEZY_WEBHOOK_SECRET`. +5. Make sure to select at least the following updates to be sent: + - `order_created` + - `subscription_created` + - `subscription_updated` + - `subscription_cancelled` +6. Click `save`. + +You're now ready to start consuming Lemon Squeezy webhook events in local development. \ No newline at end of file diff --git a/opensaas-sh/blog/src/content/docs/guides/payment-integrations/polar.mdx b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/polar.mdx new file mode 100644 index 000000000..646f557d8 --- /dev/null +++ b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/polar.mdx @@ -0,0 +1,146 @@ +--- +title: Polar +banner: + content: | + Have an Open SaaS app in production? We'll send you some swag! πŸ‘• +--- +import ngrok from '@assets/ngrok.png'; +import polarUserTable from '@assets/polar/user-table.png'; +import polarWebhookLogs from '@assets/polar/webhook-log.png'; +import { Image } from 'astro:assets'; + +First, make sure you've defined your payment processor in `src/payment/paymentProcessor.ts`, as described in the [important first steps](/guides/payment-integrations/). + +Next, you'll need to create a Polar account in the sandbox mode. You can do that [here](https://sandbox.polar.sh/). + +:::tip[Star our Repo on GitHub! 🌟] +We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! + +If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) +::: + +### Sandbox and Production Mode + +Polar features two separate environemnts: [sandbox](https://sandbox.polar.sh/dashboard) and [production](https://polar.sh/dashboard). +They are fully isolated from each other, which means that you need to create seperate organizations for each of them. They also feature indepdendant products, sales, access tokens, etc. + +For local development and testing, you'll want to use Polar's sandbox mode. +To enable sandbox mode, make sure that `POLAR_SANDBOX_MODE` is set to `true` in your `.env.server` file: + +```ini title=".env.server" +POLAR_SANDBOX_MODE=true +``` + +When you're ready to [deploy your app](/guides/deploying/), we'll remind you to set this to `false`. + +### Create Polar Organization Access Token + +Once you've created your account, you'll need to get your organization access token. +You can do that by: +1. Navigating to the `Developers` section under the `Settings > General` page. +2. Click on the `New Token` button to create a new token. +3. Give your token a name (e.g., "Open SaaS Development"). +4. Select an expiration date. `No expiration` is fine for sandbox mode, but discouraged for production mode. +5. Select the following scopes: + - `checkouts:write` + - `customer_sessions:write` + - `customers:read` + - `customers:write` + - `orders:read` +6. Copy the generated token and add it to your `.env.server` file: + ```ini title=".env.server" + POLAR_ORGANIZATION_ACCESS_TOKEN=polar_oat_... + ``` + +### Create Products + +To create Polar products, in your Polar dashboard: +1. Navigate to `Prodcuts > Catalogue` page. +2. Click on the `+ New Product` button to create a new product. +3. Fill in the product details: + - **Name**: e.g., "Hobby Plan", "Pro Plan", or "10 Credits". + - **Description**: Brief description of what the product includes. + - **Pricing**: Select "Recurring subscription" for recurring plans or "One-time purchase" for credits, then configure the desired pricing. +4. Configure any remaining optional fields. +5. Click `Create Product`. + +After creating each product, you'll need to copy the `Product ID` from the `Prodcuts > Catalogue` page and add it to your `.env.server` file. Tap the `β ‡` icon in the top-right corner and select `Copy Product ID`. + +Open SaaS comes with some example products, two subscriptions and one one-time purchase plan: + +```ini title=".env.server" +PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID= +PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID= +PAYMENTS_CREDITS_10_PLAN_ID= +``` + +Note that if you change the names of the price IDs, you'll need to update your server code to match these names as well. + +### Create and Use the Polar Webhook in Local Development + +Polar notifies your Wasp app about events through a webhook (for example, when a payment succeeds). During development, you need to expose your locally running Wasp server (started with `wasp start`) to the internet. Wasp server runs on port 3001 by default, so you can, for example, run `ngrok` on port 3001 to expose your server to the internet. `ngrok` will generate a public URL that you can give to Polar. + +First make sure you have installed [`ngrok`](https://ngrok.com/docs/getting-started/). + +Once `ngrok` is installed and your Wasp app is running, run: + +```sh +ngrok http 3001 +``` + +ngrok + +`ngrok` will display a forwarding address. Copy this address and append `/payments-webhook` to it. It should look something like this: + +```sh title="Callback URL" +https://89e5-2003-c7-153c-72a5-f837.ngrok-free.app/payments-webhook +``` + +Next, configure the webhook in your Polar dashboard: +1. Navigate to `Settings > Webhooks` page. +2. Click `Add Endpoint` to create a new endpoint. +3. In the URL field, paste your `ngrok` forwarding address with `/payments-webhook` appended (for example: `https://abc123.ngrok-free.app/payments-webhook`). +4. Set the `Format` to `"Raw"`. +5. Select the following events to listen for: + - `order.paid` + - `subscription.updated` +6. Click `Save`. +7. Copy the generated webhook secret and add it to your `.env.server` file: + ```ini title=".env.server" + POLAR_WEBHOOK_SECRET=polar_whs_... + ``` + +### Testing Payments in Local Development + +Before testing payments, make sure that you: +- Created and set your `POLAR_ORGANIZATION_ACCESS_TOKEN` in `.env.server`. +- Created your products and set their `_PLAN_ID` in `.env.server`. +- Created your webhook and set `POLAR_WEBHOOK_SECRET` in `.env.server`. +- Your `ngrok` tunnel is running. + +You can then test the payment flow: + +1. Click a `Buy` button for any product on the homepage. +2. You should be redirected to Polar's checkout page. +3. Fill in the checkout form with [test payment information](https://docs.polar.sh/integrate/sandbox#testing-payments). +4. Complete the payment. +5. You should be redirected back to the checkout success page. + +To verify everything executed correctly you can: +1. Check Polar webhook event logs. +2. Inspect our database's `User` table. + +To check webhook event logs: +1. Navigate to `Settings > Webhooks` page +2. Click the `Details` button of our previously created webhook. +3. Confirm all of the events have been successful: + How to check webhook logs in Polar dashboard + +To inspect the `User` table in the database: +1. Run Wasp DB studio: + ```sh + wasp db studio + ``` +2. Navigate to `localhost:5555` and check the `User` table. +3. Confirm the `subscriptionStatus` is `active` for the user who made the purchase. + User table showing updated user diff --git a/opensaas-sh/blog/src/content/docs/guides/payment-integrations/stripe.mdx b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/stripe.mdx new file mode 100644 index 000000000..93dd487b0 --- /dev/null +++ b/opensaas-sh/blog/src/content/docs/guides/payment-integrations/stripe.mdx @@ -0,0 +1,149 @@ +--- +title: Stripe +banner: + content: | + Have an Open SaaS app in production? We'll send you some swag! πŸ‘• +--- +import ngrok from '@assets/ngrok.png'; +import testApiKeys from '@assets/stripe/api-keys.png'; +import dbStudio from '@assets/stripe/db-studio.png'; +import priceIds from '@assets/stripe/price-ids.png'; +import switchPlans from '@assets/stripe/switch-plans.png'; +import testProduct from '@assets/stripe/test-product.png'; +import { Image } from 'astro:assets'; + +First, make sure you've defined your payment processor in `src/payment/paymentProcessor.ts`, as described in the [important first steps](/guides/payment-integrations/). + +Next, you'll need to create a Stripe account. You can do that [here](https://dashboard.stripe.com/register). + +:::tip[Star our Repo on GitHub! 🌟] +We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! + +If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) +::: + +### Get your test Stripe API Keys + +Once you've created your account, you'll need to get your test API keys. You can do that by navigating to [https://dashboard.stripe.com/test/apikeys](https://dashboard.stripe.com/test/apikeys) or by going to the [Stripe Dashboard](https://dashboard.stripe.com/test/dashboard) and clicking on the `Developers`: +1. Click on the `Reveal test key token` button and copy the `Secret key`. +2. Paste it in your `.env.server` file under `STRIPE_API_KEY`. + +test api keys + +### Create Test Products + +To create a test product, go to the test products url [https://dashboard.stripe.com/test/products](https://dashboard.stripe.com/test/products), or after navigating to your dashboard, click the `test mode` toggle: +1. Click on the `Add a product` button and fill in the relevant information for your product. + test product + - Select `Software as a service (SaaS)` as the product type. + - For Subscription products, select `Recurring` as the billing type. + - For One-time payment products, select `One-time` as the billing type. +2. If you want to add different price tiers for the same product (e.g. monthly and yearly), click the `Add another price` button at the buttom. + price ids +3. After you save the product, you'll be directed to the product page. +4. Copy the price IDs and paste them in the `.env.server` file. + - We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID`. + - As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS_10_PLAN_ID`. + +If you intend to let your users switch between two subscription plans, e.g. upgrade from hobby to pro, you'll need to create two separate products with their own price IDs. The ability for users to swich plans can then be configured later in the [Customer Portal](#set-up-the-customer-portal). + +Note that if you change the names of the price IDs, you'll need to update your server code to match these names as well. + +### Create a Test Customer + +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. + +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 + +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 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 + +Then select the products you'd like them to be able to switch between. + +Now, after your users have paid, they can click on `Manage Subscription` in the client and will be taken to the customer portal where they can update their current plan. + +### Stripe CLI + +To install the Stripe CLI follow the instructions [here](https://docs.stripe.com/stripe-cli/install). + +Make sure to login after you install the Stripe CLI: +```sh +stripe login +``` + +:::caution[Errors running the Stripe CLI] +If you're seeing errors, consider appending `sudo` to the stripe commands. +See this [GitHuh issue](https://github.com/stripe/stripe-cli/issues/933) for more details. +::: + +### Testing Webhooks via the Stripe CLI + +Start the Stripe CLI webhook forwarding on port 3001 where your Node server is running: + +```sh +stripe listen --forward-to localhost:3001/payments-webhook +``` + +:::caution[Webhook URL] +In older versions of this template, the webhook URL was `http://localhost:3001/stripe-webhook`. +If you're using an older version, **make sure to use the url that matches the webhook url in your `main.wasp` file payemnts API definition.** +::: + +Remember to copy and paste the outputted webhook signing secret (`whsec_...`) into your `.env.server` file under `STRIPE_WEBHOOK_SECRET`. + +In another terminal window, trigger a test event: + +```sh +stripe trigger payment_intent.succeeded +``` + +The results of the event firing will be visible in the initial terminal window. You should see messages like this: + +```sh +... +2023-11-21 09:31:09 --> invoice.paid [evt_1OEpMPILOQf67J5TjrUgRpk4] +2023-11-21 09:31:09 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5TjrUgRpk4] +2023-11-21 09:31:10 --> invoice.payment_succeeded [evt_1OEpMPILOQf67J5T3MFBr1bq] +2023-11-21 09:31:10 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5T3MFBr1bq] +2023-11-21 09:31:10 --> checkout.session.completed [evt_1OEpMQILOQf67J5ThTZ0999r] +2023-11-21 09:31:11 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMQILOQf67J5ThTZ0999r] +``` + +For more info on testing webhooks, check out https://stripe.com/docs/webhooks#test-webhook. + +:::tip[Star our Repo on GitHub! 🌟] +We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! + +If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) +::: + +### Testing Checkout and Payments via the Client + +Make sure the **Stripe CLI is running** by following the steps above. +You can then test the payment flow via the client by doing the following: + +1. Click on a Buy button on the for any of the products on the homepage. You should be redirected to the checkout page. +2. Fill in the form with the following test credit card number `4242 4242 4242 4242` and any future date for the expiration date and any 3 digits for the CVC. +3. Click on the "Pay" button. You should be redirected to the success page. +4. Check your terminal window for status messages and logs. +5. You can also check your Database via the DB Studio to see if the user entity has been updated by running: + ```sh + wasp db studio + ``` +6. Navigate to `localhost:5555` and click on the `users` table. You should see the `subscriptionStatus` is `active` for the user that just made the purchase. + db studio + +:::note +If you want to learn more about how a user's payment status, subscription status, and subscription tier affect a user's priveledges within the app, check out the [User Overview](/general/user-overview/) reference. +::: \ 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 deleted file mode 100644 index bdcb740c7..000000000 --- a/opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx +++ /dev/null @@ -1,310 +0,0 @@ ---- -title: Payments Integration -banner: - content: | - Have an Open SaaS app in production? We'll send you some swag! πŸ‘• ---- -import { Image } from 'astro:assets'; -import testApiKeys from '@assets/stripe/api-keys.png'; -import testProduct from '@assets/stripe/test-product.png'; -import priceIds from '@assets/stripe/price-ids.png'; -import switchPlans from '@assets/stripe/switch-plans.png'; -import dbStudio from '@assets/stripe/db-studio.png'; -import addProduct from '@assets/lemon-squeezy/add-product.png'; -import addVariant from '@assets/lemon-squeezy/add-variant.png'; -import variantId from '@assets/lemon-squeezy/variant-id.png'; -import subscriptionVariantIds from '@assets/lemon-squeezy/subscription-variant-ids.png'; -import ngrok from '@assets/lemon-squeezy/ngrok.png'; -import storeId from '@assets/lemon-squeezy/store-id.png'; - -This guide will show you how to set up Payments for testing and local development with the following payment processors: -- Stripe -- Lemon Squeezy - -:::note[Which should I choose?] -Stripe is the industry standard, is more configurable, and has cheaper fees. -Lemon Squeezy acts a [Merchant of Record](https://www.lemonsqueezy.com/reporting/merchant-of-record). This means they take care of paying taxes in multiple countries for you, but charge higher fees per transaction. -::: - -## Important First Steps - -First, go to `/src/payment/paymentProcessor.ts` and choose which payment processor you'd like to use, e.g. Stripe or Lemon Squeezy: - -```ts title="src/payment/paymentProcessor.ts" ins={5, 7} -import { stripePaymentProcessor } from './stripe/paymentProcessor'; -import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor'; -//... - -export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; -// or... -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`, `LEMONSQUEEZY_API_KEY` -- Make sure to also uninstall the unused dependencies: - - `npm uninstall @lemonsqueezy/lemonsqueezy.js` - - or - - `npm uninstall stripe` -- Remove any unused fields from the `User` model in the `schema.prisma` file if they exist: - - e.g. `lemonSqueezyCustomerPortalUrl` - -Now your code is ready to go with your preferred payment processor and it's time to configure your payment processor's API keys, products, and other settings. - -## Stripe - -First, you'll need to create a Stripe account. You can do that [here](https://dashboard.stripe.com/register). - -:::tip[Star our Repo on GitHub! 🌟] -We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! - -If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) -::: - -### Get your test Stripe API Keys - -Once you've created your account, you'll need to get your test API keys. You can do that by navigating to [https://dashboard.stripe.com/test/apikeys](https://dashboard.stripe.com/test/apikeys) or by going to the [Stripe Dashboard](https://dashboard.stripe.com/test/dashboard) and clicking on the `Developers`. - -test api keys - -- Click on the `Reveal test key token` button and copy the `Secret key`. -- Paste it in your `.env.server` file under `STRIPE_API_KEY=` - -### Create Test Products - -To create a test product, go to the test products url [https://dashboard.stripe.com/test/products](https://dashboard.stripe.com/test/products), or after navigating to your dashboard, click the `test mode` toggle. - -test product - -- Click on the `Add a product` button and fill in the relevant information for your product. -- Make sure you select `Software as a service (SaaS)` as the product type. -- For Subscription products, make sure you select `Recurring` as the billing type. -- For One-time payment products, make sure you select `One-time` as the billing type. -- If you intend to let your users switch between two subscription plans, e.g. upgrade from hobby to pro, you'll need to create two separate products and with their own price IDs. The ability for users to swich plans can then be configured later in the [Customer Portal](#set-up-the-customer-portal). -- If you want to add different price tiers for the same product (e.g. monthly and yearly), click the `Add another price` button at the buttom. - -price ids - -- After you save the product, you'll be directed to the product page. -- Copy the price IDs and paste them in the `.env.server` file - - We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=`. - - As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS_10_PLAN_ID=`. -- Note that if you change the names of the price IDs, you'll need to update your server code to match these names as well - -### Create a Test Customer - -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. - -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 - -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 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 - -Then select the products you'd like them to be able to switch between. - -Now, after your users have paid, they can click on `Manage Subscription` in the client and will be taken to the customer portal where they can update their current plan. - -### Install the Stripe CLI - -To install the Stripe CLI with homebrew, run the following command in your terminal: - -```sh -brew install stripe/stripe-cli/stripe -``` - -or for other install scripts or OSes, follow the instructions [here](https://stripe.com/docs/stripe-cli#install). - -Now, let's start the webhook server and get our webhook signing secret. - -First, login: -```sh -stripe login -``` - -:::caution[Errors running the Stripe CLI] -If you're seeing errors, consider appending `sudo` to the stripe commands. -See this [GitHuh issue](https://github.com/stripe/stripe-cli/issues/933) for more details. -::: - -```sh -stripe listen --forward-to localhost:3001/payments-webhook -``` - -You should see a message like this: - -```sh -> Ready! You are using Stripe API Version [2023-08-16]. Your webhook signing secret is whsec_8a... (^C to quit) -``` - -copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`. - -### Testing Webhooks via the Stripe CLI - -- In a new terminal window, run the following command: - -```sh -stripe login -``` - -- start the Stripe CLI webhook forwarding on port 3001 where your Node server is running. - -```sh -stripe listen --forward-to localhost:3001/payments-webhook -``` - -:::caution[Webhook URL] -In older versions of this template, the webhook URL was `http://localhost:3001/stripe-webhook`. -If you're using an older version, **make sure to use the url that matches the webhook url in your `main.wasp` file payemnts API definition.** -::: - -remember to copy and paste the outputted webhook signing secret (`whsec_...`) into your `.env.server` file under `STRIPE_WEBHOOK_SECRET=` if you haven't already. - -- In another terminal window, trigger a test event: - -```sh -stripe trigger payment_intent.succeeded -``` - -The results of the event firing will be visible in the initial terminal window. You should see messages like this: - -```sh -... -2023-11-21 09:31:09 --> invoice.paid [evt_1OEpMPILOQf67J5TjrUgRpk4] -2023-11-21 09:31:09 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5TjrUgRpk4] -2023-11-21 09:31:10 --> invoice.payment_succeeded [evt_1OEpMPILOQf67J5T3MFBr1bq] -2023-11-21 09:31:10 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMPILOQf67J5T3MFBr1bq] -2023-11-21 09:31:10 --> checkout.session.completed [evt_1OEpMQILOQf67J5ThTZ0999r] -2023-11-21 09:31:11 <-- [200] POST http://localhost:3001/payments-webhook [evt_1OEpMQILOQf67J5ThTZ0999r] -``` - -For more info on testing webhooks, check out https://stripe.com/docs/webhooks#test-webhook - -:::tip[Star our Repo on GitHub! 🌟] -We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! - -If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) -::: - -### Testing Checkout and Payments via the Client - -Make sure the **Stripe CLI is running** by following the steps above. -You can then test the payment flow via the client by doing the following: - -- Click on a Buy button on the for any of the products on the homepage. You should be redirected to the checkout page. -- Fill in the form with the following test credit card number `4242 4242 4242 4242` and any future date for the expiration date and any 3 digits for the CVC. - -- Click on the "Pay" button. You should be redirected to the success page. - -- Check your terminal window for status messages and logs - -- You can also check your Database via the DB Studio to see if the user entity has been updated by running: - -```sh -wasp db studio -``` - -db studio - -- Navigate to `localhost:5555` and click on the `users` table. You should see the `subscriptionStatus` is `active` for the user that just made the purchase. - -:::note -If you want to learn more about how a user's payment status, subscription status, and subscription tier affect a user's priveledges within the app, check out the [User Overview](/general/user-overview) reference. -::: - -## Lemon Squeezy - -First, make sure you've defined your payment processor in `src/payment/paymentProcessor.ts`, as described in the [important first steps](#important-first-steps). - -Next, you'll need to create a Lemon Squeezy account in test mode. You can do that [here](https://lemonsqueezy.com). - -:::tip[Star our Repo on GitHub! 🌟] -We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! - -If you're finding this template and its guides useful, consider giving us [a star on GitHub](https://github.com/wasp-lang/wasp) -::: - -### Get your test Lemon Squeezy API Keys - -Once you've created your account, you'll need to get your test API keys. You can do that by navigating to [https://app.lemonsqueezy.com/settings/api](https://app.lemonsqueezy.com/settings/api) and creating a new API key. - -- Click on the `+` button -- Give your API key a name -- Copy and paste it in your `.env.server` file under `LEMONSQUEEZY_API_KEY=` - -### Get your Lemon Squeezy Store ID - -store id - -To get your store ID, go to the [Lemon Squeezy Dashboard](https://app.lemonsqueezy.com/settings/stores) and copy the `Store ID` from the top right corner. - -Copy and paste this number in your `.env.server` file under `LEMONSQUEEZY_STORE_ID=` - -### Create Test Products - -To create a test product, go to the test products url [https://app.lemonsqueezy.com/products](https://app.lemonsqueezy.com/products). - -- Click on the `+ New Product` button and fill in the relevant information for your product. -- Fill in the general information. -- For pricing, select the type of product you'd like to create, e.g. `Subscription` for a recurring monthly payment product or `Single Payment` for credits-based product. -add product -- Make sure you select `Software as a service (SaaS)` as the Tax category type. -- If you want to add different price tiers for `Subscription` products, click on `add variant` under the `variants` tab. Here you can input the name of the variant (e.g. "Hobby", "Pro"), and that variant's price. -add variant -- For a product with no variants, on the product page, click the `...` menu button and select `Copy variant ID` -variant id -- For a product with variants, on the product page, click on the product, go to the variants tab and select `Copy ID` for each variant. -subscription variant ids -- Paste these IDs in the `.env.server` file: - - We've set you up with two example subscription product environment variables, `PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=` and `PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=`. - - As well as a one-time payment product/credits-based environment variable, `PAYMENTS_CREDITS__10_PLAN_ID=`. -- Note that if you change the names of the these environment variables, you'll need to update your app code to match these names as well. - -### Create and Use the Lemon Squeezy Webhook in Local Development - -Lemon Squeezy sends messages/updates to your Wasp app via its webhook, e.g. when a payment is successful. For that to work during development, we need to expose our locally running (via `wasp start`) Wasp app and make it available online, specifically the server part of it. Since the Wasp server runs on port 3001, you should run ngrok on port 3001, which will provide you with a public URL that you can use to configure Lemon Squeezy with. - -To do this, first make sure you have installed [ngrok](https://ngrok.com/docs/getting-started/). - -Once installed, and with your wasp app running, run: -```sh -ngrok http 3001 -``` - -ngrok - -Ngrok will output a forwarding address for you. Copy and paste this address and add `/payments-webhook` to the end (this URL path has been configured for you already in `main.wasp` under the `api paymentsWebhook` definition). It should look something like this: - -```sh title="Callback URL" -https://89e5-2003-c7-153c-72a5-f837.ngrok-free.app/payments-webhook -``` - -Now go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/settings/webhooks): -- click the `+` button. -- add the newly created webhook forwarding url to the `Callback URL` section. -- give your webhook a signing secret (a long, random string). -- copy and paste this same signing secret into your `.env.server` file under `LEMONSQUEEZY_WEBHOOK_SECRET=` -- make sure to select at least the following updates to be sent: - - order_created - - subscription_created - - subscription_updated - - subscription_cancelled -- click `save` - -You're now ready to start consuming Lemon Squeezy webhook events in local development. - -## Deploying - -Once you deploy your app, you can follow the same steps, just make sure that you are no longer in test mode within the Stripe or Lemon Squeezy Dashboards. After you've repeated the steps in live mode, add the new API keys and price/variant IDs to your environment variables in your deployed environment. 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 34facc663..a9a13c07d 100644 --- a/opensaas-sh/blog/src/content/docs/start/guided-tour.md +++ b/opensaas-sh/blog/src/content/docs/start/guided-tour.md @@ -186,19 +186,19 @@ For development purposes, Wasp provides a `Dummy` email sender which Open SaaS c We will explain more about these auth methods, and how to properly integrate them into your app, in the [Authentication Guide](/guides/authentication/). -### Subscription Payments with Stripe or Lemon Squeezy +### Subscription Payments with Stripe, Lemon Squeezy or Polar -No SaaS is complete without payments, specifically subscription payments. That's why this template comes with a fully functional Stripe or Lemon Squeezy integration. +No SaaS is complete without payments, specifically subscription payments. That's why this template comes with a fully functional Stripe, Lemon Squeezy and Polar integration. Let's take a quick look at how payments are handled in this template. 1. a user clicks the `BUY` button and a **Checkout session** is created on the server 2. the user is redirected to the Checkout page where they enter their payment info 3. the user is redirected back to the app and the Checkout session is completed -4. Stripe / Lemon Squeezy sends a webhook event to the server with the payment info +4. Stripe / Lemon Squeezy / Polar sends a webhook event to the server with the payment info 5. The app server's **webhook handler** handles the event and updates the user's subscription status -The payment processor you choose (Stripe or Lemon Squeezy) and its related functions can be found at `src/payment/paymentProcessor.ts`. The `Payment Processor` object holds the logic for creating checkout sessions, webhooks, etc. +The payment processor you choose (Stripe, Lemon Squeezy or Polar) and its related functions can be found at `src/payment/paymentProcessor.ts`. The `PaymentProcessor` object holds the logic for creating checkout sessions, webhooks, etc. The logic for creating the Checkout session is defined in the `src/payment/operation.ts` file. [Actions](https://wasp.sh/docs/data-model/operations/actions) are a type of Wasp Operation, specifically your server-side functions that are used to **write** or **update** data to the database. Once they're defined in the `main.wasp` file, you can easily call them on the client-side: @@ -226,7 +226,7 @@ const handleBuyClick = async (paymentPlanId) => { }; ``` -The webhook handler is defined in the `src/payment/webhook.ts` file. Unlike Actions and Queries in Wasp which are only to be used internally, we define the webhook handler in the `main.wasp` file as an API endpoint in order to expose it externally to Stripe +The webhook handler is defined in the `src/payment/webhook.ts` file. Unlike Actions and Queries in Wasp which are only to be used internally, we define the webhook handler in the `main.wasp` file as an API endpoint in order to expose it externally to the payment processor. ```js title="main.wasp" api paymentsWebhook { @@ -238,7 +238,7 @@ api paymentsWebhook { Within the webhook handler, we look for specific events that the Payment Processor sends us to let us know which payment was completed and for which user. Then we update the user's subscription status in the database. -To learn more about configuring the app to handle your products and payments, check out the [Payments Integration guide](/guides/payments-integration/). +To learn more about configuring the app to handle your products and payments, check out the [Payment Integrations guide](/guides/payment-integrations/). :::tip[Star our Repo on GitHub! 🌟] We've packed in a ton of features and love into this SaaS starter, and offer it all to you for free! @@ -277,7 +277,7 @@ For more info on integrating Plausible or Google Analytics, check out the [Analy When you first start your Open SaaS app straight from the template, it will run, but many of the services won't work because they lack your own API keys. Here are list of services that need your API keys to work properly: - Auth Methods (Google, GitHub) -- Stripe or Lemon Squeezy +- Stripe, Lemon Squeezy or Polar - OpenAI (Chat GPT API) - Email Sending (Sendgrid) -- you must set this up if you're using the `email` Auth method - Analytics (Plausible or Google Analytics) diff --git a/template/app/.env.server.example b/template/app/.env.server.example index b602ea362..c426bcf45 100644 --- a/template/app/.env.server.example +++ b/template/app/.env.server.example @@ -3,51 +3,60 @@ # If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here. # DATABASE_URL= -# For testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_..." +# For testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_...". 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 +# 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_... -# For testing, create a new store in test mode on https://lemonsqueezy.com +# For testing, create a new store in test mode on https://lemonsqueezy.com. LEMONSQUEEZY_API_KEY=eyJ... -# After creating a store, you can find your store id in the store settings https://app.lemonsqueezy.com/settings/stores +# After creating a store, you can find your store id in the store settings https://app.lemonsqueezy.com/settings/stores. LEMONSQUEEZY_STORE_ID=012345 -# define your own webhook secret when creating a new webhook on https://app.lemonsqueezy.com/settings/webhooks +# define your own webhook secret when creating a new webhook on https://app.lemonsqueezy.com/settings/webhooks. LEMONSQUEEZY_WEBHOOK_SECRET=my-webhook-secret -# If using Stripe, go to https://dashboard.stripe.com/test/products and click on + Add Product -# If using Lemon Squeezy, go to https://app.lemonsqueezy.com/products and create new products and variants +# Generate a token at https://sandbox.polar.sh/dashboard/[your-org-slug]/settings. +POLAR_ORGANIZATION_ACCESS_TOKEN=polar_oat_... +# Define your own webhook secret when creating a new webhook at https://sandbox.polar.sh/dashboard/[your-org-slug]/settings/webhooks. +POLAR_WEBHOOK_SECRET=polar_whs_... +# For production, set this to false, make sure to generate production organization and products. +POLAR_SANDBOX_MODE=true + +# If using Stripe, go to https://dashboard.stripe.com/test/products and click on `+ Add Product`. +# If using Lemon Squeezy, go to https://app.lemonsqueezy.com/products and create new products and variants. +# If using Polar, go to https://sandbox.polar.sh/dashboard/[your-org-slug]/products and click on `+ New Product`. PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345 PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=012345 PAYMENTS_CREDITS_10_PLAN_ID=012345 -# set this as a comma-separated list of emails you want to give admin privileges to upon registeration +# Set this as a comma-separated list of emails you want to give admin privileges to upon registeration. ADMIN_EMAILS=me@example.com,you@example.com,them@example.com -# see our guide for setting up google auth: https://wasp.sh/docs/auth/social-auth/google +# See our guide for setting up google auth: https://wasp.sh/docs/auth/social-auth/google. GOOGLE_CLIENT_ID=722... GOOGLE_CLIENT_SECRET=GOC... -# get your sendgrid api key at https://app.sendgrid.com/settings/api_keys +# Get your sendgrid api key at https://app.sendgrid.com/settings/api_keys. SENDGRID_API_KEY=test... -# (OPTIONAL) get your openai api key at https://platform.openai.com/account +# (OPTIONAL) Get your openai api key at https://platform.openai.com/account. OPENAI_API_KEY=sk-k... -# (OPTIONAL) get your plausible api key at https://plausible.io/login or https://your-plausible-instance.com/login +# (OPTIONAL) Get your plausible api key at https://plausible.io/login or https://your-plausible-instance.com/login. PLAUSIBLE_API_KEY=gUTgtB... -# You will find your site id in the Plausible dashboard. It will look like 'opensaas.sh' +# You will find your site id in the Plausible dashboard. It will look like 'opensaas.sh'. PLAUSIBLE_SITE_ID=yoursite.com -PLAUSIBLE_BASE_URL=https://plausible.io/api # if you are self-hosting plausible, change this to your plausible instance's base url +# If you are self-hosting plausible, change this to your plausible instance's base url. +PLAUSIBLE_BASE_URL=https://plausible.io/api -# (OPTIONAL) get your google service account key at https://console.cloud.google.com/iam-admin/serviceaccounts +# (OPTIONAL) Get your google service account key at https://console.cloud.google.com/iam-admin/serviceaccounts. GOOGLE_ANALYTICS_CLIENT_EMAIL=email@example.gserviceaccount.com -# Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info. +# Make sure you convert the private key within the JSON file to base64 first. See the docs for more info. GOOGLE_ANALYTICS_PRIVATE_KEY=LS02... -# You will find your Property ID in the Google Analytics dashboard. It will look like '987654321' +# You will find your Property ID in the Google Analytics dashboard. It will look like '987654321'. GOOGLE_ANALYTICS_PROPERTY_ID=123456789 -# (OPTIONAL) get your aws s3 credentials at https://console.aws.amazon.com and create a new IAM user with S3 access +# (OPTIONAL) Get your aws s3 credentials at https://console.aws.amazon.com and create a new IAM user with S3 access. AWS_S3_IAM_ACCESS_KEY=ACK... AWS_S3_IAM_SECRET_KEY=t+33a... AWS_S3_FILES_BUCKET=your-bucket-name diff --git a/template/app/package.json b/template/app/package.json index a30fe3f35..faab01bfb 100644 --- a/template/app/package.json +++ b/template/app/package.json @@ -13,6 +13,7 @@ "@headlessui/react": "1.7.13", "@hookform/resolvers": "^5.1.1", "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", + "@polar-sh/sdk": "^0.34.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", diff --git a/template/app/src/analytics/stats.ts b/template/app/src/analytics/stats.ts index 73f6dd281..0ee5cdeee 100644 --- a/template/app/src/analytics/stats.ts +++ b/template/app/src/analytics/stats.ts @@ -8,8 +8,11 @@ import { getSources, } from "./providers/plausibleAnalyticsUtils"; // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; +import { OrderStatus } from "@polar-sh/sdk/models/components/orderstatus.js"; import { paymentProcessor } from "../payment/paymentProcessor"; import { SubscriptionStatus } from "../payment/plans"; +import { polarClient } from "../payment/polar/polarClient"; +import { assertUnreachable } from "../shared/utils"; export type DailyStatsProps = { dailyStats?: DailyStats; @@ -60,10 +63,11 @@ export const calculateDailyStats: DailyStatsJob = async ( case "lemonsqueezy": totalRevenue = await fetchTotalLemonSqueezyRevenue(); break; + case "polar": + totalRevenue = await fetchTotalPolarRevenue(); + break; default: - throw new Error( - `Unsupported payment processor: ${paymentProcessor.id}`, - ); + assertUnreachable(paymentProcessor.id); } const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews(); @@ -212,3 +216,24 @@ async function fetchTotalLemonSqueezyRevenue() { throw error; } } + +async function fetchTotalPolarRevenue(): Promise { + let totalRevenue = 0; + + const result = await polarClient.orders.list({ + limit: 100, + }); + + for await (const page of result) { + const orders = page.result.items || []; + + for (const order of orders) { + if (order.status === OrderStatus.Paid && order.totalAmount > 0) { + totalRevenue += order.totalAmount; + } + } + } + + // Revenue is in cents so we convert to dollars + return totalRevenue / 100; +} diff --git a/template/app/src/payment/PricingPage.tsx b/template/app/src/payment/PricingPage.tsx index a12c06db2..38012b974 100644 --- a/template/app/src/payment/PricingPage.tsx +++ b/template/app/src/payment/PricingPage.tsx @@ -125,9 +125,9 @@ const PricingPage = () => {

- Choose between Stripe and LemonSqueezy as your payment provider. Just - add your Product IDs! Try it out below with test credit card number{" "} -
+ Choose between Stripe, LemonSqueezy or Polar as your payment provider. + Just add your Product IDs! Try it out below with test credit card + number
4242 4242 4242 4242 4242 diff --git a/template/app/src/payment/paymentProcessor.ts b/template/app/src/payment/paymentProcessor.ts index 123779ba7..bd362417e 100644 --- a/template/app/src/payment/paymentProcessor.ts +++ b/template/app/src/payment/paymentProcessor.ts @@ -18,7 +18,7 @@ export interface FetchCustomerPortalUrlArgs { } export interface PaymentProcessor { - id: "stripe" | "lemonsqueezy"; + id: "stripe" | "lemonsqueezy" | "polar"; createCheckoutSession: ( args: CreateCheckoutSessionArgs, ) => Promise<{ session: { id: string; url: string } }>; @@ -35,3 +35,4 @@ export interface PaymentProcessor { */ export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; // export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; +// export const paymentProcessor: PaymentProcessor = polarPaymentProcessor; diff --git a/template/app/src/payment/polar/checkoutUtils.ts b/template/app/src/payment/polar/checkoutUtils.ts new file mode 100644 index 000000000..bf81842d1 --- /dev/null +++ b/template/app/src/payment/polar/checkoutUtils.ts @@ -0,0 +1,44 @@ +import { Checkout } from "@polar-sh/sdk/models/components/checkout.js"; +import { Customer } from "@polar-sh/sdk/models/components/customer.js"; +import { config } from "wasp/server"; +import { polarClient } from "./polarClient"; + +/** + * Returns a Polar customer for the given User email, creating a customer if none exist. + * + * NOTE: Polar enforces unique emails and `externalId`. + * Additionally, `externalId` can't be changed once set. + */ +export async function ensurePolarCustomer( + userId: string, + userEmail: string, +): Promise { + const polarCustomers = await polarClient.customers.list({ + email: userEmail, + }); + + if (polarCustomers.result.items.length === 0) { + return polarClient.customers.create({ + externalId: userId, + email: userEmail, + }); + } else { + return polarCustomers.result.items[0]; + } +} + +interface CreatePolarCheckoutSessionArgs { + productId: string; + customerId: string; +} + +export function createPolarCheckoutSession({ + productId, + customerId, +}: CreatePolarCheckoutSessionArgs): Promise { + return polarClient.checkouts.create({ + products: [productId], + successUrl: `${config.frontendUrl}/checkout?status=success`, + customerId, + }); +} diff --git a/template/app/src/payment/polar/paymentProcessor.ts b/template/app/src/payment/polar/paymentProcessor.ts new file mode 100644 index 000000000..fd54d2536 --- /dev/null +++ b/template/app/src/payment/polar/paymentProcessor.ts @@ -0,0 +1,65 @@ +import { + type CreateCheckoutSessionArgs, + type FetchCustomerPortalUrlArgs, + type PaymentProcessor, +} from "../paymentProcessor"; +import { + fetchUserPaymentProcessorUserId, + updateUserPaymentProcessorUserId, +} from "../user"; +import { + createPolarCheckoutSession, + ensurePolarCustomer, +} from "./checkoutUtils"; +import { polarClient } from "./polarClient"; +import { polarMiddlewareConfigFn, polarWebhook } from "./webhook"; + +export const polarPaymentProcessor: PaymentProcessor = { + id: "polar", + createCheckoutSession: async ({ + userId, + userEmail, + paymentPlan, + prismaUserDelegate, + }: CreateCheckoutSessionArgs) => { + const customer = await ensurePolarCustomer(userId, userEmail); + + await updateUserPaymentProcessorUserId( + { userId, paymentProcessorUserId: customer.id }, + prismaUserDelegate, + ); + + const checkoutSession = await createPolarCheckoutSession({ + productId: paymentPlan.getPaymentProcessorPlanId(), + customerId: customer.id, + }); + + return { + session: { + id: checkoutSession.id, + url: checkoutSession.url, + }, + }; + }, + fetchCustomerPortalUrl: async ({ + userId, + prismaUserDelegate, + }: FetchCustomerPortalUrlArgs) => { + const paymentProcessorUserId = await fetchUserPaymentProcessorUserId( + userId, + prismaUserDelegate, + ); + + if (!paymentProcessorUserId) { + return null; + } + + const customerSession = await polarClient.customerSessions.create({ + customerId: paymentProcessorUserId, + }); + + return customerSession.customerPortalUrl; + }, + webhook: polarWebhook, + webhookMiddlewareConfigFn: polarMiddlewareConfigFn, +}; diff --git a/template/app/src/payment/polar/polarClient.ts b/template/app/src/payment/polar/polarClient.ts new file mode 100644 index 000000000..edb80d8f3 --- /dev/null +++ b/template/app/src/payment/polar/polarClient.ts @@ -0,0 +1,10 @@ +import { Polar } from "@polar-sh/sdk"; +import { requireNodeEnvVar } from "../../server/utils"; + +export const polarClient = new Polar({ + accessToken: requireNodeEnvVar("POLAR_ORGANIZATION_ACCESS_TOKEN"), + server: + requireNodeEnvVar("POLAR_SANDBOX_MODE") === "true" + ? "sandbox" + : "production", +}); diff --git a/template/app/src/payment/polar/webhook.ts b/template/app/src/payment/polar/webhook.ts new file mode 100644 index 000000000..e59f8c327 --- /dev/null +++ b/template/app/src/payment/polar/webhook.ts @@ -0,0 +1,169 @@ +import { Subscription } from "@polar-sh/sdk/models/components/subscription.js"; +import { SubscriptionStatus } from "@polar-sh/sdk/models/components/subscriptionstatus.js"; +import { WebhookOrderPaidPayload } from "@polar-sh/sdk/models/components/webhookorderpaidpayload.js"; +import { WebhookSubscriptionUpdatedPayload } from "@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload.js"; +import { validateEvent } from "@polar-sh/sdk/webhooks"; +import express from "express"; +import type { MiddlewareConfigFn, PrismaClient } from "wasp/server"; +import type { PaymentsWebhook } from "wasp/server/api"; +import { requireNodeEnvVar } from "../../server/utils"; +import { assertUnreachable } from "../../shared/utils"; +import { UnhandledWebhookEventError } from "../errors"; +import { + getPaymentPlanIdByPaymentProcessorPlanId, + SubscriptionStatus as OpenSaasSubscriptionStatus, + PaymentPlanId, + paymentPlans, +} from "../plans"; +import { updateUserCredits, updateUserSubscription } from "../user"; + +/** + * Polar requires a raw request to construct events successfully. + */ +export const polarMiddlewareConfigFn: MiddlewareConfigFn = ( + middlewareConfig, +) => { + middlewareConfig.delete("express.json"); + middlewareConfig.set( + "express.raw", + express.raw({ type: "application/json" }), + ); + + return middlewareConfig; +}; + +export const polarWebhook: PaymentsWebhook = async ( + request, + response, + context, +) => { + const prismaUserDelegate = context.entities.User; + try { + const event = validateEvent( + request.body, + request.headers as Record, + requireNodeEnvVar("POLAR_WEBHOOK_SECRET"), + ); + + switch (event.type) { + case "order.paid": + await handleOrderPaid(event, prismaUserDelegate); + break; + case "subscription.updated": + await handleSubscriptionUpdated(event, prismaUserDelegate); + break; + default: + throw new UnhandledWebhookEventError(event.type); + } + 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. + if (process.env.NODE_ENV === "development") { + console.info("Unhandled Polar webhook event in development: ", error); + } else if (process.env.NODE_ENV === "production") { + console.error("Unhandled Polar webhook event in production: ", error); + } + + // We must return a 2XX status code, otherwise Polar will keep retrying the event. + return response.status(200).json({ error: error.message }); + } + + console.error("Polar webhook error: ", error); + if (error instanceof Error) { + return response.status(400).json({ error: error.message }); + } else { + return response + .status(500) + .json({ error: "Error processing Polar webhook event" }); + } + } +}; + +async function handleOrderPaid( + { data: order }: WebhookOrderPaidPayload, + userDelegate: PrismaClient["user"], +): Promise { + const paymentPlanId = getPaymentPlanIdByPaymentProcessorPlanId( + order.productId, + ); + + switch (paymentPlanId) { + case PaymentPlanId.Credits10: + await updateUserCredits( + { + paymentProcessorUserId: order.customerId, + numOfCreditsPurchased: paymentPlans[paymentPlanId].effect.amount, + datePaid: order.createdAt, + }, + userDelegate, + ); + break; + case PaymentPlanId.Hobby: + case PaymentPlanId.Pro: + await updateUserSubscription( + { + paymentProcessorUserId: order.customerId, + paymentPlanId, + subscriptionStatus: OpenSaasSubscriptionStatus.Active, + datePaid: order.createdAt, + }, + userDelegate, + ); + break; + default: + assertUnreachable(paymentPlanId); + } +} + +async function handleSubscriptionUpdated( + { data: subscription }: WebhookSubscriptionUpdatedPayload, + userDelegate: PrismaClient["user"], +): Promise { + const newSubscriptionStatus = getOpenSaasSubscriptionStatus(subscription); + if (!newSubscriptionStatus) { + return; + } + + const paymentPlanId = getPaymentPlanIdByPaymentProcessorPlanId( + subscription.productId, + ); + + await updateUserSubscription( + { + paymentProcessorUserId: subscription.customer.id, + subscriptionStatus: newSubscriptionStatus, + paymentPlanId, + }, + userDelegate, + ); +} + +function getOpenSaasSubscriptionStatus( + subscription: Subscription, +): OpenSaasSubscriptionStatus | undefined { + const polarToOpenSaasSubscriptionStatus: Record< + SubscriptionStatus, + OpenSaasSubscriptionStatus | undefined + > = { + trialing: OpenSaasSubscriptionStatus.Active, + active: OpenSaasSubscriptionStatus.Active, + past_due: OpenSaasSubscriptionStatus.PastDue, + canceled: OpenSaasSubscriptionStatus.Deleted, + unpaid: OpenSaasSubscriptionStatus.Deleted, + incomplete_expired: OpenSaasSubscriptionStatus.Deleted, + incomplete: undefined, + }; + + const subscriptionStatus = + polarToOpenSaasSubscriptionStatus[subscription.status]; + + if ( + subscriptionStatus === OpenSaasSubscriptionStatus.Active && + subscription.cancelAtPeriodEnd + ) { + return OpenSaasSubscriptionStatus.CancelAtPeriodEnd; + } + + return subscriptionStatus; +}