Skip to content

Commit c4c7dab

Browse files
committed
Built the Polar upgrade flow end-to-end, but **everything is hidden behind a static feature flag** (enablePolarBillingFlag) that currently always returns false.
- Feature flag: `apps/app/src/lib/feature-flags.ts` - Webhook handler (signature-verified via `@polar-sh/sdk/webhooks`): `apps/app/src/app/api/webhooks/polar/route.ts` - Checkout + portal redirects: `apps/app/src/app/api/billing/polar/checkout/route.ts`, `apps/app/src/app/api/billing/polar/portal/route.ts` - Logged-in billing status endpoint (used by UI): `apps/app/src/app/api/billing/me/route.ts` - Upgrade UI component: `apps/app/src/components/auth/upgrade-prompt.tsx` (also mounted in `apps/app/src/components/brand/brand-panel.tsx`) - Billing settings page (404s while flag is off): `apps/app/src/app/settings/billing/page.tsx` - DB fields + migration for subscription state: `apps/app/prisma/schema.prisma`, `apps/app/prisma/migrations/20260112123000_add_polar_billing_fields/migration.sql` - Polar-side setup doc: `docs/development/POLAR_SETUP.md`
1 parent d89ffc6 commit c4c7dab

13 files changed

Lines changed: 670 additions & 1 deletion

