Organize payments vertically (#225)

* organize payments vertically

* update docs

* docs updates and small changes
This commit is contained in:
vincanger
2024-07-11 11:48:43 +02:00
committed by GitHub
parent 78a9189e32
commit 0e4e76ae88
12 changed files with 105 additions and 131 deletions

View File

@@ -72,7 +72,7 @@ In general, we determine if a user has paid for an initial subscription by check
- When `deleted`, the user has reached the end of their subscription period after canceling and no longer has access to the app.
- When `past_due`, the user's automatic subscription renewal payment was declined (e.g. their credit card expired). You can choose how to handle this status within your app. For example, you can send the user an email to update their payment information:
```tsx title="src/server/webhooks/stripe.ts"
```tsx title="src/payment/stripe/webhook.ts"
import { emailSender } from "wasp/server/email";
//...

View File

@@ -61,16 +61,16 @@ If you are using a version of the OpenSaaS template with Wasp `v0.11.x` or below
├── src/ # Your code goes here.
│   ├── client/ # Your client code (React) goes here.
│   ├── server/ # Your server code (NodeJS) goes here.
│   ├── shared/ # Your shared (runtime independent) code goes here.
│   ├── auth/ # All auth-related pages/components and logic.
│   ├── file-upload/ # Logic for uploading files to S3.
│   └── .waspignore
│   └── payment/ # Logic for handling Stripe payments and webhooks.
├── .env.server # Dev environment variables for your server code.
├── .env.client # Dev environment variables for your client code.
├── .prettierrc # Prettier configuration.
├── tailwind.config.js # TailwindCSS configuration.
├── package.json
├── package-lock.json
└── .wasproot
```
@@ -86,12 +86,12 @@ This template at its core is a Wasp project, where [Wasp](https://wasp-lang.dev)
In this template, we've already defined a number of things in the `main.wasp` config file, including:
- Auth
- Routes and Pages
- Prisma Database Models
- Operations (data read and write functions)
- Background Jobs
- Email Sending
- [Auth](https://wasp-lang.dev/docs/auth/overview)
- [Routes and Pages](https://wasp-lang.dev/docs/tutorial/pages)
- [Prisma Database Models](https://wasp-lang.dev/docs/data-model/entities)
- [Operations (data read and write functions)](https://wasp-lang.dev/docs/data-model/operations/overview)
- [Background Jobs](https://wasp-lang.dev/docs/advanced/jobs)
- [Email Sending](https://wasp-lang.dev/docs/advanced/email)
By defining these things in the config file, Wasp continuously handles the boilerplate necessary with putting all these features together. You just need to focus on the business logic of your app.
@@ -127,13 +127,11 @@ All you have to do is define your server-side functions in the `main.wasp` file,
```sh
└── server
  ├── payments # Payments utility functions.
  ├── scripts # Scripts to run via Wasp, e.g. database seeding.
  ├── webhooks # The webhook handler for Stripe.
  ├── workers # Functions that run in the background as Wasp Jobs, e.g. daily stats calculation.
  ├── actions.ts # Your server-side write/mutation functions.
   ├── queries.ts # Your server-side read functions.
   └── types.ts
   └── utils.ts
```
## Main Features
@@ -199,17 +197,17 @@ Let's take a quick look at how payments are handled in this template.
4. Stripe 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 logic for creating the Stripe Checkout session is defined in the `src/server/actions.ts` file. [Actions](https://wasp-lang.dev/docs/data-model/operations/actions) are 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:
The logic for creating the Stripe Checkout session is defined in the `src/payment/operation.ts` file. [Actions](https://wasp-lang.dev/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:
a) define the action in the `main.wasp` file
```js title="main.wasp"
action generateStripeCheckoutSession {
fn: import { generateStripeCheckoutSession } from "@src/server/actions.js",
fn: import { generateStripeCheckoutSession } from "@src/payment/operations",
entities: [User]
}
```
b) implement the action in the `src/server/actions.ts` file
b) implement the action in the `src/payment/operations` file
```js title="src/server/actions.ts"
export const generateStripeCheckoutSession = async (paymentPlanId, context) => {
//...
@@ -225,11 +223,11 @@ const handleBuyClick = async (paymentPlanId) => {
};
```
The webhook handler is defined in the `src/server/webhooks/stripe.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/stripe/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
```js title="main.wasp"
api stripeWebhook {
fn: import { stripeWebhook } from "@src/server/webhooks/stripe.js",
fn: import { stripeWebhook } from "@src/payment/stripe/webhook",
httpRoute: (POST, "/stripe-webhook")
entities: [User],
}

View File

@@ -85,10 +85,6 @@ app OpenSaaS {
},
}
/* 💽 Wasp defines DB entities via Prisma Database Models:
* https://wasp-lang.dev/docs/data-model/entities
*/
entity User {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
@@ -184,11 +180,6 @@ entity Logs {=psl
level String
psl=}
/* 📡 These are the Wasp client Routes and Pages.
* You can easily make them inaccessible to the unauthenticated user w/ 'authRequired: true'.
* https://wasp-lang.dev/docs/tutorial/pages
*/
route LandingPageRoute { path: "/", to: LandingPage }
page LandingPage {
component: import LandingPage from "@src/client/landing-page/LandingPage"
@@ -227,23 +218,12 @@ page DemoAppPage {
component: import DemoAppPage from "@src/client/app/DemoAppPage"
}
route PricingPageRoute { path: "/pricing", to: PricingPage }
page PricingPage {
component: import PricingPage from "@src/client/app/PricingPage"
}
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@src/client/app/AccountPage"
}
route CheckoutRoute { path: "/checkout", to: CheckoutPage }
page CheckoutPage {
authRequired: true,
component: import Checkout from "@src/client/app/CheckoutPage"
}
//#region Admin Pages
route AdminRoute { path: "/admin", to: DashboardPage }
page DashboardPage {
@@ -306,13 +286,6 @@ page AdminUIButtonsPage {
}
//#endregion
/* ⛑ These are the Wasp Operations: server code that you can easily call
* from the client. Queries fetch stuff, Actions modify/do stuff.
* https://wasp-lang.dev/docs/data-model/operations/overview
*/
// 📝 Actions
action generateGptResponse {
fn: import { generateGptResponse } from "@src/server/actions.js",
entities: [User, Task, GptResponse]
@@ -333,11 +306,6 @@ action updateTask {
entities: [Task]
}
action generateStripeCheckoutSession {
fn: import { generateStripeCheckoutSession } from "@src/server/actions.js",
entities: [User]
}
action updateCurrentUser {
fn: import { updateCurrentUser } from "@src/server/actions.js",
entities: [User]
@@ -348,9 +316,6 @@ action updateUserById {
entities: [User]
}
// 📚 Queries
query getGptResponses {
fn: import { getGptResponses } from "@src/server/queries.js",
entities: [User, GptResponse]
@@ -371,23 +336,6 @@ query getPaginatedUsers {
entities: [User]
}
/*
* 📡 These are custom Wasp API Endpoints.
* Use them for callbacks, webhooks, API for other services to consume, etc.
* https://wasp-lang.dev/docs/advanced/apis
*/
api stripeWebhook {
fn: import { stripeWebhook } from "@src/server/webhooks/stripe.js",
entities: [User],
middlewareConfigFn: import { stripeMiddlewareFn } from "@src/server/webhooks/stripe.js",
httpRoute: (POST, "/stripe-webhook")
}
/* 🕵️‍♂️ These are the Wasp Jobs. Use them to set up recurring tasks and/or queues.
* https://wasp-lang.dev/docs/advanced/jobs
*/
job emailChecker {
executor: PgBoss,
perform: {
@@ -411,6 +359,31 @@ job dailyStatsJob {
entities: [User, DailyStats, Logs, PageViewSource]
}
//#region Payment
route PricingPageRoute { path: "/pricing", to: PricingPage }
page PricingPage {
component: import PricingPage from "@src/payment/PricingPage"
}
route CheckoutRoute { path: "/checkout", to: CheckoutPage }
page CheckoutPage {
authRequired: true,
component: import Checkout from "@src/payment/CheckoutPage"
}
action generateStripeCheckoutSession {
fn: import { generateStripeCheckoutSession } from "@src/payment/operations",
entities: [User]
}
api stripeWebhook {
fn: import { stripeWebhook } from "@src/payment/stripe/webhook",
entities: [User],
middlewareConfigFn: import { stripeMiddlewareFn } from "@src/payment/stripe/webhook",
httpRoute: (POST, "/stripe-webhook")
}
//#endregion
//#region File Upload
route FileUploadRoute { path: "/file-upload", to: FileUploadPage }

View File

@@ -1,10 +1,10 @@
import { useAuth } from 'wasp/client/auth';
import { generateStripeCheckoutSession } from 'wasp/client/operations';
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } from '../../payment/plans';
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } from './plans';
import { AiFillCheckCircle } from 'react-icons/ai';
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { cn } from '../cn';
import { cn } from '../client/cn';
import { z } from 'zod';
const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro;

View File

@@ -0,0 +1,56 @@
import { type GenerateStripeCheckoutSession } from 'wasp/server/operations';
import { HttpError } from 'wasp/server';
import { PaymentPlanId, paymentPlans, type PaymentPlanEffect } from '../payment/plans';
import { fetchStripeCustomer, createStripeCheckoutSession, type StripeMode } from './stripe/checkoutUtils';
export type StripeCheckoutSession = {
sessionUrl: string | null;
sessionId: string;
};
export const generateStripeCheckoutSession: GenerateStripeCheckoutSession<
PaymentPlanId,
StripeCheckoutSession
> = async (paymentPlanId, context) => {
if (!context.user) {
throw new HttpError(401);
}
const userEmail = context.user.email;
if (!userEmail) {
throw new HttpError(
403,
'User needs an email to make a payment. If using the usernameAndPassword Auth method, switch to an Auth method that provides an email.'
);
}
const paymentPlan = paymentPlans[paymentPlanId];
const customer = await fetchStripeCustomer(userEmail);
const session = await createStripeCheckoutSession({
priceId: paymentPlan.getStripePriceId(),
customerId: customer.id,
mode: paymentPlanEffectToStripeMode(paymentPlan.effect),
});
await context.entities.User.update({
where: {
id: context.user.id,
},
data: {
checkoutSessionId: session.id,
stripeId: customer.id,
},
});
return {
sessionUrl: session.url,
sessionId: session.id,
};
};
function paymentPlanEffectToStripeMode(planEffect: PaymentPlanEffect): StripeMode {
const effectToMode: Record<PaymentPlanEffect['kind'], StripeMode> = {
subscription: 'subscription',
credits: 'payment',
};
return effectToMode[planEffect.kind];
}

View File

@@ -1,5 +1,5 @@
import type { SubscriptionStatus } from '../../payment/plans';
import { PaymentPlanId } from '../../payment/plans';
import type { SubscriptionStatus } from '../plans';
import { PaymentPlanId } from '../plans';
import { PrismaClient } from '@prisma/client';
type UserStripePaymentDetails = {

View File

@@ -3,12 +3,12 @@ import { type StripeWebhook } from 'wasp/server/api';
import { type PrismaClient } from '@prisma/client';
import express from 'express';
import { Stripe } from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../../payment/plans';
import { updateUserStripePaymentDetails } from './stripePaymentDetails';
import { stripe } from './stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
import { updateUserStripePaymentDetails } from './paymentDetails';
import { emailSender } from 'wasp/server/email';
import { assertUnreachable } from '../../utils';
import { requireNodeEnvVar } from '../utils';
import { requireNodeEnvVar } from '../../server/utils';
import { z } from 'zod';
export const stripeWebhook: StripeWebhook = async (request, response, context) => {

View File

@@ -2,7 +2,6 @@ import { type User, type Task } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import {
type GenerateGptResponse,
type GenerateStripeCheckoutSession,
type UpdateCurrentUser,
type UpdateUserById,
type CreateTask,
@@ -10,8 +9,6 @@ import {
type UpdateTask,
} from 'wasp/server/operations';
import { GeneratedSchedule } from '../gpt/schedule';
import { PaymentPlanId, paymentPlans, type PaymentPlanEffect } from '../payment/plans';
import { fetchStripeCustomer, createStripeCheckoutSession, type StripeMode } from './stripe/checkoutUtils.js';
import OpenAI from 'openai';
const openai = setupOpenAI();
@@ -22,57 +19,7 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
export type StripeCheckoutSession = {
sessionUrl: string | null;
sessionId: string;
};
export const generateStripeCheckoutSession: GenerateStripeCheckoutSession<PaymentPlanId, StripeCheckoutSession> = async (
paymentPlanId,
context
) => {
if (!context.user) {
throw new HttpError(401);
}
const userEmail = context.user.email;
if (!userEmail) {
throw new HttpError(
403,
'User needs an email to make a payment. If using the usernameAndPassword Auth method, switch to an Auth method that provides an email.'
);
}
const paymentPlan = paymentPlans[paymentPlanId];
const customer = await fetchStripeCustomer(userEmail);
const session = await createStripeCheckoutSession({
priceId: paymentPlan.getStripePriceId(),
customerId: customer.id,
mode: paymentPlanEffectToStripeMode(paymentPlan.effect),
});
await context.entities.User.update({
where: {
id: context.user.id,
},
data: {
checkoutSessionId: session.id,
stripeId: customer.id,
},
});
return {
sessionUrl: session.url,
sessionId: session.id,
};
};
function paymentPlanEffectToStripeMode (planEffect: PaymentPlanEffect): StripeMode {
const effectToMode: Record<PaymentPlanEffect['kind'], StripeMode> = {
'subscription': 'subscription',
'credits': 'payment'
};
return effectToMode[planEffect.kind];
}
type GptPayload = {
hours: string;

View File

@@ -1,6 +1,6 @@
import { type DailyStatsJob } from 'wasp/server/jobs';
import Stripe from 'stripe';
import { stripe } from '../stripe/stripeClient';
import { stripe } from '../../payment/stripe/stripeClient';
import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js';
// import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js';