Merge branch 'main' into miho-webhooks-runtime-validations

This commit is contained in:
Mihovil Ilakovac 2025-02-20 11:23:59 +01:00
commit a969544793
11 changed files with 243 additions and 58 deletions

View File

@ -1,14 +1,15 @@
--- template/app/src/client/components/NavBar/NavBar.tsx
+++ opensaas-sh/app/src/client/components/NavBar/NavBar.tsx
@@ -32,6 +32,7 @@
@@ -32,7 +32,7 @@
!isLandingPage,
})}
>
- {isLandingPage && <Announcement />}
+ {/* {isLandingPage && <Announcement />} */}
<nav className='flex items-center justify-between p-6 lg:px-8' aria-label='Global'>
<div className='flex items-center lg:flex-1'>
<WaspRouterLink
@@ -39,9 +40,7 @@
@@ -40,9 +40,7 @@
className='flex items-center -m-1.5 p-1.5 text-gray-900 duration-300 ease-in-out hover:text-yellow-500'
>
<NavLogo />
@ -19,7 +20,7 @@
</WaspRouterLink>
</div>
<div className='flex lg:hidden'>
@@ -56,13 +55,13 @@
@@ -57,13 +55,13 @@
</div>
<div className='hidden lg:flex lg:gap-x-12'>{renderNavigationItems(navigationItems)}</div>
<div className='hidden lg:flex lg:flex-1 gap-3 justify-end items-center'>
@ -36,7 +37,7 @@
</div>
</WaspRouterLink>
) : (
@@ -77,7 +76,7 @@
@@ -78,7 +76,7 @@
<Dialog.Panel className='fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-white dark:text-white dark:bg-boxdark px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10'>
<div className='flex items-center justify-between'>
<WaspRouterLink to={routes.LandingPageRoute.to} className='-m-1.5 p-1.5'>
@ -45,7 +46,7 @@
<NavLogo />
</WaspRouterLink>
<button
@@ -96,7 +95,7 @@
@@ -97,7 +95,7 @@
{isUserLoading ? null : !user ? (
<WaspRouterLink to={routes.LoginRoute.to}>
<div className='flex justify-end items-center duration-300 ease-in-out text-gray-900 hover:text-yellow-500 dark:text-white'>
@ -54,32 +55,41 @@
</div>
</WaspRouterLink>
) : (
@@ -138,3 +137,27 @@
);
@@ -140,30 +138,26 @@
});
}
+
-const ContestURL = 'https://github.com/wasp-lang/wasp';
+const ContestURL =
+ 'https://docs.opensaas.sh/blog/';
+
+function Announcement() {
+ return (
+ <div className='flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-[#d946ef] to-[#fc0] font-semibold text-white text-center z-49'>
function Announcement() {
return (
<div className='flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-[#d946ef] to-[#fc0] font-semibold text-white text-center z-49'>
- <p
- onClick={() => window.open(ContestURL, '_blank')}
- className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow'
- >
- Support Open-Source Software!
- </p>
+ <p onClick={() => window.open(ContestURL, '_blank')} className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow'>🍪 THE MOST ANNOYING COOKIE BANNER EVER HACKATHON 🤬</p>
+ <div className='hidden lg:block self-stretch w-0.5 bg-white'></div>
+ <div
+ onClick={() => window.open(ContestURL, '_blank')}
+ className='hidden lg:block cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
+ >
<div className='hidden lg:block self-stretch w-0.5 bg-white'></div>
<div
onClick={() => window.open(ContestURL, '_blank')}
className='hidden lg:block cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
>
- Star Our Repo on Github ⭐️ →
+ Enter here and win prizes! →
+ </div>
+ <div
+ onClick={() => window.open(ContestURL, '_blank')}
+ className='lg:hidden cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
+ >
</div>
<div
onClick={() => window.open(ContestURL, '_blank')}
className='lg:hidden cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
>
- ⭐️ Star the Our Repo on Github and Support Open-Source! ⭐️
+ 🍪 The Most Annoying Cookie Banner Contest! 🤬 →
+ </div>
+ </div>
+ );
</div>
</div>
);
-}
+}
\ No newline at end of file

