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:
@@ -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 });
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user