diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index af6121a5d..ef0a46a40 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -61,13 +61,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
```
@@ -76,6 +76,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/api/src/query/builders/revenue.ts b/apps/api/src/query/builders/revenue.ts
index 935da0660..022f6b437 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: {
diff --git a/apps/basket/src/routes/webhooks/stripe.ts b/apps/basket/src/routes/webhooks/stripe.ts
index 156bf9cab..ed5c70261 100644
--- a/apps/basket/src/routes/webhooks/stripe.ts
+++ b/apps/basket/src/routes/webhooks/stripe.ts
@@ -79,10 +79,10 @@ interface WebhookSubscription {
interface WebhookEvent {
data: {
object:
- | WebhookPaymentIntent
- | WebhookCharge
- | WebhookInvoice
- | WebhookSubscription;
+ | WebhookPaymentIntent
+ | WebhookCharge
+ | WebhookInvoice
+ | WebhookSubscription;
};
id: string;
type: string;
@@ -182,9 +182,46 @@ function getConfig(hash: string): Promise {
>;
}
+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);
@@ -222,8 +259,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),
@@ -238,10 +275,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();
@@ -276,8 +316,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),
@@ -291,7 +331,9 @@ async function handleFailedPayment(
async function handleInvoicePaid(
invoice: WebhookInvoice,
- config: WebhookConfig
+ config: WebhookConfig,
+ anonymous_id: string,
+ session_id?: string,
): Promise {
const log = useLogger();
@@ -341,8 +383,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),
@@ -356,7 +398,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);
@@ -395,8 +439,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),
@@ -411,7 +455,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);
@@ -468,8 +514,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),
@@ -483,7 +529,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);
@@ -521,8 +569,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),
@@ -578,13 +626,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;
}
@@ -592,7 +677,9 @@ export const stripeWebhook = new Elysia().post(
await handleFailedPayment(
event.data.object as WebhookPaymentIntent,
result,
- "failed"
+ "failed",
+ anonymous_id,
+ session_id
);
break;
}
@@ -600,18 +687,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;
}
@@ -624,12 +720,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 d097c06f6..7cc92675c 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/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx
index b59aa208f..d25407e54 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";
import { CursorIcon } from "@phosphor-icons/react";
import { GlobeIcon } from "@phosphor-icons/react";
@@ -59,6 +65,8 @@ interface ChartDataPoint {
rawDate?: string;
sessions?: number;
visitors?: number;
+ revenue?: number;
+ refunds?: number;
[key: string]: unknown;
}
@@ -213,6 +221,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]
);
@@ -254,6 +279,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 ;
@@ -427,8 +554,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 && {
@@ -449,9 +578,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(() => {
@@ -926,10 +1062,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 d335564e3..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
@@ -16,6 +16,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";
@@ -57,6 +58,8 @@ import type {
CreateAnnotationData,
} from "@/types/annotations";
import type { DateRange } from "../../../utils/types";
+import { RevenueBar } from "./revenue-bar";
+import type { Payload } from "recharts/types/component/DefaultLegendContent";
const {
Area,
@@ -70,6 +73,7 @@ const {
Tooltip,
XAxis,
YAxis,
+ Bar,
} = Chart.Recharts;
interface TooltipPayloadEntry {
@@ -94,6 +98,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;
@@ -110,12 +138,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);
@@ -128,7 +159,7 @@ const CustomTooltip = ({
{metric.label}
@@ -140,6 +171,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)}
+
+
+ )}
);
@@ -370,6 +422,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");
@@ -402,6 +455,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,
@@ -555,7 +628,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
}
@@ -596,6 +681,20 @@ export function TrafficTrendsRechartsPlot({
/>
))}
+
+
+
+
+
+
+
+
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={[
+ ...metrics.map((metric) => ({
+ 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 (
+
+
+
+
+
+ );
+ }}
+ />
@@ -920,6 +1129,7 @@ interface TrafficTrendsChartProps {
chartData: ChartDataRow[];
dateDiff: number;
dateRange: DateRange;
+ hasRevenueData?: boolean;
isError: boolean;
isLoading: boolean;
isMobile: boolean;
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 c6ee9138d..f76625c5d 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";
@@ -97,9 +98,11 @@ const REVENUE_METRICS: RevenueChartMetric[] = [
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(v)),
- },
+ }
];
+const CURRENCY_METRICS = ["revenue", "avg_transaction", "refunds"];
+
interface RevenueChartProps {
className?: string;
data: RevenueChartDataPoint[];
@@ -107,9 +110,21 @@ interface RevenueChartProps {
isLoading: boolean;
}
+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 +219,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 +307,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 +319,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 e04cc1433..db5a08a32 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
@@ -45,6 +45,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";
@@ -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;
@@ -337,7 +346,7 @@ function RevenueSettingsSheet({
@@ -667,7 +676,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(() => {
@@ -681,9 +693,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) => ({
@@ -716,6 +734,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
return "Not configured";
};
+
return (
<>
@@ -807,6 +826,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
@@ -846,4 +866,4 @@ export function RevenueContent({ websiteId }: RevenueContentProps) {
/>
>
);
-}
+}
\ No newline at end of file
diff --git a/apps/dashboard/app/globals.css b/apps/dashboard/app/globals.css
index ee114abb0..b30707f0e 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);
@@ -353,6 +354,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 7586c6454..28d8475e0 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,19 @@ 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",
"#0891b2",
@@ -154,6 +168,9 @@ export interface ChartDataRow {
visitors?: number;
/** Stable category for Recharts X-axis; usually rawDate (YYYY-MM-DD or hourly key) */
xKey?: string;
+ // Revenue metrics
+ revenue?: number;
+ refunds?: number;
[key: string]: unknown;
}
@@ -350,9 +367,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 e85e16428..05b1718d6 100644
--- a/apps/dashboard/stores/jotai/chartAtoms.ts
+++ b/apps/dashboard/stores/jotai/chartAtoms.ts
@@ -7,6 +7,8 @@ export interface MetricVisibilityState {
pageviews: boolean;
sessions: boolean;
visitors: 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 194933b1c..8ddd883f7 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/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts
index ddcefd50a..5cbe9cad2 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/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/packages/tracker/src/index.ts b/packages/tracker/src/index.ts
index 466c910ed..14348fc73 100644
--- a/packages/tracker/src/index.ts
+++ b/packages/tracker/src/index.ts
@@ -53,7 +53,7 @@ export class Databuddy extends BaseTracker {
this.flushTrack(),
this.flushVitals(),
this.flushErrors(),
- ]).catch(() => {});
+ ]).catch(() => { });
},
clear: () => this.clear(),
setGlobalProperties: (props: Record) =>
@@ -99,6 +99,32 @@ export class Databuddy extends BaseTracker {
const interactionCleanup = initInteractionTracking(this);
this.cleanupFns.push(interactionCleanup);
}
+ 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",
+ });
+ }
+ this.checkCheckoutSession();
}
trackScreenViews() {
@@ -214,7 +240,7 @@ export class Databuddy extends BaseTracker {
if (this.sendBeacon(queue, endpoint)) {
queue.length = 0;
} else {
- fallback().catch(() => {});
+ fallback().catch(() => { });
}
}
@@ -364,7 +390,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();
@@ -420,11 +446,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;
@@ -444,7 +470,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) {
@@ -456,7 +482,7 @@ if (typeof window !== "undefined") {
try {
localStorage.removeItem("databuddy_opt_out");
localStorage.removeItem("databuddy_disabled");
- } catch {}
+ } catch { }
window.databuddyOptedOut = false;
window.databuddyDisabled = false;
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! 🎉"));