View File

@ -1,6 +1,6 @@
--- template/app/src/payment/stripe/paymentProcessor.ts
+++ opensaas-sh/app/src/payment/stripe/paymentProcessor.ts
@@ -21,7 +21,7 @@
@@ -20,7 +20,7 @@
id: userId
},
data: {

View File

@ -178,6 +178,7 @@ export const stripe = new Stripe(process.env.STRIPE_KEY!, {
<br/>- `customer.subscription.deleted`
<br/>- `customer.subscription.updated`
<br/>- `invoice.paid`
<br/>- `payment_intent.succeeded`
<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:

View File

@ -32,6 +32,7 @@ export default function AppNavBar({ navigationItems }: { navigationItems: Naviga
!isLandingPage,
})}
>
{isLandingPage && <Announcement />}
<nav className='flex items-center justify-between p-6 lg:px-8' aria-label='Global'>
<div className='flex items-center lg:flex-1'>
<WaspRouterLink
@ -138,3 +139,31 @@ function renderNavigationItems(
);
});
}
const ContestURL = 'https://github.com/wasp-lang/wasp';
function Announcement() {
return (
<div className='flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-[#d946ef] to-[#fc0] font-semibold text-white text-center z-49'>
<p
onClick={() => window.open(ContestURL, '_blank')}
className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow'
>
Support Open-Source Software!
</p>
<div className='hidden lg:block self-stretch w-0.5 bg-white'></div>
<div
onClick={() => window.open(ContestURL, '_blank')}
className='hidden lg:block cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
>
Star Our Repo on Github
</div>
<div
onClick={() => window.open(ContestURL, '_blank')}
className='lg:hidden cursor-pointer rounded-full bg-neutral-700 px-2.5 py-1 text-xs hover:bg-neutral-600 tracking-wider'
>
Star the Our Repo on Github and Support Open-Source!
</div>
</div>
);
}

View File

