stripe webhook sig & more

This commit is contained in:
vincanger 2023-11-20 17:14:58 +01:00
parent 17cba4ec16
commit af979e2cd0
17 changed files with 238 additions and 155 deletions

View File

@ -7,6 +7,8 @@
STRIPE_KEY=
# to create a test subscription, go to https://dashboard.stripe.com/test/products and click on + Add Product
SUBSCRIPTION_PRICE_ID=
# after starting the stripe cli with `stripe listen --forward-to localhost:3001/stripe-webhook` it will output your signing secret
STRIPE_WEBHOOK_SECRET=whsec_...
# see our guide for setting up google auth: https://wasp-lang.dev/docs/auth/social-auth/google
GOOGLE_CLIENT_ID=

View File

@ -65,8 +65,6 @@ app SaaSTemplate {
("@tailwindcss/typography", "^0.5.7"),
("react-hook-form", "7.43.1"),
("react-icons", "4.11.0"),
("request-ip", "3.3.0"),
("@types/request-ip", "0.0.37"),
("node-fetch", "3.3.0"),
("react-hook-form", "^7.45.4"),
("stripe", "11.15.0"),
@ -95,8 +93,9 @@ entity User {=psl
stripeId String?
checkoutSessionId String?
hasPaid Boolean @default(false)
sendEmail Boolean @default(false)
subscriptionType String?
subscriptionStatus String?
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
relatedObject RelatedObject[]
@ -143,8 +142,8 @@ entity DailyStats {=psl
paidUserCount Int @default(0)
userDelta Int @default(0)
paidUserDelta Int @default(0)
totalRevenue Int @default(0)
totalProfit Int @default(0)
totalRevenue Float @default(0)
totalProfit Float @default(0)
psl=}
entity Referrer {=psl
@ -287,11 +286,6 @@ action stripePayment {
entities: [User]
}
// action stripeCreditsPayment {
// fn: import { stripeCreditsPayment } from "@server/actions.js",
// entities: [User]
// }
action updateCurrentUser {
fn: import { updateCurrentUser } from "@server/actions.js",
entities: [User]
@ -340,8 +334,9 @@ query getPaginatedUsers {
*/
api stripeWebhook {
fn: import { stripeWebhook } from "@server/webhooks.js",
fn: import { stripeWebhook } from "@server/webhooks/stripe.js",
entities: [User],
middlewareConfigFn: import { stripeMiddlewareFn } from "@server/webhooks/stripe.js",
httpRoute: (POST, "/stripe-webhook")
}
@ -366,7 +361,8 @@ job dailyStats {
fn: import { calculateDailyStats } from "@server/workers/calculateDailyStats.js"
},
schedule: {
cron: "* * * * *" //
// every hour
cron: "0 * * * *"
},
entities: [User, DailyStats, Logs]
}

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "DailyStats" ALTER COLUMN "totalProfit" SET DEFAULT 0,
ALTER COLUMN "totalProfit" SET DATA TYPE DOUBLE PRECISION,
ALTER COLUMN "totalRevenue" SET DEFAULT 0,
ALTER COLUMN "totalRevenue" SET DATA TYPE DOUBLE PRECISION;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "subscriptionType" TEXT;

View File

@ -54,6 +54,16 @@ export default function App({ children }: { children: ReactNode }) {
}
}, [user, referrer]);
useEffect(() => {
if (location.hash) {
const id = location.hash.replace('#', '');
const element = document.getElementById(id);
if (element) {
element.scrollIntoView();
}
}
}, [location]);
return (
<>
{isAdminDashboard ? (

View File

@ -12,7 +12,8 @@ const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps)
if ( !weeklyStats || isLoading) return;
weeklyStats.sort((a, b) => b.id - a.id);
console.log('weeklyStats[1]?.totalRevenue; ', !!weeklyStats && weeklyStats)
return ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
const percentage = ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
return Math.floor(percentage);
}, [weeklyStats]);
useEffect(() => {

View File

@ -2,16 +2,15 @@ import { User } from '@wasp/entities';
import { useQuery } from '@wasp/queries'
import getRelatedObjects from '@wasp/queries/getRelatedObjects'
import logout from '@wasp/auth/logout';
import stripePayment from '@wasp/actions/stripePayment';
import { useState, Dispatch, SetStateAction } from 'react';
import { Link } from '@wasp/router'
import { CUSTOMER_PORTAL_LINK } from '../const';
// get your own link from your stripe dashboard: https://dashboard.stripe.com/settings/billing/portal
const CUSTOMER_PORTAL_LINK = 'https://billing.stripe.com/p/login/test_8wM8x17JN7DT4zC000';
export default function Example({ user }: { user: User }) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { data: relatedObjects, isLoading: isLoadingRelatedObjects } = useQuery(getRelatedObjects)
const { data: relatedObjects, isLoading: isLoadingRelatedObjects } = useQuery(getRelatedObjects);
return (
<div className='mt-10 px-6'>
@ -69,26 +68,13 @@ export default function Example({ user }: { user: User }) {
}
function BuyMoreButton({ isLoading, setIsLoading }: { isLoading: boolean, setIsLoading: Dispatch<SetStateAction<boolean>> }) {
const handleClick = async () => {
try {
setIsLoading(true);
const stripeResults = await stripePayment();
if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self');
}
} catch (error: any) {
alert(error?.message ?? 'Something went wrong.')
} finally {
setIsLoading(false);
}
};
return (
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
<button onClick={handleClick} className={`font-medium text-sm text-indigo-600 hover:text-indigo-500 ${isLoading && 'animate-pulse'}`}>
<Link to='/' hash='pricing' className={`font-medium text-sm text-indigo-600 hover:text-indigo-500 ${isLoading && 'animate-pulse'}`}>
{!isLoading ? 'Buy More/Upgrade' : 'Loading...'}
</button>
</Link>
</div>
);
}

View File

@ -1,11 +1,11 @@
import { User } from '@wasp/entities';
import { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
export default function CheckoutPage({ user }: { user: User }) {
const [hasPaid, setHasPaid] = useState('loading');
export default function CheckoutPage() {
const [paymentStatus, setPaymentStatus] = useState('loading');
const history = useHistory();
const location = useLocation();
useEffect(() => {
function delayedRedirect() {
@ -14,14 +14,14 @@ export default function CheckoutPage({ user }: { user: User }) {
}, 4000);
}
const urlParams = new URLSearchParams(window.location.search);
const cancel = urlParams.get('canceled');
const success = urlParams.get('success');
const credits = urlParams.get('credits');
if (cancel) {
setHasPaid('canceled');
} else if (success) {
setHasPaid('paid');
const queryParams = new URLSearchParams(location.search);
const isSuccess = queryParams.get('success')
const isCanceled = queryParams.get('canceled')
if (isCanceled) {
setPaymentStatus('canceled');
} else if (isSuccess) {
setPaymentStatus('paid');
} else {
history.push('/account');
}
@ -29,20 +29,22 @@ export default function CheckoutPage({ user }: { user: User }) {
return () => {
clearTimeout(delayedRedirect());
};
}, []);
}, [location]);
return (
<div className='flex min-h-full flex-col justify-center mt-10 sm:px-6 lg:px-8'>
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10'>
<h1>
{hasPaid === 'paid'
{paymentStatus === 'paid'
? '🥳 Payment Successful!'
: hasPaid === 'canceled'
: paymentStatus === 'canceled'
? '😢 Payment Canceled'
: hasPaid === 'error' && '🙄 Payment Error'}
: paymentStatus === 'error' && '🙄 Payment Error'}
</h1>
{hasPaid !== 'loading' && (
{paymentStatus !== 'loading' && (
<span className='text-center'>
You are being redirected to your account page... <br />
</span>

View File

@ -28,7 +28,7 @@ export default function PricingPage() {
const clickHandler = async () => {
setIsLoading(true);
try {
const response = await stripePayment();
const response = await stripePayment('hobby');
if (response?.sessionUrl) {
window.open(response.sessionUrl, '_self');
}

6
src/client/const.ts Normal file
View File

@ -0,0 +1,6 @@
// get your own link from your stripe dashboard: https://dashboard.stripe.com/settings/billing/portal
// and your own test link: https://dashboard.stripe.com/test/settings/billing/portal
const isDev = process.env.NODE_ENV === 'development';
export const CUSTOMER_PORTAL_LINK = isDev
? 'https://billing.stripe.com/p/login/test_8wM8x17JN7DT4zC000'
: '<insert-prod-link-here>';

View File

@ -22,10 +22,10 @@ export function useReferrer() {
console.log('referrer', referrer);
if (!!refValue && refValue !== UNKOWN_REFERRER) {
setReferrer(values);
history.replace({
search: '',
});
}
history.replace({
search: '',
});
}, [referrer]);
return [referrer, setReferrer] as const;

View File

@ -9,12 +9,33 @@ import daBoi from '../static/magic-app-gen-logo.png';
import { features, navigation, tiers, faqs, footerNavigation } from './contentSections';
import useAuth from '@wasp/auth/useAuth';
import DropdownUser from '../components/DropdownUser';
import { useHistory } from 'react-router-dom';
import stripePayment from '@wasp/actions/stripePayment';
import { CUSTOMER_PORTAL_LINK } from '../const';
export default function LandingPage() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { data: user, isLoading: isUserLoading } = useAuth();
const history = useHistory();
async function handleBuyNowClick(tierId: string) {
if (!user) {
history.push('/login');
return;
}
try {
let stripeResults = await stripePayment(tierId);
if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self');
}
} catch (error: any) {
console.error(error?.message ?? 'Something went wrong.');
}
}
return (
<div className='bg-white'>
{/* Header */}
@ -335,10 +356,12 @@ export default function LandingPage() {
))}
</ul>
</div>
<a
aria-describedby={tier.id}
href={!!user ? '/account' : '/login'}
className={`
{!!user && user.hasPaid ? (
<a
href={CUSTOMER_PORTAL_LINK}
aria-describedby='manage-subscription'
className={`
${tier.id === 'enterprise-tier' ? 'opacity-50 cursor-not-allowed' : 'opacity-100 cursor-pointer'}
${
tier.bestDeal
? 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400'
@ -346,9 +369,26 @@ export default function LandingPage() {
}
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
`}
>
{!!user ? 'Buy plan' : 'Log in to buy plan'}
</a>
>
{tier.id === 'enterprise-tier' ? 'Contact us' : 'Manage Subscription'}
</a>
) : (
<button
onClick={() => handleBuyNowClick(tier.id)}
aria-describedby={tier.id}
className={`
${tier.id === 'enterprise-tier' ? 'opacity-50 cursor-not-allowed' : 'opacity-100 cursor-pointer'}
${
tier.bestDeal
? 'bg-yellow-500 text-white hover:text-white shadow-sm hover:bg-yellow-400'
: 'text-gray-600 ring-1 ring-inset ring-purple-200 hover:ring-purple-400'
}
'mt-8 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-yellow-400'
`}
>
{tier.id === 'enterprise-tier' ? 'Contact us' : !!user ? 'Buy plan' : 'Log in to buy plan'}
</button>
)}
</div>
))}
</div>

View File

@ -44,14 +44,14 @@ export const tiers = [
{
name: 'Hobby',
id: 'hobby-tier',
priceMonthly: 'free',
description: 'try it out for free',
features: ['5 credits', 'no expiration date', 'no credit card required'],
priceMonthly: '$9.99',
description: 'All you need to get started',
features: ['Limited monthly usage', 'Basic support'],
},
{
name: 'Pro',
id: 'pro-tier',
priceMonthly: '$10',
priceMonthly: '$19.99',
description: 'Our most popular plan',
features: ['Unlimited monthly usage', 'Priority customer support'],
bestDeal: true,
@ -59,7 +59,7 @@ export const tiers = [
{
name: 'Enterprise',
id: 'enterprise-tier',
priceMonthly: '$50',
priceMonthly: '$500',
description: 'Big business means big money',
features: ['Unlimited monthly usage', '24/7 customer support', 'Advanced analytics'],
},

View File

@ -4,70 +4,42 @@ import type { RelatedObject, User } from '@wasp/entities';
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
import type { StripePaymentResult, OpenAIResponse } from './types';
import { UpdateCurrentUser, SaveReferrer, UpdateUserReferrer, UpdateUserById } from '@wasp/actions/types';
import Stripe from 'stripe';
import { fetchStripeCustomer, createStripeCheckoutSession } from './stripeUtils.js';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
export const stripePayment: StripePayment<void, StripePaymentResult> = async (_args, context) => {
if (!context.user) {
export const stripePayment: StripePayment<string, StripePaymentResult> = async (tier, context) => {
if (!context.user || !context.user.email) {
throw new HttpError(401);
}
let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({
email: context.user.email!,
});
if (!stripeCustomers.data.length) {
console.log('creating customer');
customer = await stripe.customers.create({
email: context.user.email!,
});
} else {
console.log('using existing customer');
customer = stripeCustomers.data[0];
}
const session: Stripe.Checkout.Session = await stripe.checkout.sessions.create({
line_items: [
{
price: process.env.SUBSCRIPTION_PRICE_ID!,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
customer_update: {
address: 'auto',
},
customer: customer.id,
});
const priceId = tier === 'hobby-tier' ? 'HOBBY_SUBSCRIPTION_PRICE_ID' : 'PRO_SUBSCRIPTION_PRICE_ID';
let customer: Stripe.Customer;
let session: Stripe.Checkout.Session;
try {
customer = await fetchStripeCustomer(context.user.email);
session = await createStripeCheckoutSession({
priceId,
customerId: customer.id,
});
} catch (error: any) {
throw new HttpError(500, error.message);
}
await context.entities.User.update({
where: {
id: context.user.id,
},
data: {
checkoutSessionId: session?.id ?? null,
stripeId: customer.id ?? null,
checkoutSessionId: session.id,
stripeId: customer.id,
},
});
if (!session) {
throw new HttpError(402, 'Could not create a Stripe session');
} else {
return {
sessionUrl: session.url,
sessionId: session.id,
};
}
return {
sessionUrl: session.url,
sessionId: session.id,
};
};
type GptPayload = {
@ -168,10 +140,10 @@ export const updateUserById: UpdateUserById<{ id: number; data: Partial<User> },
data,
});
console.log('updated user', updatedUser.id)
console.log('updated user', updatedUser.id);
return updatedUser;
}
};
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
if (!context.user) {
@ -188,7 +160,6 @@ export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (
});
};
export const saveReferrer: SaveReferrer<{ name: string }, void> = async ({ name }, context) => {
await context.entities.Referrer.upsert({
where: {
@ -204,7 +175,7 @@ export const saveReferrer: SaveReferrer<{ name: string }, void> = async ({ name
},
},
});
}
};
export const updateUserReferrer: UpdateUserReferrer<{ name: string }, void> = async ({ name }, context) => {
if (!context.user) {
@ -222,4 +193,4 @@ export const updateUserReferrer: UpdateUserReferrer<{ name: string }, void> = as
},
},
});
}
};

44
src/server/stripeUtils.ts Normal file
View File

@ -0,0 +1,44 @@
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
export async function fetchStripeCustomer(customerEmail: string) {
let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({
email: customerEmail,
});
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;
}
export async function createStripeCheckoutSession({ priceId, customerId }: { priceId: string; customerId: string }) {
return await stripe.checkout.sessions.create({
line_items: [
{
price: process.env[priceId]!,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
customer_update: {
address: 'auto',
},
customer: customerId,
});
}

View File

@ -1,23 +1,9 @@
import express from 'express';
import { StripeWebhook } from '@wasp/apis/types';
import type { MiddlewareConfigFn } from '@wasp/middleware';
import { emailSender } from '@wasp/email/index.js';
import Stripe from 'stripe';
import requestIp from 'request-ip';
export const STRIPE_WEBHOOK_IPS = [
'3.18.12.63',
'3.130.192.231',
'13.235.14.237',
'13.235.122.149',
'18.211.135.69',
'35.154.171.200',
'52.15.183.38',
'54.88.130.119',
'54.88.130.237',
'54.187.174.169',
'54.187.205.235',
'54.187.216.72',
];
// make sure the api version matches the version in the Stripe dashboard
const stripe = new Stripe(process.env.STRIPE_KEY!, {
@ -25,22 +11,23 @@ const stripe = new Stripe(process.env.STRIPE_KEY!, {
});
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
if (process.env.NODE_ENV === 'production') {
const detectedIp = requestIp.getClientIp(request) as string;
const isStripeIP = STRIPE_WEBHOOK_IPS.includes(detectedIp);
const sig = request.headers['stripe-signature'] as string;
let event: Stripe.Event;
if (!isStripeIP) {
console.log('IP address not from Stripe: ', detectedIp);
return response.status(403).json({ received: false });
}
console.log('\n\nsig: ', sig)
try {
event = stripe.webhooks.constructEvent(request.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
// console.table({sig: 'stripe webhook signature verified', type: event.type})
} catch (err: any) {
console.log(err.message);
return response.status(400).send(`Webhook Error: ${err.message}`);
}
let event: Stripe.Event;
// let event: Stripe.Event;
let userStripeId: string | null = null;
try {
event = request.body;
if (event.type === 'checkout.session.completed') {
console.log('Checkout session completed');
const session = event.data.object as Stripe.Checkout.Session;
@ -52,8 +39,8 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
console.log('line_items: ', line_items);
if (line_items?.data[0]?.price?.id === process.env.SUBSCRIPTION_PRICE_ID) {
console.log('Subscription purchased ');
if (line_items?.data[0]?.price?.id === process.env.HOBBY_SUBSCRIPTION_PRICE_ID) {
console.log('Hobby subscription purchased ');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
@ -61,6 +48,19 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
data: {
hasPaid: true,
datePaid: new Date(),
subscriptionType: 'hobby',
},
});
} else if (line_items?.data[0]?.price?.id === process.env.PRO_SUBSCRIPTION_PRICE_ID) {
console.log('Pro subscription purchased ');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
hasPaid: true,
datePaid: new Date(),
subscriptionType: 'pro',
},
});
}
@ -190,3 +190,27 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
response.status(400).send(`Webhook Error: ${err?.message}`);
}
};
// MIDDELWARE EXAMPLE
// const defaultGlobalMiddleware: MiddlewareConfig = new Map([
// ['helmet', helmet()],
// ['cors', cors({ origin: config.allowedCORSOrigins })],
// ['logger', logger('dev')],
// ['express.json', express.json()],
// ['express.urlencoded', express.urlencoded({ extended: false })],
// ['cookieParser', cookieParser()],
// ]);
export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.delete('express.json');
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
return middlewareConfig;
// let updatedMiddlewareConfig = new Map([
// // New entry as an array: [key, value]
// ['express.raw', express.raw({ type: 'application/json' })],
// ...Array.from(middlewareConfig.entries()),
// ]);
// return updatedMiddlewareConfig;
};

View File

@ -64,12 +64,6 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
},
});
await context.entities.Logs.create({
data: {
message: `Daily stats calculated for ${nowUTC.toDateString()}`,
level: 'job-info',
},
});
} catch (error: any) {
console.error('Error calculating daily stats: ', error);
await context.entities.Logs.create({