Skip to content

Commit 1d71e05

Browse files
Add Stripe integration for payment processing
1 parent beb4100 commit 1d71e05

File tree

11 files changed

+470
-184
lines changed

11 files changed

+470
-184
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
2+
import { cookies } from "next/headers";
3+
import { NextResponse } from "next/server";
4+
5+
import { stripe } from "@/libs/stripe";
6+
import { getURL } from "@/libs/helpers";
7+
import { createOrRetrieveCustomer } from "@/libs/supabaseAdmin";
8+
9+
export async function POST(request: Request) {
10+
const { price, quantity = 1, metadata = {} } = await request.json();
11+
12+
try {
13+
const supabase = createRouteHandlerClient({
14+
cookies,
15+
});
16+
const {
17+
data: { user },
18+
} = await supabase.auth.getUser();
19+
20+
const customer = await createOrRetrieveCustomer({
21+
uuid: user?.id || "",
22+
email: user?.email || "",
23+
});
24+
25+
//@ts-ignore
26+
const session = await stripe.checkout.sessions.create({
27+
payment_method_types: ["card"],
28+
billing_address_collection: "required",
29+
customer,
30+
line_items: [
31+
{
32+
price: price.id,
33+
quantity,
34+
},
35+
],
36+
mode: "subscription",
37+
allow_promotion_codes: true,
38+
subscription_data: {
39+
trial_from_plan: true,
40+
metadata,
41+
},
42+
success_url: `${getURL()}/account`,
43+
cancel_url: `${getURL()}/`,
44+
});
45+
46+
return NextResponse.json({ sessionId: session.id });
47+
} catch (err: any) {
48+
console.error(err);
49+
return new NextResponse("Internal Error", { status: 500 });
50+
}
51+
}

app/api/create-portal-link/route.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
2+
import { cookies } from "next/headers";
3+
import { NextResponse } from "next/server";
4+
5+
import { stripe } from "@/libs/stripe";
6+
import { getURL } from "@/libs/helpers";
7+
import { createOrRetrieveCustomer } from "@/libs/supabaseAdmin";
8+
9+
export async function POST() {
10+
try {
11+
const supabase = createRouteHandlerClient({
12+
cookies,
13+
});
14+
15+
const {
16+
data: { user },
17+
} = await supabase.auth.getUser();
18+
19+
if (!user) throw Error("Could not get user");
20+
21+
const customer = await createOrRetrieveCustomer({
22+
uuid: user.id || "",
23+
email: user.email || "",
24+
});
25+
26+
if (!customer) throw Error("Could not get customer");
27+
const { url } = await stripe.billingPortal.sessions.create({
28+
customer,
29+
return_url: `${getURL()}/account`,
30+
});
31+
32+
return NextResponse.json({ url });
33+
} catch (error: any) {
34+
console.error(error);
35+
new NextResponse("Internal Error", { status: 500 });
36+
}
37+
}

app/api/webhooks/route.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Stripe from "stripe";
2+
import { NextResponse } from "next/server";
3+
import { headers } from "next/headers";
4+
5+
import { stripe } from "@/libs/stripe";
6+
import {
7+
upsertProductRecord,
8+
upsertPriceRecord,
9+
manageSubscriptionStatusChange,
10+
} from "@/libs/supabaseAdmin";
11+
12+
const relevantEvents = new Set([
13+
"product.created",
14+
"product.updated",
15+
"price.created",
16+
"price.updated",
17+
"checkout.session.completed",
18+
"customer.subscription.created",
19+
"customer.subscription.updated",
20+
"customer.subscription.deleted",
21+
]);
22+
23+
export async function POST(request: Request) {
24+
const body = await request.text();
25+
const sig = headers().get("Stripe-Signature");
26+
27+
const webhookSecret =
28+
process.env.STRIPE_WEBHOOK_SECRET_LIVE ?? process.env.STRIPE_WEBHOOK_SECRET;
29+
let event: Stripe.Event;
30+
31+
try {
32+
if (!sig || !webhookSecret) return;
33+
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
34+
} catch (error: any) {
35+
console.error(error.message);
36+
return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 });
37+
}
38+
39+
if (relevantEvents.has(event.type)) {
40+
try {
41+
switch (event.type) {
42+
case "product.created":
43+
case "product.updated":
44+
await upsertProductRecord(event.data.object as Stripe.Product);
45+
break;
46+
case "price.created":
47+
case "price.updated":
48+
await upsertPriceRecord(event.data.object as Stripe.Price);
49+
break;
50+
case "customer.subscription.created":
51+
case "customer.subscription.updated":
52+
case "customer.subscription.deleted":
53+
const subscription = event.data.object as Stripe.Subscription;
54+
await manageSubscriptionStatusChange(
55+
subscription.id,
56+
subscription.customer as string,
57+
event.type === "customer.subscription.created"
58+
);
59+
break;
60+
case "checkout.session.completed":
61+
const checkoutSession = event.data.object as Stripe.Checkout.Session;
62+
if (checkoutSession.mode === "subscription") {
63+
const subscriptionId = checkoutSession.subscription;
64+
await manageSubscriptionStatusChange(
65+
subscriptionId as string,
66+
checkoutSession.customer as string,
67+
true
68+
);
69+
}
70+
break;
71+
default:
72+
throw new Error("Unhandled relevant event!");
73+
}
74+
} catch (error) {
75+
console.error(error);
76+
return new NextResponse(
77+
'Webhook Error: "Webhook handler failed. View logs."',
78+
{ status: 400 }
79+
);
80+
}
81+
}
82+
83+
return NextResponse.json({ received: true }, { status: 200 });
84+
}

libs/helpers.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Price } from "@/types/types";
2+
3+
export const getURL = () => {
4+
let url =
5+
process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env.
6+
process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel.
7+
"http://localhost:3000/";
8+
// Make sure to include `https://` when not localhost.
9+
url = url.includes("http") ? url : `https://${url}`;
10+
// Make sure to including trailing `/`.
11+
url = url.charAt(url.length - 1) === "/" ? url : `${url}/`;
12+
return url;
13+
};
14+
15+
export const postData = async ({
16+
url,
17+
data,
18+
}: {
19+
url: string;
20+
data?: { price: Price };
21+
}) => {
22+
console.log("posting pstData,", url, data);
23+
24+
const res: Response = await fetch(url, {
25+
method: "POST",
26+
headers: new Headers({ "Content-Type": "application/json" }),
27+
credentials: "same-origin",
28+
body: JSON.stringify(data),
29+
});
30+
31+
if (!res.ok) {
32+
console.error("Error in postData", { url, data, res });
33+
throw Error(res.statusText);
34+
}
35+
36+
return res.json();
37+
};
38+
39+
export const toDateTime = (secs: number) => {
40+
var t = new Date("1970-01-01T00:30:00Z"); // Unix epoch start.
41+
t.setSeconds(secs);
42+
return t;
43+
};

libs/stripe.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Stripe from "stripe";
2+
3+
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
4+
apiVersion: "2023-10-16",
5+
appInfo: {
6+
name: "Spotify Clone Application",
7+
version: "0.1.0",
8+
},
9+
});

libs/stripeClient.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { loadStripe, Stripe } from "@stripe/stripe-js";
2+
3+
let stripePromise: Promise<Stripe | null>;
4+
5+
export const getStripe = () => {
6+
if (!stripePromise) {
7+
stripePromise = loadStripe(
8+
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ""
9+
);
10+
}
11+
12+
return stripePromise;
13+
};

0 commit comments

Comments
 (0)