Skip to content

Commit

Permalink
feat: introduce dunning billing status (#2976)
Browse files Browse the repository at this point in the history
  • Loading branch information
zachaysan committed Nov 20, 2023
1 parent 7ef4856 commit 975c7b0
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 1 deletion.
32 changes: 32 additions & 0 deletions api/organisations/chargebee/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import CharField, Serializer


class PaymentFailedInvoiceSerializer(Serializer):
dunning_status = CharField(allow_null=False)
subscription_id = CharField(allow_null=False)

def validate_dunning_status(self, value):
if value != "in_progress":
raise ValidationError("To be valid dunning must be in progress")
return value


class PaymentSucceededInvoiceSerializer(Serializer):
subscription_id = CharField(allow_null=False)


class PaymentFailedContentSerializer(Serializer):
invoice = PaymentFailedInvoiceSerializer(required=True)


class PaymentSucceededContentSerializer(Serializer):
invoice = PaymentSucceededInvoiceSerializer(required=True)


class PaymentFailedSerializer(Serializer):
content = PaymentFailedContentSerializer(required=True)


class PaymentSucceededSerializer(Serializer):
content = PaymentSucceededContentSerializer(required=True)
86 changes: 86 additions & 0 deletions api/organisations/chargebee/webhook_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import logging

from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response

from organisations.models import Subscription
from organisations.subscriptions.constants import (
SUBSCRIPTION_BILLING_STATUS_ACTIVE,
SUBSCRIPTION_BILLING_STATUS_DUNNING,
)

from .serializers import PaymentFailedSerializer, PaymentSucceededSerializer

logger = logging.getLogger(__name__)


def payment_failed(request: Request) -> Response:
serializer = PaymentFailedSerializer(data=request.data)

try:
serializer.is_valid(raise_exception=True)
except ValidationError:
logger.warning(
"Serializer failure during chargebee payment failed processing",
exc_info=True,
)
return Response(status=status.HTTP_200_OK)

subscription_id = serializer.validated_data["content"]["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 payment_succeeded(request: Request) -> Response:
serializer = PaymentSucceededSerializer(data=request.data)

try:
serializer.is_valid(raise_exception=True)
except ValidationError:
logger.warning(
"Serializer failure during chargebee payment failed processing",
exc_info=True,
)
return Response(status=status.HTTP_200_OK)

subscription_id = serializer.validated_data["content"]["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)
18 changes: 18 additions & 0 deletions api/organisations/migrations/0049_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', '0048_add_default_subscription_to_orphaned_organisations'),
]

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 @@ -203,6 +204,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 @@ -232,6 +240,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 = None
self.save()
# If the date is in the future, a recurring task takes it.
if cancellation_date <= timezone.now():
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
16 changes: 15 additions & 1 deletion api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
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

from organisations.chargebee import webhook_handlers
from organisations.exceptions import OrganisationHasNoPaidSubscription
from organisations.models import (
Organisation,
Expand Down Expand Up @@ -255,16 +257,28 @@ def my_permissions(self, request, pk):

@api_view(["POST"])
@authentication_classes([BasicAuthentication])
def chargebee_webhook(request):
def chargebee_webhook(request: Request) -> Response:
"""
Endpoint to handle webhooks from chargebee.
Payment failure and payment succeeded webhooks are filtered out and processed
to determine which of our subscriptions are in a dunning state.
The remaining webhooks are processed if they have subscription data:
- If subscription is active, check to see if plan has changed and update if so. Always update cancellation date to
None to ensure that if a subscription is reactivated, it is updated on our end.
- 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 webhook_handlers.payment_failed(request)
if event_type == "payment_succeeded":
return webhook_handlers.payment_succeeded(request)

if request.data.get("content") and "subscription" in request.data.get("content"):
subscription_data: dict = request.data["content"]["subscription"]
customer_email: str = request.data["content"]["customer"]["email"]
Expand Down

0 comments on commit 975c7b0

Please sign in to comment.