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: introduce dunning billing status #2976

Merged
merged 11 commits into from
Nov 20, 2023
18 changes: 18 additions & 0 deletions api/organisations/migrations/0048_subscription_billing_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.23 on 2023-11-15 16:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('organisations', '0047_organisation_force_2fa'),
]

operations = [
migrations.AddField(
model_name='subscription',
name='billing_status',
field=models.CharField(blank=True, choices=[('ACTIVE', 'Active'), ('DUNNING', 'Dunning')], max_length=20, null=True),
),
]
9 changes: 9 additions & 0 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
MAX_API_CALLS_IN_FREE_PLAN,
MAX_PROJECTS_IN_FREE_PLAN,
MAX_SEATS_IN_FREE_PLAN,
SUBSCRIPTION_BILLING_STATUSES,
SUBSCRIPTION_PAYMENT_METHODS,
XERO,
)
Expand Down Expand Up @@ -183,6 +184,13 @@ class Subscription(LifecycleModelMixin, SoftDeleteExportableModel):
cancellation_date = models.DateTimeField(blank=True, null=True)
customer_id = models.CharField(max_length=100, blank=True, null=True)

# Free and cancelled subscriptions are blank.
billing_status = models.CharField(
max_length=20,
choices=SUBSCRIPTION_BILLING_STATUSES,
blank=True,
null=True,
)
payment_method = models.CharField(
max_length=20,
choices=SUBSCRIPTION_PAYMENT_METHODS,
Expand Down Expand Up @@ -212,6 +220,7 @@ def update_mailer_lite_subscribers(self):

def cancel(self, cancellation_date=timezone.now(), update_chargebee=True):
self.cancellation_date = cancellation_date
self.billing_status = ""
self.save()
if self.payment_method == CHARGEBEE and update_chargebee:
cancel_chargebee_subscription(self.subscription_id)
Expand Down
12 changes: 12 additions & 0 deletions api/organisations/subscriptions/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@
(AWS_MARKETPLACE, "AWS Marketplace"),
]


# Active means payments for the subscription are being processed
# without issue, dunning means the subscription is still ongoing
# but payments for one or more of the invoices are being retried.
SUBSCRIPTION_BILLING_STATUS_ACTIVE = "ACTIVE"
SUBSCRIPTION_BILLING_STATUS_DUNNING = "DUNNING"
SUBSCRIPTION_BILLING_STATUSES = [
(SUBSCRIPTION_BILLING_STATUS_ACTIVE, "Active"),
(SUBSCRIPTION_BILLING_STATUS_DUNNING, "Dunning"),
]


FREE_PLAN_SUBSCRIPTION_METADATA = BaseSubscriptionMetadata(
seats=MAX_SEATS_IN_FREE_PLAN,
api_calls=MAX_API_CALLS_IN_FREE_PLAN,
Expand Down
91 changes: 90 additions & 1 deletion api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from rest_framework.decorators import action, api_view, authentication_classes
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle

Expand Down Expand Up @@ -46,6 +47,10 @@
SubscriptionDetailsSerializer,
UpdateSubscriptionSerializer,
)
from organisations.subscriptions.constants import (
SUBSCRIPTION_BILLING_STATUS_ACTIVE,
SUBSCRIPTION_BILLING_STATUS_DUNNING,
)
from permissions.permissions_calculator import get_organisation_permission_data
from permissions.serializers import (
PermissionModelSerializer,
Expand Down Expand Up @@ -260,9 +265,87 @@ def my_permissions(self, request, pk):
return Response(serializer.data)


def chargebee_webhook_payment_failed(request: Request) -> Response:
if "content" not in request.data:
logger.warning("Missing `content` field from chargebee webhook")
return Response(status=status.HTTP_200_OK)

if "invoice" not in request.data["content"]:
logger.warning("Missing `invoice` field from chargebee webhook")
return Response(status=status.HTTP_200_OK)

invoice = request.data["content"]["invoice"]

# If dunning hasn't started ignore the declined payment.
if invoice.get("dunning_status") != "in_progress":
return Response(status=status.HTTP_200_OK)

if "subscription_id" not in invoice:
logger.info("Payment declined for non-subscription related charge, skipping.")
return Response(status=status.HTTP_200_OK)

subscription_id = invoice["subscription_id"]
try:
subscription = Subscription.objects.get(subscription_id=subscription_id)
except Subscription.DoesNotExist:
logger.warning(
"No matching subscription for chargebee payment "
f"failed webhook for subscription id {subscription_id}"
)
return Response(status=status.HTTP_200_OK)
except Subscription.MultipleObjectsReturned:
logger.warning(
"Multiple matching subscriptions for chargebee payment "
f"failed webhook for subscription id {subscription_id}"
)
return Response(status=status.HTTP_200_OK)

subscription.billing_status = SUBSCRIPTION_BILLING_STATUS_DUNNING
subscription.save()

return Response(status=status.HTTP_200_OK)


def chargebee_webhook_payment_succeeded(request: Request) -> Response:
if "content" not in request.data:
logger.warning("Missing `content` field from chargebee webhook")
return Response(status=status.HTTP_200_OK)

if "invoice" not in request.data["content"]:
logger.warning("Missing `invoice` field from chargebee webhook")
return Response(status=status.HTTP_200_OK)

invoice = request.data["content"]["invoice"]

if "subscription_id" not in invoice:
logger.info("Payment succeeded for non-subscription related charge, skipping.")
return Response(status=status.HTTP_200_OK)

subscription_id = invoice["subscription_id"]
try:
subscription = Subscription.objects.get(subscription_id=subscription_id)
except Subscription.DoesNotExist:
logger.warning(
"No matching subscription for chargebee payment "
f"succeeded webhook for subscription id {subscription_id}"
)
return Response(status=status.HTTP_200_OK)
except Subscription.MultipleObjectsReturned:
logger.warning(
"Multiple matching subscriptions for chargebee payment "
f"succeeded webhook for subscription id {subscription_id}"
)
return Response(status=status.HTTP_200_OK)

subscription.billing_status = SUBSCRIPTION_BILLING_STATUS_ACTIVE
subscription.save()

return Response(status=status.HTTP_200_OK)


@api_view(["POST"])
@authentication_classes([BasicAuthentication])
def chargebee_webhook(request):
def chargebee_webhook(request: Request) -> Response:
"""
Endpoint to handle webhooks from chargebee.

Expand All @@ -272,6 +355,12 @@ def chargebee_webhook(request):
- If subscription is cancelled or not renewing, update subscription on our end to include cancellation date and
send alert to admin users.
"""
event_type = request.data.get("event_type")

if event_type == "payment_failed":
return chargebee_webhook_payment_failed(request)
if event_type == "payment_succeeded":
return chargebee_webhook_payment_succeeded(request)

if request.data.get("content") and "subscription" in request.data.get("content"):
subscription_data: dict = request.data["content"]["subscription"]
Expand Down
90 changes: 90 additions & 0 deletions api/tests/unit/organisations/test_unit_organisation_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
MAX_API_CALLS_IN_FREE_PLAN,
MAX_PROJECTS_IN_FREE_PLAN,
MAX_SEATS_IN_FREE_PLAN,
SUBSCRIPTION_BILLING_STATUS_ACTIVE,
SUBSCRIPTION_BILLING_STATUS_DUNNING,
)
from projects.models import Project, UserProjectPermission
from segments.models import Segment
Expand Down Expand Up @@ -1340,3 +1342,91 @@ def test_list_my_groups(organisation, api_client):
"id": user_permission_group_1.id,
"name": user_permission_group_1.name,
}


def test_payment_failed_chargebee_webhook(
staff_client: FFAdminUser, subscription: Subscription
):
# Given
subscription.billing_status = SUBSCRIPTION_BILLING_STATUS_ACTIVE
subscription.subscription_id = "best_id"
subscription.save()

data = {
"id": "someId",
"occurred_at": 1699630568,
"object": "event",
"api_version": "v2",
"content": {
"invoice": {
"subscription_id": subscription.subscription_id,
"dunning_status": "in_progress",
},
},
"event_type": "payment_failed",
}

url = reverse("api-v1:chargebee-webhook")

# When
response = staff_client.post(
url, data=json.dumps(data), content_type="application/json"
)

# Then
assert response.status_code == 200
subscription.refresh_from_db()
assert subscription.billing_status == SUBSCRIPTION_BILLING_STATUS_DUNNING


def test_payment_succeeded_chargebee_webhook(
staff_client: FFAdminUser, subscription: Subscription
):
# Given
subscription.billing_status = SUBSCRIPTION_BILLING_STATUS_DUNNING
subscription.subscription_id = "best_id"
subscription.save()

data = {
"id": "someId",
"occurred_at": 1699630568,
"object": "event",
"api_version": "v2",
"content": {
"invoice": {
"subscription_id": subscription.subscription_id,
},
},
"event_type": "payment_succeeded",
}

url = reverse("api-v1:chargebee-webhook")

# When
response = staff_client.post(
url, data=json.dumps(data), content_type="application/json"
)

# Then
assert response.status_code == 200
subscription.refresh_from_db()
assert subscription.billing_status == SUBSCRIPTION_BILLING_STATUS_ACTIVE


def test_list_organisations_shows_dunning(
staff_client: FFAdminUser, subscription: Subscription
):
# Given
subscription.billing_status = SUBSCRIPTION_BILLING_STATUS_DUNNING
subscription.save()
url = reverse("api-v1:organisations:organisation-list")

# When
response = staff_client.get(url)

# Then
assert response.status_code == 200
assert len(response.data["results"]) == 1
_subscription = response.data["results"][0]["subscription"]
assert _subscription["id"] == subscription.id
assert _subscription["billing_status"] == SUBSCRIPTION_BILLING_STATUS_DUNNING