billing: Autumn webhook + customer status sync (#239)

This commit is contained in:
Ben Senescu 2026-06-04 23:04:55 -04:00 committed by GitHub
parent f46e0b9539
commit 96f365a473
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 3151 additions and 20 deletions

View File

@ -0,0 +1,10 @@
CREATE TABLE `billing_customer_status` (
`organization_id` text PRIMARY KEY NOT NULL,
`is_paying` integer DEFAULT false NOT NULL,
`paid_plan_id` text,
`customer_json` text NOT NULL,
`synced_at` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@ -148,6 +148,13 @@
"when": 1780599087400, "when": 1780599087400,
"tag": "0020_drop_delegated_users", "tag": "0020_drop_delegated_users",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1780625404317,
"tag": "0021_autumn_billing",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1,23 @@
SELECT
b.organization_id,
o.name AS organization_name,
b.is_paying,
b.paid_plan_id,
u.email,
u.name AS user_name,
a.work_for,
a.client_website_count,
a.found_via,
a.mcp_setup_intent,
a.interested_features,
a.completed_at AS onboarding_completed_at,
b.synced_at AS billing_synced_at
FROM billing_customer_status b
JOIN organization o
ON o.id = b.organization_id
LEFT JOIN user_onboarding_answers a
ON a.organization_id = b.organization_id
LEFT JOIN "user" u
ON u.id = a.user_id
WHERE b.is_paying = 1
ORDER BY b.synced_at DESC, a.completed_at DESC;

21
src/db/billing.schema.ts Normal file
View File

@ -0,0 +1,21 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { organization } from "./better-auth-schema";
export const billingCustomerStatus = sqliteTable("billing_customer_status", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
isPaying: integer("is_paying", { mode: "boolean" }).notNull().default(false),
paidPlanId: text("paid_plan_id"),
// Full Autumn customer payload — escape hatch for any field we don't flatten,
// queryable via json_extract so we never have to widen this table.
customerJson: text("customer_json").notNull(),
syncedAt: text("synced_at").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});

View File

@ -1,4 +1,5 @@
export * from "./app.schema"; export * from "./app.schema";
export * from "./better-auth-schema"; export * from "./better-auth-schema";
export * from "./billing.schema";
export * from "./gsc.schema"; export * from "./gsc.schema";
export * from "./reddit-attribution.schema"; export * from "./reddit-attribution.schema";

2
src/env.d.ts vendored
View File

@ -19,6 +19,8 @@ declare namespace Cloudflare {
LOOPS_API_KEY?: string; LOOPS_API_KEY?: string;
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string; LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string;
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string; LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
AUTUMN_SECRET_KEY?: string;
AUTUMN_WEBHOOK_SECRET?: string;
// DataForSEO API Basic auth value (base64 of login:password) // DataForSEO API Basic auth value (base64 of login:password)
DATAFORSEO_API_KEY: string; DATAFORSEO_API_KEY: string;

View File

@ -29,10 +29,10 @@ import { Route as AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer' import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
@ -145,11 +145,6 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
path: '/api/auth/$', path: '/api/auth/$',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
id: '/api/gsc/oauth/callback',
path: '/api/gsc/oauth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
id: '/help/dataforseo-api-key', id: '/help/dataforseo-api-key',
path: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key',
@ -165,6 +160,11 @@ const ProjectPProjectIdIndexRoute = ProjectPProjectIdIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
id: '/api/gsc/oauth/callback',
path: '/api/gsc/oauth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
id: '/saved', id: '/saved',
path: '/saved', path: '/saved',
@ -260,7 +260,6 @@ export interface FileRoutesByFullPath {
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -270,6 +269,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute '/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
@ -294,7 +294,6 @@ export interface FileRoutesByTo {
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
@ -302,6 +301,7 @@ export interface FileRoutesByTo {
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute '/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
@ -332,7 +332,6 @@ export interface FileRoutesById {
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -342,6 +341,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute '/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
@ -369,7 +369,6 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/gsc/oauth/callback'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
@ -379,6 +378,7 @@ export interface FileRouteTypes {
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking' | '/p/$projectId/rank-tracking'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/api/gsc/oauth/callback'
| '/p/$projectId/' | '/p/$projectId/'
| '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/rank-tracking/$configId'
| '/p/$projectId/audit/' | '/p/$projectId/audit/'
@ -403,7 +403,6 @@ export interface FileRouteTypes {
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/gsc/oauth/callback'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
@ -411,6 +410,7 @@ export interface FileRouteTypes {
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/api/gsc/oauth/callback'
| '/p/$projectId' | '/p/$projectId'
| '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/rank-tracking/$configId'
| '/p/$projectId/audit' | '/p/$projectId/audit'
@ -440,7 +440,6 @@ export interface FileRouteTypes {
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/gsc/oauth/callback'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/brand-lookup' | '/_project/p/$projectId/brand-lookup'
@ -450,6 +449,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved' | '/_project/p/$projectId/saved'
| '/api/gsc/oauth/callback'
| '/_project/p/$projectId/' | '/_project/p/$projectId/'
| '/_project/p/$projectId/rank-tracking/$configId' | '/_project/p/$projectId/rank-tracking/$configId'
| '/_project/p/$projectId/audit/' | '/_project/p/$projectId/audit/'
@ -613,13 +613,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/gsc/oauth/callback': {
id: '/api/gsc/oauth/callback'
path: '/api/gsc/oauth/callback'
fullPath: '/api/gsc/oauth/callback'
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/help/dataforseo-api-key': { '/_app/help/dataforseo-api-key': {
id: '/_app/help/dataforseo-api-key' id: '/_app/help/dataforseo-api-key'
path: '/help/dataforseo-api-key' path: '/help/dataforseo-api-key'
@ -641,6 +634,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdIndexRouteImport preLoaderRoute: typeof ProjectPProjectIdIndexRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute parentRoute: typeof ProjectPProjectIdRouteRoute
} }
'/api/gsc/oauth/callback': {
id: '/api/gsc/oauth/callback'
path: '/api/gsc/oauth/callback'
fullPath: '/api/gsc/oauth/callback'
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_project/p/$projectId/saved': { '/_project/p/$projectId/saved': {
id: '/_project/p/$projectId/saved' id: '/_project/p/$projectId/saved'
path: '/saved' path: '/saved'

View File

@ -15,6 +15,10 @@ import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_ROUTE } from "@/server/mcp/context"; import { MCP_ROUTE } from "@/server/mcp/context";
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { computeNextCheckAt } from "@/shared/rank-tracking"; import { computeNextCheckAt } from "@/shared/rank-tracking";
import {
AUTUMN_WEBHOOK_PATH,
handleAutumnWebhookRequest,
} from "@/server/billing/autumn-webhook";
const appFetch = createStartHandler(defaultStreamHandler); const appFetch = createStartHandler(defaultStreamHandler);
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch); const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
@ -26,8 +30,13 @@ function fetch(
): Response | Promise<Response> { ): Response | Promise<Response> {
const authMode = getAuthMode(env.AUTH_MODE); const authMode = getAuthMode(env.AUTH_MODE);
const publicRequest = requestWithPublicOrigin(request); const publicRequest = requestWithPublicOrigin(request);
const pathname = new URL(publicRequest.url).pathname;
if (isHostedAuthMode(authMode)) { if (isHostedAuthMode(authMode)) {
if (pathname === AUTUMN_WEBHOOK_PATH) {
return handleAutumnWebhookRequest(publicRequest);
}
return openSeoOAuthProvider.fetch( return openSeoOAuthProvider.fetch(
publicRequest, publicRequest,
env as OpenSeoOAuthEnv, env as OpenSeoOAuthEnv,
@ -37,7 +46,7 @@ function fetch(
if ( if (
(authMode === "cloudflare_access" || authMode === "local_noauth") && (authMode === "cloudflare_access" || authMode === "local_noauth") &&
new URL(publicRequest.url).pathname === MCP_ROUTE pathname === MCP_ROUTE
) { ) {
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx); return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
} }

View File

@ -0,0 +1,74 @@
import { z } from "zod";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
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) {
console.error("Autumn billing.updated sync failed", error);
return json({ error: "Webhook processing failed" }, 500);
}
}
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,
});
}

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
import { deriveBillingCustomerStatusSnapshot } from "./customer-status-model";
describe("deriveBillingCustomerStatusSnapshot", () => {
it("marks customers with an active paid subscription as paying", () => {
const snapshot = deriveBillingCustomerStatusSnapshot({
id: "org_123",
subscriptions: [{ planId: AUTUMN_PAID_PLAN_ID, status: "active" }],
});
expect(snapshot).toMatchObject({
organizationId: "org_123",
isPaying: true,
paidPlanId: AUTUMN_PAID_PLAN_ID,
});
});
it("preserves the full customer payload in customerJson", () => {
const snapshot = deriveBillingCustomerStatusSnapshot({
id: "org_123",
email: "alice@example.com",
stripeId: "cus_123",
subscriptions: [{ planId: AUTUMN_PAID_PLAN_ID, status: "active" }],
});
expect(JSON.parse(snapshot.customerJson)).toMatchObject({
id: "org_123",
email: "alice@example.com",
stripeId: "cus_123",
});
});
it("keeps non-paid customers queryable but not paying", () => {
const snapshot = deriveBillingCustomerStatusSnapshot({
id: "org_123",
subscriptions: [{ planId: "free", status: "active" }],
});
expect(snapshot.isPaying).toBe(false);
expect(snapshot.paidPlanId).toBeNull();
});
it("records a scheduled (not-yet-active) paid plan as not paying", () => {
const snapshot = deriveBillingCustomerStatusSnapshot({
id: "org_456",
subscriptions: [{ planId: AUTUMN_PAID_PLAN_ID, status: "scheduled" }],
});
expect(snapshot).toMatchObject({
organizationId: "org_456",
isPaying: false,
paidPlanId: AUTUMN_PAID_PLAN_ID,
});
});
it("prefers an active paid subscription when multiple paid rows exist", () => {
const snapshot = deriveBillingCustomerStatusSnapshot({
id: "org_789",
subscriptions: [
{ planId: AUTUMN_PAID_PLAN_ID, status: "scheduled" },
{ planId: AUTUMN_PAID_PLAN_ID, status: "active" },
],
});
expect(snapshot.isPaying).toBe(true);
expect(snapshot.paidPlanId).toBe(AUTUMN_PAID_PLAN_ID);
});
});

View File

@ -0,0 +1,50 @@
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
// The subset of the Autumn SDK's `Customer` we read. The SDK already validates
// and returns camelCase, so we type against it structurally instead of
// re-parsing; everything else is preserved verbatim in `customerJson`.
type AutumnSubscriptionInput = {
planId?: string | null;
status?: string | null;
};
type AutumnCustomerInput = {
id?: string | null;
subscriptions?: AutumnSubscriptionInput[];
[key: string]: unknown;
};
export type BillingCustomerStatusSnapshot = {
organizationId: string;
isPaying: boolean;
paidPlanId: string | null;
customerJson: string;
syncedAt: string;
};
export function deriveBillingCustomerStatusSnapshot(
customer: AutumnCustomerInput,
): BillingCustomerStatusSnapshot {
const organizationId = customer.id;
if (!organizationId) {
throw new Error("Autumn customer is missing an id");
}
const subscription = selectPaidSubscription(customer.subscriptions ?? []);
return {
organizationId,
isPaying: subscription?.status === "active",
paidPlanId: subscription?.planId ?? null,
// Full payload kept verbatim — query rarely-used fields via json_extract.
customerJson: JSON.stringify(customer),
syncedAt: new Date().toISOString(),
};
}
// A customer is "paying" when they hold the base paid plan. Prefer an active
// row, but fall back to any paid row so a not-yet-active plan still records its id.
function selectPaidSubscription(subscriptions: AutumnSubscriptionInput[]) {
const paid = subscriptions.filter((s) => s.planId === AUTUMN_PAID_PLAN_ID);
return paid.find((s) => s.status === "active") ?? paid[0] ?? null;
}

View File

@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import { db } from "@/db";
import { billingCustomerStatus } from "@/db/schema";
import { autumn } from "@/server/billing/autumn";
import {
deriveBillingCustomerStatusSnapshot,
type BillingCustomerStatusSnapshot,
} from "./customer-status-model";
export async function syncAutumnCustomerStatus(customerId: string) {
// getOrCreate is effectively a "get" here — a billing.updated webhook always
// references an existing Autumn customer. The SDK returns the camelCase shape.
const customer = await autumn.customers.getOrCreate({ customerId });
const snapshot = deriveBillingCustomerStatusSnapshot(customer);
await upsertBillingCustomerStatus(snapshot);
return snapshot;
}
async function upsertBillingCustomerStatus(
snapshot: BillingCustomerStatusSnapshot,
) {
await db
.insert(billingCustomerStatus)
.values(snapshot)
.onConflictDoUpdate({
target: billingCustomerStatus.organizationId,
set: {
isPaying: snapshot.isPaying,
paidPlanId: snapshot.paidPlanId,
customerJson: snapshot.customerJson,
syncedAt: snapshot.syncedAt,
updatedAt: sql`(current_timestamp)`,
},
});
}

View File

@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { verifySvixSignature } from "./svix";
const SECRET_BYTES = "test-webhook-secret";
const SECRET = `whsec_${Buffer.from(SECRET_BYTES).toString("base64")}`;
describe("verifySvixSignature", () => {
it("accepts a valid Svix signature", async () => {
const payload = JSON.stringify({ type: "billing.updated" });
const headers = await signedHeaders({
id: "msg_123",
payload,
timestamp: 1_000,
});
await expect(
verifySvixSignature({
headers,
nowSeconds: 1_000,
payload,
secret: SECRET,
}),
).resolves.toBe(true);
});
it("rejects tampered payloads", async () => {
const payload = JSON.stringify({ type: "billing.updated" });
const headers = await signedHeaders({
id: "msg_123",
payload,
timestamp: 1_000,
});
await expect(
verifySvixSignature({
headers,
nowSeconds: 1_000,
payload: JSON.stringify({ type: "other" }),
secret: SECRET,
}),
).resolves.toBe(false);
});
it("rejects stale timestamps", async () => {
const payload = JSON.stringify({ type: "billing.updated" });
const headers = await signedHeaders({
id: "msg_123",
payload,
timestamp: 1_000,
});
await expect(
verifySvixSignature({
headers,
nowSeconds: 1_400,
payload,
secret: SECRET,
}),
).resolves.toBe(false);
});
it("rejects malformed signatures without throwing", async () => {
const payload = JSON.stringify({ type: "billing.updated" });
const headers = new Headers({
"svix-id": "msg_123",
"svix-signature": "v1,not-base64!",
"svix-timestamp": "1000",
});
await expect(
verifySvixSignature({
headers,
nowSeconds: 1_000,
payload,
secret: SECRET,
}),
).resolves.toBe(false);
});
it("accepts Webhook-prefixed headers", async () => {
const payload = JSON.stringify({ type: "billing.updated" });
const timestamp = 1_000;
const id = "msg_123";
const signature = await sign(`${id}.${timestamp}.${payload}`);
const headers = new Headers({
"webhook-id": id,
"webhook-signature": `v1,${signature}`,
"webhook-timestamp": String(timestamp),
});
await expect(
verifySvixSignature({
headers,
nowSeconds: timestamp,
payload,
secret: SECRET,
}),
).resolves.toBe(true);
});
});
async function signedHeaders(args: {
id: string;
payload: string;
timestamp: number;
}) {
const signature = await sign(`${args.id}.${args.timestamp}.${args.payload}`);
return new Headers({
"svix-id": args.id,
"svix-signature": `v1,${signature}`,
"svix-timestamp": String(args.timestamp),
});
}
async function sign(value: string) {
const key = await crypto.subtle.importKey(
"raw",
Buffer.from(SECRET_BYTES),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(value),
);
return Buffer.from(signature).toString("base64");
}

View File

@ -0,0 +1,95 @@
const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
export async function verifySvixSignature(args: {
headers: Headers;
payload: string;
secret: string;
nowSeconds?: number;
}) {
const id = getHeader(args.headers, "svix-id", "webhook-id");
const timestamp = getHeader(
args.headers,
"svix-timestamp",
"webhook-timestamp",
);
const signatureHeader = getHeader(
args.headers,
"svix-signature",
"webhook-signature",
);
if (!id || !timestamp || !signatureHeader) {
return false;
}
const timestampSeconds = Number(timestamp);
if (!Number.isFinite(timestampSeconds)) {
return false;
}
const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestampSeconds) > SIGNATURE_TOLERANCE_SECONDS) {
return false;
}
const signedContent = `${id}.${timestamp}.${args.payload}`;
const expected = await hmacSha256(args.secret, signedContent);
return signatureHeader.split(" ").some((signaturePart) => {
const [version, signature] = signaturePart.split(",", 2);
if (version !== "v1" || !signature) return false;
try {
return constantTimeEqual(expected, base64ToBytes(signature));
} catch {
return false;
}
});
}
function getHeader(headers: Headers, ...names: string[]) {
for (const name of names) {
const value = headers.get(name);
if (value) return value;
}
}
async function hmacSha256(secret: string, value: string) {
const rawSecret = secret.startsWith("whsec_") ? secret.slice(6) : secret;
const key = await crypto.subtle.importKey(
"raw",
base64ToBytes(rawSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(value),
);
return new Uint8Array(signature);
}
function base64ToBytes(value: string) {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) {
bytes[i] = decoded.charCodeAt(i);
}
return bytes;
}
function constantTimeEqual(left: Uint8Array, right: Uint8Array) {
if (left.length !== right.length) return false;
let difference = 0;
for (let i = 0; i < left.length; i += 1) {
difference |= left[i] ^ right[i];
}
return difference === 0;
}