From 9aabc66e574c04d11c27383af34528ea5b853103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Wed, 4 Mar 2020 09:39:18 +0100 Subject: [PATCH] Pay: move some model-related methods from helpers to models Otherwise we end up in circular dependency hell --- uncloud/uncloud_pay/helpers.py | 70 ---------- .../commands/charge-negative-balance.py | 5 +- .../management/commands/generate-bills.py | 4 +- .../commands/handle-overdue-bills.py | 3 +- uncloud/uncloud_pay/models.py | 121 +++++++++++++++--- uncloud/uncloud_pay/serializers.py | 12 -- 6 files changed, 108 insertions(+), 107 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index b4216f6..d02b916 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -2,32 +2,9 @@ from functools import reduce from datetime import datetime from rest_framework import mixins from rest_framework.viewsets import GenericViewSet -from django.db.models import Q -from .models import Bill, Payment, PaymentMethod, Order from django.utils import timezone -from django.core.exceptions import ObjectDoesNotExist from calendar import monthrange -def get_balance_for(user): - bills = reduce( - lambda acc, entry: acc + entry.total, - Bill.objects.filter(owner=user), - 0) - payments = reduce( - lambda acc, entry: acc + entry.amount, - Payment.objects.filter(owner=user), - 0) - return payments - bills - -def get_payment_method_for(user): - methods = PaymentMethod.objects.filter(owner=user) - for method in methods: - # Do we want to do something with non-primary method? - if method.primary: - return method - - return None - def beginning_of_month(year, month): tz = timezone.get_current_timezone() return datetime(year=year, month=month, day=1, tzinfo=tz) @@ -38,53 +15,6 @@ def end_of_month(year, month): return datetime(year=year, month=month, day=days, hour=23, minute=59, second=59, tzinfo=tz) -def generate_bills_for(year, month, user, allowed_delay): - # /!\ We exclusively work on the specified year and month. - - # Default values for next bill (if any). Only saved at the end of - # this method, if relevant. - next_bill = Bill(owner=user, - starting_date=beginning_of_month(year, month), - ending_date=end_of_month(year, month), - creation_date=timezone.now(), - due_date=timezone.now() + allowed_delay) - - # Select all orders active on the request period. - orders = Order.objects.filter( - Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True), - owner=user) - - # Check if there is already a bill covering the order and period pair: - # * Get latest bill by ending_date: previous_bill.ending_date - # * If previous_bill.ending_date is before next_bill.ending_date, a new - # bill has to be generated. - unpaid_orders = [] - for order in orders: - try: - previous_bill = order.bill.latest('ending_date') - except ObjectDoesNotExist: - previous_bill = None - - if previous_bill == None or previous_bill.ending_date < next_bill.ending_date: - unpaid_orders.append(order) - - # Commit next_bill if it there are 'unpaid' orders. - if len(unpaid_orders) > 0: - next_bill.save() - - # It is not possible to register many-to-many relationship before - # the two end-objects are saved in database. - for order in unpaid_orders: - order.bill.add(next_bill) - - # TODO: use logger. - print("Generated bill {} (amount: {}) for user {}." - .format(next_bill.uuid, next_bill.total, user)) - - return next_bill - - # Return None if no bill was created. - class ProductViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py index 3667a03..24d53bf 100644 --- a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User -from uncloud_pay.models import Order, Bill -from uncloud_pay.helpers import get_balance_for, get_payment_method_for +from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for from datetime import timedelta from django.utils import timezone @@ -19,7 +18,7 @@ class Command(BaseCommand): balance = get_balance_for(user) if balance < 0: print("User {} has negative balance ({}), charging.".format(user.username, balance)) - payment_method = get_payment_method_for(user) + payment_method = PaymentMethod.get_primary_for(user) if payment_method != None: amount_to_be_charged = abs(balance) charge_ok = payment_method.charge(amount_to_be_charged) diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index 34432d5..a7dbe78 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -7,7 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist from datetime import timedelta, date from django.utils import timezone -from uncloud_pay.helpers import generate_bills_for +from uncloud_pay.models import Bill BILL_PAYMENT_DELAY=timedelta(days=10) @@ -28,7 +28,7 @@ class Command(BaseCommand): for user in users: now = timezone.now() - generate_bills_for( + Bill.generate_for( year=now.year, month=now.month, user=user, diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py index f4749f0..40468ba 100644 --- a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py +++ b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User -from uncloud_pay.models import Order, Bill -from uncloud_pay.helpers import get_balance_for, get_payment_method_for +from uncloud_pay.models import Order, Bill, get_balance_for from datetime import timedelta from django.utils import timezone diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 772ab38..6f18931 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,17 +1,23 @@ from django.db import models -from functools import reduce +from django.db.models import Q from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ from django.utils import timezone +from django.dispatch import receiver +from django.core.exceptions import ObjectDoesNotExist +import django.db.models.signals as signals + +import uuid +from functools import reduce from math import ceil from datetime import timedelta from calendar import monthrange + import uncloud_pay.stripe +from uncloud_pay.helpers import beginning_of_month, end_of_month from decimal import Decimal -import uuid - # Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 @@ -26,6 +32,34 @@ class RecurringPeriod(models.TextChoices): PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') +# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types +class ProductStatus(models.TextChoices): + PENDING = 'PENDING', _('Pending') + AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') + BEING_CREATED = 'BEING_CREATED', _('Being created') + ACTIVE = 'ACTIVE', _('Active') + DELETED = 'DELETED', _('Deleted') + +### +# Users. + +def get_balance_for(user): + bills = reduce( + lambda acc, entry: acc + entry.total, + Bill.objects.filter(owner=user), + 0) + payments = reduce( + lambda acc, entry: acc + entry.amount, + Payment.objects.filter(owner=user), + 0) + return payments - bills + +class StripeCustomer(models.Model): + owner = models.OneToOneField( get_user_model(), + primary_key=True, + on_delete=models.CASCADE) + stripe_id = models.CharField(max_length=32) + ### # Payments and Payment Methods. @@ -100,15 +134,19 @@ class PaymentMethod(models.Model): else: raise Exception('Cannot charge negative amount.') + + def get_primary_for(user): + methods = PaymentMethod.objects.filter(owner=user) + for method in methods: + # Do we want to do something with non-primary method? + if method.primary: + return method + + return None + class Meta: unique_together = [['owner', 'primary']] -class StripeCustomer(models.Model): - owner = models.OneToOneField( get_user_model(), - primary_key=True, - on_delete=models.CASCADE) - stripe_id = models.CharField(max_length=32) - ### # Bills & Payments. @@ -144,6 +182,55 @@ class Bill(models.Model): # A bill is final when its ending date is passed. return self.ending_date < timezone.now() + @staticmethod + def generate_for(year, month, user, allowed_delay): + # /!\ We exclusively work on the specified year and month. + + # Default values for next bill (if any). Only saved at the end of + # this method, if relevant. + next_bill = Bill(owner=user, + starting_date=beginning_of_month(year, month), + ending_date=end_of_month(year, month), + creation_date=timezone.now(), + due_date=timezone.now() + allowed_delay) + + # Select all orders active on the request period. + orders = Order.objects.filter( + Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True), + owner=user) + + # Check if there is already a bill covering the order and period pair: + # * Get latest bill by ending_date: previous_bill.ending_date + # * If previous_bill.ending_date is before next_bill.ending_date, a new + # bill has to be generated. + unpaid_orders = [] + for order in orders: + try: + previous_bill = order.bill.latest('ending_date') + except ObjectDoesNotExist: + previous_bill = None + + if previous_bill == None or previous_bill.ending_date < next_bill.ending_date: + unpaid_orders.append(order) + + # Commit next_bill if it there are 'unpaid' orders. + if len(unpaid_orders) > 0: + next_bill.save() + + # It is not possible to register many-to-many relationship before + # the two end-objects are saved in database. + for order in unpaid_orders: + order.bill.add(next_bill) + + # TODO: use logger. + print("Generated bill {} (amount: {}) for user {}." + .format(next_bill.uuid, next_bill.total, user)) + + return next_bill + + # Return None if no bill was created. + return None + class BillRecord(): """ Entry of a bill, dynamically generated from order records. @@ -258,6 +345,10 @@ class Order(models.Model): recurring_price=recurring_price, description=description) + def generate_bill(self): + pass + + class OrderRecord(models.Model): """ Order records store billing informations for products: the actual product @@ -305,15 +396,9 @@ class Product(models.Model): description = "" - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) + status = models.CharField(max_length=32, + choices=ProductStatus.choices, + default=ProductStatus.AWAITING_PAYMENT) order = models.ForeignKey(Order, on_delete=models.CASCADE, diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 94c9b61..aa75fd9 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,13 +1,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * -from .helpers import get_balance_for - -from functools import reduce -from uncloud_vm.serializers import VMProductSerializer -from uncloud_vm.models import VMProduct - -import uncloud_pay.stripe as stripe ### # Users. @@ -19,8 +12,6 @@ class UserSerializer(serializers.ModelSerializer): # Display current 'balance' balance = serializers.SerializerMethodField('get_balance') - def __sum_balance(self, entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) def get_balance(self, user): return get_balance_for(user) @@ -92,6 +83,3 @@ class OrderSerializer(serializers.ModelSerializer): model = Order fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', 'bill', 'recurring_period', 'records', 'recurring_price', 'one_time_price'] - -class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, read_only=True)