Skip to content

Commit 4ef3d81

Browse files
authored
Adds tax rate calculation support (#2772)
- Adding MaxMind app to encapsulte IP location lookups - Added models to hold basic MaxMind GeoIP data (targetting the GeoLite2 stuff but should work for the more complete datasets too) - Added importer command and API for CSV-format files - Added lookup API - Added a couple template tags to calculate tax in templates (mostly for email) - CyberSource payload includes tax amounts now - Tax data should be in the APIs for receipts
1 parent de84a57 commit 4ef3d81

32 files changed

+1272
-126
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ codekit-config.json
9090

9191
*.DS_Store
9292

93+
.vscode/
94+
9395
# Django static
9496
staticfiles/
9597

.vscode/settings.json

-5
This file was deleted.

app.json

+4
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@
206206
"description": "The maximum time difference (in minutes) from the present time to a webhook expiration date to consider a webhook 'fresh', i.e.: not in need of renewal. If the time difference is less than this value, the webhook should be renewed.",
207207
"required": false
208208
},
209+
"ECOMMERCE_FORCE_PROFILE_COUNTRY": {
210+
"description": "Force the country determination to be done with the user profile only",
211+
"required": false
212+
},
209213
"EDX_API_CLIENT_TIMEOUT": {
210214
"description": "Timeout (in seconds) for requests made via the edX API client",
211215
"required": false

ecommerce/admin.py

+25-16
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
"""admin classes for ecommerce"""
2+
from django import forms
23
from django.contrib import admin
34
from django.contrib.contenttypes.models import ContentType
4-
from django import forms
55
from django.core.exceptions import ValidationError
66

77
from courses.models import Course
88
from ecommerce.models import (
9-
Line,
10-
LineRunSelection,
11-
ProgramRunLine,
12-
Order,
13-
OrderAudit,
14-
Receipt,
9+
BulkCouponAssignment,
10+
Company,
1511
Coupon,
16-
CouponVersion,
17-
CouponPaymentVersion,
18-
CouponPayment,
19-
CouponSelection,
2012
CouponEligibility,
13+
CouponPayment,
14+
CouponPaymentVersion,
2115
CouponRedemption,
22-
Product,
23-
ProductVersion,
16+
CouponSelection,
17+
CouponVersion,
2418
DataConsentAgreement,
2519
DataConsentUser,
26-
Company,
27-
BulkCouponAssignment,
20+
Line,
21+
LineRunSelection,
22+
Order,
23+
OrderAudit,
24+
Product,
2825
ProductCouponAssignment,
26+
ProductVersion,
27+
ProgramRunLine,
28+
Receipt,
29+
TaxRate,
2930
)
30-
3131
from hubspot_xpro.task_helpers import sync_hubspot_deal
3232
from mitxpro.admin import AuditableModelAdmin, TimestampedModelAdmin
3333
from mitxpro.utils import get_field_names
@@ -600,6 +600,14 @@ def get_product(self, obj):
600600
get_product.admin_order_field = "product_coupon__product"
601601

602602

603+
class TaxRateAdmin(admin.ModelAdmin):
604+
"""Admin for TaxRate"""
605+
606+
list_display = ("id", "country_code", "tax_rate", "tax_rate_name", "active")
607+
search_fields = ("country_code", "tax_rate_name", "tax_rate")
608+
model = TaxRate
609+
610+
603611
admin.site.register(Line, LineAdmin)
604612
admin.site.register(LineRunSelection, LineRunSelectionAdmin)
605613
admin.site.register(ProgramRunLine, ProgramRunLineAdmin)
@@ -620,3 +628,4 @@ def get_product(self, obj):
620628
admin.site.register(BulkCouponAssignment, BulkCouponAssignmentAdmin)
621629
admin.site.register(ProductCouponAssignment, ProductCouponAssignmentAdmin)
622630
admin.site.register(Company, CompanyAdmin)
631+
admin.site.register(TaxRate, TaxRateAdmin)

ecommerce/api.py

+160-8
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
from django.conf import settings
1717
from django.db import transaction
1818
from django.db.models import Count, F, Max, Prefetch, Q, Subquery
19+
from django.http import HttpRequest
1920
from django.urls import reverse
21+
from ipware import get_client_ip
2022
from rest_framework.exceptions import ValidationError
2123

2224
import sheets.tasks
@@ -61,9 +63,11 @@
6163
ProductVersion,
6264
ProgramRunLine,
6365
Receipt,
66+
TaxRate,
6467
)
6568
from ecommerce.utils import positive_or_zero
6669
from hubspot_xpro.task_helpers import sync_hubspot_deal
70+
from maxmind.api import ip_to_country_code
6771
from mitxpro.utils import case_insensitive_equal, first_or_none, now_in_utc
6872

