From c85d3864fc85682f0160ef32ea7eed17d0f20d07 Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Thu, 20 Aug 2026 13:13:02 +0300 Subject: [PATCH] feat(subscription): implement RevenueCat webhook handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the shared-secret Authorization header, then maps RevenueCat's app_user_id (the restaurant id — mobile must configure Purchases with appUserID: restaurantId) to an upsert into subscriptions. INITIAL_PURCHASE/RENEWAL/PRODUCT_CHANGE/UNCANCELLATION -> active, BILLING_ISSUE -> grace_period, EXPIRATION -> suspended. CANCELLATION alone does not suspend — it only means auto-renew is off, access continues until the period actually expires. Tested end-to-end against the real Supabase project (create restaurant, POST the webhook, verify the subscriptions row). --- apps/api/src/routes/subscription.ts | 79 +++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/subscription.ts b/apps/api/src/routes/subscription.ts index 25ea8df..ad22349 100644 --- a/apps/api/src/routes/subscription.ts +++ b/apps/api/src/routes/subscription.ts @@ -1,9 +1,80 @@ import type { FastifyPluginAsync } from "fastify"; +import { env } from "../env.js"; +import { supabase } from "../lib/supabase.js"; + +type RevenueCatEventType = + | "INITIAL_PURCHASE" + | "RENEWAL" + | "PRODUCT_CHANGE" + | "CANCELLATION" + | "UNCANCELLATION" + | "EXPIRATION" + | "BILLING_ISSUE" + | "SUBSCRIBER_ALIAS" + | "SUBSCRIPTION_PAUSED" + | "TRANSFER"; + +interface RevenueCatWebhookEvent { + type: RevenueCatEventType; + app_user_id: string; + product_id: string; + expiration_at_ms: number | null; +} + +const ACTIVE_EVENTS: RevenueCatEventType[] = ["INITIAL_PURCHASE", "RENEWAL", "PRODUCT_CHANGE", "UNCANCELLATION"]; +const GRACE_EVENTS: RevenueCatEventType[] = ["BILLING_ISSUE"]; +// CANCELLATION means auto-renew was turned off, not that access ended yet — +// the subscription stays active until EXPIRATION actually fires. +const SUSPENDED_EVENTS: RevenueCatEventType[] = ["EXPIRATION"]; export const subscriptionRoutes: FastifyPluginAsync = async (app) => { - // RevenueCat webhook — backend is the source of truth for entitlement status, - // mobile app local state is never trusted (see PRD §16). - app.post("/subscription/webhook", async (_req, reply) => { - return reply.code(501).send({ message: "not implemented" }); + // Mobile app local state is never trusted for entitlement — RevenueCat's + // webhook, verified here, is the only writer of subscription status (PRD §16). + app.post("/subscription/webhook", async (req, reply) => { + const authHeader = req.headers.authorization; + const expected = env.REVENUECAT_WEBHOOK_SECRET ? `Bearer ${env.REVENUECAT_WEBHOOK_SECRET}` : null; + + if (!expected || authHeader !== expected) { + return reply.code(401).send({ message: "unauthorized" }); + } + + if (!supabase) return reply.code(500).send({ message: "server misconfigured" }); + + const body = req.body as { event?: RevenueCatWebhookEvent }; + const event = body.event; + if (!event?.app_user_id || !event.type) { + return reply.code(400).send({ message: "missing event" }); + } + + // app_user_id is the restaurant's id — the mobile app must configure the + // RevenueCat SDK with Purchases.configure({ appUserID: restaurantId }). + const restaurantId = event.app_user_id; + + let status: "active" | "grace_period" | "suspended" | null = null; + if (ACTIVE_EVENTS.includes(event.type)) status = "active"; + else if (GRACE_EVENTS.includes(event.type)) status = "grace_period"; + else if (SUSPENDED_EVENTS.includes(event.type)) status = "suspended"; + + if (!status) { + return reply.send({ received: true }); + } + + const { error } = await supabase.from("subscriptions").upsert( + { + restaurant_id: restaurantId, + status, + product_id: event.product_id, + current_period_ends_at: event.expiration_at_ms ? new Date(event.expiration_at_ms).toISOString() : null, + updated_at: new Date().toISOString(), + }, + { onConflict: "restaurant_id" }, + ); + + if (error) { + req.log.error(error); + return reply.code(500).send({ message: "failed to update subscription" }); + } + + return reply.send({ received: true }); }); };