Small build updates (#3546)

* Sites API

* Extract Revenue react api helpers

* !fixup

* Extract JS Money module to /extra

* Extract Revenue full build extras (tests pass for full)

* Update MIX_ENV=small mix test

* Remove dead code

* Add moduledocs

* Add credo config

* Trick dialyzer

* DRY revenue metrics

* Use more concise version of on_full_build macro

* Disable credo check
This commit is contained in:
hq1 2023-11-22 15:34:47 +01:00 committed by GitHub
parent af0b97e68a
commit 88e1d9dc28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1298 additions and 1062 deletions

205
.credo.exs Normal file
View File

@ -0,0 +1,205 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"test/",
"extra/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
{Credo.Check.Design.TagFIXME, []},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.WrongTestFileExtension, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.UnsafeExec, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`)
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

View File

@ -7,4 +7,3 @@ export default function Money({ formatted }) {
return "-" return "-"
} }
} }

View File

@ -7,16 +7,17 @@ import { CR_METRIC } from '../reports/metrics';
import ListReport from '../reports/list'; import ListReport from '../reports/list';
export default function Conversions(props) { export default function Conversions(props) {
const {site, query} = props const { site, query } = props
function fetchConversions() { function fetchConversions() {
return api.get(url.apiPath(site, '/conversions'), query, {limit: 9}) return api.get(url.apiPath(site, '/conversions'), query, { limit: 9 })
} }
function getFilterFor(listItem) { function getFilterFor(listItem) {
return {goal: escapeFilterValue(listItem.name)} return { goal: escapeFilterValue(listItem.name) }
} }
/*global BUILD_EXTRA*/
return ( return (
<ListReport <ListReport
fetchData={fetchConversions} fetchData={fetchConversions}
@ -24,11 +25,11 @@ export default function Conversions(props) {
keyLabel="Goal" keyLabel="Goal"
onClick={props.onGoalFilterClick} onClick={props.onGoalFilterClick}
metrics={[ metrics={[
{name: 'visitors', label: "Uniques", plot: true}, { name: 'visitors', label: "Uniques", plot: true },
{name: 'events', label: "Total", hiddenOnMobile: true}, { name: 'events', label: "Total", hiddenOnMobile: true },
CR_METRIC, CR_METRIC,
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true}, BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true} BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]} ]}
detailsLink={url.sitePath(site, '/conversions')} detailsLink={url.sitePath(site, '/conversions')}
maybeHideDetails={true} maybeHideDetails={true}

View File

@ -1,199 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom'
import * as storage from '../../util/storage'
import Bar from '../bar'
import numberFormatter from '../../util/number-formatter'
import * as api from '../../api'
import Money from './money'
import { isValidHttpUrl, trimURL } from '../../util/url'
const MOBILE_UPPER_WIDTH = 767
const DEFAULT_WIDTH = 1080
const BREAKDOWN_LIMIT = 100
export default class PropertyBreakdown extends React.Component {
constructor(props) {
super(props)
let propKey = props.goal.prop_names[0]
this.storageKey = 'goalPropTab__' + props.site.domain + props.goal.name
const storedKey = storage.getItem(this.storageKey)
if (props.goal.prop_names.includes(storedKey)) {
propKey = storedKey
}
if (props.query.filters['props']) {
propKey = Object.keys(props.query.filters['props'])[0]
}
this.state = {
loading: true,
propKey: propKey,
viewport: DEFAULT_WIDTH,
breakdown: [],
page: 1,
moreResultsAvailable: false
}
this.handleResize = this.handleResize.bind(this);
this.fetch = this.fetch.bind(this)
this.fetchAndReplace = this.fetchAndReplace.bind(this)
this.fetchAndConcat = this.fetchAndConcat.bind(this)
}
componentDidMount() {
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
this.fetchAndReplace()
if (this.props.query.period === 'realtime') {
document.addEventListener('tick', this.fetchAndReplace)
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize, false);
document.removeEventListener('tick', this.fetchAndReplace)
}
handleResize() {
this.setState({ viewport: window.innerWidth });
}
fetch({ concat }) {
if (!this.props.query.filters['goal']) return
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, { limit: BREAKDOWN_LIMIT, page: this.state.page })
.then((res) => {
let breakdown = concat ? this.state.breakdown.concat(res) : res
this.setState(() => ({
loading: false,
breakdown: breakdown,
moreResultsAvailable: res.length >= BREAKDOWN_LIMIT
}))
})
}
fetchAndReplace() {
this.fetch({ concat: false })
}
fetchAndConcat() {
this.fetch({ concat: true })
}
loadMore() {
this.setState({ loading: true, page: this.state.page + 1 }, this.fetchAndConcat.bind(this))
}
renderUrl(value) {
if (isValidHttpUrl(value.name)) {
return (
<a target="_blank" href={value.name} rel="noreferrer" className="hidden group-hover:block">
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
</a>
)
}
return null
}
renderPropContent(value, query) {
return (
<span className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all">
<Link
to={{ pathname: window.location.pathname, search: query.toString() }}
className="md:truncate hover:underline block"
>
{trimURL(value.name, 100)}
</Link>
{this.renderUrl(value)}
</span>
)
}
renderPropValue(value) {
const query = new URLSearchParams(window.location.search)
query.set('props', JSON.stringify({ [this.state.propKey]: value.name }))
const { viewport } = this.state;
return (
<div className="flex items-center justify-between my-2" key={value.name}>
<div className="flex-1 truncate">
<Bar
count={value.unique_conversions}
plot="unique_conversions"
all={this.state.breakdown}
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
>
{this.renderPropContent(value, query)}
</Bar>
</div>
<div className="flex dark:text-gray-200">
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.unique_conversions)}</span>
{
viewport > MOBILE_UPPER_WIDTH ?
(
<span
className="font-medium inline-block w-20 text-right"
>{numberFormatter(value.total_conversions)}
</span>
)
: null
}
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.conversion_rate)}%</span>
{this.props.renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={value.total_revenue} /></span>}
{this.props.renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={value.average_revenue} /></span>}
</div>
</div>
)
}
changePropKey(newKey) {
storage.setItem(this.storageKey, newKey)
this.setState({ propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false }, this.fetchAndReplace)
}
renderLoading() {
if (this.state.loading) {
return <div className="px-4 py-2"><div className="loading sm mx-auto"><div></div></div></div>
} else if (this.state.moreResultsAvailable && this.props.query.period !== 'realtime') {
return (
<div className="w-full text-center my-4">
<button onClick={this.loadMore.bind(this)} type="button" className="button">
Load more
</button>
</div>
)
}
}
renderBody() {
return this.state.breakdown.map((propValue) => this.renderPropValue(propValue))
}
renderPill(key) {
const isActive = this.state.propKey === key
if (isActive) {
return <li key={key} className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold mr-2 active-prop-heading">{key}</li>
} else {
return <li key={key} className="hover:text-indigo-600 cursor-pointer mr-2" onClick={this.changePropKey.bind(this, key)}>{key}</li>
}
}
render() {
return (
<div className="w-full pl-3 sm:pl-6 mt-4">
<div className="flex-col sm:flex-row flex items-center pb-1">
<span className="text-xs font-bold text-gray-600 dark:text-gray-300 self-start sm:self-auto mb-1 sm:mb-0">Breakdown by:</span>
<ul className="flex flex-wrap font-medium text-xs text-gray-500 dark:text-gray-400 leading-5 pl-1 sm:pl-2">
{this.props.goal.prop_names.map(this.renderPill.bind(this))}
</ul>
</div>
{this.renderBody()}
{this.renderLoading()}
</div>
)
}
}

View File

@ -12,7 +12,7 @@ export default function Properties(props) {
const { site, query } = props const { site, query } = props
const propKeyStorageName = `prop_key__${site.domain}` const propKeyStorageName = `prop_key__${site.domain}`
const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}` const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}`
const [propKey, setPropKey] = useState(choosePropKey()) const [propKey, setPropKey] = useState(choosePropKey())
useEffect(() => { useEffect(() => {
@ -42,7 +42,7 @@ export default function Properties(props) {
const storedForGoal = storage.getItem(propKeyStorageNameForGoal) const storedForGoal = storage.getItem(propKeyStorageNameForGoal)
if (storedForGoal) { return storedForGoal } if (storedForGoal) { return storedForGoal }
} }
return storage.getItem(propKeyStorageName) return storage.getItem(propKeyStorageName)
} }
@ -69,6 +69,7 @@ export default function Properties(props) {
} }
} }
/*global BUILD_EXTRA*/
function renderBreakdown() { function renderBreakdown() {
return ( return (
<ListReport <ListReport
@ -76,11 +77,11 @@ export default function Properties(props) {
getFilterFor={getFilterFor} getFilterFor={getFilterFor}
keyLabel={propKey} keyLabel={propKey}
metrics={[ metrics={[
{name: 'visitors', label: 'Visitors', plot: true}, { name: 'visitors', label: 'Visitors', plot: true },
{name: 'events', label: 'Events', hiddenOnMobile: true}, { name: 'events', label: 'Events', hiddenOnMobile: true },
query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC, query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC,
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true}, BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true} BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]} ]}
detailsLink={url.sitePath(site, `/custom-prop-values/${propKey}`)} detailsLink={url.sitePath(site, `/custom-prop-values/${propKey}`)}
maybeHideDetails={true} maybeHideDetails={true}
@ -91,16 +92,16 @@ export default function Properties(props) {
) )
} }
const getFilterFor = (listItem) => { return {'props': JSON.stringify({[propKey]: escapeFilterValue(listItem.name)})} } const getFilterFor = (listItem) => { return { 'props': JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }) } }
const comboboxValues = propKey ? [{value: propKey, label: propKey}] : [] const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500' const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500'
return ( return (
<div className="w-full mt-4"> <div className="w-full mt-4">
<div> <div>
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} /> <Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
</div> </div>
{ propKey && renderBreakdown() } {propKey && renderBreakdown()}
</div> </div>
) )
} }

View File

@ -1,15 +1,26 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Money from "../behaviours/money";
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import * as api from '../../api'
import * as url from "../../util/url"; import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter' import numberFormatter from '../../util/number-formatter'
import {parseQuery} from '../../query' import { parseQuery } from '../../query'
import { escapeFilterValue } from '../../util/filters' import { escapeFilterValue } from '../../util/filters'
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
function ConversionsModal(props) { function ConversionsModal(props) {
const site = props.site const site = props.site
const query = parseQuery(props.location.search, site) const query = parseQuery(props.location.search, site)
@ -24,7 +35,7 @@ function ConversionsModal(props) {
}, []) }, [])
function fetchData() { function fetchData() {
api.get(url.apiPath(site, `/conversions`), query, {limit: 100, page}) api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page })
.then((res) => { .then((res) => {
setLoading(false) setLoading(false)
setList(list.concat(res)) setList(list.concat(res))
@ -59,16 +70,16 @@ function ConversionsModal(props) {
<tr className="text-sm dark:text-gray-200" key={listItem.name}> <tr className="text-sm dark:text-gray-200" key={listItem.name}>
<td className="p-2"> <td className="p-2">
<Link <Link
to={{pathname: url.siteBasePath(site), search: filterSearchLink(listItem)}} to={{ pathname: url.siteBasePath(site), search: filterSearchLink(listItem) }}
className="hover:underline block truncate"> className="hover:underline block truncate">
{listItem.name} {listItem.name}
</Link> </Link>
</td> </td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td> <td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td> <td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td> <td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue}/></td> } {hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue} /></td>}
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue}/></td> } {hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue} /></td>}
</tr> </tr>
) )
} }
@ -78,7 +89,7 @@ function ConversionsModal(props) {
} }
function renderBody() { function renderBody() {
const hasRevenue = list.some((goal) => goal.total_revenue) const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue)
return ( return (
<> <>
@ -98,7 +109,7 @@ function ConversionsModal(props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ list.map((item) => renderListItem(item, hasRevenue)) } {list.map((item) => renderListItem(item, hasRevenue))}
</tbody> </tbody>
</table> </table>
</main> </main>
@ -108,9 +119,9 @@ function ConversionsModal(props) {
return ( return (
<Modal site={site}> <Modal site={site}>
{ renderBody() } {renderBody()}
{ loading && renderLoading() } {loading && renderLoading()}
{ !loading && moreResultsAvailable && renderLoadMore() } {!loading && moreResultsAvailable && renderLoadMore()}
</Modal> </Modal>
) )
} }

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import Money from "../behaviours/money";
import Modal from './modal' import Modal from './modal'
import * as api from '../../api' import * as api from '../../api'
@ -11,6 +10,18 @@ import { parseQuery } from '../../query'
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions"; import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
import { escapeFilterValue } from "../../util/filters" import { escapeFilterValue } from "../../util/filters"
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
function PropsModal(props) { function PropsModal(props) {
const site = props.site const site = props.site
const query = parseQuery(props.location.search, site) const query = parseQuery(props.location.search, site)
@ -81,11 +92,11 @@ function PropsModal(props) {
} }
function renderBody() { function renderBody() {
const hasRevenue = list.some((prop) => prop.total_revenue) const hasRevenue = BUILD_EXTRA && list.some((prop) => prop.total_revenue)
return ( return (
<> <>
<h1 className="text-xl font-bold dark:text-gray-100">{ specialTitleWhenGoalFilter(query, 'Custom Property Breakdown') }</h1> <h1 className="text-xl font-bold dark:text-gray-100">{specialTitleWhenGoalFilter(query, 'Custom Property Breakdown')}</h1>
<div className="my-4 border-b border-gray-300"></div> <div className="my-4 border-b border-gray-300"></div>
<main className="modal__content"> <main className="modal__content">

View File

@ -1,6 +1,17 @@
import numberFormatter from "../../util/number-formatter" import numberFormatter from "../../util/number-formatter"
import React from "react" import React from "react"
import Money from "../behaviours/money"
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
export const VISITORS_METRIC = { export const VISITORS_METRIC = {
name: 'visitors', name: 'visitors',
@ -32,7 +43,7 @@ export function displayMetricValue(value, metric) {
} else if (metric === CR_METRIC) { } else if (metric === CR_METRIC) {
return `${value}%` return `${value}%`
} else { } else {
return <span tooltip={value}>{ numberFormatter(value) }</span> return <span tooltip={value}>{numberFormatter(value)}</span>
} }
} }
@ -40,4 +51,4 @@ export function metricLabelFor(metric, query) {
if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel } if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel }
if (metric.goalFilterLabel && query.filters.goal) { return metric.goalFilterLabel } if (metric.goalFilterLabel && query.filters.goal) { return metric.goalFilterLabel }
return metric.label return metric.label
} }

View File

@ -0,0 +1,22 @@
defmodule Plausible.Goal.Revenue do
@moduledoc """
Currency specific functions for revenue goals
"""
def revenue?(%Plausible.Goal{currency: currency}) do
!!currency
end
def valid_currencies() do
Ecto.Enum.dump_values(Plausible.Goal, :currency)
end
def currency_options() do
options =
for code <- valid_currencies() do
{code, "#{code} - #{Cldr.Currency.display_name!(code)}"}
end
options
end
end

View File

@ -0,0 +1,37 @@
defmodule Plausible.Ingestion.Event.Revenue do
@moduledoc """
Revenue specific functions for the ingestion scope
"""
def get_revenue_attrs(
%Plausible.Ingestion.Event{request: %{revenue_source: %Money{} = revenue_source}} = event
) do
matching_goal =
Enum.find(event.site.revenue_goals, &(&1.event_name == event.clickhouse_event_attrs.name))
cond do
is_nil(matching_goal) ->
%{}
matching_goal.currency == revenue_source.currency ->
%{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(revenue_source),
revenue_reporting_currency: to_string(revenue_source.currency)
}
matching_goal.currency != revenue_source.currency ->
converted = Money.to_currency!(revenue_source, matching_goal.currency)
%{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(converted),
revenue_reporting_currency: to_string(converted.currency)
}
end
end
def get_revenue_attrs(_event), do: %{}
end

View File

@ -0,0 +1,35 @@
defmodule Plausible.Ingestion.Request.Revenue do
@moduledoc """
Revenue specific functions for the ingestion scope
"""
def put_revenue_source(%Ecto.Changeset{} = changeset, %{} = request_body) do
with revenue_source <- request_body["revenue"] || request_body["$"],
%{"amount" => _, "currency" => _} = revenue_source <-
Plausible.Helpers.JSON.decode_or_fallback(revenue_source) do
parse_revenue_source(changeset, revenue_source)
else
_any -> changeset
end
end
@valid_currencies Plausible.Goal.Revenue.valid_currencies()
defp parse_revenue_source(changeset, %{"amount" => amount, "currency" => currency}) do
with true <- currency in @valid_currencies,
{%Decimal{} = amount, _rest} <- parse_decimal(amount),
%Money{} = amount <- Money.new(currency, amount) do
Ecto.Changeset.put_change(changeset, :revenue_source, amount)
else
_any -> changeset
end
end
defp parse_decimal(value) do
case value do
value when is_binary(value) -> Decimal.parse(value)
value when is_float(value) -> {Decimal.from_float(value), nil}
value when is_integer(value) -> {Decimal.new(value), nil}
_any -> :error
end
end
end

View File

@ -0,0 +1,97 @@
defmodule Plausible.Stats.Goal.Revenue do
@moduledoc """
Revenue specific functions for the stats scope
"""
import Ecto.Query
@revenue_metrics [:average_revenue, :total_revenue]
def revenue_metrics() do
@revenue_metrics
end
def total_revenue_query(query) do
from(e in query,
select_merge: %{
total_revenue:
fragment("toDecimal64(sum(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
}
)
end
def average_revenue_query(query) do
from(e in query,
select_merge: %{
average_revenue:
fragment("toDecimal64(avg(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
}
)
end
@spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) ::
{atom() | nil, [atom()]}
@doc """
Returns the common currency for the goal filters in a query. If there are no
goal filters, multiple currencies or the site owner does not have access to
revenue goals, `nil` is returned and revenue metrics are dropped.
Aggregating revenue data works only for same currency goals. If the query is
filtered by goals with different currencies, for example, one USD and other
EUR, revenue metrics are dropped.
"""
def get_revenue_tracking_currency(site, query, metrics) do
goal_filters =
case query.filters do
%{"event:goal" => {:is, {_, goal_name}}} -> [goal_name]
%{"event:goal" => {:member, list}} -> Enum.map(list, fn {_, goal_name} -> goal_name end)
_any -> []
end
requested_revenue_metrics? = Enum.any?(metrics, &(&1 in @revenue_metrics))
filtering_by_goal? = Enum.any?(goal_filters)
revenue_goals_available? = fn ->
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end
if requested_revenue_metrics? && filtering_by_goal? && revenue_goals_available?.() do
revenue_goals_currencies =
Plausible.Repo.all(
from rg in Ecto.assoc(site, :revenue_goals),
where: rg.event_name in ^goal_filters,
select: rg.currency,
distinct: true
)
if length(revenue_goals_currencies) == 1,
do: {List.first(revenue_goals_currencies), metrics},
else: {nil, metrics -- @revenue_metrics}
else
{nil, metrics -- @revenue_metrics}
end
end
def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals)
when is_list(revenue_goals) do
for result <- results do
if matching_goal = Enum.find(revenue_goals, &(&1.event_name == result.goal)) do
cast_revenue_metrics_to_money(result, matching_goal.currency)
else
result
end
end
end
def cast_revenue_metrics_to_money(results, currency) when is_map(results) do
for {metric, value} <- results, into: %{} do
if metric in @revenue_metrics && currency do
{metric, Money.new!(value || 0, currency)}
else
{metric, value}
end
end
end
def cast_revenue_metrics_to_money(results, _), do: results
end

View File

@ -0,0 +1,27 @@
defmodule PlausibleWeb.Controllers.API.Revenue do
@moduledoc """
Revenue specific functions for the API scope
"""
@revenue_metrics Plausible.Stats.Goal.Revenue.revenue_metrics()
def format_revenue_metric({metric, value}) do
if metric in @revenue_metrics do
{metric, format_money(value)}
else
{metric, value}
end
end
def format_money(value) do
case value do
%Money{} ->
%{
short: Money.to_string!(value, format: :short, fractional_digits: 1),
long: Money.to_string!(value)
}
_any ->
value
end
end
end

View File

@ -12,18 +12,22 @@ defmodule Plausible do
end end
end end
defmacro on_small_build(do: block) do defmacro on_full_build(clauses) do
if Mix.env() in @small_builds do do_on_full_build(clauses)
quote do
unquote(block)
end
end
end end
defmacro on_full_build(do: block) do def do_on_full_build(do: block) do
do_on_full_build(do: block, else: nil)
end
def do_on_full_build(do: do_block, else: else_block) do
if Mix.env() not in @small_builds do if Mix.env() not in @small_builds do
quote do quote do
unquote(block) unquote(do_block)
end
else
quote do
unquote(else_block)
end end
end end
end end
@ -31,8 +35,10 @@ defmodule Plausible do
defmacro full_build?() do defmacro full_build?() do
full_build? = Mix.env() not in @small_builds full_build? = Mix.env() not in @small_builds
# Tricking dialyzer as per:
# https://github.com/elixir-lang/elixir/blob/v1.12.3/lib/elixir/lib/gen_server.ex#L771-L778
quote do quote do
unquote(full_build?) :erlang.phash2(1, 1) == 0 and unquote(full_build?)
end end
end end

View File

@ -231,14 +231,12 @@ defmodule Plausible.Billing.Quota do
def features_usage(%Site{} = site) do def features_usage(%Site{} = site) do
props_exist = is_list(site.allowed_event_props) && site.allowed_event_props != [] props_exist = is_list(site.allowed_event_props) && site.allowed_event_props != []
on_full_build do funnels_exist =
funnels_exist = on_full_build do
Plausible.Repo.exists?(from f in Plausible.Funnel, where: f.site_id == ^site.id) Plausible.Repo.exists?(from f in Plausible.Funnel, where: f.site_id == ^site.id)
end else
false
on_small_build do end
funnels_exist = false
end
revenue_goals_exist = revenue_goals_exist =
Plausible.Repo.exists?( Plausible.Repo.exists?(

View File

@ -8,13 +8,12 @@ defmodule Plausible.Goal do
schema "goals" do schema "goals" do
field :event_name, :string field :event_name, :string
field :page_path, :string field :page_path, :string
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
on_full_build do on_full_build do
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
many_to_many :funnels, Plausible.Funnel, join_through: Plausible.Funnel.Step many_to_many :funnels, Plausible.Funnel, join_through: Plausible.Funnel.Step
end else
field :currency, :string, virtual: true, default: nil
on_small_build do
field :funnels, {:array, :map}, virtual: true, default: [] field :funnels, {:array, :map}, virtual: true, default: []
end end
@ -23,26 +22,11 @@ defmodule Plausible.Goal do
timestamps() timestamps()
end end
def revenue?(%__MODULE__{currency: currency}) do @fields [:id, :site_id, :event_name, :page_path] ++ on_full_build(do: [:currency], else: [])
!!currency
end
def valid_currencies do
Ecto.Enum.dump_values(__MODULE__, :currency)
end
def currency_options do
options =
for code <- valid_currencies() do
{code, "#{code} - #{Cldr.Currency.display_name!(code)}"}
end
options
end
def changeset(goal, attrs \\ %{}) do def changeset(goal, attrs \\ %{}) do
goal goal
|> cast(attrs, [:id, :site_id, :event_name, :page_path, :currency]) |> cast(attrs, @fields)
|> validate_required([:site_id]) |> validate_required([:site_id])
|> cast_assoc(:site) |> cast_assoc(:site)
|> update_leading_slash() |> update_leading_slash()
@ -93,7 +77,7 @@ defmodule Plausible.Goal do
end end
defp maybe_drop_currency(changeset) do defp maybe_drop_currency(changeset) do
if get_field(changeset, :page_path) do if full_build?() and get_field(changeset, :page_path) do
delete_change(changeset, :currency) delete_change(changeset, :currency)
else else
changeset changeset

View File

@ -1,11 +1,13 @@
defmodule Plausible.Goals do defmodule Plausible.Goals do
use Plausible use Plausible
use Plausible.Repo use Plausible.Repo
use Plausible.Funnel.Const
import Ecto.Query
alias Plausible.Goal alias Plausible.Goal
alias Ecto.Multi alias Ecto.Multi
use Plausible.Funnel.Const
@spec create(Plausible.Site.t(), map(), Keyword.t()) :: @spec create(Plausible.Site.t(), map(), Keyword.t()) ::
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required} {:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
@doc """ @doc """
@ -15,15 +17,17 @@ defmodule Plausible.Goals do
refreshed by the sites cache, as revenue goals are used during ingestion. refreshed by the sites cache, as revenue goals are used during ingestion.
""" """
def create(site, params, opts \\ []) do def create(site, params, opts \\ []) do
now = Keyword.get(opts, :now, DateTime.utc_now())
upsert? = Keyword.get(opts, :upsert?, false) upsert? = Keyword.get(opts, :upsert?, false)
Repo.transaction(fn -> Repo.transaction(fn ->
case insert_goal(site, params, upsert?) do case insert_goal(site, params, upsert?) do
{:ok, :insert, goal} -> {:ok, :insert, goal} ->
# credo:disable-for-next-line Credo.Check.Refactor.Nesting on_full_build do
if Goal.revenue?(goal) do now = Keyword.get(opts, :now, DateTime.utc_now())
Plausible.Site.Cache.touch_site!(site, now) # credo:disable-for-next-line Credo.Check.Refactor.Nesting
if Plausible.Goal.Revenue.revenue?(goal) do
Plausible.Site.Cache.touch_site!(site, now)
end
end end
Repo.preload(goal, :site) Repo.preload(goal, :site)
@ -118,22 +122,13 @@ defmodule Plausible.Goals do
end end
def delete(id, site_id) do def delete(id, site_id) do
on_full_build do goal_query =
goal_query = from(g in Goal,
from(g in Goal, where: g.id == ^id,
where: g.id == ^id, where: g.site_id == ^site_id
where: g.site_id == ^site_id, )
preload: [funnels: :steps]
)
end
on_small_build do goal_query = on_full_build(do: preload(goal_query, funnels: :steps), else: goal_query)
goal_query =
from(g in Goal,
where: g.id == ^id,
where: g.site_id == ^site_id
)
end
result = result =
Multi.new() Multi.new()

View File

@ -0,0 +1,15 @@
defmodule Plausible.Helpers.JSON do
@moduledoc """
Common helpers for JSON handling
"""
def decode_or_fallback(raw) do
with raw when is_binary(raw) <- raw,
{:ok, %{} = decoded} <- Jason.decode(raw) do
decoded
else
already_a_map when is_map(already_a_map) -> already_a_map
_any -> %{}
end
end
end

View File

@ -5,6 +5,7 @@ defmodule Plausible.Ingestion.Event do
are uniformly either buffered in batches (to Clickhouse) or dropped are uniformly either buffered in batches (to Clickhouse) or dropped
(e.g. due to spam blocklist) from the processing pipeline. (e.g. due to spam blocklist) from the processing pipeline.
""" """
use Plausible
alias Plausible.Ingestion.Request alias Plausible.Ingestion.Request
alias Plausible.ClickhouseEventV2 alias Plausible.ClickhouseEventV2
alias Plausible.Site.GateKeeper alias Plausible.Site.GateKeeper
@ -220,36 +221,15 @@ defmodule Plausible.Ingestion.Event do
defp put_props(%__MODULE__{} = event), do: event defp put_props(%__MODULE__{} = event), do: event
defp put_revenue(%__MODULE__{request: %{revenue_source: %Money{} = revenue_source}} = event) do defp put_revenue(event) do
matching_goal = on_full_build do
Enum.find(event.site.revenue_goals, &(&1.event_name == event.clickhouse_event_attrs.name)) attrs = Plausible.Ingestion.Event.Revenue.get_revenue_attrs(event)
update_attrs(event, attrs)
cond do else
is_nil(matching_goal) -> event
event
matching_goal.currency == revenue_source.currency ->
update_attrs(event, %{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(revenue_source),
revenue_reporting_currency: to_string(revenue_source.currency)
})
matching_goal.currency != revenue_source.currency ->
converted = Money.to_currency!(revenue_source, matching_goal.currency)
update_attrs(event, %{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(converted),
revenue_reporting_currency: to_string(converted.currency)
})
end end
end end
defp put_revenue(event), do: event
defp put_salts(%__MODULE__{} = event) do defp put_salts(%__MODULE__{} = event) do
%{event | salts: Plausible.Session.Salts.fetch()} %{event | salts: Plausible.Session.Salts.fetch()}
end end

View File

@ -23,6 +23,7 @@ defmodule Plausible.Ingestion.Request do
""" """
use Ecto.Schema use Ecto.Schema
use Plausible
alias Ecto.Changeset alias Ecto.Changeset
@max_url_size 2_000 @max_url_size 2_000
@ -40,7 +41,11 @@ defmodule Plausible.Ingestion.Request do
field :hash_mode, :integer field :hash_mode, :integer
field :pathname, :string field :pathname, :string
field :props, :map field :props, :map
field :revenue_source, :map
on_full_build do
field :revenue_source, :map
end
field :query_params, :map field :query_params, :map
field :timestamp, :naive_datetime field :timestamp, :naive_datetime
@ -90,6 +95,14 @@ defmodule Plausible.Ingestion.Request do
end end
end end
on_full_build do
defp put_revenue_source(changeset, request_body) do
Plausible.Ingestion.Request.Revenue.put_revenue_source(changeset, request_body)
end
else
defp put_revenue_source(changeset, _request_body), do: changeset
end
defp put_remote_ip(changeset, conn) do defp put_remote_ip(changeset, conn) do
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIp.get(conn)) Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIp.get(conn))
end end
@ -192,7 +205,7 @@ defmodule Plausible.Ingestion.Request do
defp put_props(changeset, %{} = request_body) do defp put_props(changeset, %{} = request_body) do
props = props =
(request_body["m"] || request_body["meta"] || request_body["p"] || request_body["props"]) (request_body["m"] || request_body["meta"] || request_body["p"] || request_body["props"])
|> decode_json_or_fallback() |> Plausible.Helpers.JSON.decode_or_fallback()
|> Enum.reject(fn {_k, v} -> is_nil(v) || is_list(v) || is_map(v) || v == "" end) |> Enum.reject(fn {_k, v} -> is_nil(v) || is_list(v) || is_map(v) || v == "" end)
|> Enum.take(@max_props) |> Enum.take(@max_props)
|> Map.new() |> Map.new()
@ -224,46 +237,6 @@ defmodule Plausible.Ingestion.Request do
end end
end end
defp put_revenue_source(%Ecto.Changeset{} = changeset, %{} = request_body) do
with revenue_source <- request_body["revenue"] || request_body["$"],
%{"amount" => _, "currency" => _} = revenue_source <-
decode_json_or_fallback(revenue_source) do
parse_revenue_source(changeset, revenue_source)
else
_any -> changeset
end
end
@valid_currencies Plausible.Goal.valid_currencies()
defp parse_revenue_source(changeset, %{"amount" => amount, "currency" => currency}) do
with true <- currency in @valid_currencies,
{%Decimal{} = amount, _rest} <- parse_decimal(amount),
%Money{} = amount <- Money.new(currency, amount) do
Changeset.put_change(changeset, :revenue_source, amount)
else
_any -> changeset
end
end
defp decode_json_or_fallback(raw) do
with raw when is_binary(raw) <- raw,
{:ok, %{} = decoded} <- Jason.decode(raw) do
decoded
else
already_a_map when is_map(already_a_map) -> already_a_map
_any -> %{}
end
end
defp parse_decimal(value) do
case value do
value when is_binary(value) -> Decimal.parse(value)
value when is_float(value) -> {Decimal.from_float(value), nil}
value when is_integer(value) -> {Decimal.new(value), nil}
_any -> :error
end
end
defp put_query_params(changeset) do defp put_query_params(changeset) do
case Changeset.get_field(changeset, :uri) do case Changeset.get_field(changeset, :uri) do
%{query: query} when is_binary(query) -> %{query: query} when is_binary(query) ->

View File

@ -29,7 +29,7 @@ defmodule Plausible.Plugins.API.Goals do
@spec get_goals(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()} @spec get_goals(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
def get_goals(site, params) do def get_goals(site, params) do
query = Plausible.Goals.for_site_query(site, preload_funnels?: true) query = Plausible.Goals.for_site_query(site, preload_funnels?: false)
{:ok, paginate(query, params, cursor_fields: [{:id, :desc}])} {:ok, paginate(query, params, cursor_fields: [{:id, :desc}])}
end end
@ -42,24 +42,11 @@ defmodule Plausible.Plugins.API.Goals do
|> Repo.one() |> Repo.one()
end end
on_full_build do defp get_query(site) do
defp get_query(site) do from g in Plausible.Goal,
from g in Plausible.Goal, where: g.site_id == ^site.id,
where: g.site_id == ^site.id, order_by: [desc: g.id],
order_by: [desc: g.id], group_by: g.id
left_join: assoc(g, :funnels),
group_by: g.id,
preload: [:funnels]
end
end
on_small_build do
defp get_query(site) do
from g in Plausible.Goal,
where: g.site_id == ^site.id,
order_by: [desc: g.id],
group_by: g.id
end
end end
defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do

View File

@ -1,21 +1,28 @@
defmodule Plausible.Stats.Aggregate do defmodule Plausible.Stats.Aggregate do
alias Plausible.Stats.Query alias Plausible.Stats.Query
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
use Plausible
import Plausible.Stats.{Base, Imported, Util} import Plausible.Stats.{Base, Imported, Util}
import Ecto.Query import Ecto.Query
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@event_metrics [ @event_metrics [
:visitors, :visitors,
:pageviews, :pageviews,
:events, :events,
:sample_percent, :sample_percent
:average_revenue, ] ++ @revenue_metrics
:total_revenue
]
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit, :sample_percent] @session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit, :sample_percent]
def aggregate(site, query, metrics) do def aggregate(site, query, metrics) do
{currency, metrics} = get_revenue_tracking_currency(site, query, metrics) {currency, metrics} =
on_full_build do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics)
else
{nil, metrics}
end
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics)) event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
event_task = fn -> aggregate_events(site, query, event_metrics) end event_task = fn -> aggregate_events(site, query, event_metrics) end
@ -197,4 +204,12 @@ defmodule Plausible.Stats.Aggregate do
end end
defp maybe_round_value(entry), do: entry defp maybe_round_value(entry), do: entry
on_full_build do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end end

View File

@ -1,5 +1,6 @@
defmodule Plausible.Stats.Base do defmodule Plausible.Stats.Base do
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
use Plausible
alias Plausible.Stats.{Query, Filters} alias Plausible.Stats.{Query, Filters}
import Ecto.Query import Ecto.Query
@ -301,24 +302,18 @@ defmodule Plausible.Stats.Base do
|> select_event_metrics(rest) |> select_event_metrics(rest)
end end
def select_event_metrics(q, [:total_revenue | rest]) do on_full_build do
from(e in q, def select_event_metrics(q, [:total_revenue | rest]) do
select_merge: %{ q
total_revenue: |> Plausible.Stats.Goal.Revenue.total_revenue_query()
fragment("toDecimal64(sum(?) * any(_sample_factor), 3)", e.revenue_reporting_amount) |> select_event_metrics(rest)
} end
)
|> select_event_metrics(rest)
end
def select_event_metrics(q, [:average_revenue | rest]) do def select_event_metrics(q, [:average_revenue | rest]) do
from(e in q, q
select_merge: %{ |> Plausible.Stats.Goal.Revenue.average_revenue_query()
average_revenue: |> select_event_metrics(rest)
fragment("toDecimal64(avg(?) * any(_sample_factor), 3)", e.revenue_reporting_amount) end
}
)
|> select_event_metrics(rest)
end end
def select_event_metrics(q, [:sample_percent | rest]) do def select_event_metrics(q, [:sample_percent | rest]) do
@ -331,7 +326,7 @@ defmodule Plausible.Stats.Base do
|> select_event_metrics(rest) |> select_event_metrics(rest)
end end
def select_event_metrics(_, [unknown | _]), do: raise("Unknown metric " <> unknown) def select_event_metrics(_, [unknown | _]), do: raise("Unknown metric: #{unknown}")
def select_session_metrics(q, [], _query), do: q def select_session_metrics(q, [], _query), do: q

View File

@ -1,5 +1,6 @@
defmodule Plausible.Stats.Breakdown do defmodule Plausible.Stats.Breakdown do
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
use Plausible
import Plausible.Stats.{Base, Imported, Util} import Plausible.Stats.{Base, Imported, Util}
require OpenTelemetry.Tracer, as: Tracer require OpenTelemetry.Tracer, as: Tracer
alias Plausible.Stats.Query alias Plausible.Stats.Query
@ -7,9 +8,12 @@ defmodule Plausible.Stats.Breakdown do
@no_ref "Direct / None" @no_ref "Direct / None"
@not_set "(not set)" @not_set "(not set)"
@event_metrics [:visitors, :pageviews, :events, :average_revenue, :total_revenue]
@session_metrics [:visits, :bounce_rate, :visit_duration] @session_metrics [:visits, :bounce_rate, :visit_duration]
@revenue_metrics [:average_revenue, :total_revenue]
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@event_metrics [:visitors, :pageviews, :events] ++ @revenue_metrics
@event_props Plausible.Stats.Props.event_props() @event_props Plausible.Stats.Props.event_props()
def breakdown(site, query, "event:goal" = property, metrics, pagination) do def breakdown(site, query, "event:goal" = property, metrics, pagination) do
@ -23,7 +27,10 @@ defmodule Plausible.Stats.Breakdown do
event_results = event_results =
if Enum.any?(event_goals) do if Enum.any?(event_goals) do
revenue_goals = Enum.filter(event_goals, &Plausible.Goal.revenue?/1) revenue_goals =
on_full_build do
Enum.filter(event_goals, &Plausible.Goal.Revenue.revenue?/1)
end
site site
|> breakdown(event_query, "event:name", metrics, pagination) |> breakdown(event_query, "event:name", metrics, pagination)
@ -68,7 +75,13 @@ defmodule Plausible.Stats.Breakdown do
end end
def breakdown(site, query, "event:props:" <> custom_prop = property, metrics, pagination) do def breakdown(site, query, "event:props:" <> custom_prop = property, metrics, pagination) do
{currency, metrics} = get_revenue_tracking_currency(site, query, metrics) {currency, metrics} =
on_full_build do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics)
else
{nil, metrics}
end
{_limit, page} = pagination {_limit, page} = pagination
none_result = none_result =
@ -674,4 +687,12 @@ defmodule Plausible.Stats.Breakdown do
{"plausible.query.breakdown_metrics", metrics} {"plausible.query.breakdown_metrics", metrics}
]) ])
end end
on_full_build do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end end

View File

@ -1,5 +1,6 @@
defmodule Plausible.Stats.Timeseries do defmodule Plausible.Stats.Timeseries do
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
use Plausible
alias Plausible.Stats.Query alias Plausible.Stats.Query
import Plausible.Stats.{Base, Util} import Plausible.Stats.{Base, Util}
use Plausible.Stats.Fragments use Plausible.Stats.Fragments
@ -15,7 +16,9 @@ defmodule Plausible.Stats.Timeseries do
@typep value :: nil | integer() | float() @typep value :: nil | integer() | float()
@type results :: nonempty_list(%{required(:date) => Date.t(), required(metric()) => value()}) @type results :: nonempty_list(%{required(:date) => Date.t(), required(metric()) => value()})
@event_metrics [:visitors, :pageviews, :events, :average_revenue, :total_revenue] @revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@event_metrics [:visitors, :pageviews, :events] ++ @revenue_metrics
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit] @session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit]
def timeseries(site, query, metrics) do def timeseries(site, query, metrics) do
steps = buckets(query) steps = buckets(query)
@ -23,7 +26,12 @@ defmodule Plausible.Stats.Timeseries do
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics)) event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
session_metrics = Enum.filter(metrics, &(&1 in @session_metrics)) session_metrics = Enum.filter(metrics, &(&1 in @session_metrics))
{currency, event_metrics} = get_revenue_tracking_currency(site, query, event_metrics) {currency, event_metrics} =
on_full_build do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, event_metrics)
else
{nil, event_metrics}
end
[event_result, session_result] = [event_result, session_result] =
Plausible.ClickhouseRepo.parallel_tasks([ Plausible.ClickhouseRepo.parallel_tasks([
@ -234,4 +242,12 @@ defmodule Plausible.Stats.Timeseries do
end end
end) end)
end end
on_full_build do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end end

View File

@ -3,8 +3,6 @@ defmodule Plausible.Stats.Util do
Utilities for modifying stat results Utilities for modifying stat results
""" """
import Ecto.Query
@doc """ @doc """
`__internal_visits` is fetched when querying bounce rate and visit duration, as it `__internal_visits` is fetched when querying bounce rate and visit duration, as it
is needed to calculate these from imported data. This function removes that metric is needed to calculate these from imported data. This function removes that metric
@ -22,73 +20,4 @@ defmodule Plausible.Stats.Util do
def remove_internal_visits_metric(result) when is_map(result) do def remove_internal_visits_metric(result) when is_map(result) do
Map.delete(result, :__internal_visits) Map.delete(result, :__internal_visits)
end end
@revenue_metrics [:average_revenue, :total_revenue]
@spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) ::
{atom() | nil, [atom()]}
@doc """
Returns the common currency for the goal filters in a query. If there are no
goal filters, multiple currencies or the site owner does not have access to
revenue goals, `nil` is returned and revenue metrics are dropped.
Aggregating revenue data works only for same currency goals. If the query is
filtered by goals with different currencies, for example, one USD and other
EUR, revenue metrics are dropped.
"""
def get_revenue_tracking_currency(site, query, metrics) do
goal_filters =
case query.filters do
%{"event:goal" => {:is, {_, goal_name}}} -> [goal_name]
%{"event:goal" => {:member, list}} -> Enum.map(list, fn {_, goal_name} -> goal_name end)
_any -> []
end
requested_revenue_metrics? = Enum.any?(metrics, &(&1 in @revenue_metrics))
filtering_by_goal? = Enum.any?(goal_filters)
revenue_goals_available? = fn ->
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end
if requested_revenue_metrics? && filtering_by_goal? && revenue_goals_available?.() do
revenue_goals_currencies =
Plausible.Repo.all(
from rg in Ecto.assoc(site, :revenue_goals),
where: rg.event_name in ^goal_filters,
select: rg.currency,
distinct: true
)
if length(revenue_goals_currencies) == 1,
do: {List.first(revenue_goals_currencies), metrics},
else: {nil, metrics -- @revenue_metrics}
else
{nil, metrics -- @revenue_metrics}
end
end
def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals)
when is_list(revenue_goals) do
for result <- results do
if matching_goal = Enum.find(revenue_goals, &(&1.event_name == result.goal)) do
cast_revenue_metrics_to_money(result, matching_goal.currency)
else
result
end
end
end
def cast_revenue_metrics_to_money(results, currency) when is_map(results) do
for {metric, value} <- results, into: %{} do
if metric in @revenue_metrics && currency do
{metric, Money.new!(value || 0, currency)}
else
{metric, value}
end
end
end
def cast_revenue_metrics_to_money(results, _), do: results
end end

View File

@ -9,6 +9,8 @@ defmodule PlausibleWeb.Api.StatsController do
require Logger require Logger
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
plug(:validate_common_input) plug(:validate_common_input)
@doc """ @doc """
@ -339,7 +341,7 @@ defmodule PlausibleWeb.Api.StatsController do
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do
query_without_filters = Query.remove_event_filters(query, [:goal, :props]) query_without_filters = Query.remove_event_filters(query, [:goal, :props])
metrics = [:visitors, :events, :average_revenue, :total_revenue] metrics = [:visitors, :events] ++ @revenue_metrics
results_without_filters = results_without_filters =
site site
@ -386,8 +388,12 @@ defmodule PlausibleWeb.Api.StatsController do
top_stats_entry(results, comparison, "Unique visitors", :unique_visitors), top_stats_entry(results, comparison, "Unique visitors", :unique_visitors),
top_stats_entry(results, comparison, "Unique conversions", :converted_visitors), top_stats_entry(results, comparison, "Unique conversions", :converted_visitors),
top_stats_entry(results, comparison, "Total conversions", :completions), top_stats_entry(results, comparison, "Total conversions", :completions),
top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1), on_full_build do
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1), top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1)
end,
on_full_build do
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1)
end,
top_stats_entry(conversion_rate, comparison_conversion_rate, "Conversion rate", :cr) top_stats_entry(conversion_rate, comparison_conversion_rate, "Conversion rate", :cr)
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@ -1145,8 +1151,12 @@ defmodule PlausibleWeb.Api.StatsController do
%{visitors: %{value: total_visitors}} = Stats.aggregate(site, total_q, [:visitors]) %{visitors: %{value: total_visitors}} = Stats.aggregate(site, total_q, [:visitors])
metrics = metrics =
if Enum.any?(site.goals, &Plausible.Goal.revenue?/1) do on_full_build do
[:visitors, :events, :average_revenue, :total_revenue] if Enum.any?(site.goals, &Plausible.Goal.Revenue.revenue?/1) do
[:visitors, :events] ++ @revenue_metrics
else
[:visitors, :events]
end
else else
[:visitors, :events] [:visitors, :events]
end end
@ -1173,28 +1183,6 @@ defmodule PlausibleWeb.Api.StatsController do
end end
end end
@revenue_metrics [:average_revenue, :total_revenue]
defp format_revenue_metric({metric, value}) do
if metric in @revenue_metrics do
{metric, format_money(value)}
else
{metric, value}
end
end
defp format_money(value) do
case value do
%Money{} ->
%{
short: Money.to_string!(value, format: :short, fractional_digits: 1),
long: Money.to_string!(value)
}
_any ->
value
end
end
def custom_prop_values(conn, params) do def custom_prop_values(conn, params) do
site = Plausible.Repo.preload(conn.assigns.site, :owner) site = Plausible.Repo.preload(conn.assigns.site, :owner)
@ -1244,8 +1232,8 @@ defmodule PlausibleWeb.Api.StatsController do
|> Map.put(:include_imported, false) |> Map.put(:include_imported, false)
metrics = metrics =
if Map.has_key?(query.filters, "event:goal") do if full_build?() and Map.has_key?(query.filters, "event:goal") do
[:visitors, :events, :average_revenue, :total_revenue] [:visitors, :events] ++ @revenue_metrics
else else
[:visitors, :events] [:visitors, :events]
end end
@ -1486,4 +1474,13 @@ defmodule PlausibleWeb.Api.StatsController do
true -> false true -> false
end end
end end
on_full_build do
defdelegate format_revenue_metric(metric_value), to: PlausibleWeb.Controllers.API.Revenue
defdelegate format_money(money), to: PlausibleWeb.Controllers.API.Revenue
else
defp format_revenue_metric({metric, value}) do
{metric, value}
end
end
end end

View File

@ -96,9 +96,7 @@ defmodule PlausibleWeb.StatsController do
defp list_funnels(site) do defp list_funnels(site) do
Plausible.Funnels.list(site) Plausible.Funnels.list(site)
end end
end else
on_small_build do
defp list_funnels(_site) do defp list_funnels(_site) do
[] []
end end

View File

@ -3,6 +3,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
Live view for the goal creation form Live view for the goal creation form
""" """
use Phoenix.LiveView use Phoenix.LiveView
use Plausible
import PlausibleWeb.Live.Components.Form import PlausibleWeb.Live.Components.Form
alias PlausibleWeb.Live.Components.ComboBox alias PlausibleWeb.Live.Components.ComboBox
@ -144,6 +145,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
</div> </div>
<div <div
:if={full_build?()}
class="mt-6 space-y-3" class="mt-6 space-y-3"
x-data={ x-data={
Jason.encode!(%{ Jason.encode!(%{
@ -162,10 +164,11 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
<button <button
class={[ class={[
"flex items-center w-max mb-3", "flex items-center w-max mb-3",
if(assigns.has_access_to_revenue_goals?, if @has_access_to_revenue_goals? do
do: "cursor-pointer", "cursor-pointer"
else: "cursor-not-allowed" else
) "cursor-not-allowed"
end
]} ]}
aria-labelledby="enable-revenue-tracking" aria-labelledby="enable-revenue-tracking"
role="switch" role="switch"
@ -204,14 +207,17 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
submit_name={@f[:currency].name} submit_name={@f[:currency].name}
module={ComboBox} module={ComboBox}
suggest_fun={ suggest_fun={
fn on_full_build do
"", [] -> fn
Plausible.Goal.currency_options() "", [] ->
Plausible.Goal.Revenue.currency_options()
input, options -> input, options ->
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8) ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
end
end end
} }
}
/> />
</div> </div>
</div> </div>

View File

@ -125,16 +125,18 @@ defmodule PlausibleWeb.Router do
get "/timeseries", ExternalStatsController, :timeseries get "/timeseries", ExternalStatsController, :timeseries
end end
scope "/api/v1/sites", PlausibleWeb.Api do on_full_build do
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug] scope "/api/v1/sites", PlausibleWeb.Api do
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
post "/", ExternalSitesController, :create_site post "/", ExternalSitesController, :create_site
put "/shared-links", ExternalSitesController, :find_or_create_shared_link put "/shared-links", ExternalSitesController, :find_or_create_shared_link
put "/goals", ExternalSitesController, :find_or_create_goal put "/goals", ExternalSitesController, :find_or_create_goal
delete "/goals/:goal_id", ExternalSitesController, :delete_goal delete "/goals/:goal_id", ExternalSitesController, :delete_goal
get "/:site_id", ExternalSitesController, :get_site get "/:site_id", ExternalSitesController, :get_site
put "/:site_id", ExternalSitesController, :update_site put "/:site_id", ExternalSitesController, :update_site
delete "/:site_id", ExternalSitesController, :delete_site delete "/:site_id", ExternalSitesController, :delete_site
end
end end
scope "/api", PlausibleWeb do scope "/api", PlausibleWeb do

View File

@ -339,6 +339,7 @@ defmodule Plausible.Billing.PlansTest do
assert Plans.suggest_tier(user) == :growth assert Plans.suggest_tier(user) == :growth
end end
@tag :full_build_only
test "suggests Business tier for a user who used the Revenue Goals, even when they signed up before Business tier release" do test "suggests Business tier for a user who used the Revenue Goals, even when they signed up before Business tier release" do
user = insert(:user, inserted_at: ~N[2023-10-25 10:00:00]) user = insert(:user, inserted_at: ~N[2023-10-25 10:00:00])
site = insert(:site, members: [user]) site = insert(:site, members: [user])

View File

@ -2,10 +2,11 @@ defmodule Plausible.Billing.QuotaTest do
use Plausible.DataCase, async: true use Plausible.DataCase, async: true
use Plausible use Plausible
alias Plausible.Billing.{Quota, Plans} alias Plausible.Billing.{Quota, Plans}
alias Plausible.Billing.Feature.{Goals, RevenueGoals, Props, StatsAPI} alias Plausible.Billing.Feature.{Goals, Props, StatsAPI}
on_full_build do on_full_build do
alias Plausible.Billing.Feature.Funnels alias Plausible.Billing.Feature.Funnels
alias Plausible.Billing.Feature.RevenueGoals
end end
@legacy_plan_id "558746" @legacy_plan_id "558746"
@ -460,15 +461,15 @@ defmodule Plausible.Billing.QuotaTest do
assert [Funnels] == Quota.features_usage(site) assert [Funnels] == Quota.features_usage(site)
assert [Funnels] == Quota.features_usage(user) assert [Funnels] == Quota.features_usage(user)
end end
end
test "returns [RevenueGoals] when user/site uses revenue goals" do test "returns [RevenueGoals] when user/site uses revenue goals" do
user = insert(:user) user = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)]) site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
insert(:goal, currency: :USD, site: site, event_name: "Purchase") insert(:goal, currency: :USD, site: site, event_name: "Purchase")
assert [RevenueGoals] == Quota.features_usage(site) assert [RevenueGoals] == Quota.features_usage(site)
assert [RevenueGoals] == Quota.features_usage(user) assert [RevenueGoals] == Quota.features_usage(user)
end
end end
test "returns [StatsAPI] when user has a stats api key" do test "returns [StatsAPI] when user has a stats api key" do

View File

@ -48,6 +48,7 @@ defmodule Plausible.GoalsTest do
assert {"has already been taken", _} = changeset.errors[:event_name] assert {"has already been taken", _} = changeset.errors[:event_name]
end end
@tag :full_build_only
test "create/2 sets site.updated_at for revenue goal" do test "create/2 sets site.updated_at for revenue goal" do
site_1 = insert(:site, updated_at: DateTime.add(DateTime.utc_now(), -3600)) site_1 = insert(:site, updated_at: DateTime.add(DateTime.utc_now(), -3600))
@ -63,6 +64,7 @@ defmodule Plausible.GoalsTest do
:eq :eq
end end
@tag :full_build_only
test "create/2 creates revenue goal" do test "create/2 creates revenue goal" do
site = insert(:site) site = insert(:site)
{:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) {:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
@ -71,6 +73,7 @@ defmodule Plausible.GoalsTest do
assert goal.currency == :EUR assert goal.currency == :EUR
end end
@tag :full_build_only
test "create/2 returns error when site does not have access to revenue goals" do test "create/2 returns error when site does not have access to revenue goals" do
user = insert(:user, subscription: build(:growth_subscription)) user = insert(:user, subscription: build(:growth_subscription))
site = insert(:site, members: [user]) site = insert(:site, members: [user])
@ -79,6 +82,7 @@ defmodule Plausible.GoalsTest do
Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
end end
@tag :full_build_only
test "create/2 fails for unknown currency code" do test "create/2 fails for unknown currency code" do
site = insert(:site) site = insert(:site)

View File

@ -100,6 +100,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :throttle assert dropped.drop_reason == :throttle
end end
@tag :full_build_only
test "saves revenue amount" do test "saves revenue amount" do
site = insert(:site) site = insert(:site)
_goal = insert(:goal, event_name: "checkout", currency: "USD", site: site) _goal = insert(:goal, event_name: "checkout", currency: "USD", site: site)

View File

@ -171,6 +171,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert request.props["custom2"] == "property2" assert request.props["custom2"] == "property2"
end end
@tag :full_build_only
test "parses revenue source field from a json string" do test "parses revenue source field from a json string" do
payload = %{ payload = %{
name: "pageview", name: "pageview",
@ -186,6 +187,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.new("20.2") == amount assert Decimal.new("20.2") == amount
end end
@tag :full_build_only
test "sets revenue source with integer amount" do test "sets revenue source with integer amount" do
payload = %{ payload = %{
name: "pageview", name: "pageview",
@ -204,6 +206,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.equal?(amount, Decimal.new("20.0")) assert Decimal.equal?(amount, Decimal.new("20.0"))
end end
@tag :full_build_only
test "sets revenue source with float amount" do test "sets revenue source with float amount" do
payload = %{ payload = %{
name: "pageview", name: "pageview",
@ -222,6 +225,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.equal?(amount, Decimal.new("20.1")) assert Decimal.equal?(amount, Decimal.new("20.1"))
end end
@tag :full_build_only
test "parses string amounts into money structs" do test "parses string amounts into money structs" do
payload = %{ payload = %{
name: "pageview", name: "pageview",
@ -240,6 +244,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.equal?(amount, Decimal.new("12.3")) assert Decimal.equal?(amount, Decimal.new("12.3"))
end end
@tag :full_build_only
test "ignores revenue data when currency is invalid" do test "ignores revenue data when currency is invalid" do
payload = %{ payload = %{
name: "pageview", name: "pageview",
@ -424,6 +429,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert changeset.errors[:request] assert changeset.errors[:request]
end end
@tag :full_build_only
test "encodable" do test "encodable" do
params = %{ params = %{
name: "pageview", name: "pageview",

View File

@ -53,6 +53,7 @@ defmodule Plausible.Site.CacheTest do
refute Cache.get("site3.example.com", cache_name: test, force?: true) refute Cache.get("site3.example.com", cache_name: test, force?: true)
end end
@tag :full_build_only
test "cache caches revenue goals", %{test: test} do test "cache caches revenue goals", %{test: test} do
{:ok, _} = {:ok, _} =
Supervisor.start_link( Supervisor.start_link(
@ -84,6 +85,7 @@ defmodule Plausible.Site.CacheTest do
] = Enum.sort_by(cached_goals, & &1.event_name) ] = Enum.sort_by(cached_goals, & &1.event_name)
end end
@tag :full_build_only
test "cache caches revenue goals with event refresh", %{test: test} do test "cache caches revenue goals with event refresh", %{test: test} do
{:ok, _} = {:ok, _} =
Supervisor.start_link( Supervisor.start_link(

View File

@ -672,6 +672,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert Map.get(event, :"meta.value") == ["true"] assert Map.get(event, :"meta.value") == ["true"]
end end
@tag :full_build_only
test "converts revenue values into the goal currency", %{conn: conn, site: site} do test "converts revenue values into the goal currency", %{conn: conn, site: site} do
params = %{ params = %{
name: "Payment", name: "Payment",
@ -688,6 +689,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert Decimal.equal?(Decimal.new("7.14"), amount) assert Decimal.equal?(Decimal.new("7.14"), amount)
end end
@tag :full_build_only
test "revenue values can be sent with minified keys", %{conn: conn, site: site} do test "revenue values can be sent with minified keys", %{conn: conn, site: site} do
params = %{ params = %{
"n" => "Payment", "n" => "Payment",
@ -704,6 +706,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert Decimal.equal?(Decimal.new("7.14"), amount) assert Decimal.equal?(Decimal.new("7.14"), amount)
end end
@tag :full_build_only
test "saves the exact same amount when goal currency is the same as the event", %{ test "saves the exact same amount when goal currency is the same as the event", %{
conn: conn, conn: conn,
site: site site: site

View File

@ -219,6 +219,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
assert resp == [] assert resp == []
end end
@tag :full_build_only
test "returns formatted average and total values for a conversion with revenue value", %{ test "returns formatted average and total values for a conversion with revenue value", %{
conn: conn, conn: conn,
site: site site: site
@ -259,6 +260,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
] ]
end end
@tag :full_build_only
test "returns revenue metrics as nil for non-revenue goals", %{ test "returns revenue metrics as nil for non-revenue goals", %{
conn: conn, conn: conn,
site: site site: site

View File

@ -787,6 +787,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
] ]
end end
@tag :full_build_only
test "returns revenue metrics when filtering by a revenue goal", %{conn: conn, site: site} do test "returns revenue metrics when filtering by a revenue goal", %{conn: conn, site: site} do
prop_key = "logged_in" prop_key = "logged_in"
@ -844,6 +845,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
] ]
end end
@tag :full_build_only
test "returns revenue metrics when filtering by many revenue goals with same currency", %{ test "returns revenue metrics when filtering by many revenue goals with same currency", %{
conn: conn, conn: conn,
site: site site: site

View File

@ -930,6 +930,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
end end
end end
@tag :full_build_only
describe "GET /api/stats/main-graph - total_revenue plot" do describe "GET /api/stats/main-graph - total_revenue plot" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data] setup [:create_user, :log_in, :create_new_site, :add_imported_data]
@ -1009,6 +1010,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
end end
end end
@tag :full_build_only
describe "GET /api/stats/main-graph - average_revenue plot" do describe "GET /api/stats/main-graph - average_revenue plot" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data] setup [:create_user, :log_in, :create_new_site, :add_imported_data]

View File

@ -764,6 +764,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"] assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"]
end end
@tag :full_build_only
test "returns average and total when filtering by a revenue goal", %{conn: conn, site: site} do test "returns average and total when filtering by a revenue goal", %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD") insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") insert(:goal, site: site, event_name: "AddToCart", currency: "EUR")
@ -806,6 +807,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
} in top_stats } in top_stats
end end
@tag :full_build_only
test "returns average and total when filtering by many revenue goals with same currency", %{ test "returns average and total when filtering by many revenue goals with same currency", %{
conn: conn, conn: conn,
site: site site: site

View File

@ -254,6 +254,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
refute text_of_element(doc, @business_plan_box) =~ "Recommended" refute text_of_element(doc, @business_plan_box) =~ "Recommended"
end end
@tag :full_build_only
test "recommends Business tier when Revenue Goals were used during trial", %{ test "recommends Business tier when Revenue Goals were used during trial", %{
conn: conn, conn: conn,
user: user user: user
@ -475,6 +476,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
end end
@tag :full_build_only
test "warns about losing access to a feature", %{conn: conn, user: user} do test "warns about losing access to a feature", %{conn: conn, user: user} do
site = insert(:site, members: [user]) site = insert(:site, members: [user])
@ -710,6 +712,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
refute element_exists?(doc, @business_highlight_pill) refute element_exists?(doc, @business_highlight_pill)
end end
@tag :full_build_only
test "recommends Business tier when premium features used", %{conn: conn, user: user} do test "recommends Business tier when premium features used", %{conn: conn, user: user} do
site = insert(:site, members: [user]) site = insert(:site, members: [user])
insert(:goal, currency: :USD, site: site, event_name: "Purchase") insert(:goal, currency: :USD, site: site, event_name: "Purchase")

View File

@ -35,7 +35,8 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
describe "Goal submission" do describe "Goal submission" do
setup [:create_user, :log_in, :create_site] setup [:create_user, :log_in, :create_site]
test "renders form fields", %{conn: conn, site: site} do @tag :full_build_only
test "renders form fields (with currency)", %{conn: conn, site: site} do
lv = get_liveview(conn, site) lv = get_liveview(conn, site)
html = render(lv) html = render(lv)
@ -52,6 +53,22 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
assert name_of(page_path) == "goal[page_path]" assert name_of(page_path) == "goal[page_path]"
end end
@tag :small_build_only
test "renders form fields (no currency)", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
[event_name] = find(html, "input")
assert name_of(event_name) == "goal[event_name]"
html = lv |> element(~s/a#pageview-tab/) |> render_click()
[page_path_display, page_path] = find(html, "input")
assert name_of(page_path_display) == "display-page_path_input"
assert name_of(page_path) == "goal[page_path]"
end
test "renders error on empty submission", %{conn: conn, site: site} do test "renders error on empty submission", %{conn: conn, site: site} do
lv = get_liveview(conn, site) lv = get_liveview(conn, site)
html = lv |> element("form") |> render_submit() html = lv |> element("form") |> render_submit()
@ -70,6 +87,7 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
assert parent_html =~ "Custom Event" assert parent_html =~ "Custom Event"
end end
@tag :full_build_only
test "creates a revenue goal", %{conn: conn, site: site} do test "creates a revenue goal", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true) {parent, lv} = get_liveview(conn, site, with_parent?: true)
refute render(parent) =~ "SampleRevenueGoal" refute render(parent) =~ "SampleRevenueGoal"
@ -96,6 +114,7 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
describe "Combos integration" do describe "Combos integration" do
setup [:create_user, :log_in, :create_site] setup [:create_user, :log_in, :create_site]
@tag :full_build_only
test "currency combo works", %{conn: conn, site: site} do test "currency combo works", %{conn: conn, site: site} do
lv = get_liveview(conn, site) lv = get_liveview(conn, site)

View File

@ -45,6 +45,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
end end
describe "business tier" do describe "business tier" do
@tag :full_build_only
test "fails on revenue goal creation attempt with insufficient plan", %{ test "fails on revenue goal creation attempt with insufficient plan", %{
site: site, site: site,
token: token, token: token,
@ -71,6 +72,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|> assert_schema("PaymentRequiredError", spec()) |> assert_schema("PaymentRequiredError", spec())
end end
@tag :full_build_only
test "fails on bulk revenue goal creation attempt with insufficient plan", %{ test "fails on bulk revenue goal creation attempt with insufficient plan", %{
site: site, site: site,
token: token, token: token,
@ -147,6 +149,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert [%{event_name: "Signup"}] = Plausible.Goals.for_site(site) assert [%{event_name: "Signup"}] = Plausible.Goals.for_site(site)
end end
@tag :full_build_only
test "creates a revenue goal", %{conn: conn, token: token, site: site} do test "creates a revenue goal", %{conn: conn, token: token, site: site} do
url = Routes.goals_url(base_uri(), :create) url = Routes.goals_url(base_uri(), :create)
@ -177,6 +180,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert [%{event_name: "Purchase", currency: :EUR}] = Plausible.Goals.for_site(site) assert [%{event_name: "Purchase", currency: :EUR}] = Plausible.Goals.for_site(site)
end end
@tag :full_build_only
test "fails to create a revenue goal with unknown currency", %{ test "fails to create a revenue goal with unknown currency", %{
conn: conn, conn: conn,
token: token, token: token,
@ -205,6 +209,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert [%{detail: "currency: is invalid"}] = resp.errors assert [%{detail: "currency: is invalid"}] = resp.errors
end end
@tag :full_build_only
test "edge case - revenue goal exists under the same name and different currency", %{ test "edge case - revenue goal exists under the same name and different currency", %{
conn: conn, conn: conn,
token: token, token: token,
@ -277,6 +282,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
end end
describe "put /goals - bulk creation" do describe "put /goals - bulk creation" do
@tag :full_build_only
test "creates a goal of each type", %{conn: conn, token: token, site: site} do test "creates a goal of each type", %{conn: conn, token: token, site: site} do
url = Routes.goals_url(base_uri(), :create) url = Routes.goals_url(base_uri(), :create)
@ -351,6 +357,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
} in resp.errors } in resp.errors
end end
@tag :full_build_only
test "is idempotent", %{conn: conn, token: token, site: site} do test "is idempotent", %{conn: conn, token: token, site: site} do
url = Routes.goals_url(base_uri(), :create) url = Routes.goals_url(base_uri(), :create)
@ -385,6 +392,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|> assert_schema("Goal.ListResponse", spec()) |> assert_schema("Goal.ListResponse", spec())
end end
@tag :full_build_only
test "edge case - revenue goals exist under the same name and different currency", %{ test "edge case - revenue goals exist under the same name and different currency", %{
conn: conn, conn: conn,
token: token, token: token,
@ -430,6 +438,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp
end end
@tag :full_build_only
test "retrieves revenue goal by ID", %{conn: conn, site: site, token: token} do test "retrieves revenue goal by ID", %{conn: conn, site: site, token: token} do
{:ok, goal} = {:ok, goal} =
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
@ -507,6 +516,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert resp.meta.pagination.links == %{} assert resp.meta.pagination.links == %{}
end end
@tag :full_build_only
test "returns a list of goals of each possible goal type", %{ test "returns a list of goals of each possible goal type", %{
conn: conn, conn: conn,
site: site, site: site,