Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add API usage billing #3729

Merged
merged 30 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7d8e8a2
Fix naming and add api usage model
zachaysan Apr 8, 2024
2e3b185
Add task to bill monthly users
zachaysan Apr 8, 2024
5034e38
Add api call billing to __init__.py
zachaysan Apr 8, 2024
976e2ce
Add migration to fix naming and add api billing
zachaysan Apr 8, 2024
fb2652e
Create tasks for billing API usage
zachaysan Apr 8, 2024
89188ed
Create exceptions for API usage billing
zachaysan Apr 8, 2024
09a4bf0
Add missing plan constants
zachaysan Apr 8, 2024
b27c773
Add new addon names
zachaysan Apr 8, 2024
db81e9f
Add API usage tracking
zachaysan Apr 8, 2024
c312fc6
Merge branch 'main' into feat/add_api_usage_billing
zachaysan Apr 8, 2024
a7a45c3
Do not prorate
zachaysan Apr 18, 2024
d469194
Update tests to match prorate setting
zachaysan Apr 18, 2024
e9b5806
Add docstring, comments, and an additional select related param
zachaysan Apr 18, 2024
a0cf44b
Merge branch 'main' into feat/add_api_usage_billing
zachaysan Apr 18, 2024
50d65ab
Tweak comment language
zachaysan Apr 26, 2024
6c108b4
Merge branch 'main' into feat/add_api_usage_billing
zachaysan Apr 26, 2024
5142c39
Add docstring comment as suggested by Matt
zachaysan May 3, 2024
33251c1
Run the task more frequently
zachaysan May 3, 2024
7288d49
Merge branch 'main' into feat/add_api_usage_billing
zachaysan May 3, 2024
792b975
Fix many conflicts and merge branch 'main' into feat/add_api_usage_bi…
zachaysan May 10, 2024
5061750
Merge branch 'main' into feat/add_api_usage_billing
zachaysan May 10, 2024
9ed8615
Add api calls test for empty given count
zachaysan May 13, 2024
c6f2b46
Add organisations tasks test coverage
zachaysan May 13, 2024
b007844
Add add_1000_api_calls test coverage
zachaysan May 13, 2024
0ae1aeb
Add work around for test coverage
zachaysan May 13, 2024
637a8be
Merge branch 'main' into feat/add_api_usage_billing
zachaysan May 13, 2024
2c5a43d
Trigger build
zachaysan May 13, 2024
23ed02b
Update api/tests/unit/organisations/test_unit_organisations_tasks.py
zachaysan May 17, 2024
f4edbd7
Add assertion for tasks being passed in
zachaysan May 17, 2024
3410d36
Merge branch 'main' into feat/add_api_usage_billing
zachaysan May 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/organisations/chargebee/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from .chargebee import ( # noqa
add_1000_api_calls,
add_1000_api_calls_scale_up,
add_1000_api_calls_start_up,
add_single_seat,
extract_subscription_metadata,
get_customer_id_from_subscription_id,
Expand Down
56 changes: 55 additions & 1 deletion api/organisations/chargebee/chargebee.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@
from ..subscriptions.constants import CHARGEBEE
from ..subscriptions.exceptions import (
CannotCancelChargebeeSubscription,
UpgradeAPIUsageError,
UpgradeAPIUsagePaymentFailure,
UpgradeSeatsError,
UpgradeSeatsPaymentFailure,
)
from .cache import ChargebeeCache
from .constants import ADDITIONAL_SEAT_ADDON_ID
from .constants import (
ADDITIONAL_API_SCALE_UP_ADDON_ID,
ADDITIONAL_API_START_UP_ADDON_ID,
ADDITIONAL_SEAT_ADDON_ID,
)
from .metadata import ChargebeeObjMetadata

chargebee.configure(settings.CHARGEBEE_API_KEY, settings.CHARGEBEE_SITE)
Expand Down Expand Up @@ -203,6 +209,54 @@ def add_single_seat(subscription_id: str):
raise UpgradeSeatsError(msg) from e


def add_1000_api_calls_start_up(
subscription_id: str, count: int = 1, invoice_immediately: bool = False
) -> None:
add_1000_api_calls(ADDITIONAL_API_START_UP_ADDON_ID, subscription_id, count)


def add_1000_api_calls_scale_up(
subscription_id: str, count: int = 1, invoice_immediately: bool = False
) -> None:
add_1000_api_calls(ADDITIONAL_API_SCALE_UP_ADDON_ID, subscription_id, count)


def add_1000_api_calls(
addon_id: str,
subscription_id: str,
count: int = 1,
invoice_immediately: bool = False,
) -> None:
if not count:
return
try:
chargebee.Subscription.update(
subscription_id,
{
"addons": [{"id": addon_id, "quantity": count}],
"prorate": False,
"invoice_immediately": invoice_immediately,
},
)

except ChargebeeAPIError as e:
api_error_code = e.json_obj["api_error_code"]
if api_error_code in CHARGEBEE_PAYMENT_ERROR_CODES:
logger.warning(
f"Payment declined ({api_error_code}) during additional "
f"api calls upgrade to a CB subscription for subscription_id "
f"{subscription_id}"
)
raise UpgradeAPIUsagePaymentFailure() from e

msg = (
"Failed to add additional API calls to CB subscription for subscription id: %s"
% subscription_id
)
logger.error(msg)
raise UpgradeAPIUsageError(msg) from e


def _convert_chargebee_subscription_to_dictionary(
chargebee_subscription: chargebee.Subscription,
) -> dict:
Expand Down
3 changes: 3 additions & 0 deletions api/organisations/chargebee/constants.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
ADDITIONAL_SEAT_ADDON_ID = "additional-team-members-scale-up-v2-monthly"

ADDITIONAL_API_START_UP_ADDON_ID = "additional-api-start-up-monthly"
ADDITIONAL_API_SCALE_UP_ADDON_ID = "additional-api-scale-up-monthly"
30 changes: 30 additions & 0 deletions api/organisations/migrations/0054_create_api_billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.25 on 2024-04-08 15:07

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('organisations', '0053_create_api_limit_access_block'),
]

