Skip to content

Commit 111361f

Browse files
feat: Use currencyConvert function when calculating revenue
Let's now actually use the `currencyConvert` function when computing Revenue on the Web Analytics panel. We make a lot of effort to only actually try converting it if there's something to convert.
1 parent 0a250f5 commit 111361f

8 files changed

+608
-116
lines changed

posthog/hogql/database/schema/exchange_rate.py

+155
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from typing import Union
2+
3+
from posthog.hogql import ast
4+
from posthog.schema import CurrencyCode, RevenueTrackingConfig, RevenueTrackingEventItem
15
from posthog.hogql.database.models import (
26
StringDatabaseField,
37
DateDatabaseField,
@@ -19,3 +23,154 @@ def to_printed_clickhouse(self, context):
1923

2024
def to_printed_hogql(self):
2125
return "exchange_rate"
26+
27+
28+
def convert_currency_call(
29+
amount: ast.Expr, currency_from: ast.Expr, currency_to: ast.Expr, timestamp: ast.Expr | None = None
30+
) -> ast.Expr:
31+
args = [currency_from, currency_to, amount]
32+
if timestamp:
33+
args.append(timestamp)
34+
35+
return ast.Call(name="convertCurrency", args=args)
36+
37+
38+
def revenue_currency_expression(config: RevenueTrackingConfig) -> ast.Expr:
39+
exprs = []
40+
for event in config.events:
41+
exprs.extend(
42+
[
43+
ast.CompareOperation(
44+
left=ast.Field(chain=["event"]),
45+
op=ast.CompareOperationOp.Eq,
46+
right=ast.Constant(value=event.eventName),
47+
),
48+
ast.Field(chain=["events", "properties", event.revenueCurrencyProperty])
49+
if event.revenueCurrencyProperty
50+
else ast.Constant(value=None),
51+
]
52+
)
53+
54+
if len(exprs) == 0:
55+
return ast.Constant(value=None)
56+
57+
# Else clause, make sure there's a None at the end
58+
exprs.append(ast.Constant(value=None))
59+
60+
return ast.Call(name="multiIf", args=exprs)
61+
62+
63+
def revenue_comparison_and_value_exprs(
64+
event: RevenueTrackingEventItem,
65+
config: RevenueTrackingConfig,
66+
do_currency_conversion: bool = False,
67+
) -> tuple[ast.Expr, ast.Expr]:
68+
# Check whether the event is the one we're looking for
69+
comparison_expr = ast.CompareOperation(
70+
left=ast.Field(chain=["event"]),
71+
op=ast.CompareOperationOp.Eq,
72+
right=ast.Constant(value=event.eventName),
73+
)
74+
75+
# If there's a revenueCurrencyProperty, convert the revenue to the base currency from that property
76+
# Otherwise, assume we're already in the base currency
77+
# Also, assume that `base_currency` is USD by default, it'll be empty for most customers
78+
if event.revenueCurrencyProperty and do_currency_conversion:
79+
value_expr = ast.Call(
80+
name="if",
81+
args=[
82+
ast.Call(
83+
name="isNull", args=[ast.Field(chain=["events", "properties", event.revenueCurrencyProperty])]
84+
),
85+
ast.Call(
86+
name="toDecimal",
87+
args=[
88+
ast.Field(chain=["events", "properties", event.revenueProperty]),
89+
ast.Constant(value=10),
90+
],
91+
),
92+
convert_currency_call(
93+
ast.Field(chain=["events", "properties", event.revenueProperty]),
94+
ast.Field(chain=["events", "properties", event.revenueCurrencyProperty]),
95+
ast.Constant(value=(config.baseCurrency or CurrencyCode.USD).value),
96+
ast.Call(name="DATE", args=[ast.Field(chain=["events", "timestamp"])]),
97+
),
98+
],
99+
)
100+
else:
101+
value_expr = ast.Call(
102+
name="toDecimal",
103+
args=[ast.Field(chain=["events", "properties", event.revenueProperty]), ast.Constant(value=10)],
104+
)
105+
106+
return (comparison_expr, value_expr)
107+
108+
109+
def revenue_expression(
110+
config: Union[RevenueTrackingConfig, dict, None],
111+
do_currency_conversion: bool = False,
112+
) -> ast.Expr:
113+
if isinstance(config, dict):
114+
config = RevenueTrackingConfig.model_validate(config)
115+
116+
if not config or not config.events:
117+
return ast.Constant(value=None)
118+
119+
exprs: list[ast.Expr] = []
120+
for event in config.events:
121+
comparison_expr, value_expr = revenue_comparison_and_value_exprs(event, config, do_currency_conversion)
122+
exprs.extend([comparison_expr, value_expr])
123+
124+
# Else clause, make sure there's a None at the end
125+
exprs.append(ast.Constant(value=None))
126+
127+
return ast.Call(name="multiIf", args=exprs)
128+
129+
130+
def revenue_sum_expression(
131+
config: Union[RevenueTrackingConfig, dict, None],
132+
do_currency_conversion: bool = False,
133+
) -> ast.Expr:
134+
if isinstance(config, dict):
135+
config = RevenueTrackingConfig.model_validate(config)
136+
137+
if not config or not config.events:
138+
return ast.Constant(value=None)
139+
140+
exprs: list[ast.Expr] = []
141+
for event in config.events:
142+
comparison_expr, value_expr = revenue_comparison_and_value_exprs(event, config, do_currency_conversion)
143+
144+
exprs.append(
145+
ast.Call(
146+
name="sumIf",
147+
args=[
148+
ast.Call(name="ifNull", args=[value_expr, ast.Constant(value=0)]),
149+
comparison_expr,
150+
],
151+
)
152+
)
153+
154+
if len(exprs) == 1:
155+
return exprs[0]
156+
157+
return ast.Call(name="plus", args=exprs)
158+
159+
160+
def revenue_events_where_expr(config: Union[RevenueTrackingConfig, dict, None]) -> ast.Expr:
161+
if isinstance(config, dict):
162+
config = RevenueTrackingConfig.model_validate(config)
163+
164+
if not config or not config.events:
165+
return ast.Constant(value=False)
166+
167+
exprs: list[ast.Expr] = []
168+
for event in config.events:
169+
# NOTE: Dont care about conversion, only care about comparison which is independent of conversion
170+
comparison_expr, _value_expr = revenue_comparison_and_value_exprs(event, config, do_currency_conversion=False)
171+
exprs.append(comparison_expr)
172+
173+
if len(exprs) == 1:
174+
return exprs[0]
175+
176+
return ast.Or(exprs=exprs)

posthog/hogql_queries/utils/revenue.py

-95
This file was deleted.

posthog/hogql_queries/web_analytics/revenue_example_events.py

+36-9
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import json
2+
import posthoganalytics
23

34
from posthog.hogql import ast
45
from posthog.hogql.ast import CompareOperationOp
56
from posthog.hogql.constants import LimitContext
67
from posthog.hogql_queries.insights.paginators import HogQLHasMorePaginator
78
from posthog.hogql_queries.query_runner import QueryRunner
8-
from posthog.hogql_queries.utils.revenue import revenue_expression, revenue_events_expr
9+
from posthog.hogql.database.schema.exchange_rate import (
10+
revenue_expression,
11+
revenue_events_where_expr,
12+
revenue_currency_expression,
13+
)
914
from posthog.schema import (
15+
CurrencyCode,
1016
RevenueExampleEventsQuery,
1117
RevenueExampleEventsQueryResponse,
1218
CachedRevenueExampleEventsQueryResponse,
@@ -18,13 +24,21 @@ class RevenueExampleEventsQueryRunner(QueryRunner):
1824
response: RevenueExampleEventsQueryResponse
1925
cached_response: CachedRevenueExampleEventsQueryResponse
2026
paginator: HogQLHasMorePaginator
27+
do_currency_conversion: bool = False
2128

2229
def __init__(self, *args, **kwargs):
2330
super().__init__(*args, **kwargs)
2431
self.paginator = HogQLHasMorePaginator.from_limit_context(
2532
limit_context=LimitContext.QUERY, limit=self.query.limit if self.query.limit else None
2633
)
2734

35+
self.do_currency_conversion = posthoganalytics.feature_enabled(
36+
"web-analytics-revenue-tracking-conversion",
37+
str(self.team.organization_id),
38+
groups={"organization": str(self.team.organization_id)},
39+
group_properties={"organization": {"id": str(self.team.organization_id)}},
40+
)
41+
2842
def to_query(self) -> ast.SelectQuery:
2943
tracking_config = self.query.revenueTrackingConfig
3044

@@ -40,7 +54,14 @@ def to_query(self) -> ast.SelectQuery:
4054
],
4155
),
4256
ast.Field(chain=["event"]),
43-
ast.Alias(alias="revenue", expr=revenue_expression(tracking_config)),
57+
ast.Alias(
58+
alias="original_revenue", expr=revenue_expression(tracking_config, do_currency_conversion=False)
59+
),
60+
ast.Alias(alias="revenue", expr=revenue_expression(tracking_config, self.do_currency_conversion)),
61+
ast.Alias(alias="original_currency", expr=revenue_currency_expression(tracking_config)),
62+
ast.Alias(
63+
alias="currency", expr=ast.Constant(value=(tracking_config.baseCurrency or CurrencyCode.USD).value)
64+
),
4465
ast.Call(
4566
name="tuple",
4667
args=[
@@ -56,7 +77,7 @@ def to_query(self) -> ast.SelectQuery:
5677
select_from=ast.JoinExpr(table=ast.Field(chain=["events"])),
5778
where=ast.And(
5879
exprs=[
59-
revenue_events_expr(tracking_config),
80+
revenue_events_where_expr(tracking_config),
6081
ast.CompareOperation(
6182
op=CompareOperationOp.NotEq,
6283
left=ast.Field(chain=["revenue"]), # refers to the Alias above
@@ -88,14 +109,17 @@ def calculate(self):
88109
},
89110
row[1],
90111
row[2],
91-
{
92-
"id": row[3][0],
93-
"created_at": row[3][1],
94-
"distinct_id": row[3][2],
95-
"properties": json.loads(row[3][3]),
96-
},
112+
row[3],
97113
row[4],
98114
row[5],
115+
{
116+
"id": row[6][0],
117+
"created_at": row[6][1],
118+
"distinct_id": row[6][2],
119+
"properties": json.loads(row[6][3]),
120+
},
121+
row[7],
122+
row[8],
99123
)
100124
for row in response.results
101125
]
@@ -104,7 +128,10 @@ def calculate(self):
104128
columns=[
105129
"*",
106130
"event",
131+
"original_revenue",
107132
"revenue",
133+
"original_revenue_currency",
134+
"revenue_currency",
108135
"person",
109136
"session_id",
110137
"timestamp",

0 commit comments

Comments
 (0)