File tree

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
"dependencies": {
2626
"@base-ui/react": "^1.0.0",
27+
"@polar-sh/sdk": "^0.42.1",
2728
"@prisma/adapter-pg": "^7.2.0",
2829
"@prisma/client": "^7.2.0",
2930
"@radix-ui/react-alert-dialog": "^1.1.5",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- AlterTable
2+
ALTER TABLE "public"."user_metadata"
3+
ADD COLUMN "subscription_tier" TEXT,
4+
ADD COLUMN "subscription_status" TEXT,
5+
ADD COLUMN "subscription_cancel_at_period_end" BOOLEAN,
6+
ADD COLUMN "subscription_current_period_end" TIMESTAMP(3),
7+
ADD COLUMN "subscription_ends_at" TIMESTAMP(3),
8+
ADD COLUMN "polar_customer_id" TEXT,
9+
ADD COLUMN "polar_subscription_id" TEXT;

apps/app/prisma/schema.prisma

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ model UserMetadata {
117117
subscriptionTier String @default("free") @map("subscription_tier")
118118
subscriptionStatus String @default("active") @map("subscription_status")
119119
120+
// Billing (Polar)
121+
subscriptionCancelAtPeriodEnd Boolean? @map("subscription_cancel_at_period_end")
122+
subscriptionCurrentPeriodEnd DateTime? @map("subscription_current_period_end")
123+
subscriptionEndsAt DateTime? @map("subscription_ends_at")
124+
polarCustomerId String? @map("polar_customer_id")
125+
polarSubscriptionId String? @map("polar_subscription_id")
126+
120127
/// [FeatureFlags]
121128
featureFlags Json? @map("feature_flags")
122129
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { NextResponse } from "next/server";
2+
import invariant from "tiny-invariant";
3+
4+
import { verifySession } from "@/lib/auth/session";
5+
import { getUserDb } from "@/lib/data/dal";
6+
import { enablePolarBillingFlag } from "@/lib/feature-flags";
7+
8+
export const runtime = "nodejs";
9+
10+
export type BillingMeResponse = {
11+
tier: "free" | "brand";
12+
status: string | null;
13+
cancelAtPeriodEnd: boolean | null;
14+
currentPeriodEnd: string | null;
15+
endsAt: string | null;
16+
};
17+
18+
export async function GET() {
19+
const enabled = await enablePolarBillingFlag();
20+
if (!enabled) {
21+
return new NextResponse("Not Found", { status: 404 });
22+
}
23+
24+
const session = await verifySession();
25+
if (!session.isAuth) {
26+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27+
}
28+
29+
invariant(session.userId, "userId must be defined when isAuth is true");
30+
const db = await getUserDb(session.userId);
31+
32+
const metadata = await db.userMetadata.findUnique({
33+
where: { userId: session.userId },
34+
select: {
35+
subscriptionTier: true,
36+
subscriptionStatus: true,
37+
subscriptionCancelAtPeriodEnd: true,
38+
subscriptionCurrentPeriodEnd: true,
39+
subscriptionEndsAt: true,
40+
},
41+
});
42+
43+
const tier = metadata?.subscriptionTier === "brand" ? "brand" : "free";
44+
45+
const response: BillingMeResponse = {
46+
tier,
47+
status: metadata?.subscriptionStatus ?? null,
48+
cancelAtPeriodEnd: metadata?.subscriptionCancelAtPeriodEnd ?? null,
49+
currentPeriodEnd: metadata?.subscriptionCurrentPeriodEnd
50+
? metadata.subscriptionCurrentPeriodEnd.toISOString()
51+
: null,
52+
endsAt: metadata?.subscriptionEndsAt ? metadata.subscriptionEndsAt.toISOString() : null,
53+
};
54+
55+
return NextResponse.json(response);
56+
}
57+
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { NextResponse } from "next/server";
2+
import invariant from "tiny-invariant";
3+
4+
import { verifySession } from "@/lib/auth/session";
5+
import { enablePolarBillingFlag } from "@/lib/feature-flags";
6+
import {
7+
getAppBaseUrlFromRequest,
8+
polarApiFetch,
9+
requirePolarProductId,
10+
} from "@/lib/billing/polar";
11+
12+
export const runtime = "nodejs";
13+
14+
function safeReturnPath(input: string | null): string {
15+
if (!input) return "/settings/billing";
16+
if (!input.startsWith("/")) return "/settings/billing";
17+
return input;
18+
}
19+
20+
type PolarCheckoutResponse = {
21+
url: string;
22+
};
23+
24+
export async function GET(request: Request) {
25+
const enabled = await enablePolarBillingFlag();
26+
if (!enabled) {
27+
return new NextResponse("Not Found", { status: 404 });
28+
}
29+
30+
const session = await verifySession();
31+
if (!session.isAuth || !session.userId) {
32+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
33+
}
34+
35+
const email = session.session?.user?.email;
36+
invariant(email, "Authenticated session must include user email for billing");
37+
38+
const requestUrl = new URL(request.url);
39+
const baseUrl = getAppBaseUrlFromRequest(request);
40+
const returnToPath = safeReturnPath(requestUrl.searchParams.get("returnTo"));
41+
42+
const returnUrl = new URL(returnToPath, baseUrl);
43+
const successUrl = new URL(returnToPath, baseUrl);
44+
successUrl.searchParams.set("checkout", "success");
45+
46+
const productId = requirePolarProductId();
47+
48+
const checkout = await polarApiFetch<PolarCheckoutResponse>("/v1/checkouts/", {
49+
method: "POST",
50+
body: JSON.stringify({
51+
products: [productId],
52+
customer_email: email,
53+
external_customer_id: session.userId,
54+
success_url: successUrl.toString(),
55+
return_url: returnUrl.toString(),
56+
metadata: { user_id: session.userId },
57+
}),
58+
});
59+
60+
return NextResponse.redirect(checkout.url, { status: 303 });
61+
}
62+
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { NextResponse } from "next/server";
2+
3+
import { verifySession } from "@/lib/auth/session";
4+
import { enablePolarBillingFlag } from "@/lib/feature-flags";
5+
import { getAppBaseUrlFromRequest, polarApiFetch } from "@/lib/billing/polar";
6+
7+
export const runtime = "nodejs";
8+
9+
function safeReturnPath(input: string | null): string {
10+
if (!input) return "/settings/billing";
11+
if (!input.startsWith("/")) return "/settings/billing";
12+
return input;
13+
}
14+
15+
type PolarCustomerSessionResponse = {
16+
customer_portal_url: string;
17+
};
18+
19+
export async function GET(request: Request) {
20+
const enabled = await enablePolarBillingFlag();
21+
if (!enabled) {
22+
return new NextResponse("Not Found", { status: 404 });
23+
}
24+
25+
const session = await verifySession();
26+
if (!session.isAuth || !session.userId) {
27+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
28+
}
29+
30+
const requestUrl = new URL(request.url);
31+
const baseUrl = getAppBaseUrlFromRequest(request);
32+
const returnToPath = safeReturnPath(requestUrl.searchParams.get("returnTo"));
33+
const returnUrl = new URL(returnToPath, baseUrl);
34+
35+
const customerSession = await polarApiFetch<PolarCustomerSessionResponse>(
36+
"/v1/customer-sessions/",
37+
{
38+
method: "POST",
39+
body: JSON.stringify({
40+
external_customer_id: session.userId,
41+
return_url: returnUrl.toString(),
42+
}),
43+
},
44+
);
45+
46+
return NextResponse.redirect(customerSession.customer_portal_url, { status: 303 });
47+
}
48+
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { NextResponse } from "next/server";
2+
import invariant from "tiny-invariant";
3+
import { WebhookVerificationError, validateEvent } from "@polar-sh/sdk/webhooks";
4+
5+
import { enablePolarBillingFlag } from "@/lib/feature-flags";
6+
import { prisma } from "@/lib/prisma";
7+
import { requirePolarWebhookSecret } from "@/lib/billing/polar";
8+
9+
export const runtime = "nodejs";
10+
11+
type PolarSubscription = {
12+
id: string;
13+
status?: string | null;
14+
customer_id?: string | null;
15+
checkout_id?: string | null;
16+
cancel_at_period_end?: boolean | null;
17+
current_period_end?: string | null;
18+
ends_at?: string | null;
19+
customer?: {
20+
external_id?: string | null;
21+
email?: string | null;
22+
} | null;
23+
metadata?: Record<string, unknown> | null;
24+
};
25+
26+
function parseDate(value: string | null | undefined): Date | null {
27+
if (!value) return null;
28+
const date = new Date(value);
29+
return Number.isNaN(date.getTime()) ? null : date;
30+
}
31+
32+
function deriveTier(params: {
33+
eventType: string;
34+
status: string | null;
35+
cancelAtPeriodEnd: boolean | null;
36+
}): "free" | "brand" {
37+
const normalizedType = params.eventType.toLowerCase();
38+
if (normalizedType === "subscription.revoked") return "free";
39+
if (normalizedType === "subscription.cancelled") return "free";
40+
41+
switch (params.status) {
42+
case "active":
43+
case "trialing":
44+
case "past_due":
45+
return "brand";
46+
case "canceled":
47+
return params.cancelAtPeriodEnd ? "brand" : "free";
48+
default:
49+
return "free";
50+
}
51+
}
52+
53+
async function resolveUserId(subscription: PolarSubscription): Promise<string | null> {
54+
const externalId =
55+
subscription.customer?.external_id ??
56+
(typeof subscription.metadata?.user_id === "string" ? subscription.metadata.user_id : null);
57+
if (externalId) return externalId;
58+
59+
const email = subscription.customer?.email;
60+
if (!email) return null;
61+
62+
const user = await prisma.user.findFirst({
63+
where: { email },
64+
select: { id: true },
65+
});
66+
return user?.id ?? null;
67+
}
68+
69+
export async function POST(request: Request) {
70+
const enabled = await enablePolarBillingFlag();
71+
if (!enabled) {
72+
return new NextResponse("Not Found", { status: 404 });
73+
}
74+
75+
const body = Buffer.from(await request.arrayBuffer());
76+
const headers = Object.fromEntries(request.headers.entries());
77+
78+
try {
79+
const secret = requirePolarWebhookSecret();
80+
const event = validateEvent(body, headers, secret) as { type: string; data: unknown };
81+
82+
if (!event?.type) {
83+
return NextResponse.json({ error: "Invalid webhook payload" }, { status: 400 });
84+
}
85+
86+
const eventType = String(event.type);
87+
if (!eventType.toLowerCase().startsWith("subscription.")) {
88+
return new NextResponse("", { status: 202 });
89+
}
90+
91+
const subscription = event.data as PolarSubscription;
92+
invariant(subscription?.id, "Subscription payload missing id");
93+
94+
const userId = await resolveUserId(subscription);
95+
if (!userId) {
96+
return NextResponse.json({ error: "Unable to resolve user" }, { status: 422 });
97+
}
98+
99+
const status = subscription.status ?? null;
100+
const cancelAtPeriodEnd = subscription.cancel_at_period_end ?? null;
101+
const tier = deriveTier({ eventType, status, cancelAtPeriodEnd });
102+
103+
await prisma.userMetadata.upsert({
104+
where: { userId },
105+
create: {
106+
userId,
107+
subscriptionTier: tier,
108+
subscriptionStatus: status,
109+
subscriptionCancelAtPeriodEnd: cancelAtPeriodEnd,
110+
subscriptionCurrentPeriodEnd: parseDate(subscription.current_period_end),
111+
subscriptionEndsAt: parseDate(subscription.ends_at),
112+
polarCustomerId: subscription.customer_id ?? null,
113+
polarSubscriptionId: subscription.id,
114+
},
115+
update: {
116+
subscriptionTier: tier,
117+
subscriptionStatus: status,
118+
subscriptionCancelAtPeriodEnd: cancelAtPeriodEnd,
119+
subscriptionCurrentPeriodEnd: parseDate(subscription.current_period_end),
120+
subscriptionEndsAt: parseDate(subscription.ends_at),
121+
polarCustomerId: subscription.customer_id ?? null,
122+
polarSubscriptionId: subscription.id,
123+
},
124+
});
125+
126+
return new NextResponse("", { status: 202 });
127+
} catch (error) {
128+
if (error instanceof WebhookVerificationError) {
129+
return new NextResponse("", { status: 403 });
130+
}
131+
132+
const message = error instanceof Error ? error.message : "Webhook handler failed";
133+
return NextResponse.json({ error: message }, { status: 500 });
134+
}
135+
}
136+

0 commit comments

Comments
 (0)