Skip to content

Commit

Permalink
Merge pull request #136 from UoaWDCC/tarun/feature/stripe-express
Browse files Browse the repository at this point in the history
Add Stripe Payments, Clerk Login, Database Changes
  • Loading branch information
gmat224 authored Nov 1, 2024
2 parents 652992d + 3fed833 commit d60f43e
Show file tree
Hide file tree
Showing 53 changed files with 2,015 additions and 796 deletions.
57 changes: 57 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,60 @@ There is a yarn script called yarn watch-types that would automatically create c
```bash
yarn watch-types
```

# Instructions for setting up Stripe:

1. Install Stripe CLI and login using the AUIS acount. Refer to instructions here: https://docs.stripe.com/stripe-cli
2. Setup the appropriate .env files for both frontend and backend: see the #resources chat in discord.
3. Setup webhook by following the Stripe docs: https://docs.stripe.com/payments/checkout/fulfill-orders?lang=node . Ensure that you use the following code to setup the webhook listen endpoint: `stripe listen --forward-to localhost:3000/api/stripe/webhook`

Additional info: Stripe restricts all production web apps to run in HTTPS. HTTP for development is fine.

# Notes:
Schema: users = admin users for strapi
Schema: people = users that sign up
Schema: user_tickets/people_tickets = tickets that the users purchase

Additional info: Stripe restricts all production web apps to run in HTTPS. HTTP for development is fine.

Stripe will pay AUIS's bank account every 7-14 days' based on Stripe's risk assessment of AUIS. Read more here: https://docs.stripe.com/payouts

Stripe API Documentation: https://docs.stripe.com/api/

Accept a payment (embedded form): https://docs.stripe.com/payments/accept-a-payment?platform=web&ui=embedded-form

Handle Payments (webhook included): https://docs.stripe.com/payments/handling-payment-events

Fulfill Orders (Webhook stuff): https://docs.stripe.com/payments/checkout/fulfill-orders

Register Webhook (FOR PRODUCTION): https://docs.stripe.com/webhooks#register-webhook

Product and Pricing: https://docs.stripe.com/products-prices/how-products-and-prices-work

# Instructions for setting up Clerk
Follow Step 1: Connect except the "DEPLOY YOUR APP ONLINE" part: https://dashboard.ngrok.com/get-started/setup/windows

Step 2: Run `ngrok http --url=https://gelding-trusty-exactly.ngrok-free.app 3000`.

Step 3: Now try to create a new user and login. You should now see an event of type "user.created" in the console.

# Instructions for testing with Clerk:

Email addresses
Any email with the +clerk_test subaddress is a test email address. No emails will be sent, and they can be verified with the code 424242.

For example:

[email protected]
[email protected]

# Additional articles for Clerk
https://clerk.com/docs/integrations/webhooks/overview

https://clerk.com/docs/integrations/webhooks/sync-data

https://ngrok.com/docs/integrations/clerk/webhooks/

https://docs.svix.com/receiving/verifying-payloads/how#nodejs-express

https://clerk.com/docs/deployments/overview
Empty file.
61 changes: 18 additions & 43 deletions api/controller/authController.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Request, Response } from "express";
import asyncHandler from "../middleware/asyncHandler";

import { db } from "../db/config/db";
import { users } from "../models/users";
import { sql } from "drizzle-orm";
import { insertUserByEmail, deleteUserByEmail } from "../gateway/getUsers";

