mirror of
https://github.com/plausible/analytics.git
synced 2024-08-16 21:30:29 +03:00
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:
parent
af0b97e68a
commit
88e1d9dc28
205
.credo.exs
Normal file
205
.credo.exs
Normal 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`.
|
||||
#
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -7,4 +7,3 @@ export default function Money({ formatted }) {
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
@ -7,16 +7,17 @@ import { CR_METRIC } from '../reports/metrics';
|
||||
import ListReport from '../reports/list';
|
||||
|
||||
export default function Conversions(props) {
|
||||
const {site, query} = props
|
||||
const { site, query } = props
|
||||
|
||||
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) {
|
||||
return {goal: escapeFilterValue(listItem.name)}
|
||||
return { goal: escapeFilterValue(listItem.name) }
|
||||
}
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchConversions}
|
||||
@ -24,11 +25,11 @@ export default function Conversions(props) {
|
||||
keyLabel="Goal"
|
||||
onClick={props.onGoalFilterClick}
|
||||
metrics={[
|
||||
{name: 'visitors', label: "Uniques", plot: true},
|
||||
{name: 'events', label: "Total", hiddenOnMobile: true},
|
||||
{ name: 'visitors', label: "Uniques", plot: true },
|
||||
{ name: 'events', label: "Total", hiddenOnMobile: true },
|
||||
CR_METRIC,
|
||||
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
|
||||
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
|
||||
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
|
||||
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
|
||||
]}
|
||||
detailsLink={url.sitePath(site, '/conversions')}
|
||||
maybeHideDetails={true}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ export default function Properties(props) {
|
||||
const { site, query } = props
|
||||
const propKeyStorageName = `prop_key__${site.domain}`
|
||||
const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}`
|
||||
|
||||
|
||||
const [propKey, setPropKey] = useState(choosePropKey())
|
||||
|
||||
useEffect(() => {
|
||||
@ -42,7 +42,7 @@ export default function Properties(props) {
|
||||
const storedForGoal = storage.getItem(propKeyStorageNameForGoal)
|
||||
if (storedForGoal) { return storedForGoal }
|
||||
}
|
||||
|
||||
|
||||
return storage.getItem(propKeyStorageName)
|
||||
}
|
||||
|
||||
@ -69,6 +69,7 @@ export default function Properties(props) {
|
||||
}
|
||||
}
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
function renderBreakdown() {
|
||||
return (
|
||||
<ListReport
|
||||
@ -76,11 +77,11 @@ export default function Properties(props) {
|
||||
getFilterFor={getFilterFor}
|
||||
keyLabel={propKey}
|
||||
metrics={[
|
||||
{name: 'visitors', label: 'Visitors', plot: true},
|
||||
{name: 'events', label: 'Events', hiddenOnMobile: true},
|
||||
{ name: 'visitors', label: 'Visitors', plot: true },
|
||||
{ name: 'events', label: 'Events', hiddenOnMobile: true },
|
||||
query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC,
|
||||
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
|
||||
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
|
||||
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
|
||||
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
|
||||
]}
|
||||
detailsLink={url.sitePath(site, `/custom-prop-values/${propKey}`)}
|
||||
maybeHideDetails={true}
|
||||
@ -91,16 +92,16 @@ export default function Properties(props) {
|
||||
)
|
||||
}
|
||||
|
||||
const getFilterFor = (listItem) => { return {'props': JSON.stringify({[propKey]: escapeFilterValue(listItem.name)})} }
|
||||
const comboboxValues = propKey ? [{value: propKey, label: propKey}] : []
|
||||
const getFilterFor = (listItem) => { return { 'props': JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }) } }
|
||||
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'
|
||||
|
||||
return (
|
||||
<div className="w-full mt-4">
|
||||
<div>
|
||||
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
|
||||
</div>
|
||||
{ propKey && renderBreakdown() }
|
||||
<div>
|
||||
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
|
||||
</div>
|
||||
{propKey && renderBreakdown()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,26 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from 'react-router-dom'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import Money from "../behaviours/money";
|
||||
|
||||
import Modal from './modal'
|
||||
import * as api from '../../api'
|
||||
import * as url from "../../util/url";
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import {parseQuery} from '../../query'
|
||||
import { parseQuery } from '../../query'
|
||||
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) {
|
||||
const site = props.site
|
||||
const query = parseQuery(props.location.search, site)
|
||||
@ -24,7 +35,7 @@ function ConversionsModal(props) {
|
||||
}, [])
|
||||
|
||||
function fetchData() {
|
||||
api.get(url.apiPath(site, `/conversions`), query, {limit: 100, page})
|
||||
api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page })
|
||||
.then((res) => {
|
||||
setLoading(false)
|
||||
setList(list.concat(res))
|
||||
@ -59,16 +70,16 @@ function ConversionsModal(props) {
|
||||
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
|
||||
<td className="p-2">
|
||||
<Link
|
||||
to={{pathname: url.siteBasePath(site), search: filterSearchLink(listItem)}}
|
||||
to={{ pathname: url.siteBasePath(site), search: filterSearchLink(listItem) }}
|
||||
className="hover:underline block truncate">
|
||||
{listItem.name}
|
||||
{listItem.name}
|
||||
</Link>
|
||||
</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">{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.average_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>}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@ -78,7 +89,7 @@ function ConversionsModal(props) {
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const hasRevenue = list.some((goal) => goal.total_revenue)
|
||||
const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -98,7 +109,7 @@ function ConversionsModal(props) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ list.map((item) => renderListItem(item, hasRevenue)) }
|
||||
{list.map((item) => renderListItem(item, hasRevenue))}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
@ -108,9 +119,9 @@ function ConversionsModal(props) {
|
||||
|
||||
return (
|
||||
<Modal site={site}>
|
||||
{ renderBody() }
|
||||
{ loading && renderLoading() }
|
||||
{ !loading && moreResultsAvailable && renderLoadMore() }
|
||||
{renderBody()}
|
||||
{loading && renderLoading()}
|
||||
{!loading && moreResultsAvailable && renderLoadMore()}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from 'react-router-dom'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import Money from "../behaviours/money";
|
||||
|
||||
import Modal from './modal'
|
||||
import * as api from '../../api'
|
||||
@ -11,6 +10,18 @@ import { parseQuery } from '../../query'
|
||||
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
|
||||
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) {
|
||||
const site = props.site
|
||||
const query = parseQuery(props.location.search, site)
|
||||
@ -81,11 +92,11 @@ function PropsModal(props) {
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const hasRevenue = list.some((prop) => prop.total_revenue)
|
||||
const hasRevenue = BUILD_EXTRA && list.some((prop) => prop.total_revenue)
|
||||
|
||||
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>
|
||||
<main className="modal__content">
|
||||
|
@ -1,6 +1,17 @@
|
||||
import numberFormatter from "../../util/number-formatter"
|
||||
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 = {
|
||||
name: 'visitors',
|
||||
@ -32,7 +43,7 @@ export function displayMetricValue(value, metric) {
|
||||
} else if (metric === CR_METRIC) {
|
||||
return `${value}%`
|
||||
} 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.goalFilterLabel && query.filters.goal) { return metric.goalFilterLabel }
|
||||
return metric.label
|
||||
}
|
||||
}
|
||||
|
22
extra/lib/plausible/goal/revenue.ex
Normal file
22
extra/lib/plausible/goal/revenue.ex
Normal 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
|
37
extra/lib/plausible/ingestion/event/revenue.ex
Normal file
37
extra/lib/plausible/ingestion/event/revenue.ex
Normal 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
|
35
extra/lib/plausible/ingestion/request/revenue.ex
Normal file
35
extra/lib/plausible/ingestion/request/revenue.ex
Normal 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
|
97
extra/lib/plausible/stats/goal/revenue.ex
Normal file
97
extra/lib/plausible/stats/goal/revenue.ex
Normal 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
|
27
extra/lib/plausible_web/controllers/api/revenue.ex
Normal file
27
extra/lib/plausible_web/controllers/api/revenue.ex
Normal 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
|
@ -12,18 +12,22 @@ defmodule Plausible do
|
||||
end
|
||||
end
|
||||
|
||||
defmacro on_small_build(do: block) do
|
||||
if Mix.env() in @small_builds do
|
||||
quote do
|
||||
unquote(block)
|
||||
end
|
||||
end
|
||||
defmacro on_full_build(clauses) do
|
||||
do_on_full_build(clauses)
|
||||
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
|
||||
quote do
|
||||
unquote(block)
|
||||
unquote(do_block)
|
||||
end
|
||||
else
|
||||
quote do
|
||||
unquote(else_block)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -31,8 +35,10 @@ defmodule Plausible do
|
||||
defmacro full_build?() do
|
||||
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
|
||||
unquote(full_build?)
|
||||
:erlang.phash2(1, 1) == 0 and unquote(full_build?)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -231,14 +231,12 @@ defmodule Plausible.Billing.Quota do
|
||||
def features_usage(%Site{} = site) do
|
||||
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)
|
||||
end
|
||||
|
||||
on_small_build do
|
||||
funnels_exist = false
|
||||
end
|
||||
else
|
||||
false
|
||||
end
|
||||
|
||||
revenue_goals_exist =
|
||||
Plausible.Repo.exists?(
|
||||
|
@ -8,13 +8,12 @@ defmodule Plausible.Goal do
|
||||
schema "goals" do
|
||||
field :event_name, :string
|
||||
field :page_path, :string
|
||||
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
on_small_build do
|
||||
else
|
||||
field :currency, :string, virtual: true, default: nil
|
||||
field :funnels, {:array, :map}, virtual: true, default: []
|
||||
end
|
||||
|
||||
@ -23,26 +22,11 @@ defmodule Plausible.Goal do
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def revenue?(%__MODULE__{currency: currency}) do
|
||||
!!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
|
||||
@fields [:id, :site_id, :event_name, :page_path] ++ on_full_build(do: [:currency], else: [])
|
||||
|
||||
def changeset(goal, attrs \\ %{}) do
|
||||
goal
|
||||
|> cast(attrs, [:id, :site_id, :event_name, :page_path, :currency])
|
||||
|> cast(attrs, @fields)
|
||||
|> validate_required([:site_id])
|
||||
|> cast_assoc(:site)
|
||||
|> update_leading_slash()
|
||||
@ -93,7 +77,7 @@ defmodule Plausible.Goal do
|
||||
end
|
||||
|
||||
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)
|
||||
else
|
||||
changeset
|
||||
|
@ -1,11 +1,13 @@
|
||||
defmodule Plausible.Goals do
|
||||
use Plausible
|
||||
use Plausible.Repo
|
||||
use Plausible.Funnel.Const
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Plausible.Goal
|
||||
alias Ecto.Multi
|
||||
|
||||
use Plausible.Funnel.Const
|
||||
|
||||
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
|
||||
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
|
||||
@doc """
|
||||
@ -15,15 +17,17 @@ defmodule Plausible.Goals do
|
||||
refreshed by the sites cache, as revenue goals are used during ingestion.
|
||||
"""
|
||||
def create(site, params, opts \\ []) do
|
||||
now = Keyword.get(opts, :now, DateTime.utc_now())
|
||||
upsert? = Keyword.get(opts, :upsert?, false)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
case insert_goal(site, params, upsert?) do
|
||||
{:ok, :insert, goal} ->
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
|
||||
if Goal.revenue?(goal) do
|
||||
Plausible.Site.Cache.touch_site!(site, now)
|
||||
on_full_build do
|
||||
now = Keyword.get(opts, :now, DateTime.utc_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
|
||||
|
||||
Repo.preload(goal, :site)
|
||||
@ -118,22 +122,13 @@ defmodule Plausible.Goals do
|
||||
end
|
||||
|
||||
def delete(id, site_id) do
|
||||
on_full_build do
|
||||
goal_query =
|
||||
from(g in Goal,
|
||||
where: g.id == ^id,
|
||||
where: g.site_id == ^site_id,
|
||||
preload: [funnels: :steps]
|
||||
)
|
||||
end
|
||||
goal_query =
|
||||
from(g in Goal,
|
||||
where: g.id == ^id,
|
||||
where: g.site_id == ^site_id
|
||||
)
|
||||
|
||||
on_small_build do
|
||||
goal_query =
|
||||
from(g in Goal,
|
||||
where: g.id == ^id,
|
||||
where: g.site_id == ^site_id
|
||||
)
|
||||
end
|
||||
goal_query = on_full_build(do: preload(goal_query, funnels: :steps), else: goal_query)
|
||||
|
||||
result =
|
||||
Multi.new()
|
||||
|
15
lib/plausible/helpers/json.ex
Normal file
15
lib/plausible/helpers/json.ex
Normal 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
|
@ -5,6 +5,7 @@ defmodule Plausible.Ingestion.Event do
|
||||
are uniformly either buffered in batches (to Clickhouse) or dropped
|
||||
(e.g. due to spam blocklist) from the processing pipeline.
|
||||
"""
|
||||
use Plausible
|
||||
alias Plausible.Ingestion.Request
|
||||
alias Plausible.ClickhouseEventV2
|
||||
alias Plausible.Site.GateKeeper
|
||||
@ -220,36 +221,15 @@ defmodule Plausible.Ingestion.Event do
|
||||
|
||||
defp put_props(%__MODULE__{} = event), do: event
|
||||
|
||||
defp put_revenue(%__MODULE__{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) ->
|
||||
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)
|
||||
})
|
||||
defp put_revenue(event) do
|
||||
on_full_build do
|
||||
attrs = Plausible.Ingestion.Event.Revenue.get_revenue_attrs(event)
|
||||
update_attrs(event, attrs)
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
defp put_revenue(event), do: event
|
||||
|
||||
defp put_salts(%__MODULE__{} = event) do
|
||||
%{event | salts: Plausible.Session.Salts.fetch()}
|
||||
end
|
||||
|
@ -23,6 +23,7 @@ defmodule Plausible.Ingestion.Request do
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
use Plausible
|
||||
alias Ecto.Changeset
|
||||
|
||||
@max_url_size 2_000
|
||||
@ -40,7 +41,11 @@ defmodule Plausible.Ingestion.Request do
|
||||
field :hash_mode, :integer
|
||||
field :pathname, :string
|
||||
field :props, :map
|
||||
field :revenue_source, :map
|
||||
|
||||
on_full_build do
|
||||
field :revenue_source, :map
|
||||
end
|
||||
|
||||
field :query_params, :map
|
||||
|
||||
field :timestamp, :naive_datetime
|
||||
@ -90,6 +95,14 @@ defmodule Plausible.Ingestion.Request do
|
||||
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
|
||||
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIp.get(conn))
|
||||
end
|
||||
@ -192,7 +205,7 @@ defmodule Plausible.Ingestion.Request do
|
||||
defp put_props(changeset, %{} = request_body) do
|
||||
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.take(@max_props)
|
||||
|> Map.new()
|
||||
@ -224,46 +237,6 @@ defmodule Plausible.Ingestion.Request do
|
||||
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
|
||||
case Changeset.get_field(changeset, :uri) do
|
||||
%{query: query} when is_binary(query) ->
|
||||
|
@ -29,7 +29,7 @@ defmodule Plausible.Plugins.API.Goals do
|
||||
|
||||
@spec get_goals(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
|
||||
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}])}
|
||||
end
|
||||
@ -42,24 +42,11 @@ defmodule Plausible.Plugins.API.Goals do
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
on_full_build do
|
||||
defp get_query(site) do
|
||||
from g in Plausible.Goal,
|
||||
where: g.site_id == ^site.id,
|
||||
order_by: [desc: 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
|
||||
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
|
||||
|
||||
defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do
|
||||
|
@ -1,21 +1,28 @@
|
||||
defmodule Plausible.Stats.Aggregate do
|
||||
alias Plausible.Stats.Query
|
||||
use Plausible.ClickhouseRepo
|
||||
use Plausible
|
||||
import Plausible.Stats.{Base, Imported, Util}
|
||||
import Ecto.Query
|
||||
|
||||
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
|
||||
|
||||
@event_metrics [
|
||||
:visitors,
|
||||
:pageviews,
|
||||
:events,
|
||||
:sample_percent,
|
||||
:average_revenue,
|
||||
:total_revenue
|
||||
]
|
||||
:visitors,
|
||||
:pageviews,
|
||||
:events,
|
||||
:sample_percent
|
||||
] ++ @revenue_metrics
|
||||
|
||||
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit, :sample_percent]
|
||||
|
||||
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_task = fn -> aggregate_events(site, query, event_metrics) end
|
||||
@ -197,4 +204,12 @@ defmodule Plausible.Stats.Aggregate do
|
||||
end
|
||||
|
||||
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
|
||||
|
@ -1,5 +1,6 @@
|
||||
defmodule Plausible.Stats.Base do
|
||||
use Plausible.ClickhouseRepo
|
||||
use Plausible
|
||||
alias Plausible.Stats.{Query, Filters}
|
||||
import Ecto.Query
|
||||
|
||||
@ -301,24 +302,18 @@ defmodule Plausible.Stats.Base do
|
||||
|> select_event_metrics(rest)
|
||||
end
|
||||
|
||||
def select_event_metrics(q, [:total_revenue | rest]) do
|
||||
from(e in q,
|
||||
select_merge: %{
|
||||
total_revenue:
|
||||
fragment("toDecimal64(sum(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
|
||||
}
|
||||
)
|
||||
|> select_event_metrics(rest)
|
||||
end
|
||||
on_full_build do
|
||||
def select_event_metrics(q, [:total_revenue | rest]) do
|
||||
q
|
||||
|> Plausible.Stats.Goal.Revenue.total_revenue_query()
|
||||
|> select_event_metrics(rest)
|
||||
end
|
||||
|
||||
def select_event_metrics(q, [:average_revenue | rest]) do
|
||||
from(e in q,
|
||||
select_merge: %{
|
||||
average_revenue:
|
||||
fragment("toDecimal64(avg(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
|
||||
}
|
||||
)
|
||||
|> select_event_metrics(rest)
|
||||
def select_event_metrics(q, [:average_revenue | rest]) do
|
||||
q
|
||||
|> Plausible.Stats.Goal.Revenue.average_revenue_query()
|
||||
|> select_event_metrics(rest)
|
||||
end
|
||||
end
|
||||
|
||||
def select_event_metrics(q, [:sample_percent | rest]) do
|
||||
@ -331,7 +326,7 @@ defmodule Plausible.Stats.Base do
|
||||
|> select_event_metrics(rest)
|
||||
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
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
defmodule Plausible.Stats.Breakdown do
|
||||
use Plausible.ClickhouseRepo
|
||||
use Plausible
|
||||
import Plausible.Stats.{Base, Imported, Util}
|
||||
require OpenTelemetry.Tracer, as: Tracer
|
||||
alias Plausible.Stats.Query
|
||||
@ -7,9 +8,12 @@ defmodule Plausible.Stats.Breakdown do
|
||||
@no_ref "Direct / None"
|
||||
@not_set "(not set)"
|
||||
|
||||
@event_metrics [:visitors, :pageviews, :events, :average_revenue, :total_revenue]
|
||||
@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()
|
||||
|
||||
def breakdown(site, query, "event:goal" = property, metrics, pagination) do
|
||||
@ -23,7 +27,10 @@ defmodule Plausible.Stats.Breakdown do
|
||||
|
||||
event_results =
|
||||
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
|
||||
|> breakdown(event_query, "event:name", metrics, pagination)
|
||||
@ -68,7 +75,13 @@ defmodule Plausible.Stats.Breakdown do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
none_result =
|
||||
@ -674,4 +687,12 @@ defmodule Plausible.Stats.Breakdown do
|
||||
{"plausible.query.breakdown_metrics", metrics}
|
||||
])
|
||||
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
|
||||
|
@ -1,5 +1,6 @@
|
||||
defmodule Plausible.Stats.Timeseries do
|
||||
use Plausible.ClickhouseRepo
|
||||
use Plausible
|
||||
alias Plausible.Stats.Query
|
||||
import Plausible.Stats.{Base, Util}
|
||||
use Plausible.Stats.Fragments
|
||||
@ -15,7 +16,9 @@ defmodule Plausible.Stats.Timeseries do
|
||||
@typep value :: nil | integer() | float()
|
||||
@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]
|
||||
def timeseries(site, query, metrics) do
|
||||
steps = buckets(query)
|
||||
@ -23,7 +26,12 @@ defmodule Plausible.Stats.Timeseries do
|
||||
event_metrics = Enum.filter(metrics, &(&1 in @event_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] =
|
||||
Plausible.ClickhouseRepo.parallel_tasks([
|
||||
@ -234,4 +242,12 @@ defmodule Plausible.Stats.Timeseries do
|
||||
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
|
||||
|
@ -3,8 +3,6 @@ defmodule Plausible.Stats.Util do
|
||||
Utilities for modifying stat results
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@doc """
|
||||
`__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
|
||||
@ -22,73 +20,4 @@ defmodule Plausible.Stats.Util do
|
||||
def remove_internal_visits_metric(result) when is_map(result) do
|
||||
Map.delete(result, :__internal_visits)
|
||||
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
|
||||
|
@ -9,6 +9,8 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
require Logger
|
||||
|
||||
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
|
||||
|
||||
plug(:validate_common_input)
|
||||
|
||||
@doc """
|
||||
@ -339,7 +341,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do
|
||||
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 =
|
||||
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 conversions", :converted_visitors),
|
||||
top_stats_entry(results, comparison, "Total conversions", :completions),
|
||||
top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1),
|
||||
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1),
|
||||
on_full_build do
|
||||
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)
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
@ -1145,8 +1151,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
%{visitors: %{value: total_visitors}} = Stats.aggregate(site, total_q, [:visitors])
|
||||
|
||||
metrics =
|
||||
if Enum.any?(site.goals, &Plausible.Goal.revenue?/1) do
|
||||
[:visitors, :events, :average_revenue, :total_revenue]
|
||||
on_full_build do
|
||||
if Enum.any?(site.goals, &Plausible.Goal.Revenue.revenue?/1) do
|
||||
[:visitors, :events] ++ @revenue_metrics
|
||||
else
|
||||
[:visitors, :events]
|
||||
end
|
||||
else
|
||||
[:visitors, :events]
|
||||
end
|
||||
@ -1173,28 +1183,6 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
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
|
||||
site = Plausible.Repo.preload(conn.assigns.site, :owner)
|
||||
|
||||
@ -1244,8 +1232,8 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> Map.put(:include_imported, false)
|
||||
|
||||
metrics =
|
||||
if Map.has_key?(query.filters, "event:goal") do
|
||||
[:visitors, :events, :average_revenue, :total_revenue]
|
||||
if full_build?() and Map.has_key?(query.filters, "event:goal") do
|
||||
[:visitors, :events] ++ @revenue_metrics
|
||||
else
|
||||
[:visitors, :events]
|
||||
end
|
||||
@ -1486,4 +1474,13 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
true -> false
|
||||
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
|
||||
|
@ -96,9 +96,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
defp list_funnels(site) do
|
||||
Plausible.Funnels.list(site)
|
||||
end
|
||||
end
|
||||
|
||||
on_small_build do
|
||||
else
|
||||
defp list_funnels(_site) do
|
||||
[]
|
||||
end
|
||||
|
@ -3,6 +3,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
|
||||
Live view for the goal creation form
|
||||
"""
|
||||
use Phoenix.LiveView
|
||||
use Plausible
|
||||
import PlausibleWeb.Live.Components.Form
|
||||
alias PlausibleWeb.Live.Components.ComboBox
|
||||
|
||||
@ -144,6 +145,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={full_build?()}
|
||||
class="mt-6 space-y-3"
|
||||
x-data={
|
||||
Jason.encode!(%{
|
||||
@ -162,10 +164,11 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
|
||||
<button
|
||||
class={[
|
||||
"flex items-center w-max mb-3",
|
||||
if(assigns.has_access_to_revenue_goals?,
|
||||
do: "cursor-pointer",
|
||||
else: "cursor-not-allowed"
|
||||
)
|
||||
if @has_access_to_revenue_goals? do
|
||||
"cursor-pointer"
|
||||
else
|
||||
"cursor-not-allowed"
|
||||
end
|
||||
]}
|
||||
aria-labelledby="enable-revenue-tracking"
|
||||
role="switch"
|
||||
@ -204,14 +207,17 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
|
||||
submit_name={@f[:currency].name}
|
||||
module={ComboBox}
|
||||
suggest_fun={
|
||||
fn
|
||||
"", [] ->
|
||||
Plausible.Goal.currency_options()
|
||||
on_full_build do
|
||||
fn
|
||||
"", [] ->
|
||||
Plausible.Goal.Revenue.currency_options()
|
||||
|
||||
input, options ->
|
||||
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
|
||||
input, options ->
|
||||
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
|
||||
end
|
||||
end
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -125,16 +125,18 @@ defmodule PlausibleWeb.Router do
|
||||
get "/timeseries", ExternalStatsController, :timeseries
|
||||
end
|
||||
|
||||
scope "/api/v1/sites", PlausibleWeb.Api do
|
||||
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
|
||||
on_full_build do
|
||||
scope "/api/v1/sites", PlausibleWeb.Api do
|
||||
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
|
||||
|
||||
post "/", ExternalSitesController, :create_site
|
||||
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
|
||||
put "/goals", ExternalSitesController, :find_or_create_goal
|
||||
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
|
||||
get "/:site_id", ExternalSitesController, :get_site
|
||||
put "/:site_id", ExternalSitesController, :update_site
|
||||
delete "/:site_id", ExternalSitesController, :delete_site
|
||||
post "/", ExternalSitesController, :create_site
|
||||
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
|
||||
put "/goals", ExternalSitesController, :find_or_create_goal
|
||||
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
|
||||
get "/:site_id", ExternalSitesController, :get_site
|
||||
put "/:site_id", ExternalSitesController, :update_site
|
||||
delete "/:site_id", ExternalSitesController, :delete_site
|
||||
end
|
||||
end
|
||||
|
||||
scope "/api", PlausibleWeb do
|
||||
|
@ -339,6 +339,7 @@ defmodule Plausible.Billing.PlansTest do
|
||||
assert Plans.suggest_tier(user) == :growth
|
||||
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
|
||||
user = insert(:user, inserted_at: ~N[2023-10-25 10:00:00])
|
||||
site = insert(:site, members: [user])
|
||||
|
@ -2,10 +2,11 @@ defmodule Plausible.Billing.QuotaTest do
|
||||
use Plausible.DataCase, async: true
|
||||
use Plausible
|
||||
alias Plausible.Billing.{Quota, Plans}
|
||||
alias Plausible.Billing.Feature.{Goals, RevenueGoals, Props, StatsAPI}
|
||||
alias Plausible.Billing.Feature.{Goals, Props, StatsAPI}
|
||||
|
||||
on_full_build do
|
||||
alias Plausible.Billing.Feature.Funnels
|
||||
alias Plausible.Billing.Feature.RevenueGoals
|
||||
end
|
||||
|
||||
@legacy_plan_id "558746"
|
||||
@ -460,15 +461,15 @@ defmodule Plausible.Billing.QuotaTest do
|
||||
assert [Funnels] == Quota.features_usage(site)
|
||||
assert [Funnels] == Quota.features_usage(user)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns [RevenueGoals] when user/site uses revenue goals" do
|
||||
user = insert(:user)
|
||||
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
|
||||
insert(:goal, currency: :USD, site: site, event_name: "Purchase")
|
||||
test "returns [RevenueGoals] when user/site uses revenue goals" do
|
||||
user = insert(:user)
|
||||
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
|
||||
insert(:goal, currency: :USD, site: site, event_name: "Purchase")
|
||||
|
||||
assert [RevenueGoals] == Quota.features_usage(site)
|
||||
assert [RevenueGoals] == Quota.features_usage(user)
|
||||
assert [RevenueGoals] == Quota.features_usage(site)
|
||||
assert [RevenueGoals] == Quota.features_usage(user)
|
||||
end
|
||||
end
|
||||
|
||||
test "returns [StatsAPI] when user has a stats api key" do
|
||||
|
@ -48,6 +48,7 @@ defmodule Plausible.GoalsTest do
|
||||
assert {"has already been taken", _} = changeset.errors[:event_name]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "create/2 sets site.updated_at for revenue goal" do
|
||||
site_1 = insert(:site, updated_at: DateTime.add(DateTime.utc_now(), -3600))
|
||||
|
||||
@ -63,6 +64,7 @@ defmodule Plausible.GoalsTest do
|
||||
:eq
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "create/2 creates revenue goal" do
|
||||
site = insert(:site)
|
||||
{:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||
@ -71,6 +73,7 @@ defmodule Plausible.GoalsTest do
|
||||
assert goal.currency == :EUR
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "create/2 returns error when site does not have access to revenue goals" do
|
||||
user = insert(:user, subscription: build(:growth_subscription))
|
||||
site = insert(:site, members: [user])
|
||||
@ -79,6 +82,7 @@ defmodule Plausible.GoalsTest do
|
||||
Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "create/2 fails for unknown currency code" do
|
||||
site = insert(:site)
|
||||
|
||||
|
@ -100,6 +100,7 @@ defmodule Plausible.Ingestion.EventTest do
|
||||
assert dropped.drop_reason == :throttle
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "saves revenue amount" do
|
||||
site = insert(:site)
|
||||
_goal = insert(:goal, event_name: "checkout", currency: "USD", site: site)
|
||||
|
@ -171,6 +171,7 @@ defmodule Plausible.Ingestion.RequestTest do
|
||||
assert request.props["custom2"] == "property2"
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "parses revenue source field from a json string" do
|
||||
payload = %{
|
||||
name: "pageview",
|
||||
@ -186,6 +187,7 @@ defmodule Plausible.Ingestion.RequestTest do
|
||||
assert Decimal.new("20.2") == amount
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "sets revenue source with integer amount" do
|
||||
payload = %{
|
||||
name: "pageview",
|
||||
@ -204,6 +206,7 @@ defmodule Plausible.Ingestion.RequestTest do
|
||||
assert Decimal.equal?(amount, Decimal.new("20.0"))
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "sets revenue source with float amount" do
|
||||
payload = %{
|
||||
name: "pageview",
|
||||
@ -222,6 +225,7 @@ defmodule Plausible.Ingestion.RequestTest do
|
||||
assert Decimal.equal?(amount, Decimal.new("20.1"))
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "parses string amounts into money structs" do
|
||||
payload = %{
|
||||
name: "pageview",
|
||||
@ -240,6 +244,7 @@ defmodule Plausible.Ingestion.RequestTest do
|
||||
assert Decimal.equal?(amount, Decimal.new("12.3"))
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "ignores revenue data when currency is invalid" do
|
||||
payload = %{
|
||||
name: "pageview",
|
||||
@ -424,6 +429,7 @@ defmodule Plausible.Ingestion.RequestTest do
|
||||
assert changeset.errors[:request]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "encodable" do
|
||||
params = %{
|
||||
name: "pageview",
|
||||
|
@ -53,6 +53,7 @@ defmodule Plausible.Site.CacheTest do
|
||||
refute Cache.get("site3.example.com", cache_name: test, force?: true)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "cache caches revenue goals", %{test: test} do
|
||||
{:ok, _} =
|
||||
Supervisor.start_link(
|
||||
@ -84,6 +85,7 @@ defmodule Plausible.Site.CacheTest do
|
||||
] = Enum.sort_by(cached_goals, & &1.event_name)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "cache caches revenue goals with event refresh", %{test: test} do
|
||||
{:ok, _} =
|
||||
Supervisor.start_link(
|
||||
|
@ -672,6 +672,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
|
||||
assert Map.get(event, :"meta.value") == ["true"]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "converts revenue values into the goal currency", %{conn: conn, site: site} do
|
||||
params = %{
|
||||
name: "Payment",
|
||||
@ -688,6 +689,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
|
||||
assert Decimal.equal?(Decimal.new("7.14"), amount)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "revenue values can be sent with minified keys", %{conn: conn, site: site} do
|
||||
params = %{
|
||||
"n" => "Payment",
|
||||
@ -704,6 +706,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
|
||||
assert Decimal.equal?(Decimal.new("7.14"), amount)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "saves the exact same amount when goal currency is the same as the event", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -219,6 +219,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
assert resp == []
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "returns formatted average and total values for a conversion with revenue value", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
@ -259,6 +260,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
|
||||
]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "returns revenue metrics as nil for non-revenue goals", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
|
@ -787,6 +787,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
|
||||
]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "returns revenue metrics when filtering by a revenue goal", %{conn: conn, site: site} do
|
||||
prop_key = "logged_in"
|
||||
|
||||
@ -844,6 +845,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
|
||||
]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "returns revenue metrics when filtering by many revenue goals with same currency", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
|
@ -930,6 +930,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
||||
end
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
describe "GET /api/stats/main-graph - total_revenue plot" do
|
||||
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
|
||||
|
||||
@ -1009,6 +1010,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
||||
end
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
describe "GET /api/stats/main-graph - average_revenue plot" do
|
||||
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
|
||||
|
||||
|
@ -764,6 +764,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"]
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
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: "AddToCart", currency: "EUR")
|
||||
@ -806,6 +807,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
} in top_stats
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "returns average and total when filtering by many revenue goals with same currency", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
|
@ -254,6 +254,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
|
||||
refute text_of_element(doc, @business_plan_box) =~ "Recommended"
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "recommends Business tier when Revenue Goals were used during trial", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
@ -475,6 +476,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
|
||||
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "warns about losing access to a feature", %{conn: conn, user: user} do
|
||||
site = insert(:site, members: [user])
|
||||
|
||||
@ -710,6 +712,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
|
||||
refute element_exists?(doc, @business_highlight_pill)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "recommends Business tier when premium features used", %{conn: conn, user: user} do
|
||||
site = insert(:site, members: [user])
|
||||
insert(:goal, currency: :USD, site: site, event_name: "Purchase")
|
||||
|
@ -35,7 +35,8 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
|
||||
describe "Goal submission" do
|
||||
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)
|
||||
html = render(lv)
|
||||
|
||||
@ -52,6 +53,22 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
|
||||
assert name_of(page_path) == "goal[page_path]"
|
||||
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
|
||||
lv = get_liveview(conn, site)
|
||||
html = lv |> element("form") |> render_submit()
|
||||
@ -70,6 +87,7 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
|
||||
assert parent_html =~ "Custom Event"
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "creates a revenue goal", %{conn: conn, site: site} do
|
||||
{parent, lv} = get_liveview(conn, site, with_parent?: true)
|
||||
refute render(parent) =~ "SampleRevenueGoal"
|
||||
@ -96,6 +114,7 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
|
||||
describe "Combos integration" do
|
||||
setup [:create_user, :log_in, :create_site]
|
||||
|
||||
@tag :full_build_only
|
||||
test "currency combo works", %{conn: conn, site: site} do
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
|
@ -45,6 +45,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
end
|
||||
|
||||
describe "business tier" do
|
||||
@tag :full_build_only
|
||||
test "fails on revenue goal creation attempt with insufficient plan", %{
|
||||
site: site,
|
||||
token: token,
|
||||
@ -71,6 +72,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
|> assert_schema("PaymentRequiredError", spec())
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "fails on bulk revenue goal creation attempt with insufficient plan", %{
|
||||
site: site,
|
||||
token: token,
|
||||
@ -147,6 +149,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
assert [%{event_name: "Signup"}] = Plausible.Goals.for_site(site)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "creates a revenue goal", %{conn: conn, token: token, site: site} do
|
||||
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)
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "fails to create a revenue goal with unknown currency", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
@ -205,6 +209,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
assert [%{detail: "currency: is invalid"}] = resp.errors
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "edge case - revenue goal exists under the same name and different currency", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
@ -277,6 +282,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
end
|
||||
|
||||
describe "put /goals - bulk creation" do
|
||||
@tag :full_build_only
|
||||
test "creates a goal of each type", %{conn: conn, token: token, site: site} do
|
||||
url = Routes.goals_url(base_uri(), :create)
|
||||
|
||||
@ -351,6 +357,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
} in resp.errors
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "is idempotent", %{conn: conn, token: token, site: site} do
|
||||
url = Routes.goals_url(base_uri(), :create)
|
||||
|
||||
@ -385,6 +392,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
|> assert_schema("Goal.ListResponse", spec())
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "edge case - revenue goals exist under the same name and different currency", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
@ -430,6 +438,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|
||||
assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "retrieves revenue goal by ID", %{conn: conn, site: site, token: token} do
|
||||
{:ok, goal} =
|
||||
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 == %{}
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "returns a list of goals of each possible goal type", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
|
Loading…
Reference in New Issue
Block a user