summaryrefslogtreecommitdiff
path: root/apps/web/app/api/billing/webhook/route.ts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api/billing/webhook/route.ts
downloadasa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz
asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker. Includes three subscription tiers (free/pro/developer), API key auth, read-only REST API, webhook push notifications, Stripe billing with proration, and PWA support.
Diffstat (limited to 'apps/web/app/api/billing/webhook/route.ts')
-rw-r--r--apps/web/app/api/billing/webhook/route.ts181
1 files changed, 181 insertions, 0 deletions
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts
new file mode 100644
index 0000000..8aed7d0
--- /dev/null
+++ b/apps/web/app/api/billing/webhook/route.ts
@@ -0,0 +1,181 @@
+import { NextResponse } from "next/server"
+import type Stripe from "stripe"
+import { getStripe } from "@/lib/stripe"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { rateLimit } from "@/lib/rate-limit"
+
+function determineTierFromSubscription(
+ subscription: Stripe.Subscription
+): "pro" | "developer" {
+ const priceIdentifier = subscription.items?.data?.[0]?.price?.id
+ const developerPriceIdentifiers = [
+ process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER,
+ process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER,
+ ]
+
+ if (priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)) {
+ return "developer"
+ }
+
+ return "pro"
+}
+
+function extractPeriodEnd(subscription: Stripe.Subscription): string | null {
+ const firstItem = subscription.items?.data?.[0]
+ if (firstItem?.current_period_end) {
+ return new Date(firstItem.current_period_end * 1000).toISOString()
+ }
+
+ if (subscription.cancel_at) {
+ return new Date(subscription.cancel_at * 1000).toISOString()
+ }
+
+ return null
+}
+
+async function updateBillingState(
+ stripeCustomerIdentifier: string,
+ updates: Record<string, unknown>
+) {
+ const adminClient = createSupabaseAdminClient()
+ const { error } = await adminClient
+ .from("user_profiles")
+ .update(updates)
+ .eq("stripe_customer_identifier", stripeCustomerIdentifier)
+
+ if (error) {
+ console.error("Failed to update billing state:", error)
+ }
+}
+
+async function handleCheckoutSessionCompleted(
+ session: Stripe.Checkout.Session
+) {
+ if (session.mode !== "subscription" || !session.subscription) return
+
+ const userIdentifier = session.client_reference_id
+ if (!userIdentifier) return
+
+ const subscription = await getStripe().subscriptions.retrieve(
+ session.subscription as string,
+ { expand: ["items.data"] }
+ )
+
+ const adminClient = createSupabaseAdminClient()
+ await adminClient
+ .from("user_profiles")
+ .update({
+ tier: determineTierFromSubscription(subscription),
+ stripe_customer_identifier: session.customer as string,
+ stripe_subscription_identifier: subscription.id,
+ stripe_subscription_status: subscription.status,
+ stripe_current_period_end: extractPeriodEnd(subscription),
+ })
+ .eq("id", userIdentifier)
+}
+
+async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
+ const stripeCustomerIdentifier = subscription.customer as string
+
+ const updates: Record<string, unknown> = {
+ stripe_subscription_status: subscription.status,
+ stripe_current_period_end: extractPeriodEnd(subscription),
+ }
+
+ if (subscription.status === "active") {
+ updates.tier = determineTierFromSubscription(subscription)
+ }
+
+ await updateBillingState(stripeCustomerIdentifier, updates)
+}
+
+async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
+ const stripeCustomerIdentifier = subscription.customer as string
+
+ await updateBillingState(stripeCustomerIdentifier, {
+ tier: "free",
+ stripe_subscription_identifier: null,
+ stripe_subscription_status: "canceled",
+ stripe_current_period_end: null,
+ })
+}
+
+async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
+ const stripeCustomerIdentifier = invoice.customer as string
+
+ await updateBillingState(stripeCustomerIdentifier, {
+ stripe_subscription_status: "past_due",
+ })
+}
+
+async function handleInvoicePaid(invoice: Stripe.Invoice) {
+ const stripeCustomerIdentifier = invoice.customer as string
+ const lineItem = invoice.lines?.data?.[0]
+ const priceIdentifier = (lineItem as unknown as { price?: { id?: string } } | undefined)?.price?.id
+ const developerPriceIdentifiers = [
+ process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER,
+ process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER,
+ ]
+ const tier =
+ priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)
+ ? "developer"
+ : "pro"
+
+ await updateBillingState(stripeCustomerIdentifier, {
+ tier,
+ stripe_subscription_status: "active",
+ })
+}
+
+export async function POST(request: Request) {
+ const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"
+ const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ }
+
+ const body = await request.text()
+ const signature = request.headers.get("stripe-signature")
+
+ if (!signature) {
+ return NextResponse.json({ error: "Missing signature" }, { status: 400 })
+ }
+
+ let event: Stripe.Event
+
+ try {
+ event = getStripe().webhooks.constructEvent(
+ body,
+ signature,
+ process.env.STRIPE_WEBHOOK_SECRET!
+ )
+ } catch {
+ return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
+ }
+
+ switch (event.type) {
+ case "checkout.session.completed":
+ await handleCheckoutSessionCompleted(
+ event.data.object as Stripe.Checkout.Session
+ )
+ break
+ case "customer.subscription.updated":
+ await handleSubscriptionUpdated(
+ event.data.object as Stripe.Subscription
+ )
+ break
+ case "customer.subscription.deleted":
+ await handleSubscriptionDeleted(
+ event.data.object as Stripe.Subscription
+ )
+ break
+ case "invoice.payment_failed":
+ await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
+ break
+ case "invoice.paid":
+ await handleInvoicePaid(event.data.object as Stripe.Invoice)
+ break
+ }
+
+ return NextResponse.json({ received: true })
+}