tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
get stripe async
awarm.space
2 weeks ago
5a6b1d4f
eaf8239f
+52
-79
8 changed files
expand all
collapse all
unified
split
actions
cancelSubscription.ts
createCheckoutSession.ts
app
api
checkout
success
route.ts
inngest
functions
stripe_handle_checkout_completed.ts
stripe_handle_subscription_updated.ts
webhooks
stripe
route.ts
stripe
client.ts
products.ts
+2
-2
actions/cancelSubscription.ts
···
1
1
"use server";
2
2
3
3
import { getIdentityData } from "./getIdentityData";
4
4
-
import { stripe } from "stripe/client";
4
4
+
import { getStripe } from "stripe/client";
5
5
import { supabaseServerClient } from "supabase/serverClient";
6
6
import { Ok, Err, type Result } from "src/result";
7
7
···
23
23
return Err("No active subscription found");
24
24
}
25
25
26
26
-
await stripe.subscriptions.update(sub.stripe_subscription_id, {
26
26
+
await getStripe().subscriptions.update(sub.stripe_subscription_id, {
27
27
cancel_at_period_end: true,
28
28
});
29
29
+5
-5
actions/createCheckoutSession.ts
···
1
1
"use server";
2
2
3
3
import { getIdentityData } from "./getIdentityData";
4
4
-
import { stripe } from "stripe/client";
4
4
+
import { getStripe } from "stripe/client";
5
5
import { supabaseServerClient } from "supabase/serverClient";
6
6
-
import { PRICE_IDS } from "stripe/products";
6
6
+
import { getPriceId } from "stripe/products";
7
7
import { Ok, Err, type Result } from "src/result";
8
8
9
9
export async function createCheckoutSession(
···
15
15
return Err("Not authenticated");
16
16
}
17
17
18
18
-
const priceId = PRICE_IDS[cadence];
18
18
+
const priceId = await getPriceId(cadence);
19
19
if (!priceId) {
20
20
-
return Err("Price not configured. Set STRIPE_PRICE_MONTHLY_ID and STRIPE_PRICE_YEARLY_ID env vars.");
20
20
+
return Err("No Stripe price found. Run the sync script first.");
21
21
}
22
22
23
23
// Check for existing Stripe customer
···
43
43
44
44
const cancelUrl = returnUrl || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
45
45
46
46
-
const session = await stripe.checkout.sessions.create({
46
46
+
const session = await getStripe().checkout.sessions.create({
47
47
mode: "subscription",
48
48
line_items: [{ price: priceId, quantity: 1 }],
49
49
client_reference_id: identity.id,
+7
-17
app/api/checkout/success/route.ts
···
1
1
import { NextRequest, NextResponse } from "next/server";
2
2
-
import { stripe } from "stripe/client";
2
2
+
import { getStripe } from "stripe/client";
3
3
import { supabaseServerClient } from "supabase/serverClient";
4
4
-
import { parseEntitlements } from "stripe/products";
4
4
+
import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products";
5
5
6
6
export async function GET(req: NextRequest) {
7
7
const sessionId = req.nextUrl.searchParams.get("session_id");
···
12
12
}
13
13
14
14
try {
15
15
-
const session = await stripe.checkout.sessions.retrieve(sessionId, {
16
16
-
expand: ["subscription", "subscription.items.data.price.product"],
15
15
+
const session = await getStripe().checkout.sessions.retrieve(sessionId, {
16
16
+
expand: ["subscription"],
17
17
});
18
18
19
19
const identityId = session.client_reference_id;
···
22
22
typeof session.subscription === "object" ? session.subscription : null;
23
23
24
24
if (identityId && sub) {
25
25
-
const priceItem = sub.items.data[0];
26
26
-
const product =
27
27
-
priceItem?.price.product &&
28
28
-
typeof priceItem.price.product === "object" &&
29
29
-
!("deleted" in priceItem.price.product)
30
30
-
? priceItem.price.product
31
31
-
: null;
32
32
-
const periodEnd = priceItem?.current_period_end ?? 0;
25
25
+
const periodEnd = sub.items.data[0]?.current_period_end ?? 0;
26
26
+
const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata);
33
27
34
28
// Optimistic upsert — idempotent with webhook handler
35
29
await supabaseServerClient.from("user_subscriptions").upsert(
···
37
31
identity_id: identityId,
38
32
stripe_customer_id: customerId,
39
33
stripe_subscription_id: sub.id,
40
40
-
plan: product?.name || "Leaflet Pro",
34
34
+
plan: PRODUCT_DEFINITION.name,
41
35
status: sub.status,
42
36
current_period_end: new Date(periodEnd * 1000).toISOString(),
43
37
updated_at: new Date().toISOString(),
44
38
},
45
39
{ onConflict: "identity_id" },
46
40
);
47
47
-
48
48
-
const entitlements = product
49
49
-
? parseEntitlements(product.metadata)
50
50
-
: { publication_analytics: true };
51
41
52
42
for (const key of Object.keys(entitlements)) {
53
43
await supabaseServerClient.from("user_entitlements").upsert(
+10
-24
app/api/inngest/functions/stripe_handle_checkout_completed.ts
···
1
1
import { inngest } from "../client";
2
2
-
import { stripe } from "stripe/client";
2
2
+
import { getStripe } from "stripe/client";
3
3
import { supabaseServerClient } from "supabase/serverClient";
4
4
-
import { parseEntitlements } from "stripe/products";
4
4
+
import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products";
5
5
6
6
export const stripe_handle_checkout_completed = inngest.createFunction(
7
7
{ id: "stripe-handle-checkout-completed" },
8
8
{ event: "stripe/checkout.session.completed" },
9
9
async ({ event, step }) => {
10
10
const session = await step.run("fetch-checkout-session", async () => {
11
11
-
const s = await stripe.checkout.sessions.retrieve(event.data.sessionId, {
12
12
-
expand: ["subscription", "subscription.items.data.price.product"],
13
13
-
});
11
11
+
const s = await getStripe().checkout.sessions.retrieve(
12
12
+
event.data.sessionId,
13
13
+
{ expand: ["subscription"] },
14
14
+
);
14
15
const sub =
15
16
typeof s.subscription === "object" ? s.subscription : null;
16
16
-
const priceItem = sub?.items.data[0];
17
17
-
const product =
18
18
-
priceItem?.price.product &&
19
19
-
typeof priceItem.price.product === "object" &&
20
20
-
!("deleted" in priceItem.price.product)
21
21
-
? priceItem.price.product
22
22
-
: null;
23
23
-
const periodEnd = priceItem?.current_period_end ?? 0;
17
17
+
const periodEnd = sub?.items.data[0]?.current_period_end ?? 0;
24
18
25
19
return {
26
20
identityId: s.client_reference_id,
···
28
22
subId: sub?.id ?? null,
29
23
subStatus: sub?.status ?? null,
30
24
periodEnd,
31
31
-
productName: product?.name || "Leaflet Pro",
32
32
-
productMetadata: product?.metadata ?? null,
33
25
};
34
26
});
35
27
···
38
30
}
39
31
40
32
await step.run("upsert-subscription-and-entitlements", async () => {
41
41
-
// Upsert user_subscriptions
33
33
+
const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata);
34
34
+
42
35
await supabaseServerClient.from("user_subscriptions").upsert(
43
36
{
44
37
identity_id: session.identityId!,
45
38
stripe_customer_id: session.customerId,
46
39
stripe_subscription_id: session.subId!,
47
47
-
plan: session.productName,
40
40
+
plan: PRODUCT_DEFINITION.name,
48
41
status: session.subStatus,
49
42
current_period_end: new Date(
50
43
session.periodEnd * 1000,
···
53
46
},
54
47
{ onConflict: "identity_id" },
55
48
);
56
56
-
57
57
-
// Parse entitlements from product metadata and upsert
58
58
-
const entitlements = session.productMetadata
59
59
-
? parseEntitlements(
60
60
-
session.productMetadata as Record<string, string>,
61
61
-
)
62
62
-
: { publication_analytics: true };
63
49
64
50
for (const key of Object.keys(entitlements)) {
65
51
await supabaseServerClient.from("user_entitlements").upsert(
+8
-21
app/api/inngest/functions/stripe_handle_subscription_updated.ts
···
1
1
import { inngest } from "../client";
2
2
-
import { stripe } from "stripe/client";
2
2
+
import { getStripe } from "stripe/client";
3
3
import { supabaseServerClient } from "supabase/serverClient";
4
4
-
import { parseEntitlements } from "stripe/products";
4
4
+
import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products";
5
5
6
6
export const stripe_handle_subscription_updated = inngest.createFunction(
7
7
{ id: "stripe-handle-subscription-updated" },
8
8
{ event: "stripe/customer.subscription.updated" },
9
9
async ({ event, step }) => {
10
10
const subData = await step.run("fetch-subscription", async () => {
11
11
-
const sub = await stripe.subscriptions.retrieve(
11
11
+
const sub = await getStripe().subscriptions.retrieve(
12
12
event.data.subscriptionId,
13
13
-
{ expand: ["items.data.price.product"] },
14
13
);
15
15
-
const priceItem = sub.items.data[0];
16
16
-
const product =
17
17
-
priceItem?.price.product &&
18
18
-
typeof priceItem.price.product === "object" &&
19
19
-
!("deleted" in priceItem.price.product)
20
20
-
? priceItem.price.product
21
21
-
: null;
22
22
-
const periodEnd = priceItem?.current_period_end ?? 0;
14
14
+
const periodEnd = sub.items.data[0]?.current_period_end ?? 0;
23
15
24
16
return {
25
17
id: sub.id,
26
18
customerId: sub.customer as string,
27
27
-
status: sub.status,
19
19
+
status: sub.cancel_at_period_end ? "canceling" : sub.status,
28
20
periodEnd,
29
29
-
productName: product?.name || "Leaflet Pro",
30
30
-
productMetadata: product?.metadata ?? null,
31
21
};
32
22
});
33
23
34
24
await step.run("update-subscription-and-entitlements", async () => {
25
25
+
const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata);
26
26
+
35
27
// Find the identity by stripe_customer_id
36
28
const { data: existingSub } = await supabaseServerClient
37
29
.from("user_subscriptions")
···
51
43
.from("user_subscriptions")
52
44
.update({
53
45
status: subData.status,
54
54
-
plan: subData.productName,
46
46
+
plan: PRODUCT_DEFINITION.name,
55
47
current_period_end: new Date(
56
48
subData.periodEnd * 1000,
57
49
).toISOString(),
···
60
52
.eq("identity_id", existingSub.identity_id);
61
53
62
54
// Update entitlement expiry dates for all entitlements from this subscription
63
63
-
const entitlements = subData.productMetadata
64
64
-
? parseEntitlements(
65
65
-
subData.productMetadata as Record<string, string>,
66
66
-
)
67
67
-
: {};
68
55
for (const key of Object.keys(entitlements)) {
69
56
await supabaseServerClient
70
57
.from("user_entitlements")
+2
-2
app/api/webhooks/stripe/route.ts
···
1
1
import { NextRequest, NextResponse } from "next/server";
2
2
-
import { stripe } from "stripe/client";
2
2
+
import { getStripe } from "stripe/client";
3
3
import { inngest } from "app/api/inngest/client";
4
4
5
5
export async function POST(req: NextRequest) {
···
11
11
12
12
let event;
13
13
try {
14
14
-
event = stripe.webhooks.constructEvent(
14
14
+
event = getStripe().webhooks.constructEvent(
15
15
body,
16
16
signature,
17
17
process.env.STRIPE_WEBHOOK_SECRET as string,
+10
-3
stripe/client.ts
···
1
1
import Stripe from "stripe";
2
2
3
3
-
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
4
4
-
apiVersion: "2026-02-25.clover",
5
5
-
});
3
3
+
let _stripe: Stripe | null = null;
4
4
+
5
5
+
export function getStripe(): Stripe {
6
6
+
if (!_stripe) {
7
7
+
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
8
8
+
apiVersion: "2026-02-25.clover",
9
9
+
});
10
10
+
}
11
11
+
return _stripe;
12
12
+
}
+8
-5
stripe/products.ts
···
23
23
},
24
24
};
25
25
26
26
-
// Populated at runtime by sync script or looked up dynamically
27
27
-
export const PRICE_IDS: Record<"month" | "year", string> = {
28
28
-
month: process.env.STRIPE_PRICE_MONTHLY_ID || "",
29
29
-
year: process.env.STRIPE_PRICE_YEARLY_ID || "",
30
30
-
};
26
26
+
export async function getPriceId(
27
27
+
cadence: "month" | "year",
28
28
+
): Promise<string | null> {
29
29
+
const { getStripe } = await import("./client");
30
30
+
const key = PRICE_DEFINITIONS[cadence].lookup_key;
31
31
+
const prices = await getStripe().prices.list({ lookup_keys: [key] });
32
32
+
return prices.data[0]?.id ?? null;
33
33
+
}
31
34
32
35
export function parseEntitlements(
33
36
metadata: Record<string, string> | null,