revenue report enhancements.
Some checks are pending
Node.js CI / build (push) Waiting to run

This commit is contained in:
Francis Cao 2026-02-21 10:57:52 -08:00
parent bbf569e8bc
commit 5144aff9cd
10 changed files with 822 additions and 227 deletions

View file

@ -12,7 +12,7 @@ import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/component
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) { export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
const { t, labels, messages, getMessage } = useMessages(); const { t, labels, messages, getErrorMessage } = useMessages();
const user = useUser(); const user = useUser();
const { user: login } = useLoginQuery(); const { user: login } = useLoginQuery();
@ -30,7 +30,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
}; };
return ( return (
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}> <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={user}>
<FormField name="username" label={t(labels.username)}> <FormField name="username" label={t(labels.username)}>
<TextField data-test="input-username" /> <TextField data-test="input-username" />
</FormField> </FormField>

View file

@ -1,19 +1,16 @@
import { Column, Grid, Row, Text } from '@umami/react-zen'; import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import classNames from 'classnames'; import { useMemo, useState } from 'react';
import { colord } from 'colord'; import { GridRow } from '@/components/common/GridRow';
import { useCallback, useMemo, useState } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { TypeIcon } from '@/components/common/TypeIcon'; import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
import { CurrencySelect } from '@/components/input/CurrencySelect'; import { CurrencySelect } from '@/components/input/CurrencySelect';
import { ListTable } from '@/components/metrics/ListTable'; import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricLabel } from '@/components/metrics/MetricLabel';
import { MetricsBar } from '@/components/metrics/MetricsBar'; import { MetricsBar } from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts'; import { RevenueChart } from '@/components/metrics/RevenueChart';
import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants'; import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
import { formatLongCurrency, formatLongNumber } from '@/lib/format'; import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage'; import { getItem, setItem } from '@/lib/storage';
@ -35,83 +32,49 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
}; };
const { t, labels } = useMessages(); const { t, labels } = useMessages();
const { locale, dateLocale } = useLocale(); const { compare, isAllTime } = useDateRange();
const { countryNames } = useCountryNames(locale);
const { data, error, isLoading } = useResultQuery<any>('revenue', { const { data, error, isLoading } = useResultQuery<any>('revenue', {
websiteId, websiteId,
startDate, startDate,
endDate, endDate,
currency, currency,
compare,
}); });
const renderCountryName = useCallback(
({ label: code }) => (
<Row className={classNames(locale)} gap>
<TypeIcon type="country" value={code} />
<Text>{countryNames[code] || t(labels.unknown)}</Text>
</Row>
),
[countryNames, locale],
);
const chartData: any = useMemo(() => {
if (!data) return [];
const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
obj[x].push({ x: t, y });
return obj;
}, {});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, startDate, endDate, unit]);
const metrics = useMemo(() => { const metrics = useMemo(() => {
if (!data) return []; if (!data) return [];
const { sum, count, unique_count } = data.total; const { sum, count, average, unique_count, comparison } = data.total;
return [ return [
{ {
value: sum, value: sum,
label: t(labels.total), label: t(labels.total),
formatValue: n => formatLongCurrency(n, currency), change: comparison ? sum - comparison.sum : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
}, },
{ {
value: count ? sum / count : 0, value: average,
label: t(labels.average), label: t(labels.average),
formatValue: n => formatLongCurrency(n, currency), change: comparison ? average - comparison.average : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
}, },
{ {
value: count, value: count,
label: t(labels.transactions), label: t(labels.transactions),
change: comparison ? count - comparison.count : 0,
formatValue: formatLongNumber, formatValue: formatLongNumber,
}, },
{ {
value: unique_count, value: unique_count,
label: t(labels.uniqueCustomers), label: t(labels.uniqueCustomers),
change: comparison ? unique_count - comparison.unique_count : 0,
formatValue: formatLongNumber, formatValue: formatLongNumber,
}, },
] as any; ] as any;
}, [data, locale]); }, [data]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); const renderLabel = (type: string) => (data: any) => <MetricLabel type={type} data={data} />;
return ( return (
<Column gap> <Column gap>
@ -122,37 +85,119 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{data && ( {data && (
<Column gap> <Column gap>
<MetricsBar> <MetricsBar>
{metrics?.map(({ label, value, formatValue }) => { {metrics?.map(({ label, value, change, formatValue }) => {
return ( return (
<MetricCard key={label} value={value} label={label} formatValue={formatValue} /> <MetricCard
key={label}
value={value}
label={label}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
/>
); );
})} })}
</MetricsBar> </MetricsBar>
<Panel> <Panel>
<BarChart <RevenueChart
chartData={chartData} data={data.chart}
unit={unit}
minDate={startDate} minDate={startDate}
maxDate={endDate} maxDate={endDate}
unit={unit}
stacked={true}
currency={currency} currency={currency}
renderXLabel={renderXLabel}
height="400px"
/>
</Panel>
<Panel>
<ListTable
title={t(labels.country)}
metric={t(labels.revenue)}
data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}))}
currency={currency}
renderLabel={renderCountryName}
/> />
</Panel> </Panel>
<Grid gap="3">
<GridRow layout="two">
<Panel>
<Heading size="2xl">{t(labels.sources)}</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{t(labels.referrers)}</Tab>
<Tab id="channel">{t(labels.channels)}</Tab>
</TabList>
<TabPanel id="referrer">
<ListTable
title={t(labels.referrer)}
metric={t(labels.revenue)}
data={data?.referrer.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('referrer')}
/>
</TabPanel>
<TabPanel id="channel">
<ListTable
title={t(labels.channel)}
metric={t(labels.revenue)}
data={data?.channel.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('channel')}
/>
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Heading size="2xl">{t(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{t(labels.countries)}</Tab>
<Tab id="region">{t(labels.regions)}</Tab>
</TabList>
<TabPanel id="country">
<ListTable
title={t(labels.country)}
metric={t(labels.revenue)}
data={data?.country.map(
({ name, value }: { name: string; value: number }) => ({
label: name,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('country')}
/>
</TabPanel>
<TabPanel id="region">
<ListTable
title={t(labels.region)}
metric={t(labels.revenue)}
data={data?.region.map(
({
name,
value,
country,
}: {
name: string;
value: number;
country: string;
}) => ({
label: name,
country,
count: Number(value),
percent: (value / data?.total.sum) * 100,
}),
)}
currency={currency}
renderLabel={renderLabel('region')}
/>
</TabPanel>
</Tabs>
</Panel>
</GridRow>
</Grid>
</Column> </Column>
)} )}
</LoadingPanel> </LoadingPanel>

View file

@ -1,8 +1,11 @@
import { getCompareDate } from '@/lib/date';
import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema'; import { reportResultSchema } from '@/lib/schema';
import { canViewWebsite } from '@/permissions'; import { canViewWebsite } from '@/permissions';
import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue'; import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue';
import { getRevenueMetrics } from '@/queries/sql/reports/getRevenueMetrics';
import { getRevenueStats } from '@/queries/sql/reports/getRevenueStats';
export async function POST(request: Request) { export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema); const { auth, body, error } = await parseRequest(request, reportResultSchema);
@ -20,7 +23,19 @@ export async function POST(request: Request) {
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId); const filters = await getQueryFilters(body.filters, websiteId);
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); const [{ chart }, total, metrics] = await Promise.all([
getRevenue(websiteId, parameters as RevenuParameters, filters),
getRevenueStats(websiteId, parameters as RevenuParameters, filters),
getRevenueMetrics(websiteId, parameters as RevenuParameters, filters),
]);
return json(data); const { compare = 'prev' } = parameters as RevenuParameters;
const { startDate, endDate } = getCompareDate(compare, parameters.startDate, parameters.endDate);
const comparison = await getRevenueStats(
websiteId,
{ ...(parameters as RevenuParameters), startDate, endDate },
filters,
);
return json({ chart, total: { ...total, comparison }, ...metrics });
} }