operations = [
migrations.RenameModel(
old_name='OranisationAPIUsageNotification',
new_name='OrganisationAPIUsageNotification',
),
migrations.CreateModel(
name='OrganisationAPIBilling',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('api_overage', models.IntegerField()),
('immediate_invoice', models.BooleanField(default=False)),
('billed_at', models.DateTimeField()),
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True, null=True)),
('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_billing', to='organisations.organisation')),
],
),
]
29 changes: 28 additions & 1 deletion api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ def erase_api_notifications(self):
self.organisation.api_usage_notifications.all().delete()


class OranisationAPIUsageNotification(models.Model):
class OrganisationAPIUsageNotification(models.Model):
organisation = models.ForeignKey(
Organisation, on_delete=models.CASCADE, related_name="api_usage_notifications"
)
Expand Down Expand Up @@ -490,3 +490,30 @@ class HubspotOrganisation(models.Model):
hubspot_id = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)


class OrganisationAPIBilling(models.Model):
"""
Tracks API billing for when accounts go over their API usage
limits. This model is what allows subsequent billing runs
to not double bill an organisation for the same use.

Even though api_overage is charge per thousand API calls, this
class tracks the actual rounded count of API calls that are
billed for (i.e., 52000 for an account with 52233 api calls).
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
We're intentionally rounding down to the closest thousands.

The option to set immediate_invoice means whether or not the
API billing was processed immediately versus pushed onto the
subsequent subscription billing period.
"""

organisation = models.ForeignKey(
Organisation, on_delete=models.CASCADE, related_name="api_billing"
)
api_overage = models.IntegerField(null=False)
immediate_invoice = models.BooleanField(null=False, default=False)
billed_at = models.DateTimeField(null=False)

