Skip to content

Conversation

@sanne-san
Copy link
Contributor

Changes

  • As a first step towards improving the subscription settings page, this commit adds usage notifications for billing limits. These are not yet being displayed in the UI.

Tests

  • Automated tests have been added

Changelog

  • This PR does not make a user-facing change

Documentation

  • This change does not need a documentation update

Dark mode

  • This PR does not change the UI

- As a first step towards improving the subscription settings page, this commit adds usage notifications for billing limits. These are not yet being displayed in the UI.
@sanne-san sanne-san force-pushed the sanne-billing-notifications branch from 4fe93b2 to 0936c29 Compare February 10, 2026 08:36
@sanne-san sanne-san force-pushed the sanne-billing-notifications branch from 4d89e7b to db8fa96 Compare February 10, 2026 09:15
Copy link
Contributor

@RobertJoonas RobertJoonas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like where we're going with this! As you've probably realized, the billing logic is incredibly complex and there are a lot of different considerations at every step. So even a small PR like this has a bunch of comments 😄

PS: The screen recordings you shared are 💯👌! Excited to see those changes in :)

7. Site limit reached
8. Team member limit reached
"""
def determine_notification_type(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of a single function determining the priority of usage notifications. Makes it easier to display a single notice at a time which makes perfect sense to not overwhelm customers! 👏

Codewise, seeing a few possible improvements here:

  1. Moving the function

I think this function could move elsewhere, so that this module, located at lib/plausible_web/components only defines actual components.

A suitable place would perhaps be the Plausible.Billing.Quota module, since it's already dealing with usage and limits. The name could also change, perhaps to usage_notification_type?

  1. We should try to cut down on the number of arguments to make the function signature more readable. Especially in tests.

My proposal is to keep only two arguments: usage_notification_type(team, usage)

Every argument is derivable from team (see the subscription function in PlausibleWeb.SettingsController), but the reason I would keep usage separate is being able to test it more easily. Generating actual usage in tests is cumbersome.

Note that the usage argument would become a map with all the different usage aspects included in it. There's the Plausible.Teams.Billing.quota_usage function that returns everything, including team_members, sites and monthly_pageviews.

So in PlausibleWeb.SettingsController, I imagine we could be passing only the following assigns to the subscription.html.heex template:

def subscription(conn, _params) do
    team = conn.assigns.current_team
    subscription = Teams.Billing.get_subscription(team)
    usage = Teams.Billing.quota_usage(team)

    render(conn, :subscription,
      layout: {PlausibleWeb.LayoutView, :settings},
      team: team,
      subscription: subscription,
      usage: usage
    )
  end

Plausible.Teams.GracePeriod.expired?(team) ->
:dashboard_locked

Plausible.Teams.on_trial?(team) == false and is_nil(subscription) ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Plausible.Teams.on_trial?(team) == false and is_nil(subscription) ->
not Plausible.Teams.on_trial?(team) and is_nil(subscription) ->

Comment on lines 473 to 485
pageview_limit != :unlimited and
is_map_key(pageview_usage, :last_cycle) and is_map_key(pageview_usage, :penultimate_cycle) and
Plausible.Billing.Quota.exceeds_last_two_usage_cycles?(pageview_usage, pageview_limit) ->
:traffic_exceeded_sustained

is_map_key(pageview_usage, :last_cycle) and
pageview_usage.last_cycle.total > pageview_limit and
pageview_limit != :unlimited ->
:traffic_exceeded_last_cycle

pageview_limit != :unlimited and is_map_key(pageview_usage, :current_cycle) and
pageview_usage.current_cycle.total >= pageview_limit * 0.9 ->
:pageview_approaching_limit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing an opportunity to 1) extract some predicate functions and 2) merge these three cond branches into a single one that handles billing cycles in a separate function.

Also, the pageview limit is only :unlimited for trials, so instead of checking whether the pageview_limit is unlimited, we could be checking whether the team is on trial, with the already existing predicate function. Here's what I'm thinking:

cond do
  # ...
  not Plausible.Teams.on_trial?(team) and has_billing_cycles?(usage.monthly_pageviews) ->
    pageview_cycle_usage_notification_type(usage.monthly_pageviews, monthly_pageview_limit)
  # ...
end

defp pageview_cycle_usage_notification_type(usage, limit) do
  exceeded = Plausible.Billing.Quota.exceeded_cycles(usage, limit)

  cond do
    :penultimate_cycle in exceeded and :last_cycle_exceeded ->
      :traffic_exceeded_sustained
    :last_cycle in exceeded ->
      :traffic_exceeded_last_cycle
    usage.current_cycle >= limit * 0.9 ->
      :pageview_approaching_limit
  end
end

The has_billing_cycles?/1 function could be a public predicate function in the Plausible.Billing.Quota module, and could also be used in the exceeds_monthly_pageview_limit? function (where the behaviour depends on whether the usage is in billing cycles or last 30 days). Using predicates like that is a good practice as it'll give us a better overview in the code about where this specific logic matters.

About the pageview_cycle_usage_notification_type function -- I think we should opt to use the actual limit checking function that also takes the allowance_margins into account (10% by default). E.g. currently, in the situation where both penultimate and last cycles are exceeded by less than 10% (e.g. 10,150/10,000 and 10,999/10,000), the exceeds_last_two_usage_cycles? would return false, while usage.last_cycle > 10,000 would return true. That said though, I'm not fully sure how to handle these margins.


site_usage >= site_limit and site_limit != :unlimited and
team_member_usage >= team_member_limit and team_member_limit != :unlimited ->
:limits_reached_combined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming this to :site_and_team_member_limit_reached? Currently, "combined" makes it sound a bit like pageview limit is reached too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good idea!

- Move `determine_notification_type` function from Notice component to `Plausible.Billing.Quota`
- Rename `determine_notification_type` to `usage_notification_type`
- Cut down on the number of arguments to make the `usage_notification_type` function more readable
- Add public `has_billing_cycles` predicate function
- Update `exceeds_last_two_usage_cycles` to use `has_billing_cycles`
- Extract `pageview_cycle_usage_notification_type` for pageview limit notifications
- Rename :limits_reached_combined to :site_and_team_member_limit_reached
- Update all tests to use realistic data structures
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants