From de18dd0420b42becca9fa7ac03bd167e815855b1 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Tue, 12 Nov 2024 12:49:37 -0600 Subject: [PATCH] Payment Gateway: Add tax support (#172) --- .../20241107_212223_jkachel_add_tax.md | 43 +++++++++++++++++++ .../mitol/payment_gateway/api.py | 19 ++++++-- .../mitol/payment_gateway/payment_utils.py | 7 +++ src/payment_gateway/pyproject.toml | 2 +- .../payment_gateway/api/test_cybersource.py | 21 +++++++-- .../utils/test_payment_utils.py | 22 +++++++++- tests/testapp/factories.py | 9 +++- uv.lock | 4 +- 8 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 src/payment_gateway/changelog.d/20241107_212223_jkachel_add_tax.md diff --git a/src/payment_gateway/changelog.d/20241107_212223_jkachel_add_tax.md b/src/payment_gateway/changelog.d/20241107_212223_jkachel_add_tax.md new file mode 100644 index 00000000..bff25a8c --- /dev/null +++ b/src/payment_gateway/changelog.d/20241107_212223_jkachel_add_tax.md @@ -0,0 +1,43 @@ + + + + +### Added + +- Adds support for tax collection. +- Bumps CyberSource REST Client package to at least 0.0.54. +- Adds a helper for quantizing decimals for currency amounts. + + + + + diff --git a/src/payment_gateway/mitol/payment_gateway/api.py b/src/payment_gateway/mitol/payment_gateway/api.py index 9dc63f78..71788d72 100644 --- a/src/payment_gateway/mitol/payment_gateway/api.py +++ b/src/payment_gateway/mitol/payment_gateway/api.py @@ -34,7 +34,11 @@ InvalidTransactionException, RefundDuplicateException, ) -from mitol.payment_gateway.payment_utils import clean_request_data, strip_nones +from mitol.payment_gateway.payment_utils import ( + clean_request_data, + quantize_decimal, + strip_nones, +) @dataclass @@ -362,6 +366,10 @@ class CyberSourcePaymentGateway( def _generate_line_items(self, cart): """ Generates CyberSource-formatted line items based on what's in the cart. + + The unit price being stored should be the unit price after any discounts + have been applied. The tax amount should be the _total_ for the line. + Args: cart: List of CartItems @@ -370,9 +378,11 @@ def _generate_line_items(self, cart): """ # noqa: D401 lines = {} cart_total = 0 + tax_total = 0 for i, line in enumerate(cart): cart_total += line.quantity * line.unitprice + tax_total += line.taxable lines[f"item_{i}_code"] = str(line.code) lines[f"item_{i}_name"] = str(line.name)[:254] @@ -381,7 +391,7 @@ def _generate_line_items(self, cart): lines[f"item_{i}_tax_amount"] = str(line.taxable) lines[f"item_{i}_unit_price"] = str(line.unitprice) - return (lines, cart_total) + return (lines, cart_total, tax_total) def _generate_cybersource_sa_signature(self, payload): """ @@ -438,7 +448,7 @@ def prepare_checkout( stored anywhere. """ # noqa: D401 - (line_items, total) = self._generate_line_items(order.items) + (line_items, total, tax_total) = self._generate_line_items(order.items) formatted_merchant_fields = {} @@ -455,7 +465,8 @@ def prepare_checkout( payload = { "access_key": settings.MITOL_PAYMENT_GATEWAY_CYBERSOURCE_ACCESS_KEY, - "amount": str(total), + "amount": str(quantize_decimal(total + tax_total)), + "tax_amount": str(quantize_decimal(tax_total)), "consumer_id": consumer_id, "currency": "USD", "locale": "en-us", diff --git a/src/payment_gateway/mitol/payment_gateway/payment_utils.py b/src/payment_gateway/mitol/payment_gateway/payment_utils.py index eb64b6d4..1c18980b 100644 --- a/src/payment_gateway/mitol/payment_gateway/payment_utils.py +++ b/src/payment_gateway/mitol/payment_gateway/payment_utils.py @@ -1,5 +1,7 @@ """Utilities for the Payment Gateway""" +from decimal import Decimal + # To delete None values in Input Request Json body def clean_request_data(request_data): @@ -21,3 +23,8 @@ def strip_nones(datasource): retval[key] = datasource[key] return retval + + +def quantize_decimal(value, precision=2): + """Quantize a decimal value to the specified precision""" + return Decimal(value).quantize(Decimal("0.{}".format("0" * precision))) diff --git a/src/payment_gateway/pyproject.toml b/src/payment_gateway/pyproject.toml index 6e012aa3..482251c6 100644 --- a/src/payment_gateway/pyproject.toml +++ b/src/payment_gateway/pyproject.toml @@ -3,7 +3,7 @@ name = "mitol-django-payment-gateway" version = "2023.12.19" description = "Django application to handle payment processing" dependencies = [ -"cybersource-rest-client-python>=0.0.36", +"cybersource-rest-client-python>=0.0.59", "django-stubs>=1.13.1", "django>=3.0", "mitol-django-common" diff --git a/tests/mitol/payment_gateway/api/test_cybersource.py b/tests/mitol/payment_gateway/api/test_cybersource.py index 8fae2109..6c3c91b0 100644 --- a/tests/mitol/payment_gateway/api/test_cybersource.py +++ b/tests/mitol/payment_gateway/api/test_cybersource.py @@ -5,6 +5,7 @@ from collections import namedtuple from dataclasses import dataclass from datetime import datetime +from decimal import Decimal from typing import Dict import pytest @@ -81,10 +82,11 @@ def generate_test_cybersource_payload(order, cartitems, transaction_uuid): cancel_url = "https://duckduckgo.com" test_line_items = {} - test_total = 0 + test_total = tax_total = 0 for idx, line in enumerate(cartitems): test_total += line.quantity * line.unitprice + tax_total += line.taxable test_line_items[f"item_{idx}_code"] = str(line.code) test_line_items[f"item_{idx}_name"] = str(line.name)[:254] @@ -97,7 +99,8 @@ def generate_test_cybersource_payload(order, cartitems, transaction_uuid): test_payload = { "access_key": settings.MITOL_PAYMENT_GATEWAY_CYBERSOURCE_ACCESS_KEY, - "amount": str(test_total), + "amount": str(Decimal(test_total + tax_total).quantize(Decimal("0.01"))), + "tax_amount": str(Decimal(tax_total).quantize(Decimal("0.01"))), "consumer_id": consumer_id, "currency": "USD", "locale": "en-us", @@ -140,7 +143,14 @@ def test_invalid_payload_generation(order, cartitems): assert isinstance(checkout_data, TypeError) -def test_cybersource_payload_generation(order, cartitems): +@pytest.mark.parametrize( + ("with_tax"), + [ + (True), + (False), + ], +) +def test_cybersource_payload_generation(order, cartitems, with_tax): """ Starts a payment through the payment gateway, and then checks to make sure there's stuff in the payload that it generates. The transaction is not sent @@ -151,6 +161,11 @@ def test_cybersource_payload_generation(order, cartitems): cancel_url = "https://duckduckgo.com" order.items = cartitems + # By default, the cart items will have tax. + if not with_tax: + for idx in range(len(order.items)): + order.items[idx].taxable = 0 + checkout_data = PaymentGateway.start_payment( MITOL_PAYMENT_GATEWAY_CYBERSOURCE, order, diff --git a/tests/mitol/payment_gateway/utils/test_payment_utils.py b/tests/mitol/payment_gateway/utils/test_payment_utils.py index ff90cd4e..9b4385ff 100644 --- a/tests/mitol/payment_gateway/utils/test_payment_utils.py +++ b/tests/mitol/payment_gateway/utils/test_payment_utils.py @@ -1,7 +1,13 @@ """Tests for payment_gateway application utils""" # noqa: INP001 +from decimal import Decimal + import pytest -from mitol.payment_gateway.payment_utils import clean_request_data, strip_nones +from mitol.payment_gateway.payment_utils import ( + clean_request_data, + quantize_decimal, + strip_nones, +) @pytest.mark.parametrize( @@ -44,3 +50,17 @@ def test_strip_nones(): test_ds2 = strip_nones(ds2) assert test_ds2 == ds2 + + +def test_quantize_decimal(): + """ + Tests quantize_decimal to make sure that the decimal is quantized to + the correct precision. + """ + + test_decimal = 1.23456789 + test_precision = 2 + + quantized_decimal = quantize_decimal(test_decimal, test_precision) + + assert quantized_decimal == Decimal("1.23") diff --git a/tests/testapp/factories.py b/tests/testapp/factories.py index 6a8195c8..eaadcf0d 100644 --- a/tests/testapp/factories.py +++ b/tests/testapp/factories.py @@ -1,9 +1,10 @@ """Test factories""" import string +from decimal import Decimal import faker -from factory import Factory, SubFactory, fuzzy +from factory import Factory, LazyAttribute, SubFactory, fuzzy from factory.django import DjangoModelFactory from mitol.common.factories import UserFactory from mitol.digitalcredentials.factories import ( @@ -46,8 +47,12 @@ class Meta: code = fuzzy.FuzzyText(length=6) quantity = fuzzy.FuzzyInteger(1, 5, 1) name = FAKE.sentence(nb_words=3) - taxable = 0 unitprice = fuzzy.FuzzyDecimal(1, 300, precision=2) + taxable = LazyAttribute( + lambda o: Decimal(o.unitprice * Decimal(FAKE.random_number(2) * 0.01)).quantize( + Decimal("0.01") + ) + ) class OrderFactory(Factory): diff --git a/uv.lock b/uv.lock index 82ca6c4e..a6066c2e 100644 --- a/uv.lock +++ b/uv.lock @@ -1338,7 +1338,7 @@ requires-dist = [ [[package]] name = "mitol-django-geoip" -version = "2024.10.25" +version = "2024.11.5" source = { editable = "src/geoip" } dependencies = [ { name = "django" }, @@ -1556,7 +1556,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "cybersource-rest-client-python", specifier = ">=0.0.36" }, + { name = "cybersource-rest-client-python", specifier = ">=0.0.59" }, { name = "django", specifier = ">=3.0" }, { name = "django-stubs", specifier = ">=1.13.1" }, { name = "mitol-django-common", editable = "src/common" },