created_at = models.DateTimeField(null=True, auto_now_add=True)
updated_at = models.DateTimeField(null=True, auto_now=True)
7 changes: 7 additions & 0 deletions api/organisations/subscriptions/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
)
FREE_PLAN_ID = "free"
TRIAL_SUBSCRIPTION_ID = "trial"
SCALE_UP = "scale-up"
SCALE_UP_12_MONTHS_V2 = "scale-up-12-months-v2"
SCALE_UP_QUARTERLY_V2_SEMIANNUAL = "scale-up-quarterly-v2-semiannual"
SCALE_UP_V2 = "scale-up-v2"
STARTUP = "startup"
STARTUP_ANNUAL_V2 = "startup-annual-v2"
STARTUP_V2 = "startup-v2"


class SubscriptionCacheEntity(Enum):
Expand Down
12 changes: 12 additions & 0 deletions api/organisations/subscriptions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ class UpgradeSeatsPaymentFailure(APIException):
)


class UpgradeAPIUsageError(APIException):
default_detail = "Failed to upgrade API use in Chargebee"


class UpgradeAPIUsagePaymentFailure(APIException):
status_code = 400
default_detail = (
"API usage upgrade has failed due to a payment issue. "
"If this persists, contact the organisation admin."
)


class SubscriptionDoesNotSupportSeatUpgrade(APIException):
status_code = 400
default_detail = "Please Upgrade your plan to add additional seats/users"
121 changes: 113 additions & 8 deletions api/organisations/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.mail import send_mail
from django.db.models import Max
from django.db.models import F, Max
from django.template.loader import render_to_string
from django.utils import timezone

