Skip to content

Commit 01bfbe8

Browse files
Merge pull request #56 from hack4impact/feature/49-backend-actions
Checkout backend actions
2 parents f9efa90 + 0fae44a commit 01bfbe8

4 files changed

Lines changed: 259 additions & 2 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"use server";
2+
3+
import { headers } from "next/headers";
4+
import { and, eq } from "drizzle-orm";
5+
import type Stripe from "stripe";
6+
7+
import { db } from "@/lib/db";
8+
import { coachingSessions, serviceBookings, services } from "@/lib/db/schema";
9+
import { getOrCreateStripeCustomer, stripe } from "@/lib/stripe";
10+
import { createClient } from "@/utils/supabase/server";
11+
12+
export type CheckoutResult = { url: string } | { error: string };
13+
14+
async function getDefaultPriceId(stripeProductId: string): Promise<string> {
15+
const product = await stripe.products.retrieve(stripeProductId);
16+
const defaultPriceId = product.default_price;
17+
if (!defaultPriceId) {
18+
throw new Error(`Stripe product ${stripeProductId} has no default price`);
19+
}
20+
return defaultPriceId as string;
21+
}
22+
23+
async function getRequestOrigin(): Promise<string> {
24+
const origin = (await headers()).get("origin");
25+
if (!origin) {
26+
throw new Error("Missing request origin");
27+
}
28+
return origin;
29+
}
30+
31+
type CreateSessionResult =
32+
| { session: Stripe.Checkout.Session }
33+
| { error: string };
34+
35+
async function createStripeCheckoutSession(params: {
36+
userId: string;
37+
email: string;
38+
stripeProductId: string;
39+
metadata: Record<string, string>;
40+
}): Promise<CreateSessionResult> {
41+
const priceId = await getDefaultPriceId(params.stripeProductId);
42+
43+
const customerId = await getOrCreateStripeCustomer(
44+
params.userId,
45+
params.email,
46+
);
47+
const origin = await getRequestOrigin();
48+
49+
const session = await stripe.checkout.sessions.create({
50+
customer: customerId,
51+
mode: "payment",
52+
payment_method_types: ["card"],
53+
line_items: [{ price: priceId, quantity: 1 }],
54+
success_url: `${origin}/checkout/success`,
55+
cancel_url: `${origin}/checkout/cancel`,
56+
metadata: params.metadata,
57+
});
58+
59+
if (!session.url)
60+
return { error: "Stripe did not return a checkout URL" };
61+
return { session };
62+
}
63+
64+
export async function checkoutServiceBooking({
65+
serviceId,
66+
}: {
67+
serviceId: string;
68+
}): Promise<CheckoutResult> {
69+
const supabase = await createClient();
70+
const {
71+
data: { user },
72+
} = await supabase.auth.getUser();
73+
if (!user) return { error: "Not authenticated" };
74+
75+
const service = await db.query.services.findFirst({
76+
where: eq(services.id, serviceId),
77+
});
78+
if (!service) return { error: "Service not found" };
79+
if (service.status !== "active")
80+
return { error: "Service is not available" };
81+
if (service.type !== "programs")
82+
return { error: "Service is not a program" };
83+
84+
const [row] = await db
85+
.insert(serviceBookings)
86+
.values({
87+
userId: user.id,
88+
serviceId: service.id,
89+
status: "awaiting_payment",
90+
})
91+
.returning({ id: serviceBookings.id });
92+
93+
const result = await createStripeCheckoutSession({
94+
userId: user.id,
95+
email: user.email!,
96+
stripeProductId: service.stripeProductId,
97+
metadata: {
98+
type: "program",
99+
bookingId: row.id,
100+
},
101+
});
102+
if ("error" in result) {
103+
await db.delete(serviceBookings).where(eq(serviceBookings.id, row.id));
104+
return { error: result.error };
105+
}
106+
107+
await db
108+
.update(serviceBookings)
109+
.set({ stripeOrderId: result.session.id })
110+
.where(eq(serviceBookings.id, row.id));
111+
112+
return { url: result.session.url! };
113+
}
114+
115+
export async function checkoutCoachingSession({
116+
coachingSessionId,
117+
}: {
118+
coachingSessionId: string;
119+
}): Promise<CheckoutResult> {
120+
const supabase = await createClient();
121+
const {
122+
data: { user },
123+
} = await supabase.auth.getUser();
124+
if (!user) return { error: "Not authenticated" };
125+
126+
const row = await db.query.coachingSessions.findFirst({
127+
where: and(
128+
eq(coachingSessions.id, coachingSessionId),
129+
eq(coachingSessions.userId, user.id),
130+
),
131+
});
132+
if (!row) return { error: "Coaching session not found" };
133+
if (row.status !== "awaiting_payment")
134+
return { error: "Coaching session is not awaiting payment" };
135+
136+
const service = await db.query.services.findFirst({
137+
where: eq(services.id, row.serviceId),
138+
});
139+
if (!service) return { error: "Service not found" };
140+
141+
const result = await createStripeCheckoutSession({
142+
userId: user.id,
143+
email: user.email!,
144+
stripeProductId: service.stripeProductId,
145+
metadata: {
146+
type: "private_lesson",
147+
coachingSessionId: row.id,
148+
},
149+
});
150+
if ("error" in result) {
151+
await db
152+
.delete(coachingSessions)
153+
.where(eq(coachingSessions.id, row.id));
154+
return { error: result.error };
155+
}
156+
157+
await db
158+
.update(coachingSessions)
159+
.set({ stripeOrderId: result.session.id })
160+
.where(eq(coachingSessions.id, row.id));
161+
162+
return { url: result.session.url! };
163+
}
164+

