Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
809e982
Stripe only changes
FranjoMindek Sep 17, 2025
3df17a2
changes
FranjoMindek Sep 17, 2025
d0a2e15
further improvements
FranjoMindek Sep 17, 2025
1560833
remove typ catch
FranjoMindek Sep 21, 2025
fec25ab
split payment details funcs
FranjoMindek Sep 21, 2025
05b0fad
add awaits
FranjoMindek Sep 21, 2025
91e2f0d
name
FranjoMindek Sep 21, 2025
de70c30
Merge branch 'main' into franjo/refactor-stripe-payment-provider
FranjoMindek Sep 21, 2025
0cbe24c
formatting
FranjoMindek Sep 21, 2025
5dce0da
fix format
FranjoMindek Sep 21, 2025
4d6a64f
update
FranjoMindek Sep 21, 2025
1541759
map to switch
FranjoMindek Sep 21, 2025
dddc128
wording
FranjoMindek Sep 21, 2025
d7b5f8f
fix typo
FranjoMindek Sep 22, 2025
b764193
Merge branch 'main' into franjo/refactor-stripe-payment-provider
FranjoMindek Sep 24, 2025
d6a608a
format
FranjoMindek Sep 24, 2025
346da0f
fix diffs?
FranjoMindek Sep 24, 2025
4d9fb58
Merge remote-tracking branch 'origin/main' into franjo/refactor-strip…
FranjoMindek Sep 24, 2025
8371bd4
docs: update LLM files after documentation changes
FranjoMindek Sep 24, 2025
e5caba8
comments
FranjoMindek Oct 1, 2025
2e5ea07
wording
FranjoMindek Oct 1, 2025
f09865f
wording
FranjoMindek Oct 1, 2025
b4f80d3
wording
FranjoMindek Oct 1, 2025
2c82386
Merge branch 'main' into franjo/refactor-stripe-payment-provider
FranjoMindek Oct 1, 2025
34209ae
fix typo
FranjoMindek Oct 1, 2025
f288f9b
to 204
FranjoMindek Oct 1, 2025
0bd0b73
Merge remote-tracking branch 'origin/main' into franjo/refactor-strip…
FranjoMindek Oct 9, 2025
375cf1a
Merge remote-tracking branch 'origin/main' into franjo/refactor-strip…
FranjoMindek Oct 11, 2025
4673ec6
update env
FranjoMindek Oct 11, 2025
fb0ff94
formatting
FranjoMindek Oct 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
STRIPE_API_KEY: ${{ secrets.STRIPE_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_CUSTOMER_PORTAL_URL: https://billing.stripe.com/p/login/test_8wM8x17JN7DT4zC000
PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID: ${{ secrets.STRIPE_HOBBY_SUBSCRIPTION_PRICE_ID }}
PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID: ${{ secrets.STRIPE_PRO_SUBSCRIPTION_PRICE_ID }}
PAYMENTS_CREDITS_10_PLAN_ID: ${{ secrets.STRIPE_CREDITS_PRICE_ID }}
Expand Down
4 changes: 2 additions & 2 deletions opensaas-sh/app_diff/src/analytics/stats.ts.diff
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { type DailyStats } from "wasp/entities";
import { type DailyStatsJob } from "wasp/server/jobs";
+import { SubscriptionStatus } from "../payment/plans";
import { stripe } from "../payment/stripe/stripeClient";
import { stripeClient } from "../payment/stripe/stripeClient";
import {
getDailyPageViews,
getSources,
Expand Down Expand Up @@ -38,7 +38,7 @@

const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();

@@ -176,38 +162,3 @@
@@ -177,38 +163,3 @@
// Revenue is in cents so we convert to dollars (or your main currency unit)
return totalRevenue / 100;
}
Expand Down
21 changes: 13 additions & 8 deletions opensaas-sh/app_diff/src/payment/stripe/paymentDetails.ts.diff
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
--- template/app/src/payment/stripe/paymentDetails.ts
+++ opensaas-sh/app/src/payment/stripe/paymentDetails.ts
@@ -20,10 +20,10 @@
) => {
@@ -19,7 +19,7 @@
) {
return userDelegate.update({
where: {
- paymentProcessorUserId: userStripeId,
+ stripeId: userStripeId,
- paymentProcessorUserId: customerId,
+ stripeId: customerId,
},
data: {
- paymentProcessorUserId: userStripeId,
+ stripeId: userStripeId,
subscriptionPlan,
subscriptionStatus,
datePaid,
@@ -46,7 +46,7 @@
) {
return userDelegate.update({
where: {
- paymentProcessorUserId: customerId,
+ stripeId: customerId,
},
data: {
subscriptionPlan: paymentPlanId,
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
--- template/app/src/payment/stripe/paymentProcessor.ts
+++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts
@@ -32,7 +32,7 @@
@@ -29,7 +29,7 @@
id: userId,
},
data: {
- paymentProcessorUserId: customer.id,
+ stripeId: customer.id,
},
});
if (!stripeSession.url)

2 changes: 1 addition & 1 deletion opensaas-sh/blog/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# OpenSaaS Docs and Blog
# Open SaaS Docs and Blog

This is the docs and blog for the [OpenSaaS.sh](https://opensaas.sh/) website, [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)

Expand Down
2 changes: 1 addition & 1 deletion opensaas-sh/blog/src/content/docs/guides/deploying.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, {
2. click on `+ add endpoint`
3. enter your endpoint url, which will be the url of your deployed server + `/payments-webhook`, e.g. `https://open-saas-wasp-sh-server.fly.dev/payments-webhook`
<Image src={stripeListenEvents} alt="listen events" loading="lazy" />
4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find listed in [`src/payment/stripe/webhookPayload.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhookPayload.ts):
4. select the events you want to listen to. These should be the same events you're consuming in your webhook which you can find handled in [`src/payment/stripe/webhook.ts`](https://github.com/wasp-lang/open-saas/blob/main/template/app/src/payment/stripe/webhook.ts):
<Image src={stripeSigningSecret} alt="signing secret" loading="lazy" />
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. <br/>If you've deployed to Fly.io, you can do that easily with the following command:
Expand Down
19 changes: 9 additions & 10 deletions opensaas-sh/blog/src/content/docs/guides/payments-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor;
At this point, you can delete:
- the unused payment processor code within the `/src/payment/<unused-provider>` directory,
- any unused environment variables from `.env.server` (they will be prefixed with the name of the provider your are not using):
- e.g. `STRIPE_API_KEY`, `STRIPE_CUSTOMER_PORTAL_URL`, `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_WEBHOOK_SECRET`
- e.g. `STRIPE_API_KEY`, `LEMONSQUEEZY_API_KEY`
- Make sure to also uninstall the unused dependencies:
- `npm uninstall @lemonsqueezy/lemonsqueezy.js`
- or
Expand Down Expand Up @@ -95,22 +95,21 @@ To create a test product, go to the test products url [https://dashboard.stripe.

### Create a Test Customer

To create a test customer, go to the test customers url [https://dashboard.stripe.com/test/customers](https://dashboard.stripe.com/test/customers).
You can create a test customer directly in the [Stripe Dashboard](https://dashboard.stripe.com/test/customers).

- Click on the `Add a customer` button and fill in the relevant information for your test customer.
:::note
When filling in the test customer email address, use an address you have access to and will use when logging into your SaaS app. This is important because the email address is used to identify the customer when creating a subscription and allows you to manage your test user's payments/subscriptions via the test customer portal
:::

Alternatively, Open SasS will automatically create a test customer the first time a user starts a checkout session.
This customer is linked to the email address associated with your app's user.

### Set up the Customer Portal

Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Dashboard and activate and copy the `Customer portal link`. Paste it in your `.env.server` file:
You can set up your customer portal in your [Stripe Dashboard](https://dashboard.stripe.com/test/settings/billing/portal).

```ts title=".env.server"
STRIPE_CUSTOMER_PORTAL_URL=<your-test-customer-portal-link>
```
By default, OpenSaas generates a unique customer portal link for each user on the back end.
If you'd rather provide a permanent link to the customer portal, activate and copy the `Customer portal link`.

If you'd like to give users the ability to switch between different plans, e.g. upgrade from a hobby to a pro subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`.
If you'd like to give users the ability to switch between different plans, e.g., upgrade from a "Hobby" to a "Pro" subscription, go down to the `Subscriptions` dropdown and select `customers can switch plans`.

<Image src={switchPlans} alt="switch plans" loading="lazy" />

Expand Down
2 changes: 0 additions & 2 deletions template/app/.env.server.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
STRIPE_API_KEY=sk_test_...
# After downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/payments-webhook` it will output your signing secret
STRIPE_WEBHOOK_SECRET=whsec_...
# You can find your Stripe customer portal URL in the Stripe Dashboard under the 'Customer Portal' settings.
STRIPE_CUSTOMER_PORTAL_URL=https://billing.stripe.com/...

# For testing, create a new store in test mode on https://lemonsqueezy.com
LEMONSQUEEZY_API_KEY=eyJ...
Expand Down
5 changes: 3 additions & 2 deletions template/app/src/analytics/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { listOrders } from "@lemonsqueezy/lemonsqueezy.js";
import Stripe from "stripe";
import { type DailyStats } from "wasp/entities";
import { type DailyStatsJob } from "wasp/server/jobs";
import { stripe } from "../payment/stripe/stripeClient";
import { stripeClient } from "../payment/stripe/stripeClient";
import {
getDailyPageViews,
getSources,
Expand Down Expand Up @@ -156,7 +156,8 @@ async function fetchTotalStripeRevenue() {

let hasMore = true;
while (hasMore) {
const balanceTransactions = await stripe.balanceTransactions.list(params);
const balanceTransactions =
await stripeClient.balanceTransactions.list(params);

for (const transaction of balanceTransactions.data) {
if (transaction.type === "charge") {
Expand Down
11 changes: 7 additions & 4 deletions template/app/src/payment/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ export enum PaymentPlanId {
}

export interface PaymentPlan {
// Returns the id under which this payment plan is identified on your payment processor.
// E.g. this might be price id on Stripe, or variant id on LemonSqueezy.
/**
* Returns the id under which this payment plan is identified on your payment processor.
*
* E.g. price id on Stripe, or variant id on LemonSqueezy.
*/
getPaymentProcessorPlanId: () => string;
effect: PaymentPlanEffect;
}
Expand All @@ -24,7 +27,7 @@ export type PaymentPlanEffect =
| { kind: "subscription" }
| { kind: "credits"; amount: number };

export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
export const paymentPlans = {
[PaymentPlanId.Hobby]: {
getPaymentProcessorPlanId: () =>
requireNodeEnvVar("PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID"),
Expand All @@ -40,7 +43,7 @@ export const paymentPlans: Record<PaymentPlanId, PaymentPlan> = {
requireNodeEnvVar("PAYMENTS_CREDITS_10_PLAN_ID"),
effect: { kind: "credits", amount: 10 },
},
};
} as const satisfies Record<PaymentPlanId, PaymentPlan>;
Copy link
Contributor Author

@FranjoMindek FranjoMindek Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows us to do e.g. paymentPlans.credits10.effect.amount which would throw before.


export function prettyPaymentPlanName(planId: PaymentPlanId): string {
const planToName: Record<PaymentPlanId, string> = {
Expand Down
99 changes: 51 additions & 48 deletions template/app/src/payment/stripe/checkoutUtils.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,67 @@
import type { StripeMode } from "./paymentProcessor";

import Stripe from "stripe";
import { stripe } from "./stripeClient";
import { stripeClient } from "./stripeClient";

// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000";
/**
* Returns a Stripe customer for the given User email, creating a customer if none exist.
* Implements email uniqueness logic since Stripe doesn't enforce unique emails.
*/
export async function ensureStripeCustomer(
userEmail: string,
): Promise<Stripe.Customer> {
const stripeCustomers = await stripeClient.customers.list({
email: userEmail,
});

export async function fetchStripeCustomer(customerEmail: string) {
let customer: Stripe.Customer;
try {
const stripeCustomers = await stripe.customers.list({
email: customerEmail,
if (stripeCustomers.data.length === 0) {
console.log("Creating a new Stripe customer");
return stripeClient.customers.create({
email: userEmail,
});
if (!stripeCustomers.data.length) {
console.log("creating customer");
customer = await stripe.customers.create({
email: customerEmail,
});
} else {
console.log("using existing customer");
customer = stripeCustomers.data[0];
}
return customer;
} catch (error) {
console.error(error);
throw error;
} else {
console.log("Using an existing Stripe customer");
return stripeCustomers.data[0];
}
}

interface CreateStripeCheckoutSessionParams {
priceId: string;
customerId: string;
mode: StripeMode;
priceId: Stripe.Price["id"];
customerId: Stripe.Customer["id"];
mode: Stripe.Checkout.Session.Mode;
}

export async function createStripeCheckoutSession({
priceId,
customerId,
mode,
}: CreateStripeCheckoutSessionParams) {
try {
return await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: mode,
success_url: `${DOMAIN}/checkout?status=success`,
cancel_url: `${DOMAIN}/checkout?status=canceled`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer_update: {
address: "auto",
}: CreateStripeCheckoutSessionParams): Promise<Stripe.Checkout.Session> {
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp.sh/docs/deploying
Copy link
Contributor Author

@FranjoMindek FranjoMindek Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to create a issue about solving the env vars situation on open-saas.
Use Wass's defineEnvValidationSchema.
Centralize them in a single place.

Copy link
Collaborator

@vincanger vincanger Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one already exists here #483

const CLIENT_BASE_URL =
process.env.WASP_WEB_CLIENT_URL || "http://localhost:3000";

return await stripeClient.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
customer: customerId,
});
} catch (error) {
console.error(error);
throw error;
}
],
mode,
success_url: `${CLIENT_BASE_URL}/checkout?status=success`,
cancel_url: `${CLIENT_BASE_URL}/checkout?status=canceled`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer_update: {
address: "auto",
},
// Stripe automatically creates invoices for subscriptions.
// For one-time payments, we must enable them manually.
// However, enabling invoices for subscriptions will throw an error.
invoice_creation:
mode === "payment"
? {
enabled: true,
}
: undefined,
});
}
61 changes: 41 additions & 20 deletions template/app/src/payment/stripe/paymentDetails.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,57 @@
import { PrismaClient } from "@prisma/client";
import Stripe from "stripe";
import type { SubscriptionStatus } from "../plans";
import { PaymentPlanId } from "../plans";

export const updateUserStripePaymentDetails = async (
interface UpdateUserStripeOneTimePaymentDetails {
customerId: Stripe.Customer["id"];
datePaid: Date;
numOfCreditsPurchased: number;
}

export function updateUserStripeOneTimePaymentDetails(
{
userStripeId,
subscriptionPlan,
subscriptionStatus,
customerId,
datePaid,
numOfCreditsPurchased,
}: {
userStripeId: string;
subscriptionPlan?: PaymentPlanId;
subscriptionStatus?: SubscriptionStatus;
numOfCreditsPurchased?: number;
datePaid?: Date;
},
}: UpdateUserStripeOneTimePaymentDetails,
userDelegate: PrismaClient["user"],
) {
return userDelegate.update({
where: {
paymentProcessorUserId: customerId,
},
data: {
datePaid,
credits: { increment: numOfCreditsPurchased },
},
});
}

interface UpdateUserStripeSubscriptionDetails {
customerId: Stripe.Customer["id"];
datePaid?: Date;
subscriptionStatus: SubscriptionStatus;
paymentPlanId?: PaymentPlanId;
}

export function updateUserStripeSubscriptionDetails(
{
customerId,
paymentPlanId,
subscriptionStatus,
datePaid,
}: UpdateUserStripeSubscriptionDetails,
userDelegate: PrismaClient["user"],
) => {
) {
return userDelegate.update({
where: {
paymentProcessorUserId: userStripeId,
paymentProcessorUserId: customerId,
},
data: {
paymentProcessorUserId: userStripeId,
subscriptionPlan,
subscriptionPlan: paymentPlanId,
subscriptionStatus,
datePaid,
credits:
numOfCreditsPurchased !== undefined
? { increment: numOfCreditsPurchased }
: undefined,
},
});
};
}
Loading