Skip to content

Commit

Permalink
feat: add command retry send to financial manager
Browse files Browse the repository at this point in the history
Create Django command to retry send Orders to financial manager.
Fix when retry fullfillment, the BasketTransactionIntegration was rasing mysql error.
Add tests.

fix #4
fix #2
  • Loading branch information
igobranco committed Feb 21, 2024
1 parent acc5987 commit 5f2e6b9
Show file tree
Hide file tree
Showing 20 changed files with 596 additions and 86 deletions.
5 changes: 1 addition & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,13 @@ edit the `ecommerce/settings/private.py` file add change to::
)
LOGO_URL = "https://lms.nau.edu.pt/static/nau-basic/images/nau_azul.svg"

# Use custom tax strategy
NAU_EXTENSION_OSCAR_STRATEGY_CLASS = "ecommerce_plugin_paygate.strategy.DefaultStrategy"
# Configure tax as 23% used in Portugal
NAU_EXTENSION_TAX_RATE = "0.298701299" # = 0.23/0.77

NAU_FINANCIAL_MANAGER = {
"edx": {
"url": "http://financial-manager.local.nau.fccn.pt:8000/api/billing/transaction-complete/",
"token": "abcdABCD1234",
"token": "Bearer abcdABCD1234",
}
}

Expand Down
4 changes: 2 additions & 2 deletions nau_extensions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from django.contrib import admin
from django.utils.html import format_html

from .models import BasketBillingInformation, BasketTransactionIntegration
from nau_extensions.models import (BasketBillingInformation,
BasketTransactionIntegration)

admin.site.register(BasketBillingInformation)

Expand Down
62 changes: 40 additions & 22 deletions nau_extensions/financial_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from nau_extensions.models import (BasketBillingInformation,
BasketTransactionIntegration)
from nau_extensions.utils import get_order
from opaque_keys.edx.keys import CourseKey
from oscar.core.loading import get_class, get_model

from .models import BasketTransactionIntegration
from .utils import get_order

logger = logging.getLogger(__name__)
Selector = get_class("partner.strategy", "Selector")
Order = get_model("order", "Order")
Expand Down Expand Up @@ -50,27 +50,34 @@ def sync_request_data(bti: BasketTransactionIntegration) -> dict:
# initialize strategy
basket = bti.basket
basket.strategy = Selector().strategy(user=basket.owner)
bbi = basket.basket_billing_information
bbi = BasketBillingInformation.get_by_basket(basket)
order = get_order(basket)

address_line_1 = bbi.line1
address_line_2 = bbi.line2 + (
("," + bbi.line3) if bbi.line3 and len(bbi.line3) > 0 else ""
("," + bbi.line3) if bbi.line3 and len(bbi.line3) > 0 else ''
)
order = get_order(basket)
city = bbi.line4 if bbi else ''
postal_code = bbi.postcode if bbi else ''
state = bbi.state if bbi else ''
country_code = bbi.country.iso_3166_1_a2 if bbi else ''
vat_identification_number = bbi.vatin if bbi else ''
vat_identification_country = bbi.country.iso_3166_1_a2 if bbi else ''

