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 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) => {
|
export const subscriptionRoutes: FastifyPluginAsync = async (app) => {
|
||||||
// RevenueCat webhook — backend is the source of truth for entitlement status,
|
// Mobile app local state is never trusted for entitlement — RevenueCat's
|
||||||
// mobile app local state is never trusted (see PRD §16).
|
// webhook, verified here, is the only writer of subscription status (PRD §16).
|
||||||
app.post("/subscription/webhook", async (_req, reply) => {
|
app.post("/subscription/webhook", async (req, reply) => {
|
||||||
return reply.code(501).send({ message: "not implemented" });
|
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