From 70743836da203e5226d37e16481eb43b03e93dd0 Mon Sep 17 00:00:00 2001 From: Thomas Ballard Date: Fri, 17 Apr 2026 00:10:40 -0400 Subject: [PATCH 1/2] update schema.ts according to alex's changes before he commits onto his branch (services enum, title, description, price, isactive, stripe product id, status). Added relationship of coach id to profile (will discuss). added stripeorderid to servicebookings. stripeorderid to coaching services. --- lib/db/schema.ts | 19 ++++++++++++++----- pnpm-lock.yaml | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 07ff6d3..f111c53 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -26,6 +26,12 @@ export const sessionStatusEnum = pgEnum("session_status", [ "cancelled", "completed", ]); +export const serviceStatusEnum = pgEnum("service_status", [ + "active", + "archived", + "deleted", + "disabled", +]); export const profiles = pgTable("profiles", { id: uuid("id").primaryKey(), @@ -39,13 +45,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(), }); @@ -61,6 +68,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(), }); @@ -94,6 +102,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(), }); @@ -126,4 +135,4 @@ export const purchases = pgTable("purchases", { currency: text("currency").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 741da9d..bf1e926 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@stripe/stripe-js': + specifier: ^9.0.0 + version: 9.2.0 '@supabase/ssr': specifier: ^0.9.0 version: 0.9.0(@supabase/supabase-js@2.100.1) @@ -47,6 +50,9 @@ importers: shadcn: specifier: ^4.1.0 version: 4.1.1(@types/node@20.19.37)(typescript@5.9.3) + stripe: + specifier: ^21.0.1 + version: 21.0.1(@types/node@20.19.37) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -1791,6 +1797,10 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stripe/stripe-js@9.2.0': + resolution: {integrity: sha512-YSzLC0t6VS9MDdPTynSMqU8IxrItFUjkDORALFT6sSMR/XZ5Vgm3RDp/Gk7z727MC4A9s4MFVel0gF0c7+kdrg==} + engines: {node: '>=12.16'} + '@supabase/auth-js@2.100.1': resolution: {integrity: sha512-c5FB4nrG7cs1mLSzFGuIVl2iR2YO5XkSJ96uF4zubYm8YDn71XOi2emE9sBm/avfGCj61jaRBLOvxEAVnpys0Q==} engines: {node: '>=20.0.0'} @@ -4085,6 +4095,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@21.0.1: + resolution: {integrity: sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5915,6 +5934,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stripe/stripe-js@9.2.0': {} + '@supabase/auth-js@2.100.1': dependencies: tslib: 2.8.1 @@ -8450,6 +8471,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@21.0.1(@types/node@20.19.37): + optionalDependencies: + '@types/node': 20.19.37 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 From cac31f807d6147501f67d1b49b453b42038a33e6 Mon Sep 17 00:00:00 2001 From: Thomas Ballard Date: Fri, 17 Apr 2026 12:09:57 -0400 Subject: [PATCH 2/2] feat(checkout): add coaching and booking checkout actions --- app/api/webhooks/stripe/route.ts | 24 ++++- app/checkout/actions.ts | 145 +++++++++++++++++++++++++++++++ app/coaching/actions.ts | 54 ++++++++++++ lib/db/schema.ts | 2 + 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 app/checkout/actions.ts create mode 100644 app/coaching/actions.ts diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 0b97d77..cf23a09 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -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"; @@ -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; diff --git a/app/checkout/actions.ts b/app/checkout/actions.ts new file mode 100644 index 0000000..d3524ce --- /dev/null +++ b/app/checkout/actions.ts @@ -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; +} + +async function createStripeCheckoutSession(params: { + userId: string; + email: string; + stripeProductId: string; + metadata: Record; +}) { + 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 { + 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 !== "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 { + 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), + }); + 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! }; +} diff --git a/app/coaching/actions.ts b/app/coaching/actions.ts new file mode 100644 index 0000000..5958807 --- /dev/null +++ b/app/coaching/actions.ts @@ -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 { + 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 }; +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index f111c53..9318065 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -15,12 +15,14 @@ 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",