diff --git a/uncloud/models.py b/uncloud/models.py index d956637..c585ad2 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -6,6 +6,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import FieldError from uncloud import COUNTRIES +from .selectors import filter_for_when class UncloudModel(models.Model): """ @@ -67,6 +68,52 @@ class UncloudAddress(models.Model): abstract = True +class UncloudValidTimeFrame(models.Model): + """ + A model that allows to limit validity of something to a certain + time frame. Used for versioning basically. + + Logic: + + """ + + class Meta: + abstract = True + + constraints = [ + models.UniqueConstraint(fields=['owner'], + condition=models.Q(active=True), + name='one_active_card_per_user') + ] + + + valid_from = models.DateTimeField(default=timezone.now, null=True, blank=True) + valid_to = models.DateTimeField(null=True, blank=True) + + @classmethod + def get_current(cls, *args, **kwargs): + now = timezone.now() + + # With both given + cls.objects.filter(valid_from__lte=now, + valid_to__gte=now) + + # With to missing + cls.objects.filter(valid_from__lte=now, + valid_to__isnull=true) + + # With from missing + cls.objects.filter(valid_from__isnull=true, + valid_to__gte=now) + + # Both missing + cls.objects.filter(valid_from__isnull=true, + valid_to__gte=now) + + + + + ### # UncloudNetworks are used as identifiers - such they are a base of uncloud @@ -138,9 +185,6 @@ class UncloudProvider(UncloudAddress): Find active provide at a certain time - if there was any """ - if not when: - when = timezone.now() - return cls.objects.get(Q(starting_date__gte=when, ending_date__lte=when) | Q(starting_date__gte=when, ending_date__isnull=True)) diff --git a/uncloud/selectors.py b/uncloud/selectors.py new file mode 100644 index 0000000..52b8548 --- /dev/null +++ b/uncloud/selectors.py @@ -0,0 +1,23 @@ +from django.db.models import Q +from django.utils import timezone + +def filter_for_when(queryset, when=None): + """ + Return a filtered queryset which is valid for the given date + + Logic: + + Look for entries that have a starting date before when + and either + - No ending date + - Ending date after "when" + + Returns a queryset, you'll neet to apply .first() or similar on it + + """ + + if not when: + when = timezone.now() + + return queryset.filter(starting_date__lte=when).filter(Q(ending_date__gte=when) | + Q(ending_date__isnull=True)) diff --git a/uncloud/urls.py b/uncloud/urls.py index bf3672c..a30d4aa 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -61,12 +61,16 @@ router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduc router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork') router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes') -# Payment related +# Payment related for a user router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard') router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress') +# Generic helper views that are usually not needed +router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate') + + urlpatterns = [ path(r'api/', include(router.urls), name='api'), diff --git a/uncloud_auth/templates/uncloud_auth/login.html b/uncloud_auth/templates/uncloud_auth/login.html index 887467b..2a3bfd6 100644 --- a/uncloud_auth/templates/uncloud_auth/login.html +++ b/uncloud_auth/templates/uncloud_auth/login.html @@ -1,14 +1,21 @@ {% extends 'uncloud/base.html' %} -{% load bootstrap4 %} +{% load bootstrap5 %} -{% block body %} -

Login to uncloud

-
- {% csrf_token %} - {% bootstrap_form form %} - {% buttons %} - - {% endbuttons %} -
+{% block bootstrap5_content %} +
+
+
+ +

Login to uncloud

+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + {% endbuttons %} +
+
+
+
{% endblock %} diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index adaabef..49e8da2 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -233,6 +233,7 @@ class RecurringPeriod(models.Model): class BillingAddress(UncloudAddress): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) vat_number = models.CharField(max_length=100, default="", blank=True) + vat_number_verified = models.BooleanField(default=False) active = models.BooleanField(default=False) class Meta: diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py index 5f86657..2a5ad4a 100644 --- a/uncloud_pay/selectors.py +++ b/uncloud_pay/selectors.py @@ -1,6 +1,9 @@ from django.utils import timezone from django.db import transaction +from django.db.models import Q +from uncloud.selectors import filter_for_when +from uncloud.models import UncloudProvider from .models import * def get_payments_for_user(user): @@ -24,3 +27,32 @@ def get_balance_for_user(user): def get_billing_address_for_user(user): return BillingAddress.objects.get(owner=user, active=True) + +def get_vat_rate(billing_address, when=None): + """ + Returns the VAT rate for business to customer. + + B2B is always 0% with the exception of trading within the own country + """ + + country = billing_address.country + + # Need to have a provider country + uncloud_provider = filter_for_when(UncloudProvider.objects.all()).get() + vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first() + + # By default we charge VAT. This affects: + # - Same country sales (VAT applied) + # - B2C to EU (VAT applied) + rate = vatrate.rate + + # Exception: if... + # - the billing_address is in EU, + # - the vat_number has been set + # - the vat_number has been verified + # Then we do not charge VAT + + if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified: + rate = 0 + + return rate diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 3906482..b5de192 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -42,6 +42,13 @@ class BillingAddressSerializer(serializers.ModelSerializer): exclude = [ "owner" ] +class VATRateSerializer(serializers.ModelSerializer): + + class Meta: + model = VATRate + fields = '__all__' + + ################################################################################ # Unchecked code diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index ca91cc9..c07c83b 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -5,6 +5,7 @@ from django.utils import timezone from .models import * from uncloud_service.models import GenericServiceProduct +from uncloud.models import UncloudProvider import json @@ -463,3 +464,26 @@ class BillingAddressTestCase(TestCase): self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, BillingAddress.get_address_for, self.user) + +class VATRatesTestCase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + self.user_addr = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + UncloudProvider.populate_db_defaults() + + + + def test_get_rate_for_user(self): + """ + Raise an error, when there is no address + """ diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 073f7c9..d3a104b 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -295,6 +295,11 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Order.objects.filter(owner=self.request.user) +class VATRateViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = VATRateSerializer + permission_classes = [permissions.IsAuthenticated] + queryset = VATRate.objects.all() + class BillingAddressViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin,