feat(subscription): implement RevenueCat webhook handler

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).
This commit is contained in:
AyrisAI
2026-08-20 13:13:02 +03:00
parent a719d41776
commit c85d3864fc
+75 -4
View File
@@ -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 });
});
};