app/api/webhooks/stripe/route.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { deleteCouponIfExhausted, stripe, syncStripeData } from "@/lib/stripe";
33
import { db } from "@/lib/db";
4-
import { profiles, purchases } from "@/lib/db/schema";
5-
import { eq } from "drizzle-orm";
4+
import {
5+
coachingSessions,
6+
profiles,
7+
purchases,
8+
serviceBookings,
9+
} from "@/lib/db/schema";
10+
import { and, eq } from "drizzle-orm";
611
import Stripe from "stripe";
712

813
const allowedEvents: Stripe.Event.Type[] = [
@@ -43,6 +48,33 @@ export async function POST(request: NextRequest) {
4348

4449
if (event.type === "checkout.session.completed") {
4550
const session = event.data.object as Stripe.Checkout.Session;
51+
const metadata = session.metadata ?? {};
52+
53+
if (metadata.type === "private_lesson" && metadata.coachingSessionId) {
54+
await db
55+
.update(coachingSessions)
56+
.set({ status: "pending", stripeOrderId: session.id })
57+
.where(
58+
and(
59+
eq(coachingSessions.id, metadata.coachingSessionId),
60+
eq(coachingSessions.status, "awaiting_payment"),
61+
),
62+
);
63+
return NextResponse.json({ received: true });
64+
}
65+
66+
if (metadata.type === "program" && metadata.bookingId) {
67+
await db
68+
.update(serviceBookings)
69+
.set({ status: "confirmed", stripeOrderId: session.id })
70+
.where(
71+
and(
72+
eq(serviceBookings.id, metadata.bookingId),
73+
eq(serviceBookings.status, "awaiting_payment"),
74+
),
75+
);
76+
return NextResponse.json({ received: true });
77+
}
4678

4779
for (const d of session.discounts ?? []) {
4880
const couponId =

app/coaching/actions.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use server";
2+
3+
import { eq } from "drizzle-orm";
4+
5+
import { db } from "@/lib/db";
6+
import { coachingSessions, services } from "@/lib/db/schema";
7+
import { createClient } from "@/utils/supabase/server";
8+
9+
export type Availability = { start: string; end: string };
10+
11+
export type SubmitAvailabilitiesResult =
12+
| { coachingSessionId: string }
13+
| { error: string };
14+
15+
export async function submitAvailabilities({
16+
serviceId,
17+
availabilities,
18+
}: {
19+
serviceId: string;
20+
availabilities: Availability[];
21+
}): Promise<SubmitAvailabilitiesResult> {
22+
if (!availabilities?.length)
23+
return { error: "At least one availability window is required" };
24+
25+
const supabase = await createClient();
26+
const {
27+
data: { user },
28+
} = await supabase.auth.getUser();
29+
if (!user) return { error: "Not authenticated" };
30+
31+
const service = await db.query.services.findFirst({
32+
where: eq(services.id, serviceId),
33+
});
34+
if (!service) return { error: "Service not found" };
35+
if (service.status !== "active")
36+
return { error: "Service is not available" };
37+
if (service.type !== "private_lessons")
38+
return { error: "Service is not a private lesson" };
39+
if (!service.coachId) return { error: "Service has no coach assigned" };
40+
41+
const [row] = await db
42+
.insert(coachingSessions)
43+
.values({
44+
userId: user.id,
45+
serviceId: service.id,
46+
coachId: service.coachId,
47+
durationMinutes: service.durationMinutes,
48+
selectedTimeSlots: availabilities,
49+
status: "awaiting_payment",
50+
})
51+
.returning({ id: coachingSessions.id });
52+
53+
return { coachingSessionId: row.id };
54+
}

lib/db/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ export const serviceTypeEnum = pgEnum("service_type", [
2424
"programs",
2525
]);
2626
export const bookingStatusEnum = pgEnum("booking_status", [
27+
"awaiting_payment",
2728
"pending",
2829
"confirmed",
2930
"cancelled",
3031
]);
3132
export const webinarTierEnum = pgEnum("webinar_tier", ["free", "premium"]);
3233
export const sessionStatusEnum = pgEnum("session_status", [
34+
"awaiting_payment",
3335
"pending",
3436
"confirmed",
3537
"cancelled",
@@ -69,6 +71,9 @@ export const services = pgTable("services", {
6971
durationMinutes: integer("duration_minutes").notNull(),
7072
stripeProductId: text("stripe_product_id").notNull(),
7173
status: serviceStatusEnum("status").notNull().default("active"),
74+
coachId: uuid("coach_id").references(() => profiles.id, {
75+
onDelete: "set null",
76+
}),
7277
createdAt: timestamp("created_at").defaultNow().notNull(),
7378
updatedAt: timestamp("updated_at").defaultNow().notNull(),
7479
});
@@ -84,6 +89,7 @@ export const serviceBookings = pgTable("service_bookings", {
8489
status: bookingStatusEnum("status").notNull().default("pending"),
8590
notes: text("notes"),
8691
isActive: boolean("is_active").notNull().default(true),
92+
stripeOrderId: text("stripe_order_id").unique(),
8793
createdAt: timestamp("created_at").defaultNow().notNull(),
8894
updatedAt: timestamp("updated_at").defaultNow().notNull(),
8995
});
@@ -117,6 +123,7 @@ export const coachingSessions = pgTable("coaching_sessions", {
117123
meetingUrl: text("meeting_url"),
118124
notes: text("notes"),
119125
selectedTimeSlots: jsonb("selected_time_slots").notNull(),
126+
stripeOrderId: text("stripe_order_id").unique(),
120127
createdAt: timestamp("created_at").defaultNow().notNull(),
121128
updatedAt: timestamp("updated_at").defaultNow().notNull(),
122129
});

0 commit comments

Comments
 (0)