Skip to content
Open
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
24 changes: 23 additions & 1 deletion app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { stripe, syncStripeData } from "@/lib/stripe";
import { db } from "@/lib/db";
import { profiles, purchases } from "@/lib/db/schema";
import {
coachingSessions,
profiles,
purchases,
serviceBookings,
} from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import Stripe from "stripe";

Expand Down Expand Up @@ -43,6 +48,23 @@ export async function POST(request: NextRequest) {

if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const metadata = session.metadata ?? {};

if (metadata.type === "coaching_session" && metadata.coachingSessionId) {
await db
.update(coachingSessions)
.set({ status: "pending", stripeOrderId: session.id })
.where(eq(coachingSessions.id, metadata.coachingSessionId));
return NextResponse.json({ received: true });
}

if (metadata.type === "service_booking" && metadata.bookingId) {
await db
.update(serviceBookings)
.set({ status: "confirmed", stripeOrderId: session.id })
.where(eq(serviceBookings.id, metadata.bookingId));
return NextResponse.json({ received: true });
}

if (session.mode === "payment" && session.customer) {
const customerId = session.customer as string;
Expand Down
145 changes: 145 additions & 0 deletions app/checkout/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use server";

import { headers } from "next/headers";
import { and, eq } from "drizzle-orm";
import type Stripe from "stripe";

import { db } from "@/lib/db";
import { coachingSessions, serviceBookings, services } from "@/lib/db/schema";
import { getOrCreateStripeCustomer, stripe } from "@/lib/stripe";
import { createClient } from "@/utils/supabase/server";

export type CheckoutResult = { url: string } | { error: string };

async function getDefaultPriceId(stripeProductId: string) {
const product = await stripe.products.retrieve(stripeProductId);
const defaultPrice = product.default_price;
if (typeof defaultPrice === "string") return defaultPrice;
return (defaultPrice as Stripe.Price | null)?.id ?? null;
}

Comment on lines +14 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should actions.ts only be for writes and maybe have a dedicated queries.ts for reads? also maybe we can use this instead?

https://github.com/hack4impact/mcld-project/pull/55/changes#diff-a1f48e30688a36b94c8e7eee51cc927f8e9ae87e7a37bf957a8db7bec0f672bbR196

async function createStripeCheckoutSession(params: {
userId: string;
email: string;
stripeProductId: string;
metadata: Record<string, string>;
}) {
const priceId = await getDefaultPriceId(params.stripeProductId);
if (!priceId) return { error: "Service has no Stripe price configured" };

const customerId = await getOrCreateStripeCustomer(
params.userId,
params.email,
);
const origin = (await headers()).get("origin") ?? "";

const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "payment",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/checkout/success`,
cancel_url: `${origin}/checkout/cancel`,
metadata: params.metadata,
});

if (!session.url)
return { error: "Stripe did not return a checkout URL" };
return { session };
}

export async function checkoutServiceBooking({
serviceId,
}: {
serviceId: string;
}): Promise<CheckoutResult> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: "Not authenticated" };

const service = await db.query.services.findFirst({
where: eq(services.id, serviceId),
});
Comment on lines +62 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

if (!service) return { error: "Service not found" };
if (service.status !== "active")
return { error: "Service is not available" };
if (service.type !== "booking")
return { error: "Service is not a booking" };

const [row] = await db
.insert(serviceBookings)
.values({
userId: user.id,
serviceId: service.id,
status: "awaiting_payment",
})
.returning({ id: serviceBookings.id });

const result = await createStripeCheckoutSession({
userId: user.id,
email: user.email!,
stripeProductId: service.stripeProductId,
metadata: {
type: "service_booking",
bookingId: row.id,
},
});
if ("error" in result) {
await db.delete(serviceBookings).where(eq(serviceBookings.id, row.id));
return { error: result.error };
}

await db
.update(serviceBookings)
.set({ stripeOrderId: result.session.id })
.where(eq(serviceBookings.id, row.id));

return { url: result.session.url! };
}

export async function checkoutCoachingSession({
coachingSessionId,
}: {
coachingSessionId: string;
}): Promise<CheckoutResult> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: "Not authenticated" };

const row = await db.query.coachingSessions.findFirst({
where: and(
eq(coachingSessions.id, coachingSessionId),
eq(coachingSessions.userId, user.id),
),
});
if (!row) return { error: "Coaching session not found" };
if (row.status !== "awaiting_payment")
return { error: "Coaching session is not awaiting payment" };

const service = await db.query.services.findFirst({
where: eq(services.id, row.serviceId),
});
Comment on lines +123 to +125
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same as previous comment

if (!service) return { error: "Service not found" };

const result = await createStripeCheckoutSession({
userId: user.id,
email: user.email!,
stripeProductId: service.stripeProductId,
metadata: {
type: "coaching_session",
coachingSessionId: row.id,
},
});
if ("error" in result) return { error: result.error };

await db
.update(coachingSessions)
.set({ stripeOrderId: result.session.id })
.where(eq(coachingSessions.id, row.id));

return { url: result.session.url! };
}
54 changes: 54 additions & 0 deletions app/coaching/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use server";

import { eq } from "drizzle-orm";

import { db } from "@/lib/db";
import { coachingSessions, services } from "@/lib/db/schema";
import { createClient } from "@/utils/supabase/server";

export type Availability = { start: string; end: string };

export type SubmitAvailabilitiesResult =
| { coachingSessionId: string }
| { error: string };

export async function submitAvailabilities({
serviceId,
availabilities,
}: {
serviceId: string;
availabilities: Availability[];
}): Promise<SubmitAvailabilitiesResult> {
if (!availabilities?.length)
return { error: "At least one availability window is required" };

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: "Not authenticated" };

const service = await db.query.services.findFirst({
where: eq(services.id, serviceId),
});
if (!service) return { error: "Service not found" };
if (service.status !== "active")
return { error: "Service is not available" };
if (service.type !== "coaching_session")
return { error: "Service is not a coaching session" };
if (!service.coachId) return { error: "Service has no coach assigned" };

const [row] = await db
.insert(coachingSessions)
.values({
userId: user.id,
serviceId: service.id,
coachId: service.coachId,
durationMinutes: service.durationMinutes,
selectedTimeSlots: availabilities,
status: "awaiting_payment",
})
.returning({ id: coachingSessions.id });

return { coachingSessionId: row.id };
}
21 changes: 16 additions & 5 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,25 @@ export const serviceTypeEnum = pgEnum("service_type", [
"booking",
]);
export const bookingStatusEnum = pgEnum("booking_status", [
"awaiting_payment",
"pending",
"confirmed",
"cancelled",
]);
export const webinarTierEnum = pgEnum("webinar_tier", ["free", "premium"]);
export const sessionStatusEnum = pgEnum("session_status", [
"awaiting_payment",
"pending",
"confirmed",
"cancelled",
"completed",
]);
export const serviceStatusEnum = pgEnum("service_status", [
"active",
"archived",
"deleted",
"disabled",
]);

export const profiles = pgTable("profiles", {
id: uuid("id").primaryKey(),
Expand All @@ -39,13 +47,14 @@ export const profiles = pgTable("profiles", {

export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
description: text("description"),
type: serviceTypeEnum("type").notNull(),
scheduledAt: jsonb("scheduled_at"),
durationMinutes: integer("duration_minutes").notNull(),
price: integer("price").notNull().default(0),
isActive: boolean("is_active").notNull().default(true),
stripeProductId: text("stripe_product_id").notNull(),
status: serviceStatusEnum("status").notNull().default("active"),
coachId: uuid("coach_id").references(() => profiles.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
Expand All @@ -61,6 +70,7 @@ export const serviceBookings = pgTable("service_bookings", {
status: bookingStatusEnum("status").notNull().default("pending"),
notes: text("notes"),
isActive: boolean("is_active").notNull().default(true),
stripeOrderId: text("stripe_order_id").unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
Expand Down Expand Up @@ -94,6 +104,7 @@ export const coachingSessions = pgTable("coaching_sessions", {
meetingUrl: text("meeting_url"),
notes: text("notes"),
selectedTimeSlots: jsonb("selected_time_slots").notNull(),
stripeOrderId: text("stripe_order_id").unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
Expand Down Expand Up @@ -126,4 +137,4 @@ export const purchases = pgTable("purchases", {
currency: text("currency").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
});
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading