Skip to content

Commit

Permalink
Dynamic bank currency (#2482)
Browse files Browse the repository at this point in the history
* Add Currency model

* Bank currency rate from db

* Add currency choices

* Create currencies in migration

* Rename ue to currency_rate
  • Loading branch information
paulinenik authored Dec 26, 2024
1 parent 623f574 commit 0ebb6e7
Show file tree
Hide file tree
Showing 24 changed files with 221 additions and 26 deletions.
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"apps.diplomas.factory",
"apps.orders.factory",
"apps.products.factory",
"apps.banking.factory",
"core.factory",

"apps.products.fixtures",
Expand Down
5 changes: 5 additions & 0 deletions src/apps/banking/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from apps.banking.admin.currency_rate import CurrencyRateAdmin

__all__ = [
"CurrencyRateAdmin",
]
32 changes: 32 additions & 0 deletions src/apps/banking/admin/currency_rate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Any

from django.contrib.admin.models import CHANGE, LogEntry
from django.http import HttpRequest

from apps.banking.models import CurrencyRate
from core.admin import ModelAdmin, admin
from core.tasks import write_admin_log


@admin.register(CurrencyRate)
class CurrencyRateAdmin(ModelAdmin):
list_display = ["name", "rate"]

def get_object(self, request: HttpRequest, object_id: str, from_field: str | None = None) -> CurrencyRate | None:
obj = super().get_object(request, object_id, from_field)
if obj:
obj.__original_rate = obj.rate
return obj

def log_change(self, request: HttpRequest, obj: CurrencyRate, message: Any) -> LogEntry:
if obj.rate != obj.__original_rate: # type: ignore[attr-defined]
return write_admin_log(
action_flag=CHANGE,
app="banking",
change_message=f"Currency rate was changed from {obj.__original_rate} to {obj.rate}", # type: ignore[attr-defined]
model="CurrencyRate",
object_id=obj.id,
user_id=request.user.id,
)
else:
return super().log_change(request, obj, message)
11 changes: 10 additions & 1 deletion src/apps/banking/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from apps.banking.exceptions import CurrencyRateDoesNotExist
from apps.banking.models import CurrencyRate

if TYPE_CHECKING:
from django_stubs_ext import StrPromise

Expand All @@ -17,7 +20,6 @@
class Bank(metaclass=ABCMeta):
currency = "RUB"
currency_symbol = "₽"
ue: Decimal = Decimal(1) # ue stands for «условные единицы», this is some humour from 2000's
acquiring_percent: Decimal = Decimal(0) # we use it for analytics
name: "StrPromise" = _("—")

Expand Down Expand Up @@ -67,6 +69,13 @@ def user(self) -> "User":
def is_partial_refund_available(self) -> bool:
return False

@classmethod
def get_currency_rate(cls) -> Decimal:
try:
return CurrencyRate.objects.get(name=cls.currency).rate
except CurrencyRate.DoesNotExist:
raise CurrencyRateDoesNotExist(f"Currency {cls.currency} is not supported")

def get_formatted_amount(self, amount: Decimal) -> int:
from apps.banking import price_calculator

Expand Down
4 changes: 4 additions & 0 deletions src/apps/banking/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ class BankException(Exception):

class BankDoesNotExist(BankException):
"""Bank with given id does not exist."""


class CurrencyRateDoesNotExist(BankException):
"""Currency rate with given name does not exist."""
9 changes: 9 additions & 0 deletions src/apps/banking/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Any

from apps.banking.models import CurrencyRate
from core.test.factory import register


@register
def currency_rate(self: Any, **kwargs: dict[str, Any]) -> CurrencyRate:
return self.mixer.blend("banking.CurrencyRate", **kwargs)
50 changes: 50 additions & 0 deletions src/apps/banking/migrations/0003_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 4.2.17 on 2024-12-24 18:15
from decimal import Decimal

from django.db import migrations, models

import core.models


def create_currencies(apps, schema_editor):
del schema_editor

CurrencyRate = apps.get_model("banking.CurrencyRate")

CurrencyRate.objects.create(name="RUB", rate=Decimal(1))
CurrencyRate.objects.create(name="USD", rate=Decimal(90))
CurrencyRate.objects.create(name="KZT", rate=Decimal("0.18"))
CurrencyRate.objects.create(name="KIS", rate=Decimal(1))


class Migration(migrations.Migration):
initial = True

dependencies = [
("banking", "0002_drop_recipient_model"),
]

operations = [
migrations.CreateModel(
name="CurrencyRate",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"name",
models.CharField(
choices=[("RUB", "RUB"), ("USD", "USD"), ("KZT", "KZT"), ("KIS", "KIS (for zero-price orders)")],
db_index=True,
max_length=4,
unique=True,
),
),
("rate", models.DecimalField(decimal_places=2, max_digits=6, verbose_name="Rate")),
],
options={
"verbose_name": "Currency",
"verbose_name_plural": "Currencies",
},
bases=(core.models.TestUtilsMixin, models.Model),
),
migrations.RunPython(create_currencies),
]
20 changes: 20 additions & 0 deletions src/apps/banking/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.db import models
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _

from core.models import DefaultModel


class CurrencyRate(DefaultModel):
class Currency(TextChoices):
RUB = "RUB", _("RUB")
USD = "USD", _("USD")
KZT = "KZT", _("KZT")
KIS = "KIS", _("KIS (for zero-price orders)")

name = models.CharField(max_length=4, unique=True, choices=Currency.choices, db_index=True)
rate = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_("Rate"))

class Meta:
verbose_name = _("Currency")
verbose_name_plural = _("Currencies")
2 changes: 1 addition & 1 deletion src/apps/banking/price_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

def to_bank(bank: Type[Bank], price: Decimal) -> Decimal:
"""Get sum in bank currency"""
exchanged_price = Decimal(price) / Decimal(bank.ue)
exchanged_price = Decimal(price) / Decimal(bank.get_currency_rate())

if not price % 1: # initial price contains decimal part, e.g. kopecks
exchanged_price = Decimal(round(exchanged_price))
Expand Down
17 changes: 17 additions & 0 deletions src/apps/banking/tests/tests_get_currency_rate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import pytest

from apps.banking.base import Bank
from apps.banking.exceptions import CurrencyRateDoesNotExist
from apps.banking.models import CurrencyRate

pytestmark = [pytest.mark.django_db]


def test_get_currency_rate():
assert Bank.get_currency_rate() == 1


def test_raise_if_no_currency_rate():
CurrencyRate.objects.all().delete()
with pytest.raises(CurrencyRateDoesNotExist):
Bank.get_currency_rate()
11 changes: 6 additions & 5 deletions src/apps/banking/tests/tests_price_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


@pytest.mark.parametrize(
("price", "ue", "expected"),
("price", "currency_rate", "expected"),
[
(100, 1, 100),
(100.50, 1, 100.50),
Expand All @@ -21,9 +21,10 @@
(100500.5, Decimal("0.17"), "591179.41"),
],
)
def test(price, ue, expected):
class MockBank: ...

MockBank.ue = ue
def test(price, currency_rate, expected):
class MockBank:
@classmethod
def get_currency_rate(cls):
return currency_rate

assert price_calculator.to_bank(MockBank, price) == Decimal(expected)
17 changes: 17 additions & 0 deletions src/apps/orders/migrations/0039_alter_order_ue_rate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.17 on 2024-12-24 11:29

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("orders", "0038_alter_order_bank_id_alter_refund_bank_id"),
]

operations = [
migrations.AlterField(
model_name="order",
name="ue_rate",
field=models.DecimalField(decimal_places=2, max_digits=6, verbose_name="Purchase-time UE rate"),
),
]
2 changes: 1 addition & 1 deletion src/apps/orders/models/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class Order(TimestampedModel):
shipped = models.DateTimeField(_("Date when order was shipped"), null=True, blank=True)

bank_id = models.CharField(_("User-requested bank string"), choices=BANK_CHOICES, blank=True, max_length=32)
ue_rate = models.IntegerField(_("Purchase-time UE rate"))
ue_rate = models.DecimalField(_("Purchase-time UE rate"), decimal_places=2, max_digits=6)
acquiring_percent = models.DecimalField(default=0, max_digits=4, decimal_places=2)

course = ItemField(to="products.Course", verbose_name=_("Course"), null=True, blank=True, on_delete=models.PROTECT)
Expand Down
2 changes: 1 addition & 1 deletion src/apps/orders/services/order_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def create(self) -> Order:
price=self.price, # type: ignore
promocode=self.promocode,
bank_id=self.desired_bank,
ue_rate=self.bank.ue,
ue_rate=self.bank.get_currency_rate(),
acquiring_percent=self.bank.acquiring_percent,
analytics=self._parse_analytics(self.analytics),
)
Expand Down
10 changes: 5 additions & 5 deletions src/apps/orders/tests/orders/configurable_bank/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ def dolyame_bank(mocker):


@pytest.fixture(autouse=True)
def _freeze_ue_rate(mocker):
mocker.patch("apps.tinkoff.bank.TinkoffBank.ue", 11)
mocker.patch("apps.stripebank.bank.StripeBankUSD.ue", 33)
mocker.patch("apps.stripebank.bank.StripeBankKZT.ue", 33)
mocker.patch("apps.tinkoff.dolyame.Dolyame.ue", 44)
def _freeze_currency_rate_rate(mocker):
mocker.patch("apps.tinkoff.bank.TinkoffBank.get_currency_rate", return_value=11)
mocker.patch("apps.stripebank.bank.StripeBankUSD.get_currency_rate", return_value=33)
mocker.patch("apps.stripebank.bank.StripeBankKZT.get_currency_rate", return_value=33)
mocker.patch("apps.tinkoff.dolyame.Dolyame.get_currency_rate", return_value=44)


@pytest.fixture(autouse=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def test_desired_bank_is_saved(call_purchase, bank):
("dolyame", 44),
],
)
def test_ue_rate_is_saved(call_purchase, bank, ue_rate):
def test_currency_rate_rate_is_saved(call_purchase, bank, ue_rate):
call_purchase(desired_bank=bank)

order = Order.objects.last()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@

@pytest.fixture(autouse=True)
def _freeze_stripe_course(mocker):
mocker.patch("apps.stripebank.bank.StripeBankUSD.ue", Decimal(70)) # let it be forever :'(
mocker.patch("apps.stripebank.bank.StripeBankUSD.get_currency_rate", return_value=Decimal(70)) # let it be forever :'(


@pytest.fixture(autouse=True)
def _freeze_stripe_kz_course(mocker):
mocker.patch("apps.stripebank.bank.StripeBankKZT.ue", Decimal("0.18"))
mocker.patch("apps.stripebank.bank.StripeBankKZT.get_currency_rate", return_value=Decimal("0.18"))


@pytest.mark.parametrize(
Expand Down
5 changes: 2 additions & 3 deletions src/apps/orders/tests/orders/services/tests_order_refunder.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def _adjust_settings(settings, mocker):
"[email protected]",
"[email protected]",
]
mocker.patch("apps.stripebank.bank.StripeBankUSD.ue", Decimal(80))
mocker.patch("apps.stripebank.bank.StripeBankKZT.ue", Decimal("0.18"))
mocker.patch("apps.stripebank.bank.StripeBankUSD.get_currency_rate", return_value=Decimal(80))
mocker.patch("apps.stripebank.bank.StripeBankKZT.get_currency_rate", return_value=Decimal("0.18"))


@pytest.fixture
Expand Down Expand Up @@ -66,7 +66,6 @@ def mock_stripe_refund(mocker):

@pytest.fixture
def mock_stripe_kz_refund(mocker):
mocker.patch("apps.stripebank.bank.StripeBankUSD.ue", 70)
return mocker.patch("apps.stripebank.bank.StripeBankKZT.refund")


Expand Down
2 changes: 0 additions & 2 deletions src/apps/stripebank/bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ def get_items(self) -> list[dict[str, Any]]:


class StripeBankUSD(BaseStripeBank):
ue = Decimal(90)
currency = "USD"
currency_symbol = "$"
name = _("Stripe USD")
Expand All @@ -72,7 +71,6 @@ class StripeBankUSD(BaseStripeBank):


class StripeBankKZT(BaseStripeBank):
ue = Decimal("0.18")
currency = "KZT"
currency_symbol = "₸"
name = _("Stripe KZT")
Expand Down
2 changes: 1 addition & 1 deletion src/apps/stripebank/tests/stripebank/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def stripe(order):

@pytest.fixture(autouse=True)
def _fix_stripe_course(mocker):
mocker.patch("apps.stripebank.bank.BaseStripeBank.ue", 70) # let it be forever :'(
mocker.patch("apps.stripebank.bank.BaseStripeBank.get_currency_rate", return_value=70) # let it be forever :'(


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion src/apps/stripebank/webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,4 @@ def charge_refunded(self, webhook_event: stripe.Event) -> None:
)

def convert_amount(self, stripe_amount: int) -> Decimal:
return Decimal(stripe_amount) / 100 * self.bank.ue
return Decimal(stripe_amount) / 100 * self.bank.get_currency_rate()
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
from apps.tinkoff.exceptions import TinkoffPaymentNotificationInvalidToken, TinkoffPaymentNotificationNoTokenPassed
from apps.tinkoff.token_validator import TinkoffNotificationsTokenValidator

pytestmark = [
pytest.mark.django_db,
]


@pytest.mark.parametrize(
"payload",
Expand Down
4 changes: 2 additions & 2 deletions src/core/tasks/write_admin_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ def write_admin_log(
model: str,
object_id: int,
user_id: int,
) -> None:
) -> LogEntry:
content_type_id = ContentType.objects.get(app_label=app, model=model.lower()).id

obj = apps.get_model(app, model).objects.get(id=object_id)

LogEntry.objects.log_action(
return LogEntry.objects.log_action(
action_flag=action_flag,
change_message=change_message,
content_type_id=content_type_id,
Expand Down
Loading

0 comments on commit 0ebb6e7

Please sign in to comment.