Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
115 changes: 76 additions & 39 deletions backend/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,45 +13,6 @@

import posthog
import pytz
from dateutil import parser
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.db.models import (
Count,
DecimalField,
F,
Max,
Min,
OuterRef,
Prefetch,
Q,
Subquery,
Sum,
Value,
)
from django.db.models.functions import Coalesce
from django.db.utils import IntegrityError
from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
extend_schema,
inline_serializer,
)
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import (
action,
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from api.serializers.model_serializers import (
AddOnSubscriptionRecordCreateSerializer,
AddOnSubscriptionRecordSerializer,
Expand Down Expand Up @@ -91,6 +52,33 @@
MetricAccessRequestSerializer,
MetricAccessResponseSerializer,
)
from dateutil import parser
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.db.models import (
Count,
DecimalField,
F,
Max,
Min,
OuterRef,
Prefetch,
Q,
Subquery,
Sum,
Value,
)
from django.db.models.functions import Coalesce
from django.db.utils import IntegrityError
from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
extend_schema,
inline_serializer,
)
from metering_billing.auth.auth_utils import (
PermissionPolicyMixin,
fast_api_key_validation_and_cache,
Expand Down Expand Up @@ -122,6 +110,10 @@
Tag,
)
from metering_billing.permissions import HasUserAPIKey, ValidOrganization
from metering_billing.serializers.model_serializers import DraftInvoiceSerializer
from metering_billing.serializers.request_serializers import (
DraftInvoiceRequestSerializer,
)
from metering_billing.serializers.serializer_utils import (
AddOnUUIDField,
AddOnVersionUUIDField,
Expand Down Expand Up @@ -289,6 +281,51 @@ def archive(self, request, customer_id=None):
CustomerDeleteResponseSerializer().validate(return_data)
return Response(return_data, status=status.HTTP_200_OK)

@extend_schema(
request=DraftInvoiceRequestSerializer,
parameters=[DraftInvoiceRequestSerializer],
responses={
200: inline_serializer(
name="DraftInvoiceResponse", fields={"invoice": DraftInvoiceSerializer}
)
},
)
@action(
detail=True, methods=["get"], url_path="draft_invoice", url_name="draft_invoice"
)
def draft_invoice(self, request, customer_id=None):
customer = self.get_object()
organization = request.organization
serializer = DraftInvoiceRequestSerializer(
data=request.query_params, context={"organization": organization}
)
serializer.is_valid(raise_exception=True)
sub_records = SubscriptionRecord.objects.active().filter(
organization=organization, customer=customer
)
response = {"invoices": None}
if sub_records is None or len(sub_records) == 0:
response = {"invoices": []}
else:
sub_records = sub_records.select_related("billing_plan").prefetch_related(
"billing_plan__plan_components",
"billing_plan__plan_components__billable_metric",
"billing_plan__plan_components__tiers",
"billing_plan__currency",
)
invoices = generate_invoice(
sub_records,
draft=True,
charge_next_plan=serializer.validated_data.get(
"include_next_period", True
),
)
serializer = DraftInvoiceSerializer(invoices, many=True).data
for invoice in invoices:
invoice.delete()
response = {"invoices": serializer or []}
return Response(response, status=status.HTTP_200_OK)

def perform_create(self, serializer):
try:
return serializer.save(organization=self.request.organization)
Expand Down
2 changes: 0 additions & 2 deletions backend/lotus/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
from metering_billing.views.views import (
ChangeUserOrganizationView,
CostAnalysisView,
DraftInvoiceView,
ImportCustomersView,
ImportPaymentObjectsView,
NetsuiteCustomerCSVView,
Expand Down Expand Up @@ -187,7 +186,6 @@
PeriodSubscriptionsView.as_view(),
name="period_subscriptions",
),
path("app/draft_invoice/", DraftInvoiceView.as_view(), name="draft_invoice"),
path(
"app/import_customers/",
ImportCustomersView.as_view(),
Expand Down
10 changes: 0 additions & 10 deletions backend/metering_billing/serializers/request_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,8 @@ class PeriodMetricUsageRequestSerializer(PeriodRequestSerializer):


class DraftInvoiceRequestSerializer(serializers.Serializer):
customer_id = SlugRelatedFieldWithOrganization(
slug_field="customer_id",
queryset=Customer.objects.all(),
required=True,
)
include_next_period = serializers.BooleanField(default=True)

def validate(self, data):
super().validate(data)
data["customer"] = data.pop("customer_id")
return data


class OrganizationSettingFilterSerializer(serializers.Serializer):
setting_name = serializers.MultipleChoiceField(
Expand Down
65 changes: 51 additions & 14 deletions backend/metering_billing/tests/test_draft_invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,12 @@ def test_generate_invoice(self, draft_invoice_test_common_setup):
prev_invoices_len = Invoice.objects.filter(
payment_status=Invoice.PaymentStatus.DRAFT
).count()
payload = {"customer_id": setup_dict["customer"].customer_id}
response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
)
)
assert response.status_code == status.HTTP_200_OK
new_invoices_len = Invoice.objects.filter(
payment_status=Invoice.PaymentStatus.DRAFT
Expand All @@ -152,11 +156,12 @@ def test_generate_invoice_with_price_adjustments(
br = BillingRecord.objects.filter(recurring_charge__isnull=False).first()
br.next_invoicing_date = br.invoicing_dates[0]
br.save()
payload = {
"customer_id": setup_dict["customer"].customer_id,
"include_next_period": False,
}
response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
)
)
assert response.status_code == status.HTTP_200_OK
before_cost = response.data["invoices"][0]["amount"]
pct_price_adjustment = PriceAdjustment.objects.create(
Expand All @@ -169,7 +174,12 @@ def test_generate_invoice_with_price_adjustments(
setup_dict["billing_plan"].price_adjustment = pct_price_adjustment
setup_dict["billing_plan"].save()

response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
),
)
assert response.status_code == status.HTTP_200_OK
after_cost = response.data["invoices"][0]["amount"]
assert (before_cost * Decimal("0.99") - after_cost) < Decimal("0.01")
Expand All @@ -184,7 +194,12 @@ def test_generate_invoice_with_price_adjustments(
setup_dict["billing_plan"].price_adjustment = fixed_price_adjustment
setup_dict["billing_plan"].save()

response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
),
)

