diff --git a/ee/api/test/test_billing.py b/ee/api/test/test_billing.py index d4586149f16c3..2b4d38dd85bd8 100644 --- a/ee/api/test/test_billing.py +++ b/ee/api/test/test_billing.py @@ -375,19 +375,11 @@ def mock_implementation(url: str, headers: Any = None, params: Any = None) -> Ma "unit_amount_usd": "0.00", "up_to": 1000000, "current_amount_usd": "0.00", - "current_usage": 0, - "flat_amount_usd": "0", - "projected_amount_usd": "None", - "projected_usage": None, }, { "unit_amount_usd": "0.00045", "up_to": 2000000, "current_amount_usd": "0.00", - "current_usage": 0, - "flat_amount_usd": "0", - "projected_amount_usd": "None", - "projected_usage": None, }, ], "tiered": True, @@ -418,19 +410,11 @@ def mock_implementation(url: str, headers: Any = None, params: Any = None) -> Ma "tiers": [ { "current_amount_usd": "0.00", - "current_usage": 0, - "flat_amount_usd": "0", - "projected_amount_usd": "None", - "projected_usage": None, "unit_amount_usd": "0.00", "up_to": 1000000, }, { "current_amount_usd": "0.00", - "current_usage": 0, - "flat_amount_usd": "0", - "projected_amount_usd": "None", - "projected_usage": None, "unit_amount_usd": "0.0000135", "up_to": 2000000, }, @@ -512,7 +496,7 @@ def mock_implementation(url: str, headers: Any = None, params: Any = None) -> Ma ], "current_usage": 0, "percentage_usage": 0, - "current_amount_usd": "0.00", + "current_amount_usd": 0.0, "has_exceeded_limit": False, "projected_amount_usd": 0.0, "projected_amount": 0, @@ -525,7 +509,7 @@ def mock_implementation(url: str, headers: Any = None, params: Any = None) -> Ma "usage_key": "events", "addons": [ { - "current_amount_usd": "0.00", + "current_amount_usd": 0.0, "current_usage": 0, "description": "Test Addon", "free_allocation": 10000, @@ -758,217 +742,15 @@ def mock_implementation(url: str, headers: Any = None, params: Any = None) -> Ma res_json = res.json() # Should update product usage to reflect today's usage assert res_json["products"][0]["current_usage"] == 1101000 - assert res_json["products"][0]["current_amount_usd"] == "45.45" - assert res_json["products"][0]["tiers"][0]["current_usage"] == 1000000 - assert res_json["products"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["tiers"][1]["current_usage"] == 101000 - assert res_json["products"][0]["tiers"][1]["current_amount_usd"] == "45.45" - - assert res_json["products"][0]["addons"][0]["current_usage"] == 1101000 - assert res_json["products"][0]["addons"][0]["current_amount_usd"] == "1.36" - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_usage"] == 1000000 - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_usage"] == 101000 - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_amount_usd"] == "1.36" - - # Now test when there is a usage_limit. - def mock_implementation_with_limit(url: str, headers: Any = None, params: Any = None) -> MagicMock: - mock = MagicMock() - mock.status_code = 404 - - if "api/billing/portal" in url: - mock.status_code = 200 - mock.json.return_value = {"url": "https://billing.stripe.com/p/session/test_1234"} - elif "api/billing" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_response( - customer=create_billing_customer(has_active_subscription=True), - ) - mock.json.return_value["customer"]["usage_summary"]["events"]["usage"] = 1000000 - mock.json.return_value["customer"]["usage_summary"]["events"]["limit"] = 1000000 - elif "api/products" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_products_response() - - return mock - - mock_request.side_effect = mock_implementation_with_limit - self.organization.usage = {"events": {"limit": 1000000, "usage": 1000000, "todays_usage": 100}} - self.organization.save() - - res = self.client.get("/api/billing") - assert res.status_code == 200 - res_json = res.json() - # Should update product usage to reflect today's usage - assert res_json["products"][0]["current_usage"] == 1000100 - assert res_json["products"][0]["current_amount_usd"] == "0.04" - assert res_json["products"][0]["tiers"][0]["current_usage"] == 1000000 - assert res_json["products"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["tiers"][1]["current_usage"] == 100 - assert res_json["products"][0]["tiers"][1]["current_amount_usd"] == "0.04" - - assert res_json["products"][0]["addons"][0]["current_usage"] == 1000100 - assert res_json["products"][0]["addons"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_usage"] == 1000000 - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_usage"] == 100 - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_amount_usd"] == "0.00" - - def mock_implementation_exceeds_limit(url: str, headers: Any = None, params: Any = None) -> MagicMock: - mock = MagicMock() - mock.status_code = 404 - - if "api/billing/portal" in url: - mock.status_code = 200 - mock.json.return_value = {"url": "https://billing.stripe.com/p/session/test_1234"} - elif "api/billing" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_response( - customer=create_billing_customer(has_active_subscription=True), - ) - mock.json.return_value["customer"]["usage_summary"]["events"]["usage"] = 1100000 - mock.json.return_value["customer"]["usage_summary"]["events"]["limit"] = 1000000 - elif "api/products" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_products_response() - - return mock - - mock_request.side_effect = mock_implementation_exceeds_limit - self.organization.usage = {"events": {"limit": 1000000, "usage": 1100000, "todays_usage": 1000}} - self.organization.save() - - res = self.client.get("/api/billing") - assert res.status_code == 200 - res_json = res.json() - # Should update product usage to reflect today's usage - assert res_json["products"][0]["current_usage"] == 1101000 - assert res_json["products"][0]["current_amount_usd"] == "45.00" - assert res_json["products"][0]["tiers"][0]["current_usage"] == 1000000 - assert res_json["products"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["tiers"][1]["current_usage"] == 100000 - assert res_json["products"][0]["tiers"][1]["current_amount_usd"] == "45.00" - - assert res_json["products"][0]["addons"][0]["current_usage"] == 1101000 - assert res_json["products"][0]["addons"][0]["current_amount_usd"] == "1.35" - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_usage"] == 1000000 - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_usage"] == 100000 - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_amount_usd"] == "1.35" - - # Test when the customer has no usage. Ensure that the tiered current_usage isn't set to the usage limit. - def mock_implementation_with_limit_no_usage(url: str, headers: Any = None, params: Any = None) -> MagicMock: - mock = MagicMock() - mock.status_code = 404 - - if "api/billing/portal" in url: - mock.status_code = 200 - mock.json.return_value = {"url": "https://billing.stripe.com/p/session/test_1234"} - elif "api/billing" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_response( - customer=create_billing_customer(has_active_subscription=True), - ) - mock.json.return_value["customer"]["usage_summary"]["events"]["usage"] = 0 - mock.json.return_value["customer"]["usage_summary"]["events"]["limit"] = 1000000 - elif "api/products" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_products_response() - - return mock - - mock_request.side_effect = mock_implementation_with_limit_no_usage - - self.organization.usage = {"events": {"limit": 1000000, "usage": 0, "todays_usage": 0}} - self.organization.save() - - res = self.client.get("/api/billing") - assert res.status_code == 200 - res_json = res.json() - # Should update product usage to reflect today's usage - assert res_json["products"][0]["current_usage"] == 0 assert res_json["products"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["tiers"][0]["current_usage"] == 0 assert res_json["products"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["tiers"][1]["current_usage"] == 0 assert res_json["products"][0]["tiers"][1]["current_amount_usd"] == "0.00" assert res_json["products"][0]["addons"][0]["current_usage"] == 0 assert res_json["products"][0]["addons"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["addons"][0]["tiers"][0]["current_usage"] == 0 assert res_json["products"][0]["addons"][0]["tiers"][0]["current_amount_usd"] == "0.00" - assert res_json["products"][0]["addons"][0]["tiers"][1]["current_usage"] == 0 assert res_json["products"][0]["addons"][0]["tiers"][1]["current_amount_usd"] == "0.00" - def mock_implementation_missing_customer(url: str, headers: Any = None, params: Any = None) -> MagicMock: - mock = MagicMock() - mock.status_code = 404 - - if "api/billing/portal" in url: - mock.status_code = 200 - mock.json.return_value = {"url": "https://billing.stripe.com/p/session/test_1234"} - elif "api/billing" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_response(customer=create_missing_billing_customer()) - elif "api/products" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_products_response() - - return mock - - mock_request.side_effect = mock_implementation_missing_customer - - # Test unsubscribed config - res = self.client.get("/api/billing") - self.organization.refresh_from_db() - assert self.organization.usage == { - "events": { - "limit": None, - "todays_usage": 0, - "usage": 0, - }, - "recordings": { - "limit": None, - "todays_usage": 0, - "usage": 0, - }, - "rows_synced": { - "limit": None, - "todays_usage": 0, - "usage": 0, - }, - "period": ["2022-10-07T11:12:48", "2022-11-07T11:12:48"], - } - assert self.organization.customer_id == "cus_123" - - # Now test when there is a tiered product in the response that isn't in the usage dict - def mock_implementation(url: str, headers: Any = None, params: Any = None) -> MagicMock: - mock = MagicMock() - mock.status_code = 404 - - if "api/billing/portal" in url: - mock.status_code = 200 - mock.json.return_value = {"url": "https://billing.stripe.com/p/session/test_1234"} - elif "api/billing" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_response( - customer=create_billing_customer(has_active_subscription=True), - ) - mock.json.return_value["customer"]["products"][0]["usage_key"] = "feature_flag_requests" - mock.json.return_value["customer"]["usage_summary"]["events"]["usage"] = 1000 - elif "api/products" in url: - mock.status_code = 200 - mock.json.return_value = create_billing_products_response() - - return mock - - mock_request.side_effect = mock_implementation - self.organization.usage = {"events": {"limit": 1000000, "usage": 1000, "todays_usage": 1100000}} - self.organization.save() - - res = self.client.get("/api/billing") - assert res.status_code == 200 - @patch("ee.api.billing.requests.get") def test_organization_usage_count_with_demo_project(self, mock_request, *args): def mock_implementation(url: str, headers: Any = None, params: Any = None) -> MagicMock: diff --git a/ee/billing/billing_manager.py b/ee/billing/billing_manager.py index 2f35a7be3b92e..4f735d4890b3d 100644 --- a/ee/billing/billing_manager.py +++ b/ee/billing/billing_manager.py @@ -1,7 +1,6 @@ from datetime import datetime, timedelta -from decimal import Decimal from enum import Enum -from typing import Any, Optional, Union, cast +from typing import Any, Optional, cast import jwt import requests @@ -11,7 +10,7 @@ from rest_framework.exceptions import NotAuthenticated from sentry_sdk import capture_exception -from ee.billing.billing_types import BillingStatus, Tier +from ee.billing.billing_types import BillingStatus from ee.billing.quota_limiting import set_org_usage_summary, sync_org_quota_limits from ee.models import License from ee.settings import BILLING_SERVICE_URL @@ -58,61 +57,6 @@ def handle_billing_service_error(res: requests.Response, valid_codes=(200, 404, raise Exception(f"Billing service returned bad status code: {res.status_code}", f"body:", res.text) -def compute_usage_per_tier(limited_usage: int, projected_usage: int, tiers): - remaining_usage = limited_usage - remaining_projected_usage = projected_usage or 0 - previous_tier: Optional[dict[str, Any]] = None - tier_max_usage: Union[int, float] = 0 - - result: list[Tier] = [] - for tier in tiers: - if previous_tier and previous_tier.get("up_to"): - previous_tier_up_to = previous_tier["up_to"] - else: - previous_tier_up_to = 0 - - if tier.get("up_to"): - tier_max_usage = tier["up_to"] - previous_tier_up_to - else: - tier_max_usage = float("inf") - - flat_amount_usd = Decimal(tier.get("flat_amount_usd") or 0) - unit_amount_usd = Decimal(tier.get("unit_amount_usd") or 0) - usage_this_tier = int(min(remaining_usage, tier_max_usage)) - remaining_usage -= usage_this_tier - current_amount_usd = Decimal(unit_amount_usd * usage_this_tier + flat_amount_usd).quantize(Decimal("0.01")) - previous_tier = tier - if projected_usage: - projected_usage_this_tier = int(min(remaining_projected_usage, tier_max_usage)) - remaining_projected_usage -= projected_usage_this_tier - projected_amount_usd = Decimal(unit_amount_usd * projected_usage_this_tier + flat_amount_usd).quantize( - Decimal("0.01") - ) - else: - projected_usage_this_tier = None - projected_amount_usd = None - - result.append( - Tier( - flat_amount_usd=str(flat_amount_usd), - unit_amount_usd=str(unit_amount_usd), - up_to=tier.get("up_to", None), - current_amount_usd=str(current_amount_usd), - current_usage=usage_this_tier, - projected_usage=projected_usage_this_tier, - projected_amount_usd=str(projected_amount_usd), - ) - ) - return result - - -def sum_total_across_tiers(tiers): - total = Decimal(0) - for tier in tiers: - total += Decimal(tier["current_amount_usd"]) - return total - - class BillingManager: license: Optional[License] @@ -164,50 +108,6 @@ def get_billing(self, organization: Optional[Organization], plan_keys: Optional[ product["current_usage"] = current_usage product["percentage_usage"] = current_usage / usage_limit if usage_limit else 0 - - # Also update the tiers - if product.get("tiers"): - usage_limit = product_usage.get("limit") - limited_usage = 0 - # If the usage has already exceeded the billing limit, don't increment - # today's usage - if usage_limit is not None and billing_reported_usage > usage_limit: - limited_usage = billing_reported_usage - else: - limited_usage = current_usage - - product["tiers"] = compute_usage_per_tier( - limited_usage, product["projected_usage"], product["tiers"] - ) - product["current_amount_usd"] = str(sum_total_across_tiers(product["tiers"])) - - # Update the add on tiers - # TODO: enhanced_persons: make sure this updates properly for addons with different usage keys - for addon in product.get("addons"): - if not addon.get("subscribed"): - continue - addon_usage_key = addon.get("usage_key") - if not usage_key: - continue - if addon_usage_key != usage_key: - usage = response.get("usage_summary", {}).get(addon_usage_key, {}) - usage_limit = usage.get("limit") - billing_reported_usage = usage.get("usage") or 0 - if product_usage.get("todays_usage"): - todays_usage = product_usage["todays_usage"] - current_usage = billing_reported_usage + todays_usage - addon["current_usage"] = current_usage - - limited_usage = 0 - # If the usage has already exceeded the billing limit, don't increment - # today's usage - if usage_limit is not None and billing_reported_usage > usage_limit: - limited_usage = billing_reported_usage - else: - # Otherwise, do increment toady's usage - limited_usage = current_usage - addon["tiers"] = compute_usage_per_tier(limited_usage, addon["projected_usage"], addon["tiers"]) - addon["current_amount_usd"] = str(sum_total_across_tiers(addon["tiers"])) else: products = self.get_default_products(organization) response = { diff --git a/frontend/__snapshots__/scenes-app-insights--trends-line-edit--light.png b/frontend/__snapshots__/scenes-app-insights--trends-line-edit--light.png index 0510a04db24d3..fb9a44bbb9c09 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--trends-line-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--trends-line-edit--light.png differ diff --git a/frontend/src/scenes/billing/Billing.tsx b/frontend/src/scenes/billing/Billing.tsx index 8bd8d5305432a..c6cb267ffd255 100644 --- a/frontend/src/scenes/billing/Billing.tsx +++ b/frontend/src/scenes/billing/Billing.tsx @@ -145,7 +145,7 @@ export function Billing(): JSX.Element { {billing?.has_active_subscription && ( <> Current bill total diff --git a/frontend/src/scenes/billing/BillingProduct.tsx b/frontend/src/scenes/billing/BillingProduct.tsx index be58d1a8eb168..215ddcd1efe84 100644 --- a/frontend/src/scenes/billing/BillingProduct.tsx +++ b/frontend/src/scenes/billing/BillingProduct.tsx @@ -208,7 +208,7 @@ export const BillingProduct = ({ product }: { product: BillingProductV2Type }): billing?.discount_percent ? 'discounted ' : '' }amount you have been billed for this ${ billing?.billing_period?.interval - } so far.`} + } so far. This number updates once daily.`} >
@@ -235,7 +235,7 @@ export const BillingProduct = ({ product }: { product: BillingProductV2Type }): billing?.discount_percent ? ', discounts on your account,' : '' - } and the remaining time left in this billing period.`} + } and the remaining time left in this billing period. This number updates once daily.`} >
diff --git a/frontend/src/scenes/billing/BillingProductPricingTable.tsx b/frontend/src/scenes/billing/BillingProductPricingTable.tsx index 344e6077598c7..196551d3a2ba5 100644 --- a/frontend/src/scenes/billing/BillingProductPricingTable.tsx +++ b/frontend/src/scenes/billing/BillingProductPricingTable.tsx @@ -1,5 +1,5 @@ import { IconArrowRightDown, IconInfo } from '@posthog/icons' -import { LemonTable, LemonTableColumns, Link } from '@posthog/lemon-ui' +import { LemonBanner, LemonTable, LemonTableColumns, Link } from '@posthog/lemon-ui' import { useValues } from 'kea' import { compactNumber } from 'lib/utils' @@ -192,6 +192,9 @@ export const BillingProductPricingTable = ({ .

)} + + Tier breakdowns are updated once daily and may differ from the gauge above. + ) : (