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
move stripe logic out of inngest
awarm.space
19 hours ago
a786955c
5d468544
+20
-209
6 changed files
expand all
collapse all
unified
split
app
api
inngest
functions
stripe_handle_checkout_completed.ts
stripe_handle_invoice_payment_failed.ts
stripe_handle_subscription_deleted.ts
stripe_handle_subscription_updated.ts
route.tsx
webhooks
stripe
route.ts
-70
app/api/inngest/functions/stripe_handle_checkout_completed.ts
···
1
1
-
import { inngest } from "../client";
2
2
-
import { getStripe } from "stripe/client";
3
3
-
import { supabaseServerClient } from "supabase/serverClient";
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 getStripe().checkout.sessions.retrieve(
12
12
-
event.data.sessionId,
13
13
-
{ expand: ["subscription"] },
14
14
-
);
15
15
-
const sub =
16
16
-
typeof s.subscription === "object" ? s.subscription : null;
17
17
-
const periodEnd = sub?.items.data[0]?.current_period_end ?? 0;
18
18
-
const lookupKey = sub?.items.data[0]?.price.lookup_key ?? null;
19
19
-
20
20
-
return {
21
21
-
identityId: s.client_reference_id,
22
22
-
customerId: s.customer as string,
23
23
-
subId: sub?.id ?? null,
24
24
-
subStatus: sub?.status ?? null,
25
25
-
periodEnd,
26
26
-
lookupKey,
27
27
-
};
28
28
-
});
29
29
-
30
30
-
if (!session.identityId || !session.subId) {
31
31
-
throw new Error("Missing client_reference_id or subscription");
32
32
-
}
33
33
-
34
34
-
await step.run("upsert-subscription-and-entitlements", async () => {
35
35
-
const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata);
36
36
-
37
37
-
await supabaseServerClient.from("user_subscriptions").upsert(
38
38
-
{
39
39
-
identity_id: session.identityId!,
40
40
-
stripe_customer_id: session.customerId,
41
41
-
stripe_subscription_id: session.subId!,
42
42
-
plan: session.lookupKey,
43
43
-
status: session.subStatus,
44
44
-
current_period_end: new Date(
45
45
-
session.periodEnd * 1000,
46
46
-
).toISOString(),
47
47
-
updated_at: new Date().toISOString(),
48
48
-
},
49
49
-
{ onConflict: "identity_id" },
50
50
-
);
51
51
-
52
52
-
for (const key of Object.keys(entitlements)) {
53
53
-
await supabaseServerClient.from("user_entitlements").upsert(
54
54
-
{
55
55
-
identity_id: session.identityId!,
56
56
-
entitlement_key: key,
57
57
-
granted_at: new Date().toISOString(),
58
58
-
expires_at: new Date(
59
59
-
session.periodEnd * 1000,
60
60
-
).toISOString(),
61
61
-
source: `stripe:${session.subId}`,
62
62
-
},
63
63
-
{ onConflict: "identity_id,entitlement_key" },
64
64
-
);
65
65
-
}
66
66
-
});
67
67
-
68
68
-
return { success: true };
69
69
-
},
70
70
-
);
-23
app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts
···
1
1
-
import { inngest } from "../client";
2
2
-
import { supabaseServerClient } from "supabase/serverClient";
3
3
-
4
4
-
export const stripe_handle_invoice_payment_failed = inngest.createFunction(
5
5
-
{ id: "stripe-handle-invoice-payment-failed" },
6
6
-
{ event: "stripe/invoice.payment.failed" },
7
7
-
async ({ event, step }) => {
8
8
-
await step.run("mark-subscription-past-due", async () => {
9
9
-
if (event.data.subscriptionId) {
10
10
-
await supabaseServerClient
11
11
-
.from("user_subscriptions")
12
12
-
.update({
13
13
-
status: "past_due",
14
14
-
updated_at: new Date().toISOString(),
15
15
-
})
16
16
-
.eq("stripe_subscription_id", event.data.subscriptionId);
17
17
-
}
18
18
-
});
19
19
-
20
20
-
// Entitlements remain valid until expires_at
21
21
-
return { success: true };
22
22
-
},
23
23
-
);
-21
app/api/inngest/functions/stripe_handle_subscription_deleted.ts
···
1
1
-
import { inngest } from "../client";
2
2
-
import { supabaseServerClient } from "supabase/serverClient";
3
3
-
4
4
-
export const stripe_handle_subscription_deleted = inngest.createFunction(
5
5
-
{ id: "stripe-handle-subscription-deleted" },
6
6
-
{ event: "stripe/customer.subscription.deleted" },
7
7
-
async ({ event, step }) => {
8
8
-
await step.run("mark-subscription-canceled", async () => {
9
9
-
await supabaseServerClient
10
10
-
.from("user_subscriptions")
11
11
-
.update({
12
12
-
status: "canceled",
13
13
-
updated_at: new Date().toISOString(),
14
14
-
})
15
15
-
.eq("stripe_subscription_id", event.data.subscriptionId);
16
16
-
});
17
17
-
18
18
-
// Entitlements expire naturally via expires_at — no need to delete them
19
19
-
return { success: true };
20
20
-
},
21
21
-
);
-81
app/api/inngest/functions/stripe_handle_subscription_updated.ts
···
1
1
-
import { inngest } from "../client";
2
2
-
import { getStripe } from "stripe/client";
3
3
-
import { supabaseServerClient } from "supabase/serverClient";
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 getStripe().subscriptions.retrieve(
12
12
-
event.data.subscriptionId,
13
13
-
);
14
14
-
const periodEnd = sub.items.data[0]?.current_period_end ?? 0;
15
15
-
const lookupKey = sub.items.data[0]?.price.lookup_key ?? null;
16
16
-
17
17
-
return {
18
18
-
id: sub.id,
19
19
-
customerId: sub.customer as string,
20
20
-
status: sub.cancel_at_period_end ? "canceling" : sub.status,
21
21
-
periodEnd,
22
22
-
lookupKey,
23
23
-
metadataIdentityId: sub.metadata.identity_id ?? null,
24
24
-
};
25
25
-
});
26
26
-
27
27
-
await step.run("update-subscription-and-entitlements", async () => {
28
28
-
const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata);
29
29
-
30
30
-
// Find the identity by stripe_customer_id
31
31
-
const { data: existingSub } = await supabaseServerClient
32
32
-
.from("user_subscriptions")
33
33
-
.select("identity_id")
34
34
-
.eq("stripe_customer_id", subData.customerId)
35
35
-
.single();
36
36
-
37
37
-
const identityId = existingSub?.identity_id ?? subData.metadataIdentityId;
38
38
-
39
39
-
if (!identityId) {
40
40
-
console.warn(
41
41
-
`No subscription record for customer ${subData.customerId} and no identity_id in metadata`,
42
42
-
);
43
43
-
return;
44
44
-
}
45
45
-
46
46
-
// Upsert subscription record
47
47
-
await supabaseServerClient
48
48
-
.from("user_subscriptions")
49
49
-
.upsert(
50
50
-
{
51
51
-
identity_id: identityId,
52
52
-
stripe_customer_id: subData.customerId,
53
53
-
stripe_subscription_id: subData.id,
54
54
-
status: subData.status,
55
55
-
plan: subData.lookupKey,
56
56
-
current_period_end: new Date(
57
57
-
subData.periodEnd * 1000,
58
58
-
).toISOString(),
59
59
-
updated_at: new Date().toISOString(),
60
60
-
},
61
61
-
{ onConflict: "identity_id" },
62
62
-
);
63
63
-
64
64
-
// Upsert entitlements for all entitlements from this subscription
65
65
-
for (const key of Object.keys(entitlements)) {
66
66
-
await supabaseServerClient.from("user_entitlements").upsert(
67
67
-
{
68
68
-
identity_id: identityId,
69
69
-
entitlement_key: key,
70
70
-
granted_at: new Date().toISOString(),
71
71
-
expires_at: new Date(subData.periodEnd * 1000).toISOString(),
72
72
-
source: `stripe:${subData.id}`,
73
73
-
},
74
74
-
{ onConflict: "identity_id,entitlement_key" },
75
75
-
);
76
76
-
}
77
77
-
});
78
78
-
79
79
-
return { success: true };
80
80
-
},
81
81
-
);
-8
app/api/inngest/route.tsx
···
13
13
check_oauth_session,
14
14
} from "./functions/cleanup_expired_oauth_sessions";
15
15
import { write_records_to_pds } from "./functions/write_records_to_pds";
16
16
-
import { stripe_handle_checkout_completed } from "./functions/stripe_handle_checkout_completed";
17
17
-
import { stripe_handle_subscription_updated } from "./functions/stripe_handle_subscription_updated";
18
18
-
import { stripe_handle_subscription_deleted } from "./functions/stripe_handle_subscription_deleted";
19
19
-
import { stripe_handle_invoice_payment_failed } from "./functions/stripe_handle_invoice_payment_failed";
20
16
import { sync_document_metadata } from "./functions/sync_document_metadata";
21
17
22
18
export const { GET, POST, PUT } = serve({
···
33
29
cleanup_expired_oauth_sessions,
34
30
check_oauth_session,
35
31
write_records_to_pds,
36
36
-
stripe_handle_checkout_completed,
37
37
-
stripe_handle_subscription_updated,
38
38
-
stripe_handle_subscription_deleted,
39
39
-
stripe_handle_invoice_payment_failed,
40
32
sync_document_metadata,
41
33
],
42
34
});
+20
-6
app/api/webhooks/stripe/route.ts
···
1
1
import { NextRequest, NextResponse } from "next/server";
2
2
import { getStripe } from "stripe/client";
3
3
import { inngest } from "app/api/inngest/client";
4
4
+
import { handleCheckoutCompleted } from "./handle_checkout_completed";
5
5
+
import { handleSubscriptionUpdated } from "./handle_subscription_updated";
6
6
+
import { handleSubscriptionDeleted } from "./handle_subscription_deleted";
7
7
+
import { handleInvoicePaymentFailed } from "./handle_invoice_payment_failed";
4
8
5
9
export async function POST(req: NextRequest) {
6
10
const body = await req.text();
···
22
26
}
23
27
24
28
switch (event.type) {
25
25
-
case "checkout.session.completed":
29
29
+
case "checkout.session.completed": {
30
30
+
const sessionId = event.data.object.id;
26
31
await inngest.send({
27
32
name: "stripe/checkout.session.completed",
28
28
-
data: { sessionId: event.data.object.id },
33
33
+
data: { sessionId },
29
34
});
35
35
+
await handleCheckoutCompleted(sessionId);
30
36
break;
37
37
+
}
31
38
32
39
case "customer.subscription.created":
33
33
-
case "customer.subscription.updated":
40
40
+
case "customer.subscription.updated": {
41
41
+
const subscriptionId = event.data.object.id;
34
42
await inngest.send({
35
43
name: "stripe/customer.subscription.updated",
36
36
-
data: { subscriptionId: event.data.object.id },
44
44
+
data: { subscriptionId },
37
45
});
46
46
+
await handleSubscriptionUpdated(subscriptionId);
38
47
break;
48
48
+
}
39
49
40
40
-
case "customer.subscription.deleted":
50
50
+
case "customer.subscription.deleted": {
51
51
+
const subscriptionId = event.data.object.id;
41
52
await inngest.send({
42
53
name: "stripe/customer.subscription.deleted",
43
43
-
data: { subscriptionId: event.data.object.id },
54
54
+
data: { subscriptionId },
44
55
});
56
56
+
await handleSubscriptionDeleted(subscriptionId);
45
57
break;
58
58
+
}
46
59
47
60
case "invoice.payment_failed": {
48
61
const invoice = event.data.object;
···
61
74
customerId: invoice.customer as string,
62
75
},
63
76
});
77
77
+
await handleInvoicePaymentFailed(subId);
64
78
break;
65
79
}
66
80
}