diff --git a/apps/billing/factories.py b/apps/billing/factories.py index fe12416..39d4442 100644 --- a/apps/billing/factories.py +++ b/apps/billing/factories.py @@ -15,7 +15,7 @@ class Meta: transaction_id = factory.Faker( "pystr_format", - string_format="NAU-######{random_int}", + string_format="NAU-######{{random_int}}", ) client_name = factory.Faker("name") email = factory.Faker("email") @@ -26,16 +26,16 @@ class Meta: vat_identification_number = factory.Faker("ssn") total_amount_exclude_vat = factory.Faker("pydecimal", min_value=1, max_value=100, left_digits=5, right_digits=2) payment_type = factory.fuzzy.FuzzyChoice(PAYMENT_TYPE) - transaction_type = factory.fuzzy.FuzzyChoice(TRANSACTION_TYPE[0]) + transaction_type = factory.Faker("random_element", elements=[t[0] for t in TRANSACTION_TYPE]) transaction_date = factory.Faker( "date_time_between", start_date="-1d", end_date="-5d", tzinfo=timezone.get_current_timezone() ) - document_id = factory.Faker("pystr_format", string_format="DCI-######{random_int}") + document_id = factory.Faker("pystr_format", string_format="DCI-######{{random_int}}") # Assuming 20% VAT @factory.lazy_attribute def total_amount_include_vat(self): - return self.total_amount_exclude_vat * Decimal("1.20") + return round(self.total_amount_exclude_vat * Decimal("1.20"), 2) class TransactionItemFactory(factory.django.DjangoModelFactory): @@ -54,4 +54,4 @@ class Meta: # Assuming 20% VAT @factory.lazy_attribute def amount_include_vat(self): - return self.amount_exclude_vat * Decimal("1.20") + return round(self.amount_exclude_vat * Decimal("1.20"), 2) diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index cee2d03..d818de8 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -1,5 +1,3 @@ -from copy import deepcopy - from django_countries.serializers import CountryFieldMixin from rest_framework import serializers @@ -47,8 +45,27 @@ class Meta: fields = "__all__" +class TransactionItemSerializerWithoutTransaction(TransactionItemSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + del self.fields["transaction"] + + class ProcessTransactionSerializer(CountryFieldMixin, serializers.ModelSerializer): - item = TransactionItemSerializer() + """ + Serializer for processing transactions. + This serializer is responsible for validating and processing transaction data. It includes methods for creating + transactions, executing shared revenue resources, and converting transaction data to internal and external formats. + Methods: + _execute_shared_revenue_resources(organization: Organization, product_id: str): Checks if a revenue configuration + exists for the given organization and product ID, and creates one if it doesn't exist. + _execute_billing_resources(validate_data: dict): Creates a transaction and a transaction item from the given data. + create(validate_data): Creates a transaction, a transaction item, and a revenue configuration from the given data. + to_internal_value(data): Converts the given data to an internal format. + to_representation(instance): Returns the given instance. + """ + + item = TransactionItemSerializerWithoutTransaction(required=True, allow_null=False) class Meta: model = Transaction @@ -78,60 +95,59 @@ def _execute_shared_revenue_resources( self, organization: Organization, product_id: str, - ): - revenue_configuration_exists: bool = self._has_concurrent_revenue_configuration( - organization=organization, - product_id=product_id, - ) - if not revenue_configuration_exists: - RevenueConfiguration.objects.create(**{"organization": organization, "product_id": product_id}) - - def _execute_billing_resources( - self, - validate_data: dict, - ): - transaction_data = {k: v for k, v in validate_data.items() if k != "item"} - transaction = Transaction.objects.create(**transaction_data) - - transaction_item_data = deepcopy(validate_data["item"]) - transaction_item_data["transaction"] = transaction - item = TransactionItem.objects.create(**transaction_item_data) - - return transaction, item - - def _has_concurrent_revenue_configuration( - self, - organization: Organization, - product_id: str, ): try: - return RevenueConfiguration( + revenue_configuration_exists = RevenueConfiguration( organization=organization, product_id=product_id, ).has_concurrent_revenue_configuration() + if not revenue_configuration_exists: + RevenueConfiguration.objects.create(**{"organization": organization, "product_id": product_id}) except Exception: return True + def _execute_billing_resources( + self, + validate_data: dict, + ): + item = validate_data.pop("item", None) + transaction = Transaction.objects.create(**validate_data) + item["transaction"] = transaction + item = TransactionItem.objects.create(**item) + + return item + def create(self, validate_data): try: + item = self._execute_billing_resources(validate_data=validate_data) organization, created = Organization.objects.get_or_create( - short_name=validate_data["item"]["organization_code"], - defaults={"short_name": validate_data["item"]["organization_code"]}, + short_name=item.organization_code, + defaults={"short_name": item.organization_code}, ) self._execute_shared_revenue_resources( organization=organization, - product_id=validate_data["item"]["product_id"], + product_id=item.product_id, ) return validate_data except Exception as e: raise e - def to_internal_value(self, data): - transaction, item = self._execute_billing_resources(validate_data=data) - data = TransactionSerializer(transaction).data - data["item"] = TransactionItemSerializer(item).data - - return data - def to_representation(self, instance): return instance + + def validate(self, data): + serializer_fields = set(self.fields.keys()) + data_fields = set(self.initial_data.keys()) + extra_fields = data_fields - serializer_fields + if extra_fields: + raise serializers.ValidationError(f"Extra fields: {', '.join(extra_fields)}") + + item = data.pop("item", None) + transaction = TransactionSerializer(data=data) + transaction.is_valid(raise_exception=True) + item = TransactionItemSerializerWithoutTransaction(data=item) + item.is_valid(raise_exception=True) + transaction = transaction.data + transaction["item"] = item.data + + return transaction diff --git a/apps/billing/tests/test_process_transaction.py b/apps/billing/tests/test_process_transaction.py index acdca9e..1ce8a27 100644 --- a/apps/billing/tests/test_process_transaction.py +++ b/apps/billing/tests/test_process_transaction.py @@ -1,58 +1,122 @@ +import json + +import factory from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.authtoken.models import Token from rest_framework.test import APIClient +from apps.billing.factories import TransactionFactory, TransactionItemFactory from apps.billing.models import Transaction -from apps.billing.serializers import ProcessTransactionSerializer +from apps.billing.serializers import TransactionItemSerializer, TransactionSerializer class ProcessTransactionTest(TestCase): + """ + A test case for the ProcessTransaction view. + """ + def setUp(self): - self.payload = { - "transaction_id": "a9k05000-227f-4500-b71f-9f00fba1cf5f", - "client_name": "Cliente name", - "email": "cliente@email.com", - "address_line_1": "Av. Liberdade", - "address_line_2": "", - "city": "Lisboa", - "postal_code": "1250-142", - "state": "", - "country_code": "PT", - "vat_identification_number": "PT220234835", - "vat_identification_country": "PT", - "total_amount_exclude_vat": 114.73, - "total_amount_include_vat": 149.00, - "currency": "EUR", - "item": { - "description": "The product/line text with a description of what have been bought. The field need to be a string.", - "quantity": 1, - "vat_tax": 114.73, - "amount_exclude_vat": 114.73, - "amount_include_vat": 149.00, - "organization_code": "UPorto", - "product_id": "course-v1:UPorto+CBNEEF+2023_T3", - "product_code": "CBNEEF", - }, - } + """ + Set up the test case by creating a client, endpoint, transaction, transaction item, and payload. + Also create a new user and generate a token for that user. + """ self.client = APIClient() self.endpoint = "/api/billing/transaction-complete/" + self.transaction = factory.build(dict, FACTORY_CLASS=TransactionFactory) + self.transaction_item = factory.build(dict, FACTORY_CLASS=TransactionItemFactory, transaction=None) + self.transaction["item"] = self.transaction_item + self.payload = self.transaction + # Create a new user and generate a token for that user self.user = get_user_model().objects.create_user(username="testuser", password="testpass") self.token = Token.objects.create(user=self.user) def test_create_transaction(self): + """ + Test that a transaction can be created with a valid token. + """ self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) response = self.client.post(self.endpoint, self.payload, format="json") self.assertEqual(response.status_code, 201) transaction = Transaction.objects.get(transaction_id=self.payload["transaction_id"]) - serializer = ProcessTransactionSerializer(transaction) + transaction_data = TransactionSerializer(transaction).data + transaction_data["item"] = TransactionItemSerializer(transaction.transaction_items.all()[0]).data - self.assertEqual(response.data, serializer.data) + for key, value in response.data["data"].items(): + if key == "item": + for item_key, item_value in response.data["data"]["item"].items(): + self.assertEqual(item_value, transaction_data["item"][item_key]) + self.assertEqual(value, transaction_data[key]) def test_create_transaction_without_token(self): + """ + Test that a transaction cannot be created without a valid token. + """ + response = self.client.post(self.endpoint, self.payload, format="json") + self.assertEqual(response.status_code, 401) + + def test_create_transaction_with_duplicate_transaction_id(self): + """ + Test that a transaction cannot be created with a duplicate transaction ID. + """ + # Create a new transaction with the same transaction ID as the payload + TransactionFactory.create(transaction_id=self.payload["transaction_id"]) + + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + + response = self.client.post(self.endpoint, self.payload, format="json") + response_data_message = json.loads(response.content) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response_data_message["transaction_id"][0], "transaction with this transaction id already exists." + ) + + def test_create_transaction_with_invalid_fields(self): + """ + Test that a transaction cannot be created if fields are invalid. + """ + # Set the 'amount' field to an invalid value + self.payload["amount"] = "invalid_amount" + + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + + response = self.client.post(self.endpoint, self.payload, format="json") + self.assertEqual(response.status_code, 400) + + def test_create_transaction_with_missing_fields(self): + """ + Test that a transaction cannot be created if required fields are missing. + """ + # Remove the required 'transaction_id' field from the payload + del self.payload["transaction_id"] + + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + + response = self.client.post(self.endpoint, self.payload, format="json") + self.assertEqual(response.status_code, 400) + + def test_create_transaction_with_invalid_token(self): + """ + Test that a transaction cannot be created with an invalid token. + """ + self.client.credentials(HTTP_AUTHORIZATION="Token " + "invalid_token") + response = self.client.post(self.endpoint, self.payload, format="json") self.assertEqual(response.status_code, 401) + + def test_create_transaction_with_missing_item_field(self): + """ + Test that a transaction cannot be created if the 'item' field is missing. + """ + # Remove the required 'item' field from the payload + del self.payload["item"] + + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + + response = self.client.post(self.endpoint, self.payload, format="json") + self.assertEqual(response.status_code, 400) diff --git a/apps/billing/tests/test_receipts.py b/apps/billing/tests/test_transactions.py similarity index 100% rename from apps/billing/tests/test_receipts.py rename to apps/billing/tests/test_transactions.py