diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api/billing/webhook/route.ts | |
| download | asa.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.ts | 181 |
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 }) +} |