From 7e3a75ead3f7e10b582a00b99c7eafbe288e0323 Mon Sep 17 00:00:00 2001 From: chronark Date: Thu, 10 Oct 2024 15:14:04 +0200 Subject: [PATCH] feat: billing verifications --- ...ions_key_verifications_per_month_mv_v1.sql | 2 +- ...billable_verifications_per_month_mv_v1.sql | 16 +++++++++ .../[apiId]/keys/[keyAuthId]/[keyId]/page.tsx | 2 +- .../dashboard/app/(app)/apis/[apiId]/page.tsx | 2 +- apps/dashboard/app/(app)/banner.tsx | 22 ++++++------ .../app/(app)/settings/billing/page.tsx | 11 +++--- apps/dashboard/lib/charts/sparkline.tsx | 6 ++-- apps/dashboard/lib/clickhouse/billing.ts | 36 +++++++++++++++++++ apps/dashboard/lib/clickhouse/client.ts | 2 +- apps/dashboard/lib/clickhouse/index.ts | 1 + apps/dashboard/lib/tinybird.ts | 34 +++++++++--------- internal/clickhouse-zod/src/client.ts | 2 +- tools/migrate/auditlog-import.ts | 12 +------ 13 files changed, 95 insertions(+), 53 deletions(-) create mode 100644 apps/agent/pkg/clickhouse/schema/013_create_billing_billable_verifications_per_month_mv_v1.sql create mode 100644 apps/dashboard/lib/clickhouse/billing.ts diff --git a/apps/agent/pkg/clickhouse/schema/009_create_verifications_key_verifications_per_month_mv_v1.sql b/apps/agent/pkg/clickhouse/schema/009_create_verifications_key_verifications_per_month_mv_v1.sql index 84884b20e9..a60815af9c 100644 --- a/apps/agent/pkg/clickhouse/schema/009_create_verifications_key_verifications_per_month_mv_v1.sql +++ b/apps/agent/pkg/clickhouse/schema/009_create_verifications_key_verifications_per_month_mv_v1.sql @@ -1,6 +1,6 @@ -- +goose up CREATE MATERIALIZED VIEW verifications.key_verifications_per_month_mv_v1 -TO verificatins.key_verifications_per_month_v1 +TO verifications.key_verifications_per_month_v1 AS SELECT workspace_id, diff --git a/apps/agent/pkg/clickhouse/schema/013_create_billing_billable_verifications_per_month_mv_v1.sql b/apps/agent/pkg/clickhouse/schema/013_create_billing_billable_verifications_per_month_mv_v1.sql new file mode 100644 index 0000000000..a584b9943f --- /dev/null +++ b/apps/agent/pkg/clickhouse/schema/013_create_billing_billable_verifications_per_month_mv_v1.sql @@ -0,0 +1,16 @@ +-- +goose up +CREATE MATERIALIZED VIEW billing.billable_verifications_per_month_mv_v1 +TO billing.billable_verifications_per_month_v1 +AS +SELECT + workspace_id, + count(*) AS count, + toYear(time) AS year, + toMonth(time) AS month +FROM verifications.key_verifications_per_month_v1 +WHERE outcome = 'VALID' +GROUP BY + workspace_id, + year, + month +; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx index 792b9edc95..4d65b9148a 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx @@ -12,10 +12,10 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { getTenantId } from "@/lib/auth"; import { getLastUsed } from "@/lib/clickhouse"; +import { getVerificationsPerDay, getVerificationsPerHour } from "@/lib/clickhouse"; import { getLatestVerifications } from "@/lib/clickhouse/latest_verifications"; import { and, db, eq, isNull, schema } from "@/lib/db"; import { formatNumber } from "@/lib/fmt"; -import { getVerificationsPerDay, getVerificationsPerHour } from "@/lib/clickhouse"; import { cn } from "@/lib/utils"; import { BarChart, Minus } from "lucide-react"; import ms from "ms"; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/page.tsx index 9230a22927..4eb05ea55d 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/page.tsx @@ -4,9 +4,9 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { getTenantId } from "@/lib/auth"; import { getActiveKeysPerDay, getActiveKeysPerHour, getActiveKeysPerMonth } from "@/lib/clickhouse"; +import { getVerificationsPerDay, getVerificationsPerHour } from "@/lib/clickhouse"; import { and, db, eq, isNull, schema, sql } from "@/lib/db"; import { formatNumber } from "@/lib/fmt"; -import { getVerificationsPerDay, getVerificationsPerHour } from "@/lib/clickhouse"; import { BarChart } from "lucide-react"; import { redirect } from "next/navigation"; import { type Interval, IntervalSelect } from "./select"; diff --git a/apps/dashboard/app/(app)/banner.tsx b/apps/dashboard/app/(app)/banner.tsx index 9b3514e208..bb5ab65116 100644 --- a/apps/dashboard/app/(app)/banner.tsx +++ b/apps/dashboard/app/(app)/banner.tsx @@ -1,6 +1,6 @@ import { Banner } from "@/components/banner"; +import { getBillableVerifications } from "@/lib/clickhouse"; import type { Workspace } from "@/lib/db"; -import { verifications } from "@/lib/tinybird"; import { QUOTA } from "@unkey/billing"; import ms from "ms"; import Link from "next/link"; @@ -23,20 +23,18 @@ export const UsageBanner: React.FC<{ workspace: Workspace | undefined }> = async const fmt = new Intl.NumberFormat("en-US").format; if (workspace.plan === "free") { - const [usedVerifications] = await Promise.all([ - verifications({ - workspaceId: workspace.id, - year, - month, - }).then((res) => res.data.at(0)?.success ?? 0), - ]); + const billableVerifications = await getBillableVerifications({ + workspaceId: workspace.id, + year, + month, + }); - if (usedVerifications >= QUOTA.free.maxVerifications) { + if (billableVerifications >= QUOTA.free.maxVerifications) { return (

You have exceeded your plan's monthly usage limit for verifications:{" "} - {fmt(usedVerifications)} /{" "} + {fmt(billableVerifications)} /{" "} {fmt(QUOTA.free.maxVerifications)}.{" "} Upgrade your plan @@ -58,8 +56,8 @@ export const UsageBanner: React.FC<{ workspace: Workspace | undefined }> = async {workspace.trialEnds.getTime() <= Date.now() ? "Your trial has expired." : `Your trial expires in ${ms(workspace.trialEnds.getTime() - Date.now(), { - long: true, - })}.`}{" "} + long: true, + })}.`}{" "} Add a payment method diff --git a/apps/dashboard/app/(app)/settings/billing/page.tsx b/apps/dashboard/app/(app)/settings/billing/page.tsx index cd6c65c0b7..a039ebcfaa 100644 --- a/apps/dashboard/app/(app)/settings/billing/page.tsx +++ b/apps/dashboard/app/(app)/settings/billing/page.tsx @@ -9,9 +9,10 @@ import { } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getTenantId } from "@/lib/auth"; +import { getBillableVerifications } from "@/lib/clickhouse"; import { type Workspace, db } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; -import { ratelimits, verifications } from "@/lib/tinybird"; +import { ratelimits } from "@/lib/tinybird"; import { cn } from "@/lib/utils"; import { type BillingTier, QUOTA, calculateTieredPrices } from "@unkey/billing"; import { Check, ExternalLink } from "lucide-react"; @@ -55,11 +56,11 @@ const FreeUsage: React.FC<{ workspace: Workspace }> = async ({ workspace }) => { const year = t.getUTCFullYear(); const month = t.getUTCMonth() + 1; - const usedVerifications = await verifications({ + const usedVerifications = await getBillableVerifications({ workspaceId: workspace.id, year, month, - }).then((res) => res.data.at(0)?.success ?? 0); + }); return ( @@ -184,11 +185,11 @@ const PaidUsage: React.FC<{ workspace: Workspace }> = async ({ workspace }) => { const month = startOfMonth.getUTCMonth() + 1; const [usedVerifications, usedRatelimits] = await Promise.all([ - verifications({ + getBillableVerifications({ workspaceId: workspace.id, year, month, - }).then((res) => res.data.at(0)?.success ?? 0), + }), ratelimits({ workspaceId: workspace.id, year, diff --git a/apps/dashboard/lib/charts/sparkline.tsx b/apps/dashboard/lib/charts/sparkline.tsx index d76e895097..e4a96bc460 100644 --- a/apps/dashboard/lib/charts/sparkline.tsx +++ b/apps/dashboard/lib/charts/sparkline.tsx @@ -61,7 +61,7 @@ function SparkLineInner({ startDate: dates[times.indexOf(Math.min(...times))], endDate: dates[times.indexOf(Math.max(...times))], }; - }, data); + }, [data]); const { minY, maxY } = useMemo(() => { const values = series @@ -73,7 +73,7 @@ function SparkLineInner({ minY: Math.min(...values), maxY: Math.max(...values), }; - }, [data, series, padding?.bottom, padding?.top]); + }, [data, series]); const { yScale, xScale } = useMemo(() => { const rangeY = maxY - minY; @@ -89,7 +89,7 @@ function SparkLineInner({ range: [0, width], }), }; - }, [startDate, endDate, minY, maxY, height, width, margin]); + }, [startDate, endDate, minY, maxY, height, width]); const chartContext: ChartContextType = { width, diff --git a/apps/dashboard/lib/clickhouse/billing.ts b/apps/dashboard/lib/clickhouse/billing.ts new file mode 100644 index 0000000000..0906610aba --- /dev/null +++ b/apps/dashboard/lib/clickhouse/billing.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { clickhouse } from "./client"; + +// get the billable verifications for a workspace in a specific month. +// month is not zero-indexed -> January = 1 +export async function getBillableVerifications(args: { + workspaceId: string; + year: number; + month: number; +}): Promise { + const query = clickhouse.query({ + query: ` + SELECT + sum(count) as count + FROM billing.billable_verifications_per_month_v1 + WHERE workspace_id = {workspaceId: String} + AND year = {year: Int64} + AND month = {month: Int64} + GROUP BY workspace_id, year, month + `, + params: z.object({ + workspaceId: z.string(), + year: z.number().int(), + month: z.number().int().min(1).max(12), + }), + schema: z.object({ + count: z.number().int(), + }), + }); + + const res = await query(args); + if (!res) { + return 0; + } + return res.at(0)?.count ?? 0; +} diff --git a/apps/dashboard/lib/clickhouse/client.ts b/apps/dashboard/lib/clickhouse/client.ts index 6c1ded2acd..2075394a4d 100644 --- a/apps/dashboard/lib/clickhouse/client.ts +++ b/apps/dashboard/lib/clickhouse/client.ts @@ -1,5 +1,5 @@ -import { type Clickhouse, Client, Noop } from "@unkey/clickhouse-zod"; import { env } from "@/lib/env"; +import { type Clickhouse, Client, Noop } from "@unkey/clickhouse-zod"; const { CLICKHOUSE_URL } = env(); diff --git a/apps/dashboard/lib/clickhouse/index.ts b/apps/dashboard/lib/clickhouse/index.ts index 501163784f..c01605d3f1 100644 --- a/apps/dashboard/lib/clickhouse/index.ts +++ b/apps/dashboard/lib/clickhouse/index.ts @@ -3,3 +3,4 @@ export * from "./latest_verifications"; export * from "./active_keys"; export * from "./last_used"; export * from "./verifications"; +export * from "./billing"; diff --git a/apps/dashboard/lib/tinybird.ts b/apps/dashboard/lib/tinybird.ts index 8cb7c43c9a..b13148432e 100644 --- a/apps/dashboard/lib/tinybird.ts +++ b/apps/dashboard/lib/tinybird.ts @@ -174,23 +174,23 @@ export type UnkeyAuditLog = { }; resources: Array<{ type: - | "key" - | "api" - | "workspace" - | "role" - | "permission" - | "keyAuth" - | "vercelBinding" - | "vercelIntegration" - | "ratelimitNamespace" - | "ratelimitOverride" - | "gateway" - | "llmGateway" - | "webhook" - | "reporter" - | "secret" - | "identity" - | "auditLogBucket"; + | "key" + | "api" + | "workspace" + | "role" + | "permission" + | "keyAuth" + | "vercelBinding" + | "vercelIntegration" + | "ratelimitNamespace" + | "ratelimitOverride" + | "gateway" + | "llmGateway" + | "webhook" + | "reporter" + | "secret" + | "identity" + | "auditLogBucket"; id: string; meta?: Record; diff --git a/internal/clickhouse-zod/src/client.ts b/internal/clickhouse-zod/src/client.ts index 06ce9b7a8c..87ed38d16b 100644 --- a/internal/clickhouse-zod/src/client.ts +++ b/internal/clickhouse-zod/src/client.ts @@ -36,7 +36,7 @@ export class Client implements Clickhouse { return async (params: z.input): Promise[]> => { const res = await this.client.query({ query: req.query, - query_params: req.params?.safeParse(params), + query_params: req.params?.parse(params), format: "JSONEachRow", }); const rows = await res.json(); diff --git a/tools/migrate/auditlog-import.ts b/tools/migrate/auditlog-import.ts index ad833053d6..c42c8e277f 100644 --- a/tools/migrate/auditlog-import.ts +++ b/tools/migrate/auditlog-import.ts @@ -1,14 +1,4 @@ -import { - type AuditLog, - type AuditLogTarget, - and, - asc, - eq, - gt, - isNotNull, - mysqlDrizzle, - schema, -} from "@unkey/db"; +import type { AuditLog, AuditLogTarget } from "@unkey/db"; import { mysqlDrizzle, schema } from "@unkey/db"; import { newId } from "@unkey/id"; import ms from "ms";