From 453ca7603827fe5ed4afe7fe5b6c3da950397c7d Mon Sep 17 00:00:00 2001
From: Sahil Gupta
Date: Thu, 2 Apr 2026 22:30:52 +0530
Subject: [PATCH 1/5] revenue improvements
---
apps/api/src/query/builders/revenue.ts | 165 ++++++++++++++----
.../revenue/_components/revenue-chart.tsx | 53 +++++-
.../revenue/_components/revenue-content.tsx | 85 +++++----
3 files changed, 231 insertions(+), 72 deletions(-)
diff --git a/apps/api/src/query/builders/revenue.ts b/apps/api/src/query/builders/revenue.ts
index 935da0660..02dcd3ef9 100644
--- a/apps/api/src/query/builders/revenue.ts
+++ b/apps/api/src/query/builders/revenue.ts
@@ -221,50 +221,139 @@ const ATTRIBUTION_CTE = `
export const RevenueBuilders: Record = {
revenue_overview: {
- customSql: (websiteId: string, startDate: string, endDate: string) => ({
- sql: `
- WITH ${ATTRIBUTION_CTE}
- SELECT
- sumIf(amount, type != 'refund') as total_revenue,
- countIf(type != 'refund') as total_transactions,
- sumIf(amount, type = 'refund') as refund_amount,
- countIf(type = 'refund') as refund_count,
- sumIf(amount, type = 'subscription') as subscription_revenue,
- countIf(type = 'subscription') as subscription_count,
- sumIf(amount, type = 'sale') as sale_revenue,
- countIf(type = 'sale') as sale_count,
- uniq(r_customer_id) as unique_customers,
- countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions,
- sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue
- FROM revenue_attributed
- `,
- params: { websiteId, startDate, endDate },
- }),
+ meta: {
+ title: "Revenue Overview",
+ description: "Total revenue and transaction metrics.",
+ category: "Revenue",
+ tags: ["overview", "revenue", "transactions"],
+ output_fields: [
+ { name: "total_revenue", type: "number", label: "Total Revenue" },
+ { name: "total_transactions", type: "number", label: "Total Transactions" },
+ { name: "refund_amount", type: "number", label: "Refund Amount" },
+ { name: "refund_count", type: "number", label: "Refund Count" },
+ { name: "unique_customers", type: "number", label: "Unique Customers" },
+ { name: "attributed_revenue", type: "number", label: "Attributed Revenue" },
+ { name: "attributed_transactions", type: "number", label: "Attributed Transactions" },
+ { name: "total_visitors", type: "number", label: "Total Visitors" },
+ ],
+ default_visualization: "metric",
+ supports_granularity: [],
+ version: "1.0",
+ },
+ customSql: (
+ websiteId: string,
+ startDate: string,
+ endDate: string,
+ _filters?: Filter[],
+ _granularity?: TimeUnit,
+ _limit?: number,
+ _offset?: number,
+ timezone?: string
+ ) => {
+ const tz = timezone || "UTC";
+ return {
+ sql: `
+ WITH
+ ${ATTRIBUTION_CTE},
+ visitors AS (
+ SELECT uniq(anonymous_id) as total_visitors
+ FROM analytics.events
+ WHERE client_id = {websiteId:String}
+ AND time >= toDateTime({startDate:String})
+ AND time <= toDateTime(concat({endDate:String}, ' 23:59:59'))
+ )
+ SELECT
+ sumIf(amount, type != 'refund') as total_revenue,
+ countIf(type != 'refund') as total_transactions,
+ sumIf(amount, type = 'refund') as refund_amount,
+ countIf(type = 'refund') as refund_count,
+ sumIf(amount, type = 'subscription') as subscription_revenue,
+ countIf(type = 'subscription') as subscription_count,
+ sumIf(amount, type = 'sale') as sale_revenue,
+ countIf(type = 'sale') as sale_count,
+ uniq(r_customer_id) as unique_customers,
+ countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions,
+ sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue,
+ any(total_visitors) as total_visitors
+ FROM revenue_attributed
+ CROSS JOIN visitors
+ `,
+ params: { websiteId, startDate, endDate, timezone: tz },
+ };
+ },
timeField: "created",
customizable: false,
},
revenue_time_series: {
- customSql: (websiteId: string, startDate: string, endDate: string) => ({
- sql: `
- WITH ${ATTRIBUTION_CTE}
+ meta: {
+ title: "Revenue Time Series",
+ description: "Daily or hourly breakdown of revenue metrics.",
+ category: "Revenue",
+ tags: ["timeseries", "revenue", "trends"],
+ output_fields: [
+ { name: "date", type: "datetime", label: "Date" },
+ { name: "revenue", type: "number", label: "Revenue" },
+ { name: "transactions", type: "number", label: "Transactions" },
+ { name: "customers", type: "number", label: "Customers" },
+ ],
+ default_visualization: "timeseries",
+ supports_granularity: ["hour", "day"],
+ version: "1.0",
+ },
+ customSql: (
+ websiteId: string,
+ startDate: string,
+ endDate: string,
+ _filters?: Filter[],
+ _granularity?: TimeUnit,
+ _limit?: number,
+ _offset?: number,
+ timezone?: string
+ ) => {
+ const tz = timezone || "UTC";
+ const isHourly = _granularity === "hour" || _granularity === "hourly";
+ const timeBucketFn = isHourly ? "toStartOfHour" : "toDate";
+ const dateFormat = isHourly
+ ? "formatDateTime(time_bucket, '%Y-%m-%d %H:00:00')"
+ : "toDate(time_bucket)";
+
+ return {
+ sql: `
+ WITH
+ ${ATTRIBUTION_CTE},
+ base_revenue AS (
+ SELECT
+ toTimeZone(created, {timezone:String}) as normalized_time,
+ amount,
+ type,
+ r_customer_id,
+ is_attributed
+ FROM revenue_attributed
+ ),
+ revenue_agg AS (
+ SELECT
+ ${timeBucketFn}(normalized_time) as time_bucket,
+ sumIf(amount, type != 'refund') as revenue,
+ countIf(type != 'refund') as transactions,
+ uniq(r_customer_id) as customers,
+ sumIf(amount, type = 'refund') as refund_amount,
+ countIf(type = 'refund') as refund_count,
+ sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue,
+ countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions
+ FROM base_revenue
+ GROUP BY time_bucket
+ )
SELECT
- toDate(created) as date,
- sumIf(amount, type != 'refund') as revenue,
- countIf(type != 'refund') as transactions,
- uniq(r_customer_id) as customers,
- sumIf(amount, type = 'refund') as refund_amount,
- countIf(type = 'refund') as refund_count,
- sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue,
- countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions
- FROM revenue_attributed
- GROUP BY date
- ORDER BY date ASC
- `,
- params: { websiteId, startDate, endDate },
- }),
+ ${dateFormat} as date,
+ *
+ FROM revenue_agg
+ ORDER BY time_bucket ASC`,
+ params: { websiteId, startDate, endDate, timezone: tz },
+ };
+ },
timeField: "created",
- customizable: false,
+ customizable: true,
},
revenue_by_provider: {
@@ -786,4 +875,4 @@ export const RevenueBuilders: Record = {
normalizeGeo: true,
},
},
-};
+};
\ No newline at end of file
diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx
index 4071b1b03..f0ba65fbd 100644
--- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx
@@ -1,6 +1,7 @@
"use client";
import { ChartLineIcon } from "@phosphor-icons/react";
+import dayjs from "dayjs";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { SkeletonChart } from "@/components/charts/skeleton-chart";
@@ -98,18 +99,45 @@ const REVENUE_METRICS: RevenueChartMetric[] = [
maximumFractionDigits: 0,
}).format(Math.abs(v)),
},
+ {
+ key: "revenue",
+ label: "Revenue",
+ color: "#10b981",
+ formatValue: (v) =>
+ new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(v),
+ },
];
+const CURRENCY_METRICS = ["revenue", "avg_transaction", "refunds"];
+
interface RevenueChartProps {
data: RevenueChartDataPoint[];
isLoading: boolean;
+ granularity?: string;
height?: number;
className?: string;
}
+function formatChartDate(value: string, granularity?: string): string {
+ const parsed = dayjs(value);
+ if (!parsed.isValid()) {
+ return value;
+ }
+ if (granularity === "hour" || granularity === "hourly") {
+ return parsed.format("MMM D, h:mm A");
+ }
+ return parsed.format("MMM D, YYYY");
+}
+
export function RevenueChart({
data,
isLoading,
+ granularity,
height = 350,
className,
}: RevenueChartProps) {
@@ -204,13 +232,31 @@ export function RevenueChart({
axisLine={false}
dataKey="date"
tick={chartAxisTickDefault}
+ tickFormatter={(value) => formatChartDate(value, granularity)}
tickLine={false}
/>
+ new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ style: "currency",
+ currency: "USD",
+ }).format(value)
+ }
tickLine={false}
width={chartAxisYWidthDefault}
+ yAxisId="currency"
+ />
+
(
@@ -274,8 +320,11 @@ export function RevenueChart({
key={metric.key}
name={metric.label}
stroke={metric.color}
- strokeWidth={2.5}
+ strokeWidth={metric.key === "revenue" ? 3 : 2}
type="monotone"
+ yAxisId={
+ CURRENCY_METRICS.includes(metric.key) ? "currency" : "count"
+ }
/>
))}
@@ -283,4 +332,4 @@ export function RevenueChart({
);
-}
+}
\ No newline at end of file
diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx
index e7b537344..a19d07fee 100644
--- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx
@@ -13,7 +13,6 @@ import { EyeIcon } from "@phosphor-icons/react/dist/ssr/Eye";
import { EyeSlashIcon } from "@phosphor-icons/react/dist/ssr/EyeSlash";
import { GearIcon } from "@phosphor-icons/react/dist/ssr/Gear";
import { LinkIcon } from "@phosphor-icons/react/dist/ssr/Link";
-import { ReceiptIcon } from "@phosphor-icons/react/dist/ssr/Receipt";
import { SpinnerIcon } from "@phosphor-icons/react/dist/ssr/Spinner";
import { StripeLogoIcon } from "@phosphor-icons/react/dist/ssr/StripeLogo";
import { TrendUpIcon } from "@phosphor-icons/react/dist/ssr/TrendUp";
@@ -45,6 +44,7 @@ import { cn } from "@/lib/utils";
import {
addDynamicFilterAtom,
dynamicQueryFiltersAtom,
+ type TimeGranularity,
} from "@/stores/jotai/filterAtoms";
import { WebsitePageHeader } from "../../_components/website-page-header";
import { RevenueAttributionTables } from "./revenue-attribution-tables";
@@ -66,6 +66,7 @@ interface RevenueOverview {
unique_customers: number;
attributed_transactions: number;
attributed_revenue: number;
+ total_visitors: number;
}
interface RevenueTimeSeries {
@@ -107,28 +108,36 @@ function padTimeSeriesData(
data: T[],
startDate: string,
endDate: string,
- defaultValues: Omit
-): T[] {
- if (data.length === 0) {
- return [];
- }
+ defaultValues: Omit,
+ granularity: TimeGranularity = "daily"
+) {
+ const unit = granularity === "hourly" ? "hour" : "day";
+ const format = unit === "hour" ? "YYYY-MM-DD HH:00:00" : "YYYY-MM-DD";
+
+ // Use numeric timestamps for robust matching
+ const dataMap = new Map(
+ data.map((d) => [dayjs(d.date).startOf(unit).valueOf(), d])
+ );
- const dataMap = new Map(data.map((d) => [d.date, d]));
const result: T[] = [];
- let current = dayjs(startDate);
- const end = dayjs(endDate);
+ let current = dayjs(startDate).startOf(unit);
+ const end =
+ unit === "hour"
+ ? dayjs(endDate).endOf("day")
+ : dayjs(endDate).startOf(unit);
- while (current.isBefore(end) || current.isSame(end, "day")) {
- const dateStr = current.format("YYYY-MM-DD");
- const existing = dataMap.get(dateStr);
+ while (current.isBefore(end) || current.isSame(end, unit)) {
+ const timestamp = current.valueOf();
+ const dateStr = current.format(format);
+ const existing = dataMap.get(timestamp);
if (existing) {
- result.push(existing);
+ result.push({ ...existing, date: dateStr });
} else {
result.push({ date: dateStr, ...defaultValues } as T);
}
- current = current.add(1, "day");
+ current = current.add(1, unit);
}
return result;
@@ -339,7 +348,7 @@ function RevenueSettingsSheet({
@@ -672,7 +681,10 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
) ?? []) as RevenueTimeSeries[];
const overview = overviewData[0];
- const hasData = overview && overview.total_transactions > 0;
+ const visitors = overview?.total_visitors ?? 0;
+ const totalRevenue = Number(overview?.total_revenue ?? 0);
+ const hasData =
+ overview && (overview.total_transactions > 0 || totalRevenue > 0);
const isConfigured = config?.stripeConfigured || config?.paddleConfigured;
const paddedTimeSeriesData = useMemo(() => {
@@ -686,9 +698,15 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
customers: 0,
refund_amount: 0,
refund_count: 0,
- }
+ },
+ dateRange.granularity
);
- }, [timeSeriesData, dateRange.start_date, dateRange.end_date]);
+ }, [
+ timeSeriesData,
+ dateRange.start_date,
+ dateRange.end_date,
+ dateRange.granularity,
+ ]);
const chartData = useMemo(() => {
return paddedTimeSeriesData.map((row) => ({
@@ -721,6 +739,8 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
return "Not configured";
};
+ console.log({overview,visitors});
+
return (
<>
@@ -812,6 +832,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
@@ -851,4 +872,4 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
/>
>
);
-}
+}
\ No newline at end of file
From 96fbdd329dc9e9b8962483689ef16c00e8b6128c Mon Sep 17 00:00:00 2001
From: Sahil Gupta
Date: Thu, 2 Apr 2026 22:53:55 +0530
Subject: [PATCH 2/5] review fixes
---
apps/api/src/query/builders/revenue.ts | 2 +-
.../[id]/revenue/_components/revenue-chart.tsx | 14 +-------------
.../[id]/revenue/_components/revenue-content.tsx | 5 ++---
3 files changed, 4 insertions(+), 17 deletions(-)
diff --git a/apps/api/src/query/builders/revenue.ts b/apps/api/src/query/builders/revenue.ts
index 02dcd3ef9..022f6b437 100644
--- a/apps/api/src/query/builders/revenue.ts
+++ b/apps/api/src/query/builders/revenue.ts
@@ -875,4 +875,4 @@ export const RevenueBuilders: Record = {
normalizeGeo: true,
},
},
-};
\ No newline at end of file
+};
diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx
index f0ba65fbd..80563a03b 100644
--- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx
@@ -98,19 +98,7 @@ const REVENUE_METRICS: RevenueChartMetric[] = [
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(v)),
- },
- {
- key: "revenue",
- label: "Revenue",
- color: "#10b981",
- formatValue: (v) =>
- new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(v),
- },
+ }
];
const CURRENCY_METRICS = ["revenue", "avg_transaction", "refunds"];
diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx
index a19d07fee..9773ccc71 100644
--- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx
@@ -739,8 +739,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
return "Not configured";
};
- console.log({overview,visitors});
-
+
return (
<>
From 5f8ff1699b9f54d4546c5e602ee94de555560f2d Mon Sep 17 00:00:00 2001
From: Sahil Gupta
Date: Mon, 6 Apr 2026 01:51:40 +0530
Subject: [PATCH 3/5] feat(revenue): imporving revenu attribution
---
apps/basket/src/routes/webhooks/stripe.ts | 161 ++++++++++++++----
.../app/(main)/billing/hooks/use-billing.ts | 2 +-
packages/db/src/clickhouse/client.ts | 1 +
packages/tracker/src/index.ts | 49 ++++--
4 files changed, 171 insertions(+), 42 deletions(-)
diff --git a/apps/basket/src/routes/webhooks/stripe.ts b/apps/basket/src/routes/webhooks/stripe.ts
index 055df1d4b..0e9fc2515 100644
--- a/apps/basket/src/routes/webhooks/stripe.ts
+++ b/apps/basket/src/routes/webhooks/stripe.ts
@@ -81,10 +81,10 @@ interface WebhookEvent {
type: string;
data: {
object:
- | WebhookPaymentIntent
- | WebhookCharge
- | WebhookInvoice
- | WebhookSubscription;
+ | WebhookPaymentIntent
+ | WebhookCharge
+ | WebhookInvoice
+ | WebhookSubscription;
};
}
@@ -207,9 +207,46 @@ async function getConfig(
};
}
+async function fetchSessionFromCheckoutSessionId(
+ checkoutSessionId: string,
+ ownerId: string
+) {
+ const results = await clickHouse.query({
+ query: `
+ SELECT
+ anonymous_id,
+ session_id
+ FROM analytics.custom_events
+ WHERE
+ event_name = 'checkout_session'
+ AND owner_id = {ownerId:String}
+ AND JSONExtractString(properties, 'checkout_session_id') = {checkoutSessionId:String}
+ ORDER BY timestamp DESC
+ LIMIT 1
+ `,
+ query_params: {
+ ownerId,
+ checkoutSessionId,
+ },
+ format: "JSONEachRow",
+ });
+
+ const data = (await results.json()) as any[];
+ if (data && data.length > 0) {
+ return {
+ anonymous_id: data[0].anonymous_id,
+ session_id: data[0].session_id,
+ };
+ }
+
+ return null;
+}
+
async function handlePaymentIntent(
pi: WebhookPaymentIntent,
- config: WebhookConfig
+ config: WebhookConfig,
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
const metadata = extractAnalyticsMetadata(pi.metadata);
@@ -243,8 +280,8 @@ async function handlePaymentIntent(
original_amount: amount,
original_currency: currency,
currency,
- anonymous_id: metadata.anonymous_id || undefined,
- session_id: metadata.session_id || undefined,
+ anonymous_id,
+ session_id,
customer_id: customerId,
product_name: pi.description || undefined,
metadata: JSON.stringify(metadata),
@@ -259,10 +296,13 @@ async function handlePaymentIntent(
async function handleFailedPayment(
pi: WebhookPaymentIntent,
config: WebhookConfig,
- status: "failed" | "canceled"
+ status: "failed" | "canceled",
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
const metadata = extractAnalyticsMetadata(pi.metadata);
+
const customerId = extractCustomerId(pi.customer);
const amount = (pi.amount_received ?? pi.amount) / 100;
const currency = pi.currency.toUpperCase();
@@ -293,8 +333,8 @@ async function handleFailedPayment(
original_amount: amount,
original_currency: currency,
currency,
- anonymous_id: metadata.anonymous_id || undefined,
- session_id: metadata.session_id || undefined,
+ anonymous_id,
+ session_id,
customer_id: customerId,
product_name: pi.description || undefined,
metadata: JSON.stringify(metadata),
@@ -308,7 +348,9 @@ async function handleFailedPayment(
async function handleInvoicePaid(
invoice: WebhookInvoice,
- config: WebhookConfig
+ config: WebhookConfig,
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
@@ -354,8 +396,8 @@ async function handleInvoicePaid(
original_amount: amount,
original_currency: currency,
currency,
- anonymous_id: metadata.anonymous_id || undefined,
- session_id: metadata.session_id || undefined,
+ anonymous_id,
+ session_id,
customer_id: customerId,
product_name: invoice.description || undefined,
metadata: JSON.stringify(metadata),
@@ -369,7 +411,9 @@ async function handleInvoicePaid(
async function handleInvoiceFailed(
invoice: WebhookInvoice,
- config: WebhookConfig
+ config: WebhookConfig,
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
const metadata = extractAnalyticsMetadata(invoice.metadata);
@@ -404,8 +448,8 @@ async function handleInvoiceFailed(
original_amount: amount,
original_currency: currency,
currency,
- anonymous_id: metadata.anonymous_id || undefined,
- session_id: metadata.session_id || undefined,
+ anonymous_id,
+ session_id,
customer_id: customerId,
product_name: invoice.description || undefined,
metadata: JSON.stringify(metadata),
@@ -420,7 +464,9 @@ async function handleInvoiceFailed(
async function handleSubscriptionEvent(
sub: WebhookSubscription,
config: WebhookConfig,
- eventType: string
+ eventType: string,
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
const metadata = extractAnalyticsMetadata(sub.metadata);
@@ -473,8 +519,8 @@ async function handleSubscriptionEvent(
original_amount: amount,
original_currency: currency,
currency,
- anonymous_id: metadata.anonymous_id || undefined,
- session_id: metadata.session_id || undefined,
+ anonymous_id,
+ session_id,
customer_id: customerId,
product_name: firstItem?.plan?.product || undefined,
metadata: JSON.stringify(subscriptionMetadata),
@@ -488,7 +534,9 @@ async function handleSubscriptionEvent(
async function handleRefund(
charge: WebhookCharge,
- config: WebhookConfig
+ config: WebhookConfig,
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
const metadata = extractAnalyticsMetadata(charge.metadata);
@@ -522,8 +570,8 @@ async function handleRefund(
original_amount: -amount,
original_currency: currency,
currency,
- anonymous_id: metadata.anonymous_id || undefined,
- session_id: metadata.session_id || undefined,
+ anonymous_id,
+ session_id,
customer_id: customerId,
product_name: "Refund",
metadata: JSON.stringify(metadata),
@@ -579,13 +627,50 @@ export const stripeWebhook = new Elysia().post(
const event = verification.event;
log.set({ eventType: event.type, eventId: event.id });
+ const stripeObject = event.data.object as any;
+ console.log({ stripeObject });
+
+ const checkoutSessionId =
+ stripeObject.metadata?.checkout_session_id ||
+ stripeObject.payment_details?.order_reference;
+
+ if (!checkoutSessionId) {
+ log.warn(
+ `Invalid webhook, No checkout session id found for ${event.id}`
+ );
+ set.status = 400;
+ return { error: "invalid_webhook" };
+ }
+
+ let anonymous_id = stripeObject.metadata?.anonymous_id;
+ let session_id = stripeObject.metadata?.session_id;
+
+ const existingSession = await fetchSessionFromCheckoutSessionId(
+ checkoutSessionId,
+ result.ownerId
+ );
+
+ if (existingSession) {
+ anonymous_id = existingSession.anonymous_id;
+ session_id = existingSession.session_id;
+ }
+
+ if (!anonymous_id) {
+ log.warn(
+ `Attribution pending for ${event.id} (checkout: ${checkoutSessionId}), returning 503 for retry`
+ );
+ set.status = 503;
+ return { error: "attribution_pending" };
+ }
try {
switch (event.type) {
case "payment_intent.succeeded": {
await handlePaymentIntent(
event.data.object as WebhookPaymentIntent,
- result
+ result,
+ anonymous_id,
+ session_id
);
break;
}
@@ -593,7 +678,9 @@ export const stripeWebhook = new Elysia().post(
await handleFailedPayment(
event.data.object as WebhookPaymentIntent,
result,
- "failed"
+ "failed",
+ anonymous_id,
+ session_id
);
break;
}
@@ -601,18 +688,27 @@ export const stripeWebhook = new Elysia().post(
await handleFailedPayment(
event.data.object as WebhookPaymentIntent,
result,
- "canceled"
+ "canceled",
+ anonymous_id,
+ session_id
);
break;
}
case "invoice.paid": {
- await handleInvoicePaid(event.data.object as WebhookInvoice, result);
+ await handleInvoicePaid(
+ event.data.object as WebhookInvoice,
+ result,
+ anonymous_id,
+ session_id
+ );
break;
}
case "invoice.payment_failed": {
await handleInvoiceFailed(
event.data.object as WebhookInvoice,
- result
+ result,
+ anonymous_id,
+ session_id
);
break;
}
@@ -625,12 +721,19 @@ export const stripeWebhook = new Elysia().post(
await handleSubscriptionEvent(
event.data.object as WebhookSubscription,
result,
- subEventType
+ subEventType,
+ anonymous_id,
+ session_id
);
break;
}
case "charge.refunded": {
- await handleRefund(event.data.object as WebhookCharge, result);
+ await handleRefund(
+ event.data.object as WebhookCharge,
+ result,
+ anonymous_id,
+ session_id
+ );
break;
}
default: {
diff --git a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts
index c562c7119..3e6ea1740 100644
--- a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts
+++ b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts
@@ -33,7 +33,7 @@ export function useBilling(refetch?: () => void) {
try {
await attach({
planId,
- successUrl: `${window.location.origin}/billing`,
+ successUrl: `${window.location.origin}/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: getStripeMetadata(),
});
} catch (error) {
diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts
index 483b57b2a..0bcbb5f21 100644
--- a/packages/db/src/clickhouse/client.ts
+++ b/packages/db/src/clickhouse/client.ts
@@ -13,6 +13,7 @@ export const TABLE_NAMES = {
error_spans: "analytics.error_spans",
web_vitals_spans: "analytics.web_vitals_spans",
custom_events: "analytics.custom_events",
+ revenue: "analytics.revenue",
ai_call_spans: "observability.ai_call_spans",
ai_traffic_spans: "analytics.ai_traffic_spans",
};
diff --git a/packages/tracker/src/index.ts b/packages/tracker/src/index.ts
index b3c2890b0..1ffaeb24d 100644
--- a/packages/tracker/src/index.ts
+++ b/packages/tracker/src/index.ts
@@ -52,7 +52,7 @@ export class Databuddy extends BaseTracker {
this.flushTrack(),
this.flushVitals(),
this.flushErrors(),
- ]).catch(() => {});
+ ]).catch(() => { });
},
clear: () => this.clear(),
setGlobalProperties: (props: Record) =>
@@ -100,6 +100,31 @@ export class Databuddy extends BaseTracker {
if (this.options.trackInteractions) {
initInteractionTracking(this);
}
+ this.checkCheckoutSession();
+ }
+
+ private checkCheckoutSession(): void {
+ if (this.isServer()) {
+ return;
+ }
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const sessionId = urlParams.get("session_id");
+
+ if (sessionId && (sessionId.startsWith("cs_") || sessionId.startsWith("checkout_"))) {
+ this.track("checkout_session", {
+ checkout_session_id: sessionId,
+ provider: "stripe",
+ });
+ }
+
+ const paddleCheckoutId = urlParams.get("checkout_id");
+ if (paddleCheckoutId) {
+ this.track("checkout_session", {
+ checkout_session_id: paddleCheckoutId,
+ provider: "paddle",
+ });
+ }
}
trackScreenViews() {
@@ -214,9 +239,9 @@ export class Databuddy extends BaseTracker {
}
private handlePageUnload() {
- this.flushTrack().catch(() => {});
- this.flushVitals().catch(() => {});
- this.flushErrors().catch(() => {});
+ this.flushTrack().catch(() => { });
+ this.flushVitals().catch(() => { });
+ this.flushErrors().catch(() => { });
this.pauseEngagement();
if (this.hasSentExitBeacon) {
return;
@@ -360,7 +385,7 @@ export class Databuddy extends BaseTracker {
sessionStorage.removeItem("did_session");
sessionStorage.removeItem("did_session_timestamp");
sessionStorage.removeItem("did_session_start");
- } catch {}
+ } catch { }
}
this.clearUrlParamStorage();
this.anonymousId = this.generateAnonymousId();
@@ -398,11 +423,11 @@ function initializeDatabuddy() {
if (isOptedOut()) {
window.databuddy = {
- track: () => {},
- screenView: () => {},
- clear: () => {},
- flush: () => {},
- setGlobalProperties: () => {},
+ track: () => { },
+ screenView: () => { },
+ clear: () => { },
+ flush: () => { },
+ setGlobalProperties: () => { },
options: { clientId: "", disabled: true },
};
window.db = window.databuddy;
@@ -422,7 +447,7 @@ if (typeof window !== "undefined") {
try {
localStorage.setItem("databuddy_opt_out", "true");
localStorage.setItem("databuddy_disabled", "true");
- } catch {}
+ } catch { }
window.databuddyOptedOut = true;
window.databuddyDisabled = true;
if (window.databuddy) {
@@ -434,7 +459,7 @@ if (typeof window !== "undefined") {
try {
localStorage.removeItem("databuddy_opt_out");
localStorage.removeItem("databuddy_disabled");
- } catch {}
+ } catch { }
window.databuddyOptedOut = false;
window.databuddyDisabled = false;
};
From d3e7d91cc1b7b46bfadcb74d01711dd6b53d9403 Mon Sep 17 00:00:00 2001
From: Sahil Gupta
Date: Thu, 16 Apr 2026 11:16:55 +0530
Subject: [PATCH 4/5] revenue merged view
---
CONTRIBUTING.md | 9 +-
.../[id]/_components/tabs/overview-tab.tsx | 189 +++++++++++-
.../tabs/overview/_components/revenue-bar.tsx | 123 ++++++++
.../_components/traffic-trends-chart.tsx | 270 ++++++++++++++++--
apps/dashboard/app/globals.css | 2 +
.../components/charts/metrics-constants.ts | 37 +++
apps/dashboard/lib/chart-presentation.ts | 1 +
apps/dashboard/stores/jotai/chartAtoms.ts | 4 +
package.json | 2 +-
packages/db/src/seed.ts | 69 ++++-
setup.ts | 2 +-
11 files changed, 663 insertions(+), 45 deletions(-)
create mode 100644 apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/revenue-bar.tsx
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 440234362..6aff0c0de 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -58,13 +58,13 @@ bun run dev:dashboard
8. Seed the database with sample data (optional):
```bash
-bun run db:seed [EVENT_COUNT]
+bun run db:seed [EVENT_COUNT] [--revenue]
```
-**Examples:**
-
+Example:
```bash
-bun run db:seed g0zlgMtBaXzIP1EGY2ieG 10000
+bun run db:seed g0zlgMtBaXzIP1EGY2ieG 10000 --revenue
+```
bun run db:seed d7zlgMtBaSzIL1EGR2ieR 5000
```
@@ -73,6 +73,7 @@ bun run db:seed d7zlgMtBaSzIL1EGR2ieR 5000
- Default event count is 10,000 if not specified
- Seeds events, outgoing links, errors, and web vitals data
- You can find your website ID in your website overview settings
+- Pass revenu flag to generate revenu
## 💻 Development
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx
index 399004f28..3baf1ecbf 100644
--- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx
@@ -1,5 +1,11 @@
"use client";
+import {
+ ArrowsCounterClockwiseIcon,
+ CreditCardIcon,
+ CurrencyDollarIcon,
+ TrendUpIcon,
+} from "@phosphor-icons/react";
import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine";
import { CursorIcon } from "@phosphor-icons/react/dist/ssr/Cursor";
import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe";
@@ -58,6 +64,8 @@ interface ChartDataPoint {
sessions?: number;
bounce_rate?: number;
median_session_duration?: number;
+ revenue?: number;
+ refunds?: number;
[key: string]: unknown;
}
@@ -212,6 +220,23 @@ export function WebsiteOverviewTab({
granularity: dateRange.granularity,
filters,
},
+ {
+ id: "overview-revenue",
+ parameters: [
+ "revenue_overview",
+ "revenue_time_series",
+ {
+ name: "revenue_overview",
+ start_date: previousPeriodRange.start_date,
+ end_date: previousPeriodRange.end_date,
+ granularity: previousPeriodRange.granularity,
+ id: "previous_revenue_overview",
+ },
+ ],
+ limit: QUERY_CONFIG.limit,
+ granularity: dateRange.granularity,
+ filters,
+ },
],
[dateRange.granularity, filters, previousPeriodRange]
);
@@ -253,6 +278,108 @@ export function WebsiteOverviewTab({
countries: getDataForQuery("overview-geo", "country") || [],
};
+ const revenueOverview =
+ getDataForQuery("overview-revenue", "revenue_overview")?.[0] || null;
+ const previousRevenueOverview =
+ getDataForQuery("overview-revenue", "previous_revenue_overview")?.[0] ||
+ null;
+ const revenueTimeSeries =
+ getDataForQuery("overview-revenue", "revenue_time_series") || [];
+
+ const revenueTimeSeriesMap = useMemo(() => {
+ const map = new Map();
+ for (const row of revenueTimeSeries) {
+ const key = dayjs(row.date).format("YYYY-MM-DD");
+ map.set(key, {
+ revenue: row.revenue ?? 0,
+ refunds: Math.abs(row.refund_amount ?? 0),
+ });
+ }
+ return map;
+ }, [revenueTimeSeries]);
+
+ const revenueData = useMemo(() => {
+ const totalRevenue = Number(revenueOverview?.total_revenue ?? 0);
+ const totalTransactions = revenueOverview?.total_transactions ?? 0;
+ const refundAmount = Number(revenueOverview?.refund_amount ?? 0);
+ const uniqueCustomers = revenueOverview?.unique_customers ?? 0;
+ const avgTransaction =
+ totalTransactions > 0 ? totalRevenue / totalTransactions : 0;
+ const conversionRate = revenueOverview?.total_visitors
+ ? ((uniqueCustomers / revenueOverview.total_visitors) * 100).toFixed(2)
+ : "0";
+
+ return {
+ totalRevenue,
+ totalTransactions,
+ refundAmount,
+ uniqueCustomers,
+ avgTransaction,
+ conversionRate,
+ };
+ }, [revenueOverview]);
+
+ const formatCurrency = (amount: number): string => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(amount);
+ };
+
+ const hasRevenueData =
+ revenueData.totalTransactions > 0 || revenueData.totalRevenue > 0;
+
+ const calculateRevenueTrends = useMemo(() => {
+ if (!(revenueOverview && previousRevenueOverview)) {
+ return {};
+ }
+
+ const currentRevenue = Number(revenueOverview.total_revenue ?? 0);
+ const previousRevenue = Number(previousRevenueOverview.total_revenue ?? 0);
+ const currentTransactions = revenueOverview.total_transactions ?? 0;
+ const previousTransactions =
+ previousRevenueOverview.total_transactions ?? 0;
+ const currentRefunds = Number(revenueOverview.refund_amount ?? 0);
+ const previousRefunds = Number(previousRevenueOverview.refund_amount ?? 0);
+
+ const currentUniqueCustomers = revenueOverview.unique_customers ?? 0;
+ const previousUniqueCustomers =
+ previousRevenueOverview.unique_customers ?? 0;
+ const currentVisitors = revenueOverview.total_visitors ?? 0;
+ const previousVisitors = previousRevenueOverview.total_visitors ?? 0;
+
+ const currentConversionRate =
+ currentVisitors > 0
+ ? (currentUniqueCustomers / currentVisitors) * 100
+ : 0;
+ const previousConversionRate =
+ previousVisitors > 0
+ ? (previousUniqueCustomers / previousVisitors) * 100
+ : 0;
+
+ const createTrend = (current: number, previous: number) => {
+ if (previous === 0 && current === 0) {
+ return { change: 0 };
+ }
+ if (previous === 0) {
+ return undefined;
+ }
+ const change = calculatePercentChange(current, previous);
+ return {
+ change: Math.max(-100, Math.min(1000, Math.round(change))),
+ };
+ };
+
+ return {
+ revenue: createTrend(currentRevenue, previousRevenue),
+ transactions: createTrend(currentTransactions, previousTransactions),
+ refunds: createTrend(currentRefunds, previousRefunds),
+ conversion: createTrend(currentConversionRate, previousConversionRate),
+ };
+ }, [revenueOverview, previousRevenueOverview]);
+
const createPercentageCell = () => (info: CellInfo) => {
const percentage = info.getValue() as number;
return ;
@@ -426,8 +553,10 @@ export function WebsiteOverviewTab({
const chartData = useMemo(
() =>
- processedEventsData.map(
- (event: MetricPoint): ChartDataPoint => ({
+ processedEventsData.map((event: MetricPoint): ChartDataPoint => {
+ const dateKey = dayjs(event.date).format("YYYY-MM-DD");
+ const revenueEntry = revenueTimeSeriesMap.get(dateKey);
+ return {
date: formatDateByGranularity(event.date, dateRange.granularity),
rawDate: event.date,
...(visibleMetrics.pageviews && {
@@ -448,9 +577,16 @@ export function WebsiteOverviewTab({
...(visibleMetrics.median_session_duration && {
median_session_duration: event.median_session_duration as number,
}),
- })
- ),
- [processedEventsData, dateRange.granularity, visibleMetrics]
+ revenue: revenueEntry?.revenue ?? 0,
+ refunds: revenueEntry?.refunds ?? 0,
+ };
+ }),
+ [
+ processedEventsData,
+ dateRange.granularity,
+ visibleMetrics,
+ revenueTimeSeriesMap,
+ ]
);
const miniChartData = useMemo(() => {
@@ -938,10 +1074,53 @@ export function WebsiteOverviewTab({
))}
+ {hasRevenueData && (
+
+
+
+
+
+
+ )}
+
{
+ const {
+ x = 0,
+ y = 0,
+ width = 0,
+ height = 0,
+ fill,
+ index,
+ hoveredIndex,
+ barSize = 6,
+ payload,
+ } = props;
+
+ const revenue = payload?.revenue ?? 0;
+ const refunds = payload?.refunds ?? 0;
+
+ const isHighlighted =
+ index !== undefined && hoveredIndex !== null && index === hoveredIndex;
+ const isHovering = hoveredIndex !== null;
+ const barFill = fill;
+ const barOpacity = isHovering && !isHighlighted ? 0.3 : 1;
+ const radius = Math.max(1, Math.min(4, Math.floor(barSize / 3)));
+
+ // Calculate additional height for refunds (proportional to revenue height)
+ // If revenue = 100 gives height H, then refunds = 10 should add H * (10/100) = H * 0.1
+ const refundHeight = revenue > 0 ? (refunds / revenue) * height : 0;
+ const hasRefunds = refundHeight >= 1;
+
+ // If no refunds, render simple solid bar with rounded top
+ if (!hasRefunds) {
+ const path = `
+ M${x + radius},${y}
+ L${x + width - radius},${y}
+ Q${x + width},${y} ${x + width},${y + radius}
+ L${x + width},${y + height}
+ L${x},${y + height}
+ L${x},${y + radius}
+ Q${x},${y} ${x + radius},${y}
+ Z
+ `;
+ return (
+
+
+
+ );
+ }
+
+ // Bar with refunds portion added on top
+ const refundY = y - refundHeight; // Refund portion extends upward
+ const strokeW = 1.5;
+ const halfStroke = strokeW / 2;
+
+ // Solid revenue bar (no rounded top since refund connects above)
+ const solidPath = `
+ M${x},${y}
+ L${x + width},${y}
+ L${x + width},${y + height}
+ L${x},${y + height}
+ Z
+ `;
+
+ // Hollow refund portion on top (with rounded top corners)
+ const hollowFillPath = `
+ M${x + radius},${refundY}
+ L${x + width - radius},${refundY}
+ Q${x + width},${refundY} ${x + width},${refundY + radius}
+ L${x + width},${y}
+ L${x},${y}
+ L${x},${refundY + radius}
+ Q${x},${refundY} ${x + radius},${refundY}
+ Z
+ `;
+
+ // Dashed border path for refund portion - top and sides only
+ const dashedBorderPath = `
+ M${x + halfStroke},${y}
+ L${x + halfStroke},${refundY + radius}
+ Q${x + halfStroke},${refundY + halfStroke} ${x + radius},${refundY + halfStroke}
+ L${x + width - radius},${refundY + halfStroke}
+ Q${x + width - halfStroke},${refundY + halfStroke} ${x + width - halfStroke},${refundY + radius}
+ L${x + width - halfStroke},${y}
+ `;
+
+ return (
+
+ {/* Solid revenue portion */}
+
+ {/* Hollow refund portion on top - accent color background */}
+
+ {/* Dashed border for refund portion */}
+
+
+ );
+};
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx
index 4eb835b35..afc988b25 100644
--- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx
@@ -13,6 +13,7 @@ import { AnnotationsPanel } from "@/components/charts/annotations-panel";
import {
type ChartDataRow,
METRICS,
+ REVENUE_METRICS,
} from "@/components/charts/metrics-constants";
import { RangeSelectionPopup } from "@/components/charts/range-selection-popup";
import { useDynamicDasharray } from "@/components/charts/use-dynamic-dasharray";
@@ -54,6 +55,7 @@ import type {
CreateAnnotationData,
} from "@/types/annotations";
import type { DateRange } from "../../../utils/types";
+import { RevenueBar } from "./revenue-bar";
const {
Area,
@@ -67,6 +69,7 @@ const {
Tooltip,
XAxis,
YAxis,
+ Bar,
} = Chart.Recharts;
interface TooltipPayloadEntry {
@@ -91,6 +94,30 @@ const CustomTooltip = ({
isDragging,
justFinishedDragging,
}: TooltipProps) => {
+ const { uniquePayload, refundsValue, hasRefunds, revenueEntry } =
+ useMemo(() => {
+ const map = new Map();
+
+ let refunds: number | undefined;
+
+ for (const entry of payload ?? []) {
+ if (!map.has(entry.dataKey)) {
+ map.set(entry.dataKey, entry);
+
+ if (entry.dataKey === "revenue") {
+ refunds = entry.payload?.refunds as number | undefined;
+ }
+ }
+ }
+
+ return {
+ uniquePayload: Array.from(map.values()),
+ refundsValue: refunds,
+ hasRefunds: !!refunds && refunds > 0,
+ revenueEntry: map.get("revenue"),
+ };
+ }, [payload]);
+
// Hide tooltip during or immediately after dragging
if (isDragging || justFinishedDragging) {
return null;
@@ -107,12 +134,15 @@ const CustomTooltip = ({
{label}
- {payload.map((entry) => {
+ {uniquePayload.map((entry) => {
const metric = METRICS.find((m) => m.key === entry.dataKey);
if (!metric || entry.value === undefined || entry.value === null) {
return null;
}
+ const indicatorColor =
+ entry.dataKey === "revenue" ? "var(--color-chart-6)" : metric.color;
+
const value = metric.formatValue
? metric.formatValue(entry.value, entry.payload as ChartDataRow)
: formatLocaleNumber(entry.value);
@@ -125,7 +155,7 @@ const CustomTooltip = ({
{metric.label}
@@ -137,6 +167,27 @@ const CustomTooltip = ({
);
})}
+ {/* Show refunds if present in the data */}
+ {hasRefunds && refundsValue && (
+
+
+
+ {REVENUE_METRICS.find((m) => m.key === "refunds")?.formatValue?.(
+ refundsValue,
+ revenueEntry?.payload as ChartDataRow
+ ) ?? formatLocaleNumber(refundsValue)}
+
+
+ )}
);
@@ -367,6 +418,7 @@ export function TrafficTrendsRechartsPlot({
const [isDragging, setIsDragging] = useState(false);
const [suppressTooltip, setSuppressTooltip] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
const { chartStepType } = useChartPreferences("overview-main");
@@ -399,6 +451,26 @@ export function TrafficTrendsRechartsPlot({
[rawData]
);
+ const barSize = useMemo(() => {
+ const dataLength = chartData.length;
+ if (dataLength <= 8) {
+ return 24;
+ }
+ if (dataLength <= 16) {
+ return 10;
+ }
+ if (dataLength <= 30) {
+ return 8;
+ }
+ if (dataLength <= 60) {
+ return 6;
+ }
+ if (dataLength <= 90) {
+ return 5;
+ }
+ return 10;
+ }, [chartData.length, granularity]);
+
const [DasharrayCalculator, lineDasharrays] = useDynamicDasharray({
splitIndex: chartData.length - 2,
chartType: chartStepType,
@@ -554,7 +626,11 @@ export function TrafficTrendsRechartsPlot({
)}
-
+
setHoveredIndex(null)}
+ onMouseMove={(state) => {
+ if (state.activeTooltipIndex === undefined) {
+ setHoveredIndex(null);
+ } else {
+ setHoveredIndex(state.activeTooltipIndex);
+ }
+ if (mergedFeatures.rangeSelection) {
+ handleMouseMove(state);
+ }
+ }}
onMouseUp={
mergedFeatures.rangeSelection ? handleMouseUp : undefined
}
@@ -595,6 +679,20 @@ export function TrafficTrendsRechartsPlot({
/>
))}
+
+
+
+
+
+
+
+
({
+ value: metric.label,
+ type: "circle" as const,
+ color: hiddenMetrics[metric.key]
+ ? "var(--muted-foreground)"
+ : metric.color,
+ dataKey: metric.key,
+ })),
+ {
+ value: "Revenue",
+ type: "circle" as const,
+ color: hiddenMetrics.revenue
+ ? "var(--muted-foreground)"
+ : "var(--color-chart-6)",
+ dataKey: "revenue",
+ },
+ ]}
verticalAlign="bottom"
wrapperStyle={chartRechartsLegendInteractiveWrapperStyle}
/>
)}
- {metrics.map((metric) => (
- {
- setHasAnimated(true);
- }}
- stroke={metric.color}
- strokeDasharray={
- lineDasharrays.find((line) => line.name === metric.key)
- ?.strokeDasharray || "0 0"
+ {metrics.map((metric) => {
+ const isHovered = hoveredIndex !== null && !isDragging;
+ return (
+ {
+ setHasAnimated(true);
+ }}
+ stroke={metric.color}
+ strokeDasharray={
+ lineDasharrays.find((line) => line.name === metric.key)
+ ?.strokeDasharray || "0 0"
+ }
+ strokeOpacity={isHovered ? 0.3 : 1}
+ strokeWidth={2.5}
+ style={{
+ transition:
+ "stroke-opacity 150ms ease-out, fill-opacity 150ms ease-out",
+ }}
+ type={chartStepType}
+ />
+ );
+ })}
+ {metrics.map((metric) => {
+ return (
+ line.name === metric.key)
+ ?.strokeDasharray || "0 0"
+ }
+ strokeOpacity={1}
+ strokeWidth={2.5}
+ tooltipType="none"
+ type={chartStepType}
+ />
+ );
+ })}
+ (
+
+ )}
+ stackId="revenue"
+ />
+ {
+ const xAxis = xAxisMap[0];
+ if (!xAxis || hoveredIndex === null || isDragging) {
+ return null;
}
- strokeWidth={2.5}
- type={chartStepType}
- />
- ))}
+ const xPos = xAxis.scale(chartData[hoveredIndex].xKey);
+ const prevX =
+ hoveredIndex > 0
+ ? xAxis.scale(chartData[hoveredIndex - 1].xKey)
+ : xPos -
+ (xAxis.scale(chartData[1]?.xKey || xPos) - xPos);
+ const nextX =
+ hoveredIndex < chartData.length - 1
+ ? xAxis.scale(chartData[hoveredIndex + 1].xKey)
+ : xPos +
+ (xPos - xAxis.scale(chartData.at(-2)?.xKey || xPos));
+
+ const halfWidth = (xPos - prevX) / 2;
+ const startPos = hoveredIndex === 0 ? 0 : xPos;
+ const endPos =
+ (hoveredIndex === chartData.length - 1
+ ? xAxis.scale.range()[1]
+ : xPos + (nextX - xPos) / 2) + halfWidth;
+
+ return (
+
+
+
+
+
+ );
+ }}
+ />
@@ -921,6 +1130,7 @@ interface TrafficTrendsChartProps {
dateRange: DateRange;
chartData: ChartDataRow[];
dateDiff: number;
+ hasRevenueData?: boolean;
isError: boolean;
isLoading: boolean;
isMobile: boolean;
diff --git a/apps/dashboard/app/globals.css b/apps/dashboard/app/globals.css
index 3802371f0..80b5264ad 100644
--- a/apps/dashboard/app/globals.css
+++ b/apps/dashboard/app/globals.css
@@ -46,6 +46,7 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
+ --color-chart-6: var(--chart-6);
/* Sidebar Colors */
--color-sidebar: var(--sidebar);
@@ -345,6 +346,7 @@
--chart-3: #E3A514;
--chart-4: #2D9CDB;
--chart-5: #27AE60;
+ --chart-6: #e78468;
--sidebar: #FAFAFB;
--sidebar-foreground: #27282D;
diff --git a/apps/dashboard/components/charts/metrics-constants.ts b/apps/dashboard/components/charts/metrics-constants.ts
index adabeb884..7aef1c434 100644
--- a/apps/dashboard/components/charts/metrics-constants.ts
+++ b/apps/dashboard/components/charts/metrics-constants.ts
@@ -10,6 +10,7 @@ import {
Zap,
} from "lucide-react";
import { formatLocaleNumber } from "@/lib/format-locale-number";
+import { formatCurrency } from "@/lib/formatters";
import { formatDuration } from "@/lib/utils";
const createColorSet = (
@@ -55,6 +56,18 @@ export const METRIC_COLORS = {
"#fee2e2",
"from-red-500/20 to-red-600/5"
),
+ revenue: createColorSet(
+ "#e78468",
+ "#059669",
+ "#d1fae5",
+ "from-emerald-500/20 to-emerald-600/5"
+ ),
+ refunds: createColorSet(
+ "#e78468",
+ "#e11d48",
+ "#ffe4e6",
+ "from-rose-500/20 to-rose-600/5"
+ ),
// Core Web Vitals
avg_fcp: createColorSet(
"#06b6d4",
@@ -159,6 +172,9 @@ export interface ChartDataRow {
avg_inp?: number;
p50_inp?: number;
measurements?: number;
+ // Revenue metrics
+ revenue?: number;
+ refunds?: number;
[key: string]: unknown;
}
@@ -363,9 +379,30 @@ export const ERROR_METRICS: MetricConfig[] = [
),
];
+// Revenue metrics
+export const REVENUE_METRICS: MetricConfig[] = [
+ createMetric(
+ "revenue",
+ "Revenue",
+ "revenue",
+ TrendingUp,
+ (value) => formatCurrency(value, "USD"),
+ "analytics"
+ ),
+ createMetric(
+ "refunds",
+ "Refunds",
+ "revenue",
+ TrendingUp,
+ (value) => formatCurrency(value, "USD"),
+ "analytics"
+ ),
+];
+
export const METRICS = [
...ANALYTICS_METRICS,
...PERFORMANCE_METRICS,
...CORE_WEB_VITALS_METRICS,
...ERROR_METRICS,
+ ...REVENUE_METRICS,
];
diff --git a/apps/dashboard/lib/chart-presentation.ts b/apps/dashboard/lib/chart-presentation.ts
index 705d50f66..4ec335ee4 100644
--- a/apps/dashboard/lib/chart-presentation.ts
+++ b/apps/dashboard/lib/chart-presentation.ts
@@ -95,6 +95,7 @@ export const chartSeriesPalette = [
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
+ "var(--color-chart-6)",
] as const;
export function chartSeriesColorAtIndex(index: number): string {
diff --git a/apps/dashboard/stores/jotai/chartAtoms.ts b/apps/dashboard/stores/jotai/chartAtoms.ts
index 096cffd18..b37b1fc10 100644
--- a/apps/dashboard/stores/jotai/chartAtoms.ts
+++ b/apps/dashboard/stores/jotai/chartAtoms.ts
@@ -7,6 +7,8 @@ export interface MetricVisibilityState {
sessions: boolean;
bounce_rate: boolean;
median_session_duration: boolean;
+ revenue: boolean;
+ refunds: boolean;
}
const defaultVisibleMetrics: MetricVisibilityState = {
@@ -15,6 +17,8 @@ const defaultVisibleMetrics: MetricVisibilityState = {
sessions: false,
bounce_rate: false,
median_session_duration: false,
+ revenue: true,
+ refunds: false,
};
export const metricVisibilityAtom = atomWithStorage(
diff --git a/package.json b/package.json
index c8160a0ec..c8bfa3471 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
"db:push": "dotenv -- turbo run db:push",
"db:migrate": "dotenv -- turbo run db:migrate",
"db:deploy": "dotenv -- turbo run db:deploy",
- "db:seed": "dotenv -- sh -c 'cd packages/db && bun run db:seed \"$@\"' --",
+ "db:seed": "dotenv -- bun run packages/db/src/seed.ts",
"email:dev": "dotenv -- sh -c 'cd packages/email && bun run dev'",
"sdk:build": "turbo run build --filter @databuddy/sdk --filter @databuddy/cache",
"dev:dashboard": "dotenv -- turbo dev --filter @databuddy/dashboard --filter @databuddy/api",
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts
index b889701dc..6791b34e5 100644
--- a/packages/db/src/seed.ts
+++ b/packages/db/src/seed.ts
@@ -4,8 +4,10 @@ import { clickHouse, TABLE_NAMES } from "./clickhouse/client";
import { db } from "./client";
import { websites } from "./drizzle/schema";
-const clientId = process.argv[2] || faker.string.uuid();
-const eventCount = Number(process.argv[3]) || 10_000;
+const generateRevenue = process.argv.includes("--rev");
+const args = process.argv.filter((arg) => !arg.startsWith("--"));
+const clientId = args[2] || faker.string.uuid();
+const eventCount = Number(args[3]) || 10_000;
const PATHS = [
"/",
@@ -147,8 +149,8 @@ function generatePageTitle(path: string): string {
downlink: undefined,
time_on_page: isPageExit
? Math.round(
- faker.number.float({ min: 5, max: 600, fractionDigits: 1 })
- )
+ faker.number.float({ min: 5, max: 600, fractionDigits: 1 })
+ )
: undefined,
scroll_depth: isPageExit
? faker.number.float({ min: 10, max: 100, fractionDigits: 1 })
@@ -339,6 +341,53 @@ function generatePageTitle(path: string): string {
webVitals.sort((a, b) => a.timestamp - b.timestamp);
+ const revenueTransactions = generateRevenue
+ ? Array.from({ length: Math.floor(eventCount / 50) }, (_, index) => {
+ const sessionIndex = Math.floor(
+ index / (Math.floor(eventCount / 50) / TOTAL_SESSIONS)
+ );
+ const session =
+ SESSION_POOL[Math.min(sessionIndex, SESSION_POOL.length - 1)];
+
+ const maxSessionDuration = 2 * 60 * 60 * 1000;
+ const sessionProgress =
+ (index % Math.ceil(Math.floor(eventCount / 50) / TOTAL_SESSIONS)) /
+ Math.ceil(Math.floor(eventCount / 50) / TOTAL_SESSIONS);
+ const timestamp =
+ session.sessionStartTime + sessionProgress * maxSessionDuration;
+
+ const amount = faker.number.int({ min: 40, max: 50 });
+ const isRefund = faker.helpers.maybe(() => true, { probability: 0.08 });
+ const status = isRefund ? "refunded" : "completed";
+ const type = isRefund
+ ? "refund"
+ : faker.helpers.arrayElement(["sale", "subscription"]);
+
+ return {
+ owner_id: clientId,
+ website_id: clientId,
+ transaction_id: `txn_${faker.string.uuid()}`,
+ provider: faker.helpers.arrayElement(["stripe", "paddle"]),
+ type,
+ status,
+ amount,
+ original_amount: amount,
+ original_currency: "USD",
+ currency: "USD",
+ anonymous_id: session.anonymousId,
+ session_id: session.sessionId,
+ customer_id: `cus_${faker.string.uuid()}`,
+ product_id: `prod_${faker.string.uuid()}`,
+ product_name: faker.commerce.productName(),
+ metadata: "{}",
+ created: Math.floor(timestamp / 1000),
+ synced_at: Math.floor(Date.now() / 1000),
+ };
+ })
+ : [];
+
+ revenueTransactions.sort((a, b) => a.created - b.created);
+
console.log(
`Generating seed data for client: ${clientId} on domain: ${domain}`
);
@@ -367,9 +416,21 @@ function generatePageTitle(path: string): string {
format: "JSONEachRow",
values: webVitals,
}),
+ ...(generateRevenue && revenueTransactions.length > 0
+ ? [
+ clickHouse.insert({
+ table: TABLE_NAMES.revenue,
+ format: "JSONEachRow",
+ values: revenueTransactions,
+ }),
+ ]
+ : []),
]);
console.log(
`Inserted ${events.length} events, ${outgoingLinks.length} outgoing links, ${errors.length} errors, ${webVitals.length} web vitals for client ${clientId}`
);
+ if (generateRevenue) {
+ console.log(`Inserted ${revenueTransactions.length} revenue transactions`);
+ }
})();
diff --git a/setup.ts b/setup.ts
index 03943cbc1..7668ff47f 100755
--- a/setup.ts
+++ b/setup.ts
@@ -310,7 +310,7 @@ async function main() {
log(chalk.cyan(" 2. Run 'bun run dev' to start development servers"));
log(
chalk.cyan(
- " 3. (Optional) Run 'bun run db:seed [DOMAIN] [EVENT_COUNT]' to seed sample data"
+ " 3. (Optional) Run 'bun run db:seed [EVENT_COUNT] [--revenue]' to seed sample data"
)
);
log(chalk.bold("\nHappy coding! 🎉"));
From e847763495f75adc5999a7acb7e55c29b3154823 Mon Sep 17 00:00:00 2001
From: Sahil Gupta
Date: Fri, 17 Apr 2026 14:56:30 +0530
Subject: [PATCH 5/5] fix mmetric toggle
---
.../overview/_components/traffic-trends-chart.tsx | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx
index a26addf8a..bcb7016b3 100644
--- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx
@@ -59,6 +59,7 @@ import type {
} from "@/types/annotations";
import type { DateRange } from "../../../utils/types";
import { RevenueBar } from "./revenue-bar";
+import type { Payload } from "recharts/types/component/DefaultLegendContent";
const {
Area,
@@ -914,7 +915,9 @@ export function TrafficTrendsRechartsPlot({
const metric = metrics.find((m) => m.label === label);
const isHidden = metric
? hiddenMetrics[metric.key]
- : false;
+ : label === "Revenue"
+ ? hiddenMetrics.revenue
+ : false;
return (
{
- const metric = metrics.find(
- (m) => m.label === payload.value
- );
- if (metric) {
- toggleMetric(metric.key as keyof typeof visibleMetrics);
+ onClick={(payload: Payload) => {
+ if (payload.dataKey) {
+ toggleMetric(payload.dataKey as keyof typeof visibleMetrics);
}
}}
payload={[