from integrations.flagsmith.client import get_client
from organisations import subscription_info_cache
from organisations.chargebee import (
add_1000_api_calls_scale_up,
add_1000_api_calls_start_up,
)
from organisations.models import (
APILimitAccessBlock,
OranisationAPIUsageNotification,
Organisation,
OrganisationAPIBilling,
OrganisationAPIUsageNotification,
OrganisationRole,
Subscription,
)
Expand All @@ -34,7 +39,13 @@
API_USAGE_ALERT_THRESHOLDS,
API_USAGE_GRACE_PERIOD,
)
from .subscriptions.constants import SubscriptionCacheEntity
from .subscriptions.constants import (
SCALE_UP,
SCALE_UP_V2,
STARTUP,
STARTUP_V2,
SubscriptionCacheEntity,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -130,7 +141,7 @@ def send_admin_api_usage_notification(
fail_silently=True,
)

OranisationAPIUsageNotification.objects.create(
OrganisationAPIUsageNotification.objects.create(
organisation=organisation,
percent_usage=matched_threshold,
notified_at=timezone.now(),
Expand Down Expand Up @@ -162,7 +173,7 @@ def _handle_api_usage_notifications(organisation: Organisation) -> None:
if matched_threshold is None:
return

if OranisationAPIUsageNotification.objects.filter(
if OrganisationAPIUsageNotification.objects.filter(
notified_at__gt=period_starts_at,
percent_usage=matched_threshold,
).exists():
Expand Down Expand Up @@ -197,6 +208,85 @@ def handle_api_usage_notifications() -> None:
)


def charge_for_api_call_count_overages():
now = timezone.now()

# Get the period where we're interested in any new API usage
# notifications for the relevant billing period (ie, this month).
api_usage_notified_at = now - timedelta(days=30)
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

# Since we're only interested in monthly billed accounts, set a wide
# threshold to catch as many billing periods that could be roughly
# considered to be a "monthly" subscription, while still ruling out
# non-monthly subscriptions.
month_window_start = timedelta(days=25)
month_window_end = timedelta(days=35)
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

# Only apply charges to ongoing subscriptions that are close to
# being charged due to being at the end of the billing term.
closing_billing_term = now + timedelta(hours=1)

organisation_ids = set(
OrganisationAPIUsageNotification.objects.filter(
notified_at__gte=api_usage_notified_at,
percent_usage__gte=100,
)
.exclude(
organisation__api_billing__billed_at__gt=api_usage_notified_at,
)
.values_list("organisation_id", flat=True)
)

for organisation in Organisation.objects.filter(
id__in=organisation_ids,
subscription_information_cache__current_billing_term_ends_at__lte=closing_billing_term,
subscription_information_cache__current_billing_term_ends_at__gte=now,
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
subscription_information_cache__current_billing_term_starts_at__lte=F(
"subscription_information_cache__current_billing_term_ends_at"
)
- month_window_start,
subscription_information_cache__current_billing_term_starts_at__gte=F(
"subscription_information_cache__current_billing_term_ends_at"
)
- month_window_end,
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
).select_related(
"subscription_information_cache",
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
"subscription",
):
subscription_cache = organisation.subscription_information_cache
api_usage = get_current_api_usage(organisation.id, "30d")
api_usage_ratio = api_usage / subscription_cache.allowed_30d_api_calls

if api_usage_ratio < 1.0:
logger.warning("API Usage does not match API Notification")
continue

api_overage = api_usage - subscription_cache.allowed_30d_api_calls

if organisation.subscription.plan in {SCALE_UP, SCALE_UP_V2}:
add_1000_api_calls_scale_up(
organisation.subscription.subscription_id, api_overage // 1000
)
elif organisation.subscription.plan in {STARTUP, STARTUP_V2}:
add_1000_api_calls_start_up(
organisation.subscription.subscription_id, api_overage // 1000
)
else:
logger.error(
f"Unable to bill for API overages for plan `{organisation.subscription.plan}`"
)
continue

# Save a copy of what was just billed in order to avoid
# double billing on a subsequent task run.
OrganisationAPIBilling.objects.create(
organisation=organisation,
api_overage=(1000 * (api_overage // 1000)),
immediate_invoice=False,
billed_at=now,
)


def restrict_use_due_to_api_limit_grace_period_over() -> None:
"""
Restrict API use once a grace period has ended.
Expand All @@ -208,7 +298,7 @@ def restrict_use_due_to_api_limit_grace_period_over() -> None:
grace_period = timezone.now() - timedelta(days=API_USAGE_GRACE_PERIOD)
month_start = timezone.now() - timedelta(30)
queryset = (
OranisationAPIUsageNotification.objects.filter(
OrganisationAPIUsageNotification.objects.filter(
notified_at__gt=month_start,
notified_at__lt=grace_period,
percent_usage__gte=100,
Expand Down Expand Up @@ -270,7 +360,7 @@ def unrestrict_after_api_limit_grace_period_is_stale() -> None:

month_start = timezone.now() - timedelta(30)
still_restricted_organisation_notifications = (
OranisationAPIUsageNotification.objects.filter(
OrganisationAPIUsageNotification.objects.filter(
notified_at__gt=month_start,
percent_usage__gte=100,
)
Expand All @@ -296,13 +386,28 @@ def unrestrict_after_api_limit_grace_period_is_stale() -> None:
organisation.api_limit_access_block.delete()


if settings.ENABLE_API_USAGE_ALERTING:
def register_recurring_tasks() -> None:
"""
Helper function to get codecov coverage.
"""
assert settings.ENABLE_API_USAGE_ALERTING

register_recurring_task(
run_every=timedelta(hours=12),
)(handle_api_usage_notifications)

register_recurring_task(
run_every=timedelta(minutes=30),
)(charge_for_api_call_count_overages)

register_recurring_task(
run_every=timedelta(hours=12),
)(restrict_use_due_to_api_limit_grace_period_over)

register_recurring_task(
run_every=timedelta(hours=12),
)(unrestrict_after_api_limit_grace_period_is_stale)


if settings.ENABLE_API_USAGE_ALERTING:
register_recurring_tasks() # pragma: no cover
Loading