6973

@@ -72,6 +76,89 @@
7276
ISO_8601_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
7377

7478

79+
# Calculated Tax Rate is (rate applied, adjusted amount)
80+
CalculatedTaxRate = tuple[decimal.Decimal, str, decimal.Decimal]
81+
82+
83+
def determine_visitor_country(request: HttpRequest or None) -> str or None:
84+
"""
85+
Determines the country the user is in for tax purposes.
86+
87+
For this, we require that the user has a country specified and that the IP
88+
they're connecting from is assigned to the same country.
89+
90+
Args:
91+
request (HttpRequest): the current request object
92+
Returns:
93+
The resolved country, or None
94+
"""
95+
96+
if (
97+
not request
98+
or not request.user.is_authenticated
99+
or request.user.legal_address.country is None
100+
):
101+
return None
102+
103+
profile_country_code = (
104+
request.user.legal_address.country if request.user.is_authenticated else None
105+
)
106+
107+
if settings.ECOMMERCE_FORCE_PROFILE_COUNTRY:
108+
return profile_country_code
109+
110+
try:
111+
client_ip, _ = get_client_ip(request)
112+
except TypeError:
113+
return None
114+
115+
ip_country_code = ip_to_country_code(client_ip)
116+
117+
if ip_country_code == profile_country_code:
118+
return ip_country_code
119+
120+
return None
121+
122+
123+
def calculate_tax(
124+
request: HttpRequest, item_price: decimal.Decimal
125+
) -> CalculatedTaxRate:
126+
"""
127+
Calculate the tax to be assessed for the given amount.
128+
129+
This uses the logged in user's profile and their IP to determine whether or
130+
not to charge tax - if _both_ the IP's country and the profile country code
131+
match, _and_ there's a TaxRate for the country, then we charge tax.
132+
Otherwise, we don't.
133+
134+
Args:
135+
request_ip (HttpRequest): The current request.
136+
item_price (Decimal): The amount to be taxed.
137+
Returns:
138+
tuple(rate applied, country code, adjusted amount): The rate applied and the adjusted amount based on the determined country code.
139+
"""
140+
141+
resolved_country_code = determine_visitor_country(request)
142+
143+
if resolved_country_code is None:
144+
return (0, "", item_price)
145+
146+
try:
147+
tax_rate = TaxRate.objects.filter(
148+
active=True, country_code__iexact=resolved_country_code
149+
).get()
150+
151+
tax_inclusive_amt = item_price + (
152+
decimal.Decimal(item_price) * (tax_rate.tax_rate / 100)
153+
)
154+
155+
return (tax_rate.tax_rate, resolved_country_code, tax_inclusive_amt)
156+
except TaxRate.DoesNotExist:
157+
pass
158+
159+
return (0, "", item_price)
160+
161+
75162
# pylint: disable=too-many-lines
76163
def generate_cybersource_sa_signature(payload):
77164
"""
@@ -170,19 +257,27 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre
170257

171258
line_items = {}
172259
total = 0
260+
total_tax_assessed = 0
173261
for i, line in enumerate(order.lines.all()):
174262
product_version = line.product_version
175-
unit_price = get_product_version_price_with_discount(
176-
coupon_version=coupon_version, product_version=product_version
263+
product_price_dict = get_product_version_price_with_discount_tax(
264+
coupon_version=coupon_version,
265+
product_version=product_version,
266+
tax_rate=order.tax_rate,
177267
)
178268
line_items[f"item_{i}_code"] = str(product_version.product.content_type)
179269
line_items[f"item_{i}_name"] = str(product_version.description)[:254]
180270
line_items[f"item_{i}_quantity"] = line.quantity
181271
line_items[f"item_{i}_sku"] = product_version.product.content_object.id
182-
line_items[f"item_{i}_tax_amount"] = "0"
183-
line_items[f"item_{i}_unit_price"] = str(unit_price)
272+
line_items[f"item_{i}_tax_amount"] = str(
273+
decimal.Decimal(product_price_dict["tax_assessed"]).quantize(
274+
decimal.Decimal("0.01")
275+
)
276+
)
277+
line_items[f"item_{i}_unit_price"] = str(product_price_dict["price"])
184278

185-
total += unit_price
279+
total += product_price_dict["price"]
280+
total_tax_assessed += product_price_dict["tax_assessed"]
186281

187282
# At the moment there should only be one line
188283
product_version = order.lines.first().product_version
@@ -212,7 +307,14 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre
212307

213308
return {
214309
"access_key": settings.CYBERSOURCE_ACCESS_KEY,
215-
"amount": str(total),
310+
"amount": str(
311+
decimal.Decimal(total + total_tax_assessed).quantize(
312+
decimal.Decimal("0.01")
313+
)
314+
),
315+
"tax_amount": str(
316+
decimal.Decimal(total_tax_assessed).quantize(decimal.Decimal("0.01"))
317+
),
216318
"consumer_id": order.purchaser.username,
217319
"currency": "USD",
218320
"locale": "en-us",
@@ -317,7 +419,6 @@ def get_valid_coupon_versions(
317419
# We can only get full discount for dollars-off when we know the price
318420

319421
if product is None or not product.productversions.exists():
320-
321422
coupon_version_subquery = coupon_version_subquery.filter(
322423
payment_version__amount=decimal.Decimal(1)
323424
)
@@ -522,6 +623,38 @@ def get_product_version_price_with_discount(*, coupon_version, product_version):
522623
return positive_or_zero(price - discount_amount)
523624

524625

626+
def get_product_version_price_with_discount_tax(
627+
*, coupon_version, product_version, tax_rate
628+
):
629+
"""
630+
Uses get_product_version_price_with_discount to get the price for the item
631+
and then add the specified tax amount to it.
632+
633+
Args:
634+
coupon_version (CouponVersion): the CouponVersion object
635+
product_version (ProductVersion): the ProductVersion object
636+
tax_rate (Decimal): the tax rate to apply
637+
638+
Returns:
639+
dict:
640+
- price (Decimal): discounted price for the Product
641+
- tax_assessed (Decimal): tax assessed for the discounted price
642+
"""
643+
644+
product_version_price = get_product_version_price_with_discount(
645+
coupon_version=coupon_version, product_version=product_version
646+
)
647+
648+
return {
649+
"price": product_version_price,
650+
"tax_assessed": (
651+
0
652+
if not tax_rate
653+
else decimal.Decimal(product_version_price * (tax_rate / 100))
654+
),
655+
}
656+
657+
525658
def redeem_coupon(coupon_version, order):
526659
"""
527660
Redeem a coupon for an order by creating/updating the CouponRedemption for that order.
@@ -698,15 +831,21 @@ def get_order_programs(order):
698831
]
699832

