small pricing updates

This commit is contained in:
vincanger 2024-09-09 13:52:57 +02:00
parent cb3d75c0b6
commit 48f8c5ce73
5 changed files with 61 additions and 49 deletions

View File

@ -6,7 +6,7 @@
+ "scripts": {
+ "env:pull": "npx dotenv-vault@latest pull development .env.server",
+ "env:push": "npx dotenv-vault@latest push development .env.server",
+ "deploy": "REACT_APP_STRIPE_CUSTOMER_PORTAL=https://billing.stripe.com/p/login/test_8wM8x17JN7DT4zC000 REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H wasp deploy fly deploy"
+ "deploy": "REACT_APP_GOOGLE_ANALYTICS_ID=G-H3LSJCK95H wasp deploy fly deploy"
+ },
"dependencies": {
"@aws-sdk/client-s3": "^3.523.0",

View File

@ -8,7 +8,7 @@
interface PaymentPlanCard {
name: string;
@@ -83,7 +84,7 @@
@@ -82,7 +83,7 @@
}
if (!customerPortalUrl) {
@ -17,7 +17,7 @@
}
window.open(customerPortalUrl, '_blank');
@@ -97,11 +98,18 @@
@@ -96,11 +97,18 @@
Pick your <span className='text-yellow-500'>pricing</span>
</h2>
</div>
@ -41,7 +41,7 @@
<div className='isolate mx-auto mt-16 grid max-w-md grid-cols-1 gap-y-8 lg:gap-x-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3'>
{Object.values(PaymentPlanId).map((planId) => (
<div
@@ -188,7 +196,7 @@
@@ -187,7 +195,7 @@
)}
disabled={isPaymentLoading}
>

View File

@ -14,7 +14,7 @@ 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
## 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:
@ -41,7 +41,7 @@ At this point, you can delete:
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
## Stripe
First, you'll need to create a Stripe account. You can do that [here](https://dashboard.stripe.com/register).
@ -51,7 +51,7 @@ We've packed in a ton of features and love into this SaaS starter, and offer it
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
### 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`.
@ -60,7 +60,7 @@ Once you've created your account, you'll need to get your test API keys. You can
- 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
### 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.
@ -80,7 +80,7 @@ To create a test product, go to the test products url [https://dashboard.stripe.
- 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
### 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).
@ -89,7 +89,7 @@ To create a test customer, go to the test customers url [https://dashboard.strip
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
:::
## Get your Customer Portal Link
### Get your Customer Portal Link
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:
@ -97,7 +97,7 @@ Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Da
STRIPE_CUSTOMER_PORTAL_URL=<your-test-customer-portal-link>
```
## Install the Stripe CLI
### Install the Stripe CLI
To install the Stripe CLI with homebrew, run the following command in your terminal:
@ -131,7 +131,7 @@ You should see a message like this:
copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`.
## Testing Webhooks via the Stripe CLI
### Testing Webhooks via the Stripe CLI
- In a new terminal window, run the following command:
@ -173,7 +173,7 @@ We've packed in a ton of features and love into this SaaS starter, and offer it
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
### 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:
@ -199,7 +199,7 @@ wasp db studio
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
## 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).
@ -211,7 +211,7 @@ We've packed in a ton of features and love into this SaaS starter, and offer it
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
### 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.
@ -219,7 +219,7 @@ Once you've created your account, you'll need to get your test API keys. You can
- Give your API key a name
- Copy and paste it in your `.env.server` file under `LEMONSQUEEZY_API_KEY=`
## Create Test Products
### Create Test Products
To create a test product, go to the test products url [https://app.lemonsqueezy.com/products](https://app.lemonsqueezy.com/products).
@ -239,7 +239,7 @@ To create a test product, go to the test products url [https://app.lemonsqueezy.
- 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
### 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.
@ -272,6 +272,6 @@ Now go to your [Lemon Squeezy Webhooks Dashboard](https://app.lemonsqueezy.com/s
You're now ready to start consuming Lemon Squeezy webhook events in local development.
# Deploying
## 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.

View File

@ -38,16 +38,15 @@ export const paymentPlanCards: Record<PaymentPlanId, PaymentPlanCard> = {
const PricingPage = () => {
const [isPaymentLoading, setIsPaymentLoading] = useState<boolean>(false);
const { data: user, isLoading: isUserLoading } = useAuth();
const shouldFetchCustomerPortalUrl = !!user && !!user.subscriptionStatus;
const { data: user } = useAuth();
const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== 'deleted';
const {
data: customerPortalUrl,
isLoading: isCustomerPortalUrlLoading,
error: customerPortalUrlError,
} = useQuery(getCustomerPortalUrl, { enabled: shouldFetchCustomerPortalUrl });
} = useQuery(getCustomerPortalUrl, { enabled: isUserSubscribed });
const history = useHistory();
@ -153,7 +152,7 @@ const PricingPage = () => {
))}
</ul>
</div>
{!!user && !!user.subscriptionStatus ? (
{isUserSubscribed ? (
<button
onClick={handleCustomerPortalClick}
disabled={isCustomerPortalUrlLoading}

View File

@ -59,16 +59,6 @@ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) =
return middlewareConfig;
};
const LineItemsPriceSchema = z.object({
data: z.array(
z.object({
price: z.object({
id: z.string(),
}),
})
),
});
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
prismaUserDelegate: PrismaClient["user"]
@ -77,21 +67,10 @@ export async function handleCheckoutSessionCompleted(
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
const result = LineItemsPriceSchema.safeParse(line_items);
if (!result.success) {
throw new HttpError(400, 'No price id in line item');
}
if (result.data.data.length > 1) {
throw new HttpError(400, 'More than one line item in session');
}
const lineItemPriceId = result.data.data[0].price.id;
const planId = Object.values(PaymentPlanId).find(
(planId) => paymentPlans[planId].getPaymentProcessorPlanId() === lineItemPriceId
);
if (!planId) {
throw new Error(`No plan with stripe price id ${lineItemPriceId}`);
}
const lineItemPriceId = extractPriceId(line_items);
const planId = getPlanIdByPriceId(lineItemPriceId);
const plan = paymentPlans[planId];
let subscriptionPlan: PaymentPlanId | undefined;
@ -126,6 +105,9 @@ export async function handleCustomerSubscriptionUpdated(
const userStripeId = validateUserStripeIdOrThrow(subscription.customer);
let subscriptionStatus: SubscriptionStatus | undefined;
const priceId = extractPriceId(subscription.items);
const subscriptionPlan = getPlanIdByPriceId(priceId);
// There are other subscription statuses, such as `trialing` that we are not handling and simply ignore
// If you'd like to handle more statuses, you can add more cases above. Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well
if (subscription.status === 'active') {
@ -134,7 +116,7 @@ export async function handleCustomerSubscriptionUpdated(
subscriptionStatus = 'past_due';
}
if (subscriptionStatus) {
const user = await updateUserStripePaymentDetails({ userStripeId, subscriptionStatus }, prismaUserDelegate);
const user = await updateUserStripePaymentDetails({ userStripeId, subscriptionPlan, subscriptionStatus }, prismaUserDelegate);
if (subscription.cancel_at_period_end) {
if (user.email) {
await emailSender.send({
@ -162,3 +144,34 @@ function validateUserStripeIdOrThrow(userStripeId: Stripe.Checkout.Session['cust
if (typeof userStripeId !== 'string') throw new HttpError(400, 'Customer id is not a string');
return userStripeId;
}
const LineItemsPriceSchema = z.object({
data: z.array(
z.object({
price: z.object({
id: z.string(),
}),
})
),
});
function extractPriceId(items: Stripe.Checkout.Session['line_items'] | Stripe.Subscription['items']) {
const result = LineItemsPriceSchema.safeParse(items);
if (!result.success) {
throw new HttpError(400, 'No price id in stripe event object');
}
if (result.data.data.length > 1) {
throw new HttpError(400, 'More than one item in stripe event object');
}
return result.data.data[0].price.id;
}
function getPlanIdByPriceId(priceId: string): PaymentPlanId {
const planId = Object.values(PaymentPlanId).find(
(planId) => paymentPlans[planId].getPaymentProcessorPlanId() === priceId
);
if (!planId) {
throw new Error(`No plan with Stripe price id ${priceId}`);
}
return planId;
}