billing: Autumn webhook + customer status sync (#239)
This commit is contained in:
parent
f46e0b9539
commit
96f365a473
10
drizzle/0021_autumn_billing.sql
Normal file
10
drizzle/0021_autumn_billing.sql
Normal 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
|
||||
);
|
||||
2604
drizzle/meta/0021_snapshot.json
Normal file
2604
drizzle/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -148,6 +148,13 @@
|
||||
"when": 1780599087400,
|
||||
"tag": "0020_drop_delegated_users",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1780625404317,
|
||||
"tag": "0021_autumn_billing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
23
scripts/paying-customer-onboarding-answers.sql
Normal file
23
scripts/paying-customer-onboarding-answers.sql
Normal 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
21
src/db/billing.schema.ts
Normal 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)`),
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./app.schema";
|
||||
export * from "./better-auth-schema";
|
||||
export * from "./billing.schema";
|
||||
export * from "./gsc.schema";
|
||||
export * from "./reddit-attribution.schema";
|
||||
|
||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@ -19,6 +19,8 @@ declare namespace Cloudflare {
|
||||
LOOPS_API_KEY?: string;
|
||||
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_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_KEY: string;
|
||||
|
||||
@ -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 ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||
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 ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||
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 ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
||||
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
||||
@ -145,11 +145,6 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
|
||||
id: '/api/gsc/oauth/callback',
|
||||
path: '/api/gsc/oauth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
||||
id: '/help/dataforseo-api-key',
|
||||
path: '/help/dataforseo-api-key',
|
||||
@ -165,6 +160,11 @@ const ProjectPProjectIdIndexRoute = ProjectPProjectIdIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
|
||||
id: '/api/gsc/oauth/callback',
|
||||
path: '/api/gsc/oauth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
|
||||
id: '/saved',
|
||||
path: '/saved',
|
||||
@ -260,7 +260,6 @@ export interface FileRoutesByFullPath {
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -270,6 +269,7 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
||||
@ -294,7 +294,6 @@ export interface FileRoutesByTo {
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
@ -302,6 +301,7 @@ export interface FileRoutesByTo {
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
|
||||
@ -332,7 +332,6 @@ export interface FileRoutesById {
|
||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -342,6 +341,7 @@ export interface FileRoutesById {
|
||||
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
||||
@ -369,7 +369,6 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
@ -379,6 +378,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/rank-tracking'
|
||||
| '/p/$projectId/saved'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId/'
|
||||
| '/p/$projectId/rank-tracking/$configId'
|
||||
| '/p/$projectId/audit/'
|
||||
@ -403,7 +403,6 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
@ -411,6 +410,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/saved'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId'
|
||||
| '/p/$projectId/rank-tracking/$configId'
|
||||
| '/p/$projectId/audit'
|
||||
@ -440,7 +440,6 @@ export interface FileRouteTypes {
|
||||
| '/_app/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
| '/_project/p/$projectId/brand-lookup'
|
||||
@ -450,6 +449,7 @@ export interface FileRouteTypes {
|
||||
| '/_project/p/$projectId/prompt-explorer'
|
||||
| '/_project/p/$projectId/rank-tracking'
|
||||
| '/_project/p/$projectId/saved'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/_project/p/$projectId/'
|
||||
| '/_project/p/$projectId/rank-tracking/$configId'
|
||||
| '/_project/p/$projectId/audit/'
|
||||
@ -613,13 +613,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
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': {
|
||||
id: '/_app/help/dataforseo-api-key'
|
||||
path: '/help/dataforseo-api-key'
|
||||
@ -641,6 +634,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdIndexRouteImport
|
||||
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': {
|
||||
id: '/_project/p/$projectId/saved'
|
||||
path: '/saved'
|
||||
|
||||
@ -15,6 +15,10 @@ import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
|
||||
import { MCP_ROUTE } from "@/server/mcp/context";
|
||||
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||
import {
|
||||
AUTUMN_WEBHOOK_PATH,
|
||||
handleAutumnWebhookRequest,
|
||||
} from "@/server/billing/autumn-webhook";
|
||||
|
||||
const appFetch = createStartHandler(defaultStreamHandler);
|
||||
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
|
||||
@ -26,8 +30,13 @@ function fetch(
|
||||
): Response | Promise<Response> {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
const publicRequest = requestWithPublicOrigin(request);
|
||||
const pathname = new URL(publicRequest.url).pathname;
|
||||
|
||||
if (isHostedAuthMode(authMode)) {
|
||||
if (pathname === AUTUMN_WEBHOOK_PATH) {
|
||||
return handleAutumnWebhookRequest(publicRequest);
|
||||
}
|
||||
|
||||
return openSeoOAuthProvider.fetch(
|
||||
publicRequest,
|
||||
env as OpenSeoOAuthEnv,
|
||||
@ -37,7 +46,7 @@ function fetch(
|
||||
|
||||
if (
|
||||
(authMode === "cloudflare_access" || authMode === "local_noauth") &&
|
||||
new URL(publicRequest.url).pathname === MCP_ROUTE
|
||||
pathname === MCP_ROUTE
|
||||
) {
|
||||
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
|
||||
}
|
||||
|
||||
74
src/server/billing/autumn-webhook.ts
Normal file
74
src/server/billing/autumn-webhook.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
69
src/server/billing/customer-status-model.test.ts
Normal file
69
src/server/billing/customer-status-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
src/server/billing/customer-status-model.ts
Normal file
50
src/server/billing/customer-status-model.ts
Normal 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;
|
||||
}
|
||||
35
src/server/billing/customer-status-sync.ts
Normal file
35
src/server/billing/customer-status-sync.ts
Normal 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)`,
|
||||
},
|
||||
});
|
||||
}
|
||||
131
src/server/billing/svix.test.ts
Normal file
131
src/server/billing/svix.test.ts
Normal 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");
|
||||
}
|
||||
95
src/server/billing/svix.ts
Normal file
95
src/server/billing/svix.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user