16
16
from django .conf import settings
17
17
from django .db import transaction
18
18
from django .db .models import Count , F , Max , Prefetch , Q , Subquery
19
+ from django .http import HttpRequest
19
20
from django .urls import reverse
21
+ from ipware import get_client_ip
20
22
from rest_framework .exceptions import ValidationError
21
23
22
24
import sheets .tasks
61
63
ProductVersion ,
62
64
ProgramRunLine ,
63
65
Receipt ,
66
+ TaxRate ,
64
67
)
65
68
from ecommerce .utils import positive_or_zero
66
69
from hubspot_xpro .task_helpers import sync_hubspot_deal
70
+ from maxmind .api import ip_to_country_code
67
71
from mitxpro .utils import case_insensitive_equal , first_or_none , now_in_utc
68
72
69
73
72
76
ISO_8601_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
73
77
74
78
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
+
75
162
# pylint: disable=too-many-lines
76
163
def generate_cybersource_sa_signature (payload ):
77
164
"""
@@ -170,19 +257,27 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre
170
257
171
258
line_items = {}
172
259
total = 0
260
+ total_tax_assessed = 0
173
261
for i , line in enumerate (order .lines .all ()):
174
262
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 ,
177
267
)
178
268
line_items [f"item_{ i } _code" ] = str (product_version .product .content_type )
179
269
line_items [f"item_{ i } _name" ] = str (product_version .description )[:254 ]
180
270
line_items [f"item_{ i } _quantity" ] = line .quantity
181
271
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" ])
184
278
185
- total += unit_price
279
+ total += product_price_dict ["price" ]
280
+ total_tax_assessed += product_price_dict ["tax_assessed" ]
186
281
187
282
# At the moment there should only be one line
188
283
product_version = order .lines .first ().product_version
@@ -212,7 +307,14 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre
212
307
213
308
return {
214
309
"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
+ ),
216
318
"consumer_id" : order .purchaser .username ,
217
319
"currency" : "USD" ,
218
320
"locale" : "en-us" ,
@@ -317,7 +419,6 @@ def get_valid_coupon_versions(
317
419
# We can only get full discount for dollars-off when we know the price
318
420
319
421
if product is None or not product .productversions .exists ():
320
-
321
422
coupon_version_subquery = coupon_version_subquery .filter (
322
423
payment_version__amount = decimal .Decimal (1 )
323
424
)
@@ -522,6 +623,38 @@ def get_product_version_price_with_discount(*, coupon_version, product_version):
522
623
return positive_or_zero (price - discount_amount )
523
624
524
625
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
+
525
658
def redeem_coupon (coupon_version , order ):
526
659
"""
527
660
Redeem a coupon for an order by creating/updating the CouponRedemption for that order.
@@ -698,15 +831,21 @@ def get_order_programs(order):
698
831
]
699
832
700
833
701
- def create_unfulfilled_order (validated_basket , affiliate_id = None ):
834
+ def create_unfulfilled_order (validated_basket , affiliate_id = None , ** kwargs ):
702
835
"""
703
836
Create a new Order which is not fulfilled for a purchasable Product. Note that validation should
704
837
be done in the basket REST API so the validation is not done here (different from MicroMasters).
705
838
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
+
706
842
Args:
707
843
validated_basket (ValidatedBasket): The validated Basket and related objects
708
844
affiliate_id (Optional[int]): The id of the Affiliate record to associate with this order
709
845
846
+ Keyword Args:
847
+ request (HttpRequest): The current request object, for tax calculation.
848
+
710
849
Returns:
711
850
Order: A newly created Order for the Product in the basket
712
851
"""
@@ -715,10 +854,23 @@ def create_unfulfilled_order(validated_basket, affiliate_id=None):
715
854
coupon_version = validated_basket .coupon_version ,
716
855
product_version = validated_basket .product_version ,
717
856
)
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
+
718
867
order = Order .objects .create (
719
868
status = Order .CREATED ,
720
869
purchaser = validated_basket .basket .user ,
721
870
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 ,
722
874
)
723
875
line = Line .objects .create (
724
876
order = order ,
0 commit comments