Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Organize payments vertically #225

Merged
merged 3 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion opensaas-sh/blog/src/content/docs/general/user-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
//...

Expand Down
30 changes: 14 additions & 16 deletions opensaas-sh/blog/src/content/docs/start/guided-tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

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

There is nothing left in shared?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

nope. nada. The leftovers got moved to common I believe

│   ├── auth/ # All auth-related pages/components and logic.
│   ├── file-upload/ # Logic for uploading files to S3.
│   └── .waspignore
Copy link
Member

Choose a reason for hiding this comment

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

We dropped .waspignore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

waspignore (Was Pig Nore) is in the main src dir, but I didn't think it was important enough to highlight in the docs here. WDYT?

│   └── 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
```

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
//...
Expand All @@ -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],
}
Expand Down
77 changes: 25 additions & 52 deletions template/app/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -348,9 +316,6 @@ action updateUserById {
entities: [User]
}


// 📚 Queries

query getGptResponses {
fn: import { getGptResponses } from "@src/server/queries.js",
entities: [User, GptResponse]
Expand All @@ -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: {
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
56 changes: 56 additions & 0 deletions template/app/src/payment/operations.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading