Skip to content

Commit 110ed4e

Browse files
add tests for invoice webhooks and fix condition check on invoice paid webhook (#639)
1 parent bd5346e commit 110ed4e

3 files changed

Lines changed: 161 additions & 25 deletions

File tree

.github/workflows/cypress.yml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
name: Cypress Tests
22

3-
on:
4-
push:
5-
branches: ["main"]
6-
pull_request:
7-
branches: ["main"]
8-
93
jobs:
104
cypress-tests:
115
runs-on: ubuntu-latest
@@ -90,11 +84,6 @@ jobs:
9084
run: |
9185
cd ./backend && pipenv run python manage.py runserver 0.0.0.0:8000 &
9286
93-
- name: Wait for backend to start
94-
run: |
95-
curl -IsS http://localhost:8000/api/healthcheck/ >/dev/null
96-
echo "Backend is available"
97-
9887
- name: Cypress run
9988
uses: cypress-io/github-action@v5
10089
with:

backend/metering_billing/models.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@
2525
from django.db.models.constraints import CheckConstraint, UniqueConstraint
2626
from django.db.models.functions import Cast, Coalesce
2727
from django.utils.translation import gettext_lazy as _
28+
from rest_framework_api_key.models import AbstractAPIKey
29+
from simple_history.models import HistoricalRecords
30+
from svix.api import ApplicationIn, EndpointIn, EndpointSecretRotateIn, EndpointUpdate
31+
from svix.internal.openapi_client.models.http_error import HttpError
32+
from svix.internal.openapi_client.models.http_validation_error import (
33+
HTTPValidationError,
34+
)
35+
from timezone_field import TimeZoneField
36+
2837
from metering_billing.exceptions.exceptions import (
2938
ExternalConnectionFailure,
3039
NotEditable,
@@ -77,14 +86,6 @@
7786
WEBHOOK_TRIGGER_EVENTS,
7887
)
7988
from metering_billing.webhooks import invoice_paid_webhook, usage_alert_webhook
80-
from rest_framework_api_key.models import AbstractAPIKey
81-
from simple_history.models import HistoricalRecords
82-
from svix.api import ApplicationIn, EndpointIn, EndpointSecretRotateIn, EndpointUpdate
83-
from svix.internal.openapi_client.models.http_error import HttpError
84-
from svix.internal.openapi_client.models.http_validation_error import (
85-
HTTPValidationError,
86-
)
87-
from timezone_field import TimeZoneField
8889

8990
logger = logging.getLogger("django.server")
9091
META = settings.META
@@ -1665,6 +1666,11 @@ class PaymentStatus(models.IntegerChoices):
16651666
)
16661667
invoice_past_due_webhook_sent = models.BooleanField(default=False)
16671668
history = HistoricalRecords()
1669+
__original_payment_status = None
1670+
1671+
def __init__(self, *args, **kwargs):
1672+
super(Invoice, self).__init__(*args, **kwargs)
1673+
self.__original_payment_status = self.payment_status
16681674

16691675
class Meta:
16701676
indexes = [
@@ -1701,13 +1707,14 @@ def save(self, *args, **kwargs):
17011707
next_invoice_number = "{0:06d}".format(last_invoice_number + 1)
17021708

17031709
self.invoice_number = issue_date_string + "-" + next_invoice_number
1704-
# if not self.due_date:
1705-
# self.due_date = self.issue_date + datetime.timedelta(days=1)
1706-
paid_before = self.payment_status == Invoice.PaymentStatus.PAID
17071710
super().save(*args, **kwargs)
1708-
paid_after = self.payment_status == Invoice.PaymentStatus.PAID
1709-
if not paid_before and paid_after and self.cost_due > 0:
1711+
if (
1712+
self.__original_payment_status != self.payment_status
1713+
and self.payment_status == Invoice.PaymentStatus.PAID
1714+
and self.cost_due > 0
1715+
):
17101716
invoice_paid_webhook(self, self.organization)
1717+
self.__original_payment_status = self.payment_status
17111718

17121719

17131720
class InvoiceLineItem(models.Model):

backend/metering_billing/tests/test_subscription.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import urllib.parse
44
from datetime import timedelta
5+
from unittest.mock import patch
56

67
import pytest
78
from django.urls import reverse
@@ -14,9 +15,11 @@
1415
PlanComponent,
1516
PlanVersion,
1617
PriceTier,
18+
RecurringCharge,
1719
SubscriptionRecord,
1820
)
1921
from metering_billing.serializers.serializer_utils import DjangoJSONEncoder
22+
from metering_billing.tasks import check_past_due_invoices_inner
2023
from metering_billing.utils import now_utc
2124
from metering_billing.utils.enums import (
2225
CHARGEABLE_ITEM_TYPE,
@@ -43,7 +46,11 @@ def subscription_test_common_setup(
4346
add_plan_to_product,
4447
):
4548
def do_subscription_test_common_setup(
46-
*, num_subscriptions, auth_method, user_org_and_api_key_org_different=False
49+
*,
50+
num_subscriptions,
51+
auth_method,
52+
user_org_and_api_key_org_different=False,
53+
setup_grace_period_setting=False
4754
):
4855
# set up organizations and api keys
4956
org, key = generate_org_and_api_key()
@@ -121,6 +128,14 @@ def do_subscription_test_common_setup(
121128
cost_per_batch=cpb,
122129
metric_units_per_batch=mupb,
123130
)
131+
RecurringCharge.objects.create(
132+
organization=plan.organization,
133+
plan_version=billing_plan,
134+
charge_timing=RecurringCharge.ChargeTimingType.IN_ADVANCE,
135+
charge_behavior=RecurringCharge.ChargeBehaviorType.PRORATE,
136+
amount=10,
137+
pricing_unit=billing_plan.pricing_unit,
138+
)
124139
setup_dict["billing_plan"] = billing_plan
125140

126141
(customer,) = add_customers_to_org(org, n=1)
@@ -137,6 +152,19 @@ def do_subscription_test_common_setup(
137152
setup_dict["payload"] = payload
138153
setup_dict["customer"] = customer
139154

155+
if setup_grace_period_setting:
156+
payload = {
157+
"payment_grace_period": 0,
158+
}
159+
response = setup_dict["client"].patch(
160+
reverse(
161+
"organization-detail",
162+
kwargs={"organization_id": setup_dict["org"].organization_id},
163+
),
164+
data=json.dumps(payload, cls=DjangoJSONEncoder),
165+
content_type="application/json",
166+
)
167+
assert response.status_code == status.HTTP_200_OK
140168
return setup_dict
141169

142170
return do_subscription_test_common_setup
@@ -749,3 +777,115 @@ def test_refresh_rate_metric_doesnt_fail(self, subscription_test_common_setup):
749777
setup_dict["org"].update_subscription_filter_settings(["email"])
750778
except Exception as e:
751779
assert False, e
780+
781+
782+
@pytest.mark.django_db(transaction=True)
783+
class TestInvoiceWebhooks:
784+
def test_invoice_paid_webhook(
785+
self,
786+
subscription_test_common_setup,
787+
):
788+
setup_dict = subscription_test_common_setup(
789+
num_subscriptions=1, auth_method="session_auth"
790+
)
791+
792+
prev_invoices_len = Invoice.objects.all().count()
793+
794+
params = {
795+
"customer_id": setup_dict["customer"].customer_id,
796+
}
797+
payload = {
798+
"flat_fee_behavior": FLAT_FEE_BEHAVIOR.CHARGE_FULL,
799+
"bill_usage": True,
800+
}
801+
response = setup_dict["client"].post(
802+
reverse("subscription-cancel") + "?" + urllib.parse.urlencode(params),
803+
data=json.dumps(payload, cls=DjangoJSONEncoder),
804+
content_type="application/json",
805+
)
806+
807+
new_invoices_len = Invoice.objects.all().count()
808+
assert response.status_code == status.HTTP_200_OK
809+
assert new_invoices_len == prev_invoices_len + 1
810+
811+
payload = {
812+
"payment_status": "paid",
813+
}
814+
invoice = Invoice.objects.last()
815+
with patch("metering_billing.models.invoice_paid_webhook") as mock_webhook:
816+
response = setup_dict["client"].patch(
817+
reverse(
818+
"invoice-detail",
819+
kwargs={"invoice_id": invoice.invoice_id},
820+
),
821+
data=json.dumps(payload, cls=DjangoJSONEncoder),
822+
content_type="application/json",
823+
)
824+
mock_webhook.assert_called_once_with(invoice, invoice.organization)
825+
assert response.status_code == status.HTTP_200_OK
826+
827+
def test_invoice_past_due_webhook_org_setting_not_set(
828+
self,
829+
subscription_test_common_setup,
830+
):
831+
setup_dict = subscription_test_common_setup(
832+
num_subscriptions=1,
833+
auth_method="session_auth",
834+
)
835+
836+
prev_invoices_len = Invoice.objects.all().count()
837+
838+
params = {
839+
"customer_id": setup_dict["customer"].customer_id,
840+
}
841+
payload = {
842+
"flat_fee_behavior": FLAT_FEE_BEHAVIOR.CHARGE_FULL,
843+
"bill_usage": True,
844+
}
845+
response = setup_dict["client"].post(
846+
reverse("subscription-cancel") + "?" + urllib.parse.urlencode(params),
847+
data=json.dumps(payload, cls=DjangoJSONEncoder),
848+
content_type="application/json",
849+
)
850+
851+
new_invoices_len = Invoice.objects.all().count()
852+
assert response.status_code == status.HTTP_200_OK
853+
assert new_invoices_len == prev_invoices_len + 1
854+
855+
with patch("metering_billing.tasks.invoice_past_due_webhook") as mock_webhook:
856+
check_past_due_invoices_inner()
857+
mock_webhook.assert_not_called()
858+
859+
def test_invoice_past_due_webhook_org_setting_set(
860+
self,
861+
subscription_test_common_setup,
862+
):
863+
setup_dict = subscription_test_common_setup(
864+
num_subscriptions=1,
865+
auth_method="session_auth",
866+
setup_grace_period_setting=True,
867+
)
868+
869+
prev_invoices_len = Invoice.objects.all().count()
870+
871+
params = {
872+
"customer_id": setup_dict["customer"].customer_id,
873+
}
874+
payload = {
875+
"flat_fee_behavior": FLAT_FEE_BEHAVIOR.CHARGE_FULL,
876+
"bill_usage": True,
877+
}
878+
response = setup_dict["client"].post(
879+
reverse("subscription-cancel") + "?" + urllib.parse.urlencode(params),
880+
data=json.dumps(payload, cls=DjangoJSONEncoder),
881+
content_type="application/json",
882+
)
883+
884+
new_invoices_len = Invoice.objects.all().count()
885+
assert response.status_code == status.HTTP_200_OK
886+
assert new_invoices_len == prev_invoices_len + 1
887+
invoice = Invoice.objects.last()
888+
889+
with patch("metering_billing.tasks.invoice_past_due_webhook") as mock_webhook:
890+
check_past_due_invoices_inner()
891+
mock_webhook.assert_called_once_with(invoice, invoice.organization)

0 commit comments

Comments
 (0)