View file

@ -0,0 +1,60 @@
import { colord } from 'colord';
import { useCallback, useMemo } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { useLocale } from '@/components/hooks';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
export interface RevenueChartProps {
data: { x: string; t: string; y: number; count: number }[];
unit: string;
minDate: Date;
maxDate: Date;
currency: string;
}
export function RevenueChart({ data, unit, minDate, maxDate, currency }: RevenueChartProps) {
const { locale, dateLocale } = useLocale();
const chartData: any = useMemo(() => {
if (!data?.length) return { datasets: [] };
const map = data.reduce(
(obj, { x, t, y }) => {
if (!obj[x]) obj[x] = [];
obj[x].push({ x: t, y });
return obj;
},
{} as Record<string, { x: string; y: number }[]>,
);
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], minDate, maxDate, unit, dateLocale),
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, minDate, maxDate, unit, dateLocale]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
return (
<BarChart
chartData={chartData}
unit={unit}
minDate={minDate}
maxDate={maxDate}
currency={currency}
stacked={true}
renderXLabel={renderXLabel}
height="400px"
/>
);
}

View file

@ -229,6 +229,7 @@ export const revenueReportSchema = z.object({
unit: unitParam.optional(), unit: unitParam.optional(),
timezone: timezoneParam.optional(), timezone: timezoneParam.optional(),
currency: z.string(), currency: z.string(),
compare: z.enum(['prev', 'yoy']).optional(),
}), }),
}); });

View file

@ -91,6 +91,7 @@ async function relationalQuery(
when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
when referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else '' end as "name", else '' end as "name",
session_id, session_id,
visit_id, visit_id,
@ -136,31 +137,33 @@ async function clickhouseQuery(
sum(if(t.c = 1, 1, 0)) as "bounces", sum(if(t.c = 1, 1, 0)) as "bounces",
sum(max_time-min_time) as "totaltime" sum(max_time-min_time) as "totaltime"
from ( from (
select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, select
case case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
case
when referrer_domain = '' and url_query = '' then 'direct' when referrer_domain = '' and url_query = '' then 'direct'
when multiSearchAny(url_query, [${toClickHouseStringArray( when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS, PAID_AD_PARAMS,
)}]) != 0 then 'paidAds' )}]) != 0 then 'paidAds'
when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(utm_medium, 'affiliate') > 0 then 'affiliate' when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS, SEARCH_DOMAINS,
)}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS, SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social') )}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS, EMAIL_DOMAINS,
)}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS, SHOPPING_DOMAINS,
)}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
VIDEO_DOMAINS, VIDEO_DOMAINS,
)}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video')
else '' end AS name, when referrer_domain != hostname and referrer_domain != '' then 'referral'
else '' end AS name,
session_id, session_id,
visit_id, visit_id,
count(*) c, count(*) c,

View file