export const signUp = asyncHandler(async (req: Request, res: Response) => {
throw new Error("Not implemented yet");
Expand All @@ -13,43 +10,21 @@ export const logIn = asyncHandler(async (req: Request, res: Response) => {
throw new Error("Not implemented yet");
});

export const clerkSignUp = asyncHandler(async (req: Request, res: Response) => {
try {
console.log("Webhook received:", req.body);
const email = req.body.data.email_addresses[0].email_address;

const newUser = await db
.insert(users)
.values({
email,
uoa_id: "", // default value
upi: "", // default value
institution: "Unknown", // default value
year: "Unknown", // default value
study_field: "", // default value
name: "Unknown", // default value
is_admin: false,
is_paid: false,
is_info_confirmed: false,
})
.returning({
user_id: users.user_id,
email: users.email,
uoa_id: users.uoa_id,
upi: users.upi,
institution: users.institution,
year: users.year,
study_field: users.study_field,
name: users.name,
is_admin: users.is_admin,
is_paid: users.is_paid,
is_info_confirmed: users.is_info_confirmed,
created_at: users.created_at,
});

res.json(newUser[0]);
} catch (error) {
console.error("Error handling webhook:", error);
res.status(500).send("Internal Server Error");
export const handleWebhook = asyncHandler(
async (req: Request, res: Response) => {
try {
if (req.body.type == "user.created") {
const newUserEmail: string =
req.body.data.email_addresses[0].email_address;
const newUser = await insertUserByEmail(newUserEmail);
res.json({ received: true });
} else if (req.body.type == "user.deleted") {
res.json({ received: true });
} else if (req.body.type == "user.updated") {
res.json({ received: true });
}
} catch (error) {
res.status(500).send(`Webhook error: ${error}`);
}
}
});
);
130 changes: 130 additions & 0 deletions api/controller/stripeController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import express, { Request, Response, Router, json } from "express";
import asyncHandler from "../middleware/asyncHandler";
import {
isTicketAvailableByEventId,
reserveTicket,
releaseReservedTicket,
completeTicketPurchase,
} from "../gateway/eventsGateway";
import Stripe from "stripe"; //Types and Interfaces
import { stripe } from "../stripe/stripe";

const endpointSecret: string = process.env.STRIPE_WEBHOOK_ENDPOINT as string;

// Create a checkout session based on priceId. Send a client secret back (cs_ABCD123)
export const createEventCheckoutSession = asyncHandler(
async (req: Request, res: Response) => {
const { priceId, eventId } = req.body;

// if priceId is undefined, send a 404 back.
if (priceId == undefined || priceId == "") {
return res
.send({
error:
"This event does not exist, or is not available for sale at the moment.",
})
.status(404);
}

//check eventId validity
if (!eventId || eventId == "") {
return res
.send({
error:
"This event does not exist, or is not available for sale at the moment.",
})
.status(404);
}

let ticketAvailable = await isTicketAvailableByEventId(eventId);

if (ticketAvailable == false) {
return res.send({
error:
"There are no tickets available for this event. Please come back later to see if more tickets become available.",
});
} else {
reserveTicket(0);
}

// epoch time in seconds, 30mins timeout
let session_expiry = Math.floor(new Date().getTime() / 1000 + 30 * 60);

try {
const session = await stripe.checkout.sessions.create({
//do not change anything below
ui_mode: "embedded",
expires_at: session_expiry,
line_items: [
{
// Provide the exact Price ID (pr_1234) of the product on sale
price: priceId,
quantity: 1,
},
],
mode: "payment",
payment_method_types: ["card"],
currency: "NZD",
return_url: `${process.env.DOMAIN_FRONTEND}return?session_id={CHECKOUT_SESSION_ID}`,

//changeable below:
// use metadata property
metadata: { eventId: `${eventId}` },
});

res.send({ clientSecret: session.client_secret });
} catch (error) {
res.send({ error }).status(404);
}
}
);

export const getSessionStatus = asyncHandler(
async (req: Request, res: Response) => {
const session = await stripe.checkout.sessions.retrieve(
req.query.session_id as string
);

res.send({
status: session.status,
customer_email: session.customer_details?.email,
});
}
);

export const handleWebhook = asyncHandler(
async (req: express.Request, res: express.Response): Promise<void> => {
const sig = req.headers["stripe-signature"] as string | string[] | Buffer;

let event: Stripe.Event;

try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);

if (event.type === "checkout.session.completed") {
const session: Stripe.Checkout.Session = event.data.object;
completeTicketPurchase(session.id);
} else if (event.type === "checkout.session.expired") {
const session = event.data.object;

if (
session.metadata != null &&
session.metadata["eventId"] != undefined
) {
releaseReservedTicket(parseInt(session.metadata["eventId"]));
}
}
} catch (err) {
res.status(400).send(`Webhook Error: ${err}`);
return;
}

res.json({ received: true });
}
);

export const templateFunction = asyncHandler(
async (req: Request, res: Response) => {
throw new Error("Not implemented yet");
}
);
20 changes: 0 additions & 20 deletions api/db/User.ts

This file was deleted.

3 changes: 2 additions & 1 deletion api/db/config/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DATABASE_USERNAME,
DATABASE_PASSWORD,
} from "./env";
import * as schema from "../../schemas/schema";

const sql = postgres({
host: DATABASE_HOST,
Expand All @@ -15,4 +16,4 @@ const sql = postgres({
//database: "AUIS",
});

export const db = drizzle(sql);
export const db = drizzle(sql, { schema });
3 changes: 3 additions & 0 deletions api/drizzle-schema.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL!,
},
introspect: {
casing: "preserve",
},
verbose: true,
strict: true,
});
82 changes: 82 additions & 0 deletions api/gateway/eventsGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { and, eq, sql, gt } from "drizzle-orm";
import { db } from "../db/config/db";
import { events, user_tickets, peoples, tickets } from "../schemas/schema";
import Stripe from "stripe";
import { stripe } from "../stripe/stripe";

export async function isTicketAvailableByEventId(
eventId: any
): Promise<boolean> {
let isTicketAvailable = false;

const remainingTickets = await db.query.events.findFirst({
columns: { event_capacity_remaining: true },
// Event must be LIVE (true) for reserve and sales to go through.
where: and(
and(eq(events.id, eventId), eq(events.is_live, true)),
gt(events.event_capacity_remaining, eventId)
),
});

// Handle the case where remainingTickets or event_capacity_remaining is undefined or null
if (remainingTickets && remainingTickets.event_capacity_remaining != null) {
isTicketAvailable = remainingTickets.event_capacity_remaining > 0;
}

return isTicketAvailable;
}

// @Ratchet7x5: Reserve one ticket
export async function reserveTicket(eventId: number) {
let canReserveTicket = await isTicketAvailableByEventId(eventId);
let reservedTicket;

//if ticket available, reduce by 1
if (canReserveTicket === true) {
// sql for reserving statement
reservedTicket = await db
.update(events)
.set({
event_capacity_remaining: sql`${events.event_capacity_remaining} - 1`,
})
.where(eq(events.id, eventId))
.returning();
}

return reservedTicket;
}

// @Ratchet7x5: Release one reserved ticket
export async function releaseReservedTicket(eventId: number) {
let releasedTicket;

// increment event_remaining_ticket by 1
releasedTicket = await db
.update(events)
.set({
event_capacity_remaining: sql`${events.event_capacity_remaining} + 1`,
})
.where(eq(events.id, eventId))
.returning();

return releasedTicket;
}

export async function completeTicketPurchase(sessionId: string) {
// TODO: Make this function safe to run multiple times,
// even concurrently, with the same session ID

// TODO: Make sure fulfillment hasn't already been
// peformed for this Checkout Session

//retrieve session from API with line_items expanded
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items"],
});

// Check the Checkout Session's payment_status property
// to determine if fulfillment should be peformed
if (checkoutSession.payment_status !== "unpaid") {
db.insert(tickets).values({});
}
}
Loading

0 comments on commit d60f43e

Please sign in to comment.