Changeset View
Standalone View
looper/taxes.py
- This file was added.
| """Tax calculations. | |||||
| Looper supports VAT calculations for generic electronically supplied services. | |||||
| See https://ec.europa.eu/taxation_customs/individuals/buying-goods-services-online-personal-use/buying-services/electronically-supplied-services_en # noqa: E501 | |||||
| """ | |||||
| from decimal import Decimal | |||||
| from enum import Enum, unique | |||||
| from typing import Optional, Tuple, Iterable | |||||
| import logging | |||||
| from django.utils import timezone | |||||
| from pyvat.vat_rules import VAT_RULES | |||||
| import attr | |||||
| import pyvat | |||||
| from looper.money import Money | |||||
| logger = logging.getLogger(__name__) | |||||
| logger.setLevel(logging.DEBUG) | |||||
| seller = pyvat.Party(country_code='NL', is_business=True) | |||||
| @unique | |||||
sybren: This could probably be an enum? | |||||
Done Inline ActionsIt could, but if it were, it would complicate the code without adding actual benefits and taking away some readability.
@classmethod
def as_choices(cls) -> Tuple['ProductType', str]:
return (
(ProductType.ELECTRONIC_SERVICE.value, 'Generic electronic service'),
(ProductType.DONATION.value, 'Donation'),
)
def get_taxable_item_type(self) -> Optional[pyvat.ItemType]:
"""Returned pyvat's ItemType matching our ProductType.
Out of supported types -- donation and electronic service, only the latter is taxable.
"""
if self == ProductType.ELECTRONIC_SERVICE:
return pyvat.ItemType.generic_electronic_service
return None
type = models.CharField(
choices=ProductType.as_choices(),
default=ProductType.DONATION.value,
max_length=20,
help_text='The nature and type of goods or services supplied (for tax administration)',
)
...
# in update_tax
...
product_type = self.plan.product.type
self.tax_type, self.tax_rate = customer.get_tax(product_type=ProductType(product_type))
...Enums are useful when used for enumeration, mapping attributes to strings not so much, and this class is added only to add syntactic sugar for CharField choices. railla: It could, but if it were, it would complicate the code without adding actual benefits and… | |||||
Done Inline ActionsRefactored both ProductType and TaxType into enums, seems to be indeed better all in all. railla: Refactored both `ProductType` and `TaxType` into enums, seems to be indeed better all in all. | |||||
| class TaxType(Enum): | |||||
| """Known tax types.""" | |||||
| # Blank string was a default for `SharedFields.tax_type` before other values were introduced. | |||||
| # To avoid overwriting existing data in Development Fund DB, blank string is used for NO_CHARGE | |||||
| NO_CHARGE = '' | |||||
| VAT_REVERSE_CHARGE = 'VATRC' | |||||
| VAT_CHARGE = 'VATC' | |||||
Done Inline ActionsThis field doesn't seem used in this class, and since it's a private field (my assumption, given the initial underscore) it can be removed. sybren: This field doesn't seem used in this class, and since it's a private field (my assumption… | |||||
Done Inline ActionsIt's used in looper.models.Product to define Product.type choices, it doesn't make sense to make it private though (renamed _choices to choices). railla: It's used in `looper.models.Product` to define `Product.type` choices, it doesn't make sense to… | |||||
| @classmethod | |||||
| def as_choices(cls) -> Iterable[Tuple[str, str]]: | |||||
| """Return choices for use in CharField model field.""" | |||||
| return ( | |||||
| (cls.NO_CHARGE.value, 'No tax charged'), | |||||
| (cls.VAT_CHARGE.value, 'VAT charged'), | |||||
| ( | |||||
| cls.VAT_REVERSE_CHARGE.value, | |||||
| 'No VAT charged but customer is required to account via reverse-charge', | |||||
| ), # noqa: E501 | |||||
| ) | |||||
Done Inline ActionsThis will raise a KeyError on dontations, instead of returning None as the function declaration would suggest. sybren: This will raise a `KeyError` on dontations, instead of returning `None` as the function… | |||||
Done Inline Actionsdict.get(key, value=None) returns None when key is not present (unlike getattr which doesn't have a default value for value). $ python Python 3.9.2 (default, Feb 20 2021, 18:40:11) [GCC 10.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> {}.get('foobar') is None True >>> $ python3.6 Python 3.6.12 (default, Jan 15 2021, 09:51:15) [GCC 10.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> {}.get('foobar') is None True >>> railla: `dict.get(key, value=None)` returns `None` when `key` is not present (unlike `getattr` which… | |||||
| @property | |||||
| def display_name(self) -> str: | |||||
Done Inline ActionsThis can be an enum. That way values of this type can be passed as actual TaxType instances, making type declarations easier to understand (the very generic str would become the specific TaxType). sybren: This can be an enum. That way values of this type can be passed as actual `TaxType` instances… | |||||
Done Inline ActionsSame as the above: I don't think trading readable code for better type declarations makes sense here. railla: Same as the above: I don't think trading readable code for better type declarations makes sense… | |||||
| """How tax is displayed to a customer.""" | |||||
| if self == TaxType.VAT_CHARGE: | |||||
| return 'VAT' | |||||
| return '' | |||||
Done Inline ActionsThe description of the supported scenario doesn't mention anything about charging taxes or not. This makes the 2nd sentence unclear. The second sentence also seems to indicate that "other scenarios" are actually supported in this code, which conflicts with the fact that "other scenarios" actually refers to "other than what we support". sybren: The description of the supported scenario doesn't mention anything about charging taxes or not. | |||||
Done Inline ActionsOh, the second sentence has somehow lost a not so it makes no sense at all. Should at least be the following:
I've rephrased the comment to only refer to supported scenarios:
railla: Oh, the second sentence has somehow lost a `not` so it makes no sense at all.
Should at least… | |||||
Done Inline ActionsThis comment was moved to ProductType's docstring, because there is where the described tax logic actually resides. railla: This comment was moved to `ProductType`'s docstring, because there is where the described tax… | |||||
| @unique | |||||
| class ProductType(Enum): | |||||
Done Inline ActionsThere is no definition of what is the default type. I could assume it's the first-mentioned one, or the empty one (which happen to be the same option in this particular case), but such things should be written down explicitly. sybren: There is no definition of what is the default type. I could assume it's the first-mentioned one… | |||||
Done Inline ActionsI've rephrased the comment so that it clearly refers to the SharedFields.tax_type and explains why NO_CHARGE is a blank string. railla: I've rephrased the comment so that it clearly refers to the `SharedFields.tax_type` and… | |||||
| """Currently supported types of goods or services, used in tax calculations. | |||||
| We support (and currently need) European VAT rates applicable to sales of | |||||
| generic electronic services from a Dutch seller to European buyers. | |||||
| In other scenarios, such as donations and sales to non-EU countries, taxes are not charged. | |||||
| """ | |||||
| ELECTRONIC_SERVICE = 'GES' | |||||
| DONATION = 'DNT' | |||||
Done Inline ActionsThis field doesn't seem used in this class, and since it's a private field (my assumption, given the initial underscore) it can be removed. sybren: This field doesn't seem used in this class, and since it's a private field (my assumption… | |||||
Done Inline Actionssame as the other _choices: used in the SharedFields.tax_type.choices, removed the underscore here as well. railla: same as the other `_choices`: used in the `SharedFields.tax_type.choices`, removed the… | |||||
| @classmethod | |||||
| def as_choices(cls) -> Iterable[Tuple[str, str]]: | |||||
| """Return choices for use in CharField model field.""" | |||||
| return ( | |||||
| (cls.ELECTRONIC_SERVICE.value, 'Generic electronic service'), | |||||
| (cls.DONATION.value, 'Donation'), | |||||
| ) | |||||
Done Inline ActionsI would expect a classmethod TaxType.get(...) to return an instance of TaxType. Why not turn this tuple-of-optionals into some class instance? sybren: I would expect a classmethod `TaxType.get(...)` to return an instance of `TaxType`. Why not… | |||||
Done Inline ActionsWhat is the semantic difference between returning None tax percentage or 0.0 tax percentage? Wouldn't both convey "no tax paid"? If that is the case, you can remove the Optional[] and always return a valid Decimal. If there is a semantic difference, this needs to be documented, otherwise the Optional should be removed. sybren: What is the semantic difference between returning `None` tax percentage or `0.0` tax percentage? | |||||
Done Inline ActionsI've been hesitating between keeping the semantic difference with None/NULL meaning that no tax applies, but after thinking about it some more I can't really argue for it: Will it make querying for tax reports easier? not really, even though indexing a decimal won't make much sense, the queries will most likely use tax type and tax country, not tax rate. Does it make sense from the legal standpoint, e.g. to a donation 0% tax applies, but tax does not apply when selling to US? Not sure, but then again you are probably right and we won't be using this semantics. Will get rid of the Optional[] and always return a Decimal here. railla: I've been hesitating between keeping the semantic difference with `None`/`NULL` meaning that no… | |||||
| @property | |||||
| def _taxable_item_type(self) -> Optional[pyvat.ItemType]: | |||||
| if self == ProductType.ELECTRONIC_SERVICE: | |||||
| return pyvat.ItemType.generic_electronic_service | |||||
| return None | |||||
| def get_vat_charge( | |||||
| self, | |||||
| buyer_country_code: str, | |||||
| is_business: bool, | |||||
| ) -> Optional[pyvat.VatChargeAction]: | |||||
| """Get VAT charge info, if applicable to a sale of a given product.""" | |||||
| item_type = self._taxable_item_type | |||||
| if not item_type: | |||||
| return None | |||||
| now = timezone.now().date() | |||||
| buyer = pyvat.Party(country_code=buyer_country_code, is_business=is_business) | |||||
| return pyvat.get_sale_vat_charge(now, item_type=item_type, buyer=buyer, seller=seller) | |||||
| def get_vat_rate(self, buyer_country_code: str) -> Decimal: | |||||
| """Get VAT rate for a given product sold to a given country.""" | |||||
| item_type = self._taxable_item_type | |||||
Done Inline ActionsThere should be protection here against unexpected values. Right now this just returns None, which is invalid given the declared return type. sybren: There should be protection here against unexpected values. Right now this just returns `None`… | |||||
| no_charge_rate = Decimal(0) | |||||
| if not item_type: | |||||
Done Inline ActionsThis function is only used in TaxType, and thus should be a private member of that class to express that relationship. sybren: This function is only used in `TaxType`, and thus should be a private member of that class to… | |||||
Done Inline ActionsI guess you meant ProductType, right? railla: I guess you meant `ProductType`, right?
(moved it into the class) | |||||
| return no_charge_rate | |||||
| vat_rules = VAT_RULES.get(buyer_country_code) | |||||
| if not vat_rules: | |||||
| return no_charge_rate | |||||
| rate = vat_rules.get_vat_rate(item_type) | |||||
| if rate is not None: | |||||
| assert isinstance(rate, Decimal) | |||||
| return rate | |||||
Done Inline ActionsCognitive complexity can be reduced by flipping the conditions and not nesting the conditionals. sybren: [Cognitive complexity](https://clang.llvm.org/extra/clang-tidy/checks/readability-function… | |||||
| return no_charge_rate | |||||
| def get_tax( | |||||
Done Inline ActionsFlip the condition: if not item_type: return None. That way the core functionality of the function can remain outside of conditionals. sybren: Flip the condition: `if not item_type: return None`. That way the core functionality of the… | |||||
| self, | |||||
| buyer_country_code: str, | |||||
| is_business: bool, | |||||
Done Inline ActionsSame question as above: what is the semantic difference between None tax and 0.0 tax? This MUST be documented, or the Optional should be removed. sybren: Same question as above: what is the semantic difference between `None` tax and `0.0` tax? This… | |||||
Done Inline ActionsThis function is only used in TaxType, and thus should be a private member of that class to express that relationship. sybren: This function is only used in `TaxType`, and thus should be a private member of that class to… | |||||
| ) -> Tuple['TaxType', Decimal]: | |||||
| """Return tax charge type and rate based on given buyer country and business status.""" | |||||
| vat_charge = self.get_vat_charge(buyer_country_code, is_business) | |||||
| if not vat_charge: | |||||
| return TaxType.NO_CHARGE, Decimal(0) | |||||
Done Inline ActionsWhat does it mean when vat_rules is false-y? Does that mean the VAT rules are unknown? Or that the country doesn't exist? Or something else? sybren: What does it mean when `vat_rules` is false-y? Does that mean the VAT rules are unknown? Or… | |||||
Done Inline ActionsAt this point we assume that buyer_country_code is a valid country code (it was validated elsewhere, e.g. in the form), and vat_rules being false-y means that there's no applicable VAT-rule. railla: At this point we assume that `buyer_country_code` is a valid country code (it was validated… | |||||
| action = vat_charge.action | |||||
Done Inline ActionsReduce cognitive complexity by flipping conditions & returning early. sybren: Reduce cognitive complexity by flipping conditions & returning early. | |||||
Done Inline ActionsDoesn't work well here, in my opinion, because of the nested condition: adds at least one identical return Decimal(0) statement railla: Doesn't work well here, in my opinion, because of the nested condition: adds at least one… | |||||
| if action == pyvat.VatChargeAction.reverse_charge: | |||||
| # PyVAT returns the rate 0% because it is not charged, | |||||
| # but we still want to record the tax rate that applies, | |||||
| # in order to be able to subtract the tax while calculating the final charged amount. | |||||
| rate = self.get_vat_rate(vat_charge.country_code) | |||||
| return TaxType.VAT_REVERSE_CHARGE, rate | |||||
| if action == pyvat.VatChargeAction.charge: | |||||
Done Inline ActionsThis MUST include information about whether this is the price including or excluding tax. sybren: This MUST include information about whether this is the price including or excluding tax. | |||||
| # We assume that product price includes the charged tax, | |||||
| # but we still need to record the tax amount and display it to the buyer. | |||||
Done Inline ActionsUnits MUST be documented. Is 21% tax stored as 21 or as 0.21? sybren: Units MUST be documented. Is 21% tax stored as `21` or as `0.21`? | |||||
| return TaxType.VAT_CHARGE, vat_charge.rate | |||||
| return TaxType.NO_CHARGE, Decimal(0) | |||||
Done Inline ActionsWhat is this field? sybren: What is this field? | |||||
Done Inline ActionsWhat's the semantic difference between None money and Money(0)? This must be documented, or the Optional should be removed. sybren: What's the semantic difference between `None` money and `Money(0)`? This must be documented, or… | |||||
Done Inline ActionsIf Optional is removed this error is raised: ValueError: No mandatory attributes allowed after an attribute with a default value or factory. Attribute in question: Attribute(name='tax',... attr doesn't allow to initialise the Taxable without some value of tax, not sure there's a way to have tax: Money given that Money has to be passed the a currency value (Money(0) is not a possible value). railla: If `Optional` is removed this error is raised:
`ValueError: No mandatory attributes allowed… | |||||
| @attr.s(auto_attribs=True) | |||||
| class Taxable: | |||||
| """Calculate and display tax for given price, tax rate and type.""" | |||||
| price_with_tax: Money | |||||
Done Inline Actions"including the tax" is not correct. When passed to the initialiser, it's correct, but in the case of a reverse-charge the meaning of this variable changes to "excluding the tax". Such a change of semantics is a nice source of confusion & bugs, so I would suggest refactoring this code to remove this. Maybe a property 'final_price' could be added, which would get the post-init value of 'price'; that way there is clear separation between the two. sybren: "including the tax" is not correct. When passed to the initialiser, it's correct, but in the… | |||||
Done Inline ActionsAdded Taxable.price_with_tax which stores the unchaged price, this way there's no need to change any code in blender-studio (and obvious naming seems to help too). Also, after trying to figure out how to get rid of Option[Money] for price and tax, which are only Optional because these attributes are calculated after the __init__ and Money cannot have a default value without currency, turned both into @propertys. railla: Added `Taxable.price_with_tax` which stores the unchaged price, this way there's no need to… | |||||
Done Inline ActionsNice solution! sybren: Nice solution! | |||||
| """Price amount including the tax""" | |||||
| tax_type: TaxType | |||||
Done Inline ActionsIt should be documented whether 100% = 100 or 100% = 1. sybren: It should be documented whether 100% = 100 or 100% = 1. | |||||
| tax_rate: Decimal | |||||
| """Tax rate percentage (%), e.g. value of Decimal(21) is interpreted as 21%""" | |||||
| def __attrs_post_init__(self) -> None: | |||||
| """Enforce the types after __init__ is done, because attr does not do that.""" | |||||
| assert isinstance(self.tax_type, TaxType), self.tax_type | |||||
| assert isinstance(self.tax_rate, Decimal), self.tax_rate | |||||
| assert isinstance(self.price_with_tax, Money), self.price_with_tax | |||||
Done Inline ActionsThis is another indication that the tax type should be a class instance, and not just a string. The initialisation of self.tax = Money(currency, 0) and these three if-statements can then all be removed and replaced by a few calls on self.tax_type. The specific TaxType instance can then handle the type-specific stuff. sybren: This is another indication that the tax type should be a class instance, and not just a string. | |||||
| @property | |||||
| def tax(self) -> Money: | |||||
| """Calculate tax amount, if charged or must be accounted for via reverse-charge.""" | |||||
| if self.tax_type in (TaxType.VAT_CHARGE, TaxType.VAT_REVERSE_CHARGE): | |||||
| return self.price_with_tax * (self.tax_rate / 100) | |||||
| return Money(self.currency, 0) | |||||
| @property | |||||
| def price(self) -> Money: | |||||
| """Calculate final amount the customer is being charged. | |||||
| Subtracts the tax amount from the tax-inclusive price when VAT reverse-charge applies. | |||||
| """ | |||||
| if self.tax_type == TaxType.VAT_REVERSE_CHARGE: | |||||
| return self.price_with_tax - self.tax | |||||
| return self.price_with_tax | |||||
| @property | |||||
| def currency(self) -> str: | |||||
| return self.price_with_tax.currency | |||||
| @property | |||||
| def tax_is_charged(self) -> bool: | |||||
| return self.tax_type == TaxType.VAT_CHARGE | |||||
| @property | |||||
| def charged_tax(self) -> Money: | |||||
| """Return tax amount if it must be displayed to the customer and charged.""" | |||||
| if not self.tax_is_charged or not self.tax: | |||||
| return Money(self.currency, 0) | |||||
| return self.tax | |||||
| def format_tax_amount(self) -> str: | |||||
| """Return a formatted tax to be displayed to the customer.""" | |||||
| if not self.tax: | |||||
| return '' | |||||
| if self.tax_is_charged: | |||||
| formatted_tax = self.tax.with_currency_symbol() | |||||
| return f'(includes {self.tax_type.display_name} {self.tax_rate}% {formatted_tax})' | |||||
| return '' | |||||
| def __eq__(self, other: object) -> bool: | |||||
| """Compare two Taxables.""" | |||||
| if not isinstance(other, Taxable): | |||||
| return NotImplemented | |||||
| return bool( | |||||
| self.tax == other.tax | |||||
| and self.tax_rate == other.tax_rate | |||||
| and self.tax_type == other.tax_type | |||||
| ) | |||||
This could probably be an enum?