@ -61,6 +61,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
wwhen referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else '' end AS x, else '' end AS x,
count(distinct session_id) y count(distinct session_id) y
from prefix from prefix
@ -90,31 +91,33 @@ async function clickhouseQuery(
const sql = ` const sql = `
WITH channels as ( WITH channels as (
select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, select
case case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
case
when referrer_domain = '' and url_query = '' then 'direct' when referrer_domain = '' and url_query = '' then 'direct'
when multiSearchAny(url_query, [${toClickHouseStringArray( when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS, PAID_AD_PARAMS,
)}]) != 0 then 'paidAds' )}]) != 0 then 'paidAds'
when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(utm_medium, 'affiliate') > 0 then 'affiliate' when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS, SEARCH_DOMAINS,
)}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS, SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social') )}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS, EMAIL_DOMAINS,
)}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS, SHOPPING_DOMAINS,
)}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(referrer_domain, [${toClickHouseStringArray( when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
VIDEO_DOMAINS, VIDEO_DOMAINS,
)}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video')
else '' end AS x, when referrer_domain != hostname and referrer_domain != '' then 'referral'
else '' end AS x,
count(distinct session_id) y count(distinct session_id) y
from website_event from website_event
${cohortQuery} ${cohortQuery}

View file

@ -9,12 +9,7 @@ export interface RevenuParameters {
unit: string; unit: string;
timezone: string; timezone: string;
currency: string; currency: string;
} compare?: string;
export interface RevenueResult {
chart: { x: string; t: string; y: number }[];
country: { name: string; value: number }[];
total: { sum: number; count: number; average: number; unique_count: number };
} }
export async function getRevenue( export async function getRevenue(
@ -30,7 +25,7 @@ async function relationalQuery(
websiteId: string, websiteId: string,
parameters: RevenuParameters, parameters: RevenuParameters,
filters: QueryFilters, filters: QueryFilters,
): Promise<RevenueResult> { ) {
const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters;
const { getDateSQL, rawQuery, parseFilters } = prisma; const { getDateSQL, rawQuery, parseFilters } = prisma;
const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({ const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({
@ -58,7 +53,8 @@ async function relationalQuery(
select select
revenue.event_name x, revenue.event_name x,
${getDateSQL('revenue.created_at', unit, timezone)} t, ${getDateSQL('revenue.created_at', unit, timezone)} t,
sum(revenue.revenue) y sum(revenue.revenue) y,
count(revenue.event_id) count
from revenue from revenue
${joinQuery} ${joinQuery}
${cohortQuery} ${cohortQuery}
@ -73,54 +69,14 @@ async function relationalQuery(
queryParams, queryParams,
); );
const country = await rawQuery( return { chart };
`
select
session.country as "name",
sum(revenue) as "value"
from revenue
${joinQuery}
join session
on session.website_id = revenue.website_id
and session.session_id = revenue.session_id
${cohortQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
group by session.country
`,
queryParams,
);
const total = await rawQuery(
`
select
sum(revenue.revenue) as sum,
count(distinct revenue.event_id) as count,
count(distinct revenue.session_id) as unique_count
from revenue
${joinQuery}
${cohortQuery}
${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
`,
queryParams,
).then(result => result?.[0]);
total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0;
return { chart, country, total };
} }
async function clickhouseQuery( async function clickhouseQuery(
websiteId: string, websiteId: string,
parameters: RevenuParameters, parameters: RevenuParameters,
filters: QueryFilters, filters: QueryFilters,
): Promise<RevenueResult> { ) {
const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters;
const { getDateSQL, rawQuery, parseFilters } = clickhouse; const { getDateSQL, rawQuery, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, cohortQuery, queryParams } = parseFilters({
@ -133,7 +89,7 @@ async function clickhouseQuery(
const joinQuery = filterQuery const joinQuery = filterQuery
? `any left join ( ? `any left join (
select * select *
from website_event from website_event
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
@ -143,18 +99,13 @@ async function clickhouseQuery(
and website_event.event_id = website_revenue.event_id` and website_event.event_id = website_revenue.event_id`
: ''; : '';
const chart = await rawQuery< const chart = await rawQuery<{ x: string; t: string; y: number; count: number }[]>(
{
x: string;
t: string;
y: number;
}[]
>(
` `
select select
website_revenue.event_name x, website_revenue.event_name x,
${getDateSQL('website_revenue.created_at', unit, timezone)} t, ${getDateSQL('website_revenue.created_at', unit, timezone)} t,
sum(website_revenue.revenue) y sum(website_revenue.revenue) y,
count(website_revenue.event_id) count
from website_revenue from website_revenue
${joinQuery} ${joinQuery}
${cohortQuery} ${cohortQuery}
@ -168,59 +119,5 @@ async function clickhouseQuery(
queryParams, queryParams,
); );
const country = await rawQuery< return { chart };
{
name: string;
value: number;
}[]
>(
`
select
website_event.country as "name",
sum(website_revenue.revenue) as "value"
from website_revenue
any left join (
select *
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = 2) website_event
on website_event.website_id = website_revenue.website_id
and website_event.session_id = website_revenue.session_id
and website_event.event_id = website_revenue.event_id
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(website_revenue.currency) = {currency:String}
${filterQuery}
group by website_event.country
order by value desc
`,
queryParams,
);
const total = await rawQuery<{
sum: number;
count: number;
unique_count: number;
}>(
`
select
sum(website_revenue.revenue) as sum,
uniqExact(website_revenue.event_id) as count,
uniqExact(website_revenue.session_id) as unique_count
from website_revenue
${joinQuery}
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(website_revenue.currency) = {currency:String}
${filterQuery}
`,
queryParams,
).then(result => result?.[0]);
total.average = total.count > 0 ? total.sum / total.count : 0;
return { chart, country, total };
} }

View file

@ -0,0 +1,451 @@
import clickhouse from '@/lib/clickhouse';
import {
EMAIL_DOMAINS,
PAID_AD_PARAMS,
SEARCH_DOMAINS,
SHOPPING_DOMAINS,
SOCIAL_DOMAINS,
VIDEO_DOMAINS,
} from '@/lib/constants';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
import type { RevenuParameters } from './getRevenue';
export interface RevenueMetricsResult {
country: { name: string; value: number }[];
region: { name: string; value: number; country: string }[];
referrer: { name: string; value: number }[];
channel: { name: string; value: number }[];
}
export async function getRevenueMetrics(
...args: [websiteId: string, parameters: RevenuParameters, filters: QueryFilters]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
parameters: RevenuParameters,
filters: QueryFilters,
): Promise<RevenueMetricsResult> {
const { startDate, endDate, currency } = parameters;
const { rawQuery, parseFilters } = prisma;
const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({
...filters,
websiteId,
startDate,
endDate,
currency,
});
const joinQuery =
filterQuery || cohortQuery
? `join (select *
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and event_type = 2) website_event
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.event_id = revenue.event_id`
: '';
const country = await rawQuery(
`
select
session.country as "name",
sum(revenue) as "value"
from revenue
${joinQuery}
join session
on session.website_id = revenue.website_id
and session.session_id = revenue.session_id
${cohortQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
group by session.country
order by value desc
`,
queryParams,
);
const region = await rawQuery(
`
select
session.country,
session.region as "name",
sum(revenue.revenue) as "value"
from revenue
${joinQuery}
join session
on session.website_id = revenue.website_id
and session.session_id = revenue.session_id
${cohortQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
group by session.country, session.region
order by value desc
`,
queryParams,
);
const referrer = await rawQuery(
`
WITH events AS (
select
revenue.website_id,
revenue.session_id,
sum(revenue.revenue) as "value"
from revenue
${joinQuery}
${cohortQuery}
${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
group by revenue.website_id, revenue.session_id),
revenue_data AS (
select
e.website_id,
e.session_id,
e.value,
we.min_date as created_at
from events e
join (
select session_id, min(created_at) as min_date
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
group by session_id
) we on we.session_id = e.session_id)
select
we.referrer_domain as "name",
sum(revenue_data.value) as "value"
from revenue_data
join (
select website_id, session_id, referrer_domain, created_at
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}) we
on we.website_id = revenue_data.website_id
and we.session_id = revenue_data.session_id
and we.created_at = revenue_data.created_at
group by we.referrer_domain
order by value desc
`,
queryParams,
);
const channel = await rawQuery(
`
WITH events AS (
select
revenue.website_id,
revenue.session_id,
sum(revenue.revenue) as "value"
from revenue
${joinQuery}
${cohortQuery}
${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
group by revenue.website_id, revenue.session_id),
revenue_data AS (
select
e.website_id,
e.session_id,
e.value,
we.min_date as created_at
from events e
join (
select session_id, min(created_at) as min_date
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
group by session_id
) we on we.session_id = e.session_id),
revenue_prefix AS (
select
case when we.utm_medium ilike '%cp%' OR
we.utm_medium ilike '%ppc%' OR
we.utm_medium ilike '%retargeting%' OR
we.utm_medium ilike '%paid%' then 'paid' else 'organic' end AS prefix,
we.referrer_domain,
we.url_query,
we.utm_medium,
we.utm_source,
we.hostname,
r.value
from revenue_data r
join (
select website_id, session_id, referrer_domain, url_query, utm_medium, utm_source, hostname, created_at
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}) we
on we.website_id = r.website_id
and we.session_id = r.session_id
and we.created_at = r.created_at),
channels AS (
select
case
when referrer_domain = '' and url_query = '' then 'direct'
when ${toPostgresLikeClause('url_query', PAID_AD_PARAMS)} then 'paidAds'
when ${toPostgresLikeClause('utm_medium', ['referral', 'app', 'link'])} then 'referral'
when utm_medium ilike '%affiliate%' then 'affiliate'
when utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms'
when ${toPostgresLikeClause('referrer_domain', SEARCH_DOMAINS)} or utm_medium ilike '%organic%' then concat(prefix, 'Search')
when ${toPostgresLikeClause('referrer_domain', SOCIAL_DOMAINS)} then concat(prefix, 'Social')
when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
when referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral'
else 'Unknown' end AS "name",
value
from revenue_prefix)
select name, sum(value) as value
from channels
group by name
order by value desc
`,
queryParams,
);
return { country, region, referrer, channel };
}
async function clickhouseQuery(
websiteId: string,
parameters: RevenuParameters,
filters: QueryFilters,
): Promise<RevenueMetricsResult> {
const { startDate, endDate, currency } = parameters;
const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters,
websiteId,
startDate,
endDate,
currency,
});
const joinQuery = filterQuery
? `any left join (
select *
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = 2) website_event
on website_event.website_id = website_revenue.website_id
and website_event.session_id = website_revenue.session_id
and website_event.event_id = website_revenue.event_id`
: '';
const country = await rawQuery<{ name: string; value: number }[]>(
`
select
website_event.country as "name",
sum(website_revenue.revenue) as "value"
from website_revenue
any left join (
select *
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = 2) website_event
on website_event.website_id = website_revenue.website_id
and website_event.session_id = website_revenue.session_id
and website_event.event_id = website_revenue.event_id
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(website_revenue.currency) = {currency:String}
${filterQuery}
group by website_event.country
order by value desc
`,
queryParams,
);
const region = await rawQuery<{ name: string; value: number; country: string }[]>(
`
select
website_event.country,
website_event.region as "name",
sum(website_revenue.revenue) as "value"
from website_revenue
any left join (
select *
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = 2) website_event
on website_event.website_id = website_revenue.website_id
and website_event.session_id = website_revenue.session_id
and website_event.event_id = website_revenue.event_id
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(website_revenue.currency) = {currency:String}
${filterQuery}
group by 1,2
order by value desc
`,
queryParams,
);
const referrer = await rawQuery<{ name: string; value: number }[]>(
`
WITH events AS (
select distinct
website_id,
session_id,
sum(revenue) as "value"
from website_revenue
${joinQuery}
${cohortQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(currency) = {currency:String}
${filterQuery}
group by 1,2),
revenue AS (
select
e.website_id,
e.session_id,
e.value,
we.min_date as created_at
from events e
join (select session_id, min(created_at) min_date
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
group by 1
) we
on we.session_id = e.session_id)
select
website_event.referrer_domain as "name",
sum(revenue.value) as "value"
from revenue
any left join (
select website_id, session_id, referrer_domain, created_at
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}) website_event
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.created_at = revenue.created_at
group by 1
order by value desc
`,
queryParams,
);
const channel = await rawQuery<{ name: string; value: number }[]>(
`
WITH events AS (
select distinct
website_id,
session_id,
sum(revenue) as "value"
from website_revenue
${joinQuery}
${cohortQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(currency) = {currency:String}
${filterQuery}
group by 1,2),
revenue AS (
select
e.website_id,
e.session_id,
e.value,
we.min_date as created_at
from events e
join (select session_id, min(created_at) min_date
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
group by 1
) we
on we.session_id = e.session_id),
channels AS (
select
case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
case
when referrer_domain = '' and url_query = '' then 'direct'
when multiSearchAny(lower(url_query), [${toClickHouseStringArray(
PAID_AD_PARAMS,
)}]) != 0 then 'paidAds'
when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral'
when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate'
when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms'
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SEARCH_DOMAINS,
)}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SOCIAL_DOMAINS,
)}]) != 0 then concat(prefix, 'Social')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
EMAIL_DOMAINS,
)}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email'
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
SHOPPING_DOMAINS,
)}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping')
when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray(
VIDEO_DOMAINS,
)}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video')
when referrer_domain != hostname and referrer_domain != '' then 'referral'
else 'Unknown' end AS "name",
sum(revenue.value) as "value"
from revenue
any left join (
select *
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}) website_event
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.created_at = revenue.created_at
group by 1, 2)
select name, sum(value) value
from channels
group by 1
order by value desc;
`,
queryParams,
);
return { country, region, referrer, channel };
}
function toClickHouseStringArray(arr: string[]): string {
return arr.map(p => `'${p.replace(/'/g, "\\'")}'`).join(', ');
}
function toPostgresLikeClause(column: string, arr: string[]) {
return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n ');
}

View file

@ -0,0 +1,120 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
import type { RevenuParameters } from './getRevenue';
export interface RevenueStatsResult {
sum: number;
count: number;
average: number;
unique_count: number;
}
export async function getRevenueStats(
...args: [websiteId: string, parameters: RevenuParameters, filters: QueryFilters]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
parameters: RevenuParameters,
filters: QueryFilters,
): Promise<RevenueStatsResult> {
const { startDate, endDate, currency } = parameters;
const { rawQuery, parseFilters } = prisma;
const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({
...filters,
websiteId,
startDate,
endDate,
currency,
});
const joinQuery =
filterQuery || cohortQuery
? `join (select *
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and event_type = 2) website_event
on website_event.website_id = revenue.website_id
and website_event.session_id = revenue.session_id
and website_event.event_id = revenue.event_id`
: '';
const total = await rawQuery(
`
select
sum(revenue.revenue) as sum,
count(distinct revenue.event_id) as count,
count(distinct revenue.session_id) as unique_count
from revenue
${joinQuery}
${cohortQuery}
${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
and upper(revenue.currency) = {{currency}}
${filterQuery}
`,
queryParams,
).then(result => result?.[0]);
total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0;
return total;
}
async function clickhouseQuery(
websiteId: string,
parameters: RevenuParameters,
filters: QueryFilters,
): Promise<RevenueStatsResult> {
const { startDate, endDate, currency } = parameters;
const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters,
websiteId,
startDate,
endDate,
currency,
});
const joinQuery = filterQuery
? `any left join (
select *
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = 2) website_event
on website_event.website_id = website_revenue.website_id
and website_event.session_id = website_revenue.session_id
and website_event.event_id = website_revenue.event_id`
: '';
const total = await rawQuery<{ sum: number; count: number; unique_count: number }>(
`
select
sum(website_revenue.revenue) as sum,
uniqExact(website_revenue.event_id) as count,
uniqExact(website_revenue.session_id) as unique_count
from website_revenue
${joinQuery}
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and upper(website_revenue.currency) = {currency:String}
${filterQuery}
`,
queryParams,
).then(result => result?.[0]);
total.average = total.count > 0 ? total.sum / total.count : 0;
return total;
}