metatron-open-seo/src/server/billing/autumn-webhook.ts

97 lines
3.2 KiB
TypeScript

import { z } from "zod";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import { captureServerError } from "@/server/lib/posthog";
import { trackDubSalesForOrganization } from "@/server/referrals/dub";
import { syncAutumnCustomerStatus } from "./customer-status-sync";
import { verifySvixSignature } from "./svix";
export const AUTUMN_WEBHOOK_PATH = "/api/autumn/webhook";
const autumnWebhookPayloadSchema = z
.object({
type: z.string(),
data: z.record(z.string(), z.unknown()).optional().default({}),
})
.passthrough();
type AutumnWebhookPayload = z.infer<typeof autumnWebhookPayloadSchema>;
export async function handleAutumnWebhookRequest(request: Request) {
if (request.method !== "POST") {
return new Response("Method not allowed", {
headers: { Allow: "POST" },
status: 405,
});
}
const rawPayload = await request.text();
const webhookSecret = await getRequiredEnvValue("AUTUMN_WEBHOOK_SECRET");
const isVerified = await verifySvixSignature({
headers: request.headers,
payload: rawPayload,
secret: webhookSecret,
});
if (!isVerified) {
return json({ error: "Invalid webhook signature" }, 401);
}
let payload: AutumnWebhookPayload;
try {
payload = autumnWebhookPayloadSchema.parse(JSON.parse(rawPayload));
} catch {
return json({ error: "Invalid webhook payload" }, 400);
}
// Re-syncing the customer's current state from Autumn is idempotent, so a
// replayed or out-of-order webhook simply converges to the same row — no
// dedup table needed. Svix retries on a non-2xx response.
if (payload.type === "billing.updated") {
const customerId = getCustomerId(payload);
if (!customerId) {
return json({ error: "Missing customer_id" }, 400);
}
try {
await syncAutumnCustomerStatus(customerId);
} catch (error) {
// Drizzle truncates its own message to "Failed query:"; the real driver
// detail (postgres.js code/constraint) lives on error.cause, so log that
// explicitly. Also forward to PostHog — this raw handler runs outside the
// server-function middleware, so nothing else captures it as a $exception.
console.error("Autumn billing.updated sync failed", customerId, error, {
cause: error instanceof Error ? error.cause : undefined,
});
// Webhooks carry no user; the Autumn customer id is the organization id,
// which at least makes "users affected" count organizations instead of events.
await captureServerError(
error,
{
source: "autumn_webhook",
customer_id: customerId,
},
customerId,
);
return json({ error: "Webhook processing failed" }, 500);
}
// Referral revenue attribution; internally fire-and-forget and can never
// fail the webhook response.
await trackDubSalesForOrganization(customerId);
}
return json({ received: true });
}
function getCustomerId(payload: AutumnWebhookPayload) {
const value = payload.data.customer_id;
return typeof value === "string" && value.length > 0 ? value : null;
}
function json(data: Record<string, unknown>, status = 200) {
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
status,
});
}