assert response.status_code == status.HTTP_200_OK
after_cost = response.data["invoices"][0]["amount"]
Expand All @@ -200,7 +215,12 @@ def test_generate_invoice_with_price_adjustments(
setup_dict["billing_plan"].price_adjustment = override_price_adjustment
setup_dict["billing_plan"].save()

response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
),
)

assert response.status_code == status.HTTP_200_OK
after_cost = response.data["invoices"][0]["amount"]
Expand All @@ -210,23 +230,40 @@ def test_generate_invoice_with_taxes(self, draft_invoice_test_common_setup):
setup_dict = draft_invoice_test_common_setup(auth_method="api_key")

payload = {
"customer_id": setup_dict["customer"].customer_id,
"include_next_period": False,
}
response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
),
payload,
)
assert response.status_code == status.HTTP_200_OK
before_cost = response.data["invoices"][0]["amount"]

setup_dict["org"].tax_rate = Decimal("10")
setup_dict["org"].save()
response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
),
payload,
)
assert response.status_code == status.HTTP_200_OK
after_cost = response.data["invoices"][0]["amount"]
assert (before_cost * Decimal("1.1") - after_cost) < Decimal("0.01")

setup_dict["customer"].tax_rate = Decimal("20")
setup_dict["customer"].save()
response = setup_dict["client"].get(reverse("draft_invoice"), payload)
response = setup_dict["client"].get(
reverse(
"customer-draft_invoice",
kwargs={"customer_id": setup_dict["customer"].customer_id},
),
payload,
)
assert response.status_code == status.HTTP_200_OK
after_cost = response.data["invoices"][0]["amount"]
assert (before_cost * Decimal("1.2") - after_cost) < Decimal("0.01")
Expand Down
Loading