# generate a dict with all request data
request_data = {
"transaction_id": basket.order_number,
"transaction_type": "credit",
"client_name": basket.owner.full_name,
"email": basket.owner.email,
"address_line_1": bbi.line1,
"address_line_1": address_line_1,
"address_line_2": address_line_2,
"city": bbi.line4,
"postal_code": bbi.postcode,
"state": bbi.state,
"country_code": bbi.country.iso_3166_1_a2,
"vat_identification_number": bbi.vatin,
"vat_identification_country": bbi.country.iso_3166_1_a2,
"city": city,
"postal_code": postal_code,
"state": state,
"country_code": country_code,
"vat_identification_number": vat_identification_number,
"vat_identification_country": vat_identification_country,
"total_amount_exclude_vat": basket.total_excl_tax,
"total_amount_include_vat": basket.total_incl_tax,
"currency": basket.currency,
Expand Down Expand Up @@ -102,17 +109,24 @@ def _convert_order_lines(order):
for line in order.lines.all():
# line.discount_incl_tax
# line.discount_excl_tax
course_run_key = CourseKey.from_string(line.product.course.id)
course = line.product.course
course_id = course.id if course else line.product.title
course_key = CourseKey.from_string(course.id) if course else None
organization_code = course_key.org if course else None
product_code = course_key.course if course else None
amount_exclude_vat = line.quantity * line.unit_price_excl_tax
amount_include_vat = line.quantity * line.unit_price_incl_tax
vat_tax = amount_include_vat - amount_exclude_vat
result.append(
{
"description": line.title,
"quantity": line.quantity,
"vat_tax": 1 - (line.unit_price_excl_tax - line.unit_price_incl_tax),
"amount_exclude_vat": line.quantity * line.unit_price_excl_tax,
"amount_include_vat": line.quantity * line.unit_price_incl_tax,
"organization_code": course_run_key.org,
"product_code": course_run_key.course,
"product_id": line.product.course.id,
"vat_tax": vat_tax,
"amount_exclude_vat": amount_exclude_vat,
"amount_include_vat": amount_include_vat,
"organization_code": organization_code,
"product_code": product_code,
"product_id": course_id,
}
)
return result
Expand All @@ -138,13 +152,17 @@ def send_to_financial_manager_if_enabled(
)

# update state
if basket_transaction_integration.status_code == 200:
if response.status_code == 200:
state = BasketTransactionIntegration.SENT_WITH_SUCCESS
else:
state = BasketTransactionIntegration.SENT_WITH_ERROR
basket_transaction_integration.state = state

# save the response output
basket_transaction_integration.response = response.content
try:
response_json = response.json()
except Exception as e: # pylint: disable=broad-except
logger.exception("Error can't parse send to financial manager response as json [%s]", e)

basket_transaction_integration.response = response_json
basket_transaction_integration.save()
3 changes: 1 addition & 2 deletions nau_extensions/forms.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from django import forms
from nau_extensions.models import BasketBillingInformation
from oscar.apps.address.forms import AbstractAddressForm
from oscar.core.loading import get_model

from .models import BasketBillingInformation

Basket = get_model("basket", "Basket")


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
Script to synchronize courses to Richie marketing site
"""

import logging
from datetime import datetime, timedelta

from django.core.management.base import BaseCommand
from nau_extensions.financial_manager import \
send_to_financial_manager_if_enabled
from nau_extensions.models import BasketTransactionIntegration
from oscar.core.loading import get_model

Basket = get_model("basket", "Basket")


log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Command that retries to send the BasketTransactionIntegration objects to
the Financial Manager system.
By default, will retry the BasketTransactionIntegration objects where its state is sent with
error and also send the pending to be sent that have been created more than 5 minutes ago.
"""

help = (
"Retry send the BasketTransactionIntegration objects to the "
"Financial Manager system that have been sent with error or that "
"haven't being sent on the last 5 minutes"
)

def add_arguments(self, parser):
parser.add_argument(
"--basket_id",
type=str,
default=None,
help="Basket id to synchronize, otherwise all pending baskets will be sent",
)
parser.add_argument(
"--delta_to_be_sent_in_seconds",
type=int,
default=300,
help="Delta in seconds to retry the To be sent state",
)

def handle(self, *args, **kwargs):
"""
Synchronize courses to the Richie marketing site, print to console its sync progress.
"""
btis: list = None

basket_id = kwargs["basket_id"]
if basket_id:
basket = Basket.objects.filter(id=basket_id)
if not basket:
raise ValueError(f"No basket found for basket_id={basket_id}")
bti = BasketTransactionIntegration.get_by_basket(basket)
if not bti:
raise ValueError(
f"No basket transaction integration found for basket_id={basket_id}"
)
btis = [bti]
else:
btis = BasketTransactionIntegration.objects.filter(
state__in=[
BasketTransactionIntegration.SENT_WITH_ERROR,
BasketTransactionIntegration.TO_BE_SENT,
]
)

delta_to_be_sent_in_seconds = kwargs["delta_to_be_sent_in_seconds"]
for bti in btis:
if bti.created <= datetime.now(bti.created.tzinfo) - timedelta(
seconds=delta_to_be_sent_in_seconds
):
log.info("Sending to financial manager basket_id=%d", bti.basket.id)
send_to_financial_manager_if_enabled(bti)
36 changes: 29 additions & 7 deletions nau_extensions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
from django.forms import ValidationError
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from nau_extensions.utils import get_order
from nau_extensions.vatin import check_country_vatin
from oscar.apps.address.abstract_models import AbstractAddress
from oscar.core.loading import get_model

from .utils import get_order
from .vatin import check_country_vatin

Basket = get_model("basket", "Basket")
Country = get_model("address", "Country")

Expand Down Expand Up @@ -79,6 +78,16 @@ def active_address_fields_except_country(self):
fields = self.base_fields.remove("country")
return self.get_address_field_values(fields)

@classmethod
def get_by_basket(cls, basket):
"""
Get the `BasketBillingInformation` instance from a `basket` instance.
This is required because the `basket` class doesn't know this one.
And the relation basket.basket_billing_information isn't recognized
by Django.
"""
return BasketBillingInformation.objects.filter(basket=basket).first()


class BasketTransactionIntegration(models.Model):
"""
Expand Down Expand Up @@ -120,15 +129,28 @@ class BasketTransactionIntegration(models.Model):
class Meta:
get_latest_by = "created"

@staticmethod
def create(basket):
@classmethod
def create(cls, basket):
"""
Create a new basket transaction integration for a basket.
Create a new basket basket transaction integration or reuse an existing one for a basket.
"""
order = get_order(basket)
if not order:
raise ValueError(
f"The creation of BasketTransactionIntegration requires a basket with an order"
f", basket '{basket}'"
)
return BasketTransactionIntegration(basket=basket)
bti = cls.get_by_basket(basket)
if not bti:
bti = BasketTransactionIntegration(basket=basket)
return bti

@classmethod
def get_by_basket(cls, basket):
"""
Get the `BasketTransactionIntegration` instance from a `basket` instance.
This is required because the `basket` class doesn't know this one.
And the relation basket.basket_transaction_integration isn't recognized
by Django.
"""
return BasketTransactionIntegration.objects.filter(basket=basket).first()
6 changes: 6 additions & 0 deletions nau_extensions/settings/test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""
Test settings for the ecommerce nau extensions
"""

from ecommerce.settings.test import *

INSTALLED_APPS += ("nau_extensions",)

# This setting needs to be specified on this level.
NAU_EXTENSION_OSCAR_RATE_TAX_STRATEGY_CLASS = "nau_extensions.strategy.SettingFixedRateTax"
13 changes: 8 additions & 5 deletions nau_extensions/signals.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django.db import transaction
from django.dispatch import receiver
from nau_extensions.financial_manager import \
send_to_financial_manager_if_enabled
from nau_extensions.models import BasketTransactionIntegration
from oscar.core.loading import get_class

from .models import BasketTransactionIntegration
from .tasks import send_basket_transaction_integration_to_financial_manager

post_checkout = get_class("checkout.signals", "post_checkout")


Expand All @@ -19,5 +20,7 @@ def create_and_send_basket_transaction_integration_to_financial_manager(
Create a Basket Transaction Integration object after a checkout of an Order;
then send that information to the nau-financial-manager service.
"""
BasketTransactionIntegration.create(order.basket).save()
send_basket_transaction_integration_to_financial_manager.delay(order.basket)
with transaction.atomic():
bti = BasketTransactionIntegration.create(order.basket).save()

send_to_financial_manager_if_enabled(bti)
17 changes: 9 additions & 8 deletions nau_extensions/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@
Django Oscar strategy for fixed rate tax.
Use a fixed rate tax read from a setting.
"""

from decimal import Decimal as D

from django.conf import settings
from oscar.apps.partner import strategy

from ecommerce.extensions.partner.strategy import \
CourseSeatAvailabilityPolicyMixin

class SettingFixedRateTax(strategy.FixedRateTax):
"""
A custom rate tax that loads a fixed value from a setting.
This means that everything we sell has a fixed VAT value.
"""

class DefaultStrategy(
strategy.UseFirstStockRecord,
CourseSeatAvailabilityPolicyMixin,
strategy.FixedRateTax,
strategy.Structured,
):
def get_rate(self, product, stockrecord):
"""
The rate VAT that all products have.
"""
return D(settings.NAU_EXTENSION_TAX_RATE)
15 changes: 0 additions & 15 deletions nau_extensions/tasks.py

This file was deleted.

Loading

0 comments on commit 5f2e6b9

Please sign in to comment.