700833

701-
def create_unfulfilled_order(validated_basket, affiliate_id=None):
834+
def create_unfulfilled_order(validated_basket, affiliate_id=None, **kwargs):
702835
"""
703836
Create a new Order which is not fulfilled for a purchasable Product. Note that validation should
704837
be done in the basket REST API so the validation is not done here (different from MicroMasters).
705838
839+
This now also takes the request as an argument, so that the necessary tax
840+
fields can be filled out and tax calculated as well.
841+
706842
Args:
707843
validated_basket (ValidatedBasket): The validated Basket and related objects
708844
affiliate_id (Optional[int]): The id of the Affiliate record to associate with this order
709845
846+
Keyword Args:
847+
request (HttpRequest): The current request object, for tax calculation.
848+
710849
Returns:
711850
Order: A newly created Order for the Product in the basket
712851
"""
@@ -715,10 +854,23 @@ def create_unfulfilled_order(validated_basket, affiliate_id=None):
715854
coupon_version=validated_basket.coupon_version,
716855
product_version=validated_basket.product_version,
717856
)
857+
country_code = determine_visitor_country(
858+
kwargs["request"] if "request" in kwargs else None
859+
)
860+
861+
try:
862+
tax_rate_info = TaxRate.objects.get(country_code=country_code)
863+
except (TaxRate.DoesNotExist, TaxRate.MultipleObjectsReturned):
864+
# not using get_or_create here because we don't want the rate to stick around
865+
tax_rate_info = TaxRate()
866+
718867
order = Order.objects.create(
719868
status=Order.CREATED,
720869
purchaser=validated_basket.basket.user,
721870
total_price_paid=total_price_paid,
871+
tax_country_code=country_code,
872+
tax_rate=tax_rate_info.tax_rate,
873+
tax_rate_name=tax_rate_info.tax_rate_name,
722874
)
723875
line = Line.objects.create(
724876
order=order,

0 commit comments

Comments
 (0)