@ -1,6 +1,8 @@
import type { StripeMode } from './paymentProcessor';
import Stripe from 'stripe';
import { stripe } from './stripeClient';
import { assertUnreachable } from '../../shared/utils';
// 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';
@ -27,8 +29,20 @@ export async function fetchStripeCustomer(customerEmail: string) {
}
}
export async function createStripeCheckoutSession({ userId, priceId, customerId, mode }: { userId: string, priceId: string; customerId: string; mode: StripeMode }) {
interface CreateStripeCheckoutSessionParams {
priceId: string;
customerId: string;
mode: StripeMode;
}
export async function createStripeCheckoutSession({
priceId,
customerId,
mode,
}: CreateStripeCheckoutSessionParams) {
try {
const paymentIntentData = getPaymentIntentData({ mode, priceId });
return await stripe.checkout.sessions.create({
line_items: [
{
@ -44,9 +58,29 @@ export async function createStripeCheckoutSession({ userId, priceId, customerId,
address: 'auto',
},
customer: customerId,
// Stripe only allows us to pass payment intent metadata for one-time payments, not subscriptions.
// We do this so that we can capture priceId in the payment_intent.succeeded webhook
// and easily confirm the user's payment based on the price id. For subscriptions, we can get the price id
// in the customer.subscription.updated webhook via the line_items field.
payment_intent_data: paymentIntentData,
});
} catch (error) {
console.error(error);
throw error;
}
}
function getPaymentIntentData({ mode, priceId }: { mode: StripeMode; priceId: string }):
| {
metadata: { priceId: string };
}
| undefined {
switch (mode) {
case 'subscription':
return undefined;
case 'payment':
return { metadata: { priceId } };
default:
assertUnreachable(mode);
}
}

View File

@ -11,7 +11,6 @@ export const stripePaymentProcessor: PaymentProcessor = {
createCheckoutSession: async ({ userId, userEmail, paymentPlan, prismaUserDelegate }: CreateCheckoutSessionArgs) => {
const customer = await fetchStripeCustomer(userEmail);
const stripeSession = await createStripeCheckoutSession({
userId,
priceId: paymentPlan.getPaymentProcessorPlanId(),
customerId: customer.id,
mode: paymentPlanEffectToStripeMode(paymentPlan.effect),

View File

@ -4,7 +4,7 @@ import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { Stripe } from 'stripe';
import { stripe } from './stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
import { paymentPlans, PaymentPlanId, SubscriptionStatus, PaymentPlanEffect, PaymentPlan } from '../plans';
import { updateUserStripePaymentDetails } from './paymentDetails';
import { emailSender } from 'wasp/server/email';
import { assertUnreachable } from '../../shared/utils';
@ -13,6 +13,7 @@ import { z } from 'zod';
import {
InvoicePaidData,
parseWebhookPayload,
PaymentIntentSucceededData,
SessionCompletedData,
SubscriptionDeletedData,
SubscriptionUpdatedData,
@ -43,6 +44,9 @@ export const stripeWebhook: PaymentsWebhook = async (request, response, context)
case 'invoice.paid':
await handleInvoicePaid(payload.data, prismaUserDelegate);
break;
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(payload.data, prismaUserDelegate);
break;
case 'customer.subscription.updated':
await handleCustomerSubscriptionUpdated(payload.data, prismaUserDelegate);
break;
@ -87,6 +91,9 @@ export const stripeMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) =
return middlewareConfig;
};
// Because a checkout session completed could potentially result in a failed payment,
// we can update the user's payment details here, but confirm credits or a subscription
// if the payment succeeds in other, more specific, webhooks.
export async function handleCheckoutSessionCompleted(
session: SessionCompletedData,
prismaUserDelegate: PrismaClient['user']
@ -100,32 +107,57 @@ export async function handleCheckoutSessionCompleted(
const planId = getPlanIdByPriceId(lineItemPriceId);
const plan = paymentPlans[planId];
let subscriptionPlan: PaymentPlanId | undefined;
let numOfCreditsPurchased: number | undefined;
switch (plan.effect.kind) {
case 'subscription':
subscriptionPlan = planId;
break;
case 'credits':
numOfCreditsPurchased = plan.effect.amount;
break;
default:
assertUnreachable(plan.effect);
if (plan.effect.kind === 'credits') {
return;
}
const { subscriptionPlan } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });
return updateUserStripePaymentDetails(
{ userStripeId, subscriptionPlan, numOfCreditsPurchased, datePaid: new Date() },
prismaUserDelegate
);
return updateUserStripePaymentDetails({ userStripeId, subscriptionPlan }, prismaUserDelegate);
}
// This is called when a subscription is purchased or renewed and payment succeeds.
// Invoices are not created for one-time payments, so we handle them in the payment_intent.succeeded webhook.
export async function handleInvoicePaid(invoice: InvoicePaidData, prismaUserDelegate: PrismaClient['user']) {
const userStripeId = validateUserStripeIdOrThrow(invoice.customer);
const datePaid = new Date(invoice.period_start * 1000);
return updateUserStripePaymentDetails({ userStripeId, datePaid }, prismaUserDelegate);
}
export async function handlePaymentIntentSucceeded(
paymentIntent: PaymentIntentSucceededData,
prismaUserDelegate: PrismaClient['user']
) {
// We handle invoices in the invoice.paid webhook. Invoices exist for subscription payments,
// but not for one-time payment/credits products which use the Stripe `payment` mode on checkout sessions.
if (paymentIntent.invoice) {
return;
}
const userStripeId = validateUserStripeIdOrThrow(paymentIntent.customer);
const datePaid = new Date(paymentIntent.created * 1000);
// We capture the price id from the payment intent metadata
// that we passed in when creating the checkout session in checkoutUtils.ts.
const { metadata } = paymentIntent;
if (!metadata.priceId) {
throw new HttpError(400, 'No price id found in payment intent');
}
const planId = getPlanIdByPriceId(metadata.priceId);
const plan = paymentPlans[planId];
if (plan.effect.kind === 'subscription') {
return;
}
const { numOfCreditsPurchased } = getPlanEffectPaymentDetails({ planId, planEffect: plan.effect });
return updateUserStripePaymentDetails(
{ userStripeId, numOfCreditsPurchased, datePaid },
prismaUserDelegate
);
}
export async function handleCustomerSubscriptionUpdated(
subscription: SubscriptionUpdatedData,
prismaUserDelegate: PrismaClient['user']
@ -217,3 +249,23 @@ function getPlanIdByPriceId(priceId: string): PaymentPlanId {
}
return planId;
}
function getPlanEffectPaymentDetails({
planId,
planEffect,
}: {
planId: PaymentPlanId;
planEffect: PaymentPlanEffect;
}): {
subscriptionPlan: PaymentPlanId | undefined;
numOfCreditsPurchased: number | undefined;
} {
switch (planEffect.kind) {
case 'subscription':
return { subscriptionPlan: planId, numOfCreditsPurchased: undefined };
case 'credits':
return { subscriptionPlan: undefined, numOfCreditsPurchased: planEffect.amount };
default:
assertUnreachable(planEffect);
}
}

View File

@ -20,6 +20,14 @@ export async function parseWebhookPayload(rawStripeEvent: Stripe.Event) {
throw new Error('Error parsing Stripe event object');
});
return { eventName: event.type, data: invoice };
case 'payment_intent.succeeded':
const paymentIntent = await paymentIntentSucceededDataSchema
.parseAsync(event.data.object)
.catch((e) => {
console.error(e);
throw new Error('Error parsing Stripe event object');
});
return { eventName: event.type, data: paymentIntent };
case 'customer.subscription.updated':
const updatedSubscription = await subscriptionUpdatedDataSchema
.parseAsync(event.data.object)
@ -61,6 +69,16 @@ const invoicePaidDataSchema = z.object({
period_start: z.number(),
});
// This is a subtype of Stripe.PaymentIntent from "stripe"
const paymentIntentSucceededDataSchema = z.object({
invoice: z.unknown().optional(),
created: z.number(),
metadata: z.object({
priceId: z.string(),
}),
customer: z.string(),
});
// This is a subtype of Stripe.Subscription from "stripe"
const subscriptionUpdatedDataSchema = z.object({
customer: z.string(),
@ -86,6 +104,8 @@ export type SessionCompletedData = z.infer<typeof sessionCompletedDataSchema>;
export type InvoicePaidData = z.infer<typeof invoicePaidDataSchema>;
export type PaymentIntentSucceededData = z.infer<typeof paymentIntentSucceededDataSchema>;
export type SubscriptionUpdatedData = z.infer<typeof subscriptionUpdatedDataSchema>;
export type SubscriptionDeletedData = z.infer<typeof subscriptionDeletedDataSchema>;

View File

@ -89,9 +89,9 @@ test('AI schedule generation fails on 4th attempt', async () => {
expect(tableTextContent.includes(task2.toLowerCase())).toBeFalsy();
});
test('Make test payment with Stripe', async () => {
const PLAN_NAME = 'Hobby';
await makeStripePayment({ test, page, planName: PLAN_NAME });
test('Make test payment with Stripe for hobby plan', async () => {
const planId = 'hobby';
await makeStripePayment({ test, page, planId });
});
test('User should be able to generate another schedule after payment', async () => {

View File

@ -1,10 +1,10 @@
import { test, expect, type Page } from '@playwright/test';
import { signUserUp, logUserIn, createRandomUser, makeStripePayment, type User } from './utils';
import { signUserUp, logUserIn, createRandomUser, makeStripePayment, type User, acceptAllCookies } from './utils';
let page: Page;
let testUser: User;
async function logNewUserIn() {
async function createAndLogInNewUser() {
testUser = createRandomUser();
await signUserUp({ page: page, user: testUser });
await logUserIn({ page: page, user: testUser });
@ -33,9 +33,9 @@ test('User should see Log In to Buy Plan button', async () => {
});
test('User should see the Buy Plan button before payment', async () => {
// We only need to log the user in once since the tests are running sequentially
// We only need to log the user in once since the tests are running sequentially
// and the same page is being shared between all the tests.
await logNewUserIn();
await createAndLogInNewUser();
await page.goto('/pricing');
// There are three tiers on the page, so we want to retrieve the first of the three buttons
const manageSubscriptionButton = page.getByRole('button', { name: 'Buy plan' }).first();
@ -43,10 +43,10 @@ test('User should see the Buy Plan button before payment', async () => {
await expect(manageSubscriptionButton).toBeEnabled();
});
test('Make test payment with Stripe', async () => {
const PLAN_NAME = 'Hobby';
test('Make test payment with Stripe for hobby plan', async () => {
const planId = 'hobby';
await page.goto('/');
await makeStripePayment({ test, page, planName: PLAN_NAME });
await makeStripePayment({ test, page, planId });
});
test('User should see the Manage Subscription button after payment', async () => {
@ -62,3 +62,11 @@ test('User should see the Manage Subscription button after payment', async () =>
await newTab.waitForLoadState();
await expect(newTab).toHaveURL(/^https:\/\/billing\.stripe\.com\//);
});
test('Make test payment with Stripe for 10 credits', async () => {
await createAndLogInNewUser();
await acceptAllCookies(page); // Clear the cookie consent modal so it doesn't interfere with the payment
const planId = 'credits10';
await page.goto('/');
await makeStripePayment({ test, page, planId });
});

View File

@ -39,6 +39,20 @@ export const logUserIn = async ({ page, user }: { page: Page; user: User }) => {
export const signUserUp = async ({ page, user }: { page: Page; user: User }) => {
await page.goto('/');
await page.evaluate(() => {
try {
const sessionId = localStorage.getItem('wasp:sessionId');
if (sessionId) {
localStorage.removeItem('wasp:sessionId');
}
window.location.reload();
} catch (e) {
console.error('Failed to clear localStorage:', e);
}
});
await page.waitForLoadState('domcontentloaded');
await page.getByRole('link', { name: 'Log in' }).click();
@ -62,13 +76,22 @@ export const createRandomUser = () => {
return { email, password: DEFAULT_PASSWORD } as User;
};
export const makeStripePayment = async ({ test, page, planName }: { test: any; page: Page; planName: string }) => {
export const makeStripePayment = async ({
test,
page,
planId,
}: {
test: any;
page: Page;
planId: 'hobby' | 'pro' | 'credits10';
}) => {
test.slow(); // Stripe payments take a long time to confirm and can cause tests to fail so we use a longer timeout
await page.click('text="Pricing"');
await page.waitForURL('**/pricing');
const buyBtn = page.getByRole('button', { name: 'Buy plan' }).first(); // "Hobby Plan" is the first of three plans
const buyBtn = page.locator(`button[aria-describedby="${planId}"]`);
await expect(buyBtn).toBeVisible();
await expect(buyBtn).toBeEnabled();
await buyBtn.click();
@ -92,5 +115,14 @@ export const makeStripePayment = async ({ test, page, planName }: { test: any; p
await page.waitForURL('**/checkout?success=true');
await page.waitForURL('**/account');
await expect(page.getByText(planName)).toBeVisible();
if (planId === 'credits10') {
await expect(page.getByText('Credits remaining: 13')).toBeVisible();
} else {
await expect(page.getByText(planId)).toBeVisible();
}
};
export const acceptAllCookies = async (page: Page) => {
await page.waitForSelector('button:has-text("Accept all")');
await page.click('button:has-text("Accept all")');
};