Merge branch 'vat-handling' into 'master'

VAT support handling

See merge request uncloud/uncloud!7
This commit is contained in:
fnux 2020-04-18 11:57:16 +02:00
commit cec4263621
15 changed files with 754 additions and 115 deletions

View file

@ -18,3 +18,7 @@ django-hardcopy
# schema support # schema support
pyyaml pyyaml
uritemplate uritemplate
# Comprehensive interface to validate VAT numbers, making use of the VIES
# service for European countries.
vat-validator

View file

@ -53,10 +53,11 @@ router.register(r'net/vpnreservation', netviews.VPNNetworkReservationViewSet, ba
# Pay # Pay
router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') router.register(r'address', payviews.BillingAddressViewSet, basename='address')
router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'bill', payviews.BillViewSet, basename='bill')
router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'order', payviews.OrderViewSet, basename='order')
router.register(r'payment', payviews.PaymentViewSet, basename='payment') router.register(r'payment', payviews.PaymentViewSet, basename='payment')
router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method')
# admin/staff urls # admin/staff urls

View file

@ -0,0 +1,44 @@
from django.core.management.base import BaseCommand
from uncloud_pay.models import VATRate
import csv
class Command(BaseCommand):
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
def add_arguments(self, parser):
parser.add_argument('csv_file', nargs='+', type=str)
def handle(self, *args, **options):
try:
for c_file in options['csv_file']:
print("c_file = %s" % c_file)
with open(c_file, mode='r') as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
for row in csv_reader:
if line_count == 0:
line_count += 1
obj, created = VATRate.objects.get_or_create(
start_date=row["start_date"],
stop_date=row["stop_date"] if row["stop_date"] is not "" else None,
territory_codes=row["territory_codes"],
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)
if created:
self.stdout.write(self.style.SUCCESS(
'%s. %s - %s - %s - %s' % (
line_count,
obj.start_date,
obj.stop_date,
obj.territory_codes,
obj.rate
)
))
line_count+=1
except Exception as e:
print(" *** Error occurred. Details {}".format(str(e)))

View file

@ -0,0 +1,31 @@
# Generated by Django 3.0.5 on 2020-04-15 10:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0005_auto_20200413_0924'),
]
operations = [
migrations.CreateModel(
name='VATRate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_date', models.DateField(blank=True, null=True)),
('stop_date', models.DateField(blank=True, null=True)),
('territory_codes', models.TextField(blank=True, default='')),
('currency_code', models.CharField(max_length=10)),
('rate', models.FloatField()),
('rate_type', models.TextField(blank=True, default='')),
('description', models.TextField(blank=True, default='')),
],
),
migrations.AlterField(
model_name='order',
name='recurring_period',
field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('MINUTE', 'Per Minute'), ('SECOND', 'Per Second')], default='MONTH', max_length=32),
),
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,10 +9,10 @@ from django.core.exceptions import ObjectDoesNotExist
import uuid import uuid
import logging import logging
from functools import reduce from functools import reduce
import itertools
from math import ceil from math import ceil
from datetime import timedelta from datetime import timedelta
from calendar import monthrange from calendar import monthrange
from decimal import Decimal from decimal import Decimal
import uncloud_pay.stripe import uncloud_pay.stripe
@ -33,6 +33,248 @@ decimal.getcontext().prec = AMOUNT_DECIMALS + 1
# Used to generate bill due dates. # Used to generate bill due dates.
BILL_PAYMENT_DELAY=timedelta(days=10) BILL_PAYMENT_DELAY=timedelta(days=10)
# http://xml.coverpages.org/country3166.html
COUNTRIES = (
('AD', _('Andorra')),
('AE', _('United Arab Emirates')),
('AF', _('Afghanistan')),
('AG', _('Antigua & Barbuda')),
('AI', _('Anguilla')),
('AL', _('Albania')),
('AM', _('Armenia')),
('AN', _('Netherlands Antilles')),
('AO', _('Angola')),
('AQ', _('Antarctica')),
('AR', _('Argentina')),
('AS', _('American Samoa')),
('AT', _('Austria')),
('AU', _('Australia')),
('AW', _('Aruba')),
('AZ', _('Azerbaijan')),
('BA', _('Bosnia and Herzegovina')),
('BB', _('Barbados')),
('BD', _('Bangladesh')),
('BE', _('Belgium')),
('BF', _('Burkina Faso')),
('BG', _('Bulgaria')),
('BH', _('Bahrain')),
('BI', _('Burundi')),
('BJ', _('Benin')),
('BM', _('Bermuda')),
('BN', _('Brunei Darussalam')),
('BO', _('Bolivia')),
('BR', _('Brazil')),
('BS', _('Bahama')),
('BT', _('Bhutan')),
('BV', _('Bouvet Island')),
('BW', _('Botswana')),
('BY', _('Belarus')),
('BZ', _('Belize')),
('CA', _('Canada')),
('CC', _('Cocos (Keeling) Islands')),
('CF', _('Central African Republic')),
('CG', _('Congo')),
('CH', _('Switzerland')),
('CI', _('Ivory Coast')),
('CK', _('Cook Iislands')),
('CL', _('Chile')),
('CM', _('Cameroon')),
('CN', _('China')),
('CO', _('Colombia')),
('CR', _('Costa Rica')),
('CU', _('Cuba')),
('CV', _('Cape Verde')),
('CX', _('Christmas Island')),
('CY', _('Cyprus')),
('CZ', _('Czech Republic')),
('DE', _('Germany')),
('DJ', _('Djibouti')),
('DK', _('Denmark')),
('DM', _('Dominica')),
('DO', _('Dominican Republic')),
('DZ', _('Algeria')),
('EC', _('Ecuador')),
('EE', _('Estonia')),
('EG', _('Egypt')),
('EH', _('Western Sahara')),
('ER', _('Eritrea')),
('ES', _('Spain')),
('ET', _('Ethiopia')),
('FI', _('Finland')),
('FJ', _('Fiji')),
('FK', _('Falkland Islands (Malvinas)')),
('FM', _('Micronesia')),
('FO', _('Faroe Islands')),
('FR', _('France')),
('FX', _('France, Metropolitan')),
('GA', _('Gabon')),
('GB', _('United Kingdom (Great Britain)')),
('GD', _('Grenada')),
('GE', _('Georgia')),
('GF', _('French Guiana')),
('GH', _('Ghana')),
('GI', _('Gibraltar')),
('GL', _('Greenland')),
('GM', _('Gambia')),
('GN', _('Guinea')),
('GP', _('Guadeloupe')),
('GQ', _('Equatorial Guinea')),
('GR', _('Greece')),
('GS', _('South Georgia and the South Sandwich Islands')),
('GT', _('Guatemala')),
('GU', _('Guam')),
('GW', _('Guinea-Bissau')),
('GY', _('Guyana')),
('HK', _('Hong Kong')),
('HM', _('Heard & McDonald Islands')),
('HN', _('Honduras')),
('HR', _('Croatia')),
('HT', _('Haiti')),
('HU', _('Hungary')),
('ID', _('Indonesia')),
('IE', _('Ireland')),
('IL', _('Israel')),
('IN', _('India')),
('IO', _('British Indian Ocean Territory')),
('IQ', _('Iraq')),
('IR', _('Islamic Republic of Iran')),
('IS', _('Iceland')),
('IT', _('Italy')),
('JM', _('Jamaica')),
('JO', _('Jordan')),
('JP', _('Japan')),
('KE', _('Kenya')),
('KG', _('Kyrgyzstan')),
('KH', _('Cambodia')),
('KI', _('Kiribati')),
('KM', _('Comoros')),
('KN', _('St. Kitts and Nevis')),
('KP', _('Korea, Democratic People\'s Republic of')),
('KR', _('Korea, Republic of')),
('KW', _('Kuwait')),
('KY', _('Cayman Islands')),
('KZ', _('Kazakhstan')),
('LA', _('Lao People\'s Democratic Republic')),
('LB', _('Lebanon')),
('LC', _('Saint Lucia')),
('LI', _('Liechtenstein')),
('LK', _('Sri Lanka')),
('LR', _('Liberia')),
('LS', _('Lesotho')),
('LT', _('Lithuania')),
('LU', _('Luxembourg')),
('LV', _('Latvia')),
('LY', _('Libyan Arab Jamahiriya')),
('MA', _('Morocco')),
('MC', _('Monaco')),
('MD', _('Moldova, Republic of')),
('MG', _('Madagascar')),
('MH', _('Marshall Islands')),
('ML', _('Mali')),
('MN', _('Mongolia')),
('MM', _('Myanmar')),
('MO', _('Macau')),
('MP', _('Northern Mariana Islands')),
('MQ', _('Martinique')),
('MR', _('Mauritania')),
('MS', _('Monserrat')),
('MT', _('Malta')),
('MU', _('Mauritius')),
('MV', _('Maldives')),
('MW', _('Malawi')),
('MX', _('Mexico')),
('MY', _('Malaysia')),
('MZ', _('Mozambique')),
('NA', _('Namibia')),
('NC', _('New Caledonia')),
('NE', _('Niger')),
('NF', _('Norfolk Island')),
('NG', _('Nigeria')),
('NI', _('Nicaragua')),
('NL', _('Netherlands')),
('NO', _('Norway')),
('NP', _('Nepal')),
('NR', _('Nauru')),
('NU', _('Niue')),
('NZ', _('New Zealand')),
('OM', _('Oman')),
('PA', _('Panama')),
('PE', _('Peru')),
('PF', _('French Polynesia')),
('PG', _('Papua New Guinea')),
('PH', _('Philippines')),
('PK', _('Pakistan')),
('PL', _('Poland')),
('PM', _('St. Pierre & Miquelon')),
('PN', _('Pitcairn')),
('PR', _('Puerto Rico')),
('PT', _('Portugal')),
('PW', _('Palau')),
('PY', _('Paraguay')),
('QA', _('Qatar')),
('RE', _('Reunion')),
('RO', _('Romania')),
('RU', _('Russian Federation')),
('RW', _('Rwanda')),
('SA', _('Saudi Arabia')),
('SB', _('Solomon Islands')),
('SC', _('Seychelles')),
('SD', _('Sudan')),
('SE', _('Sweden')),
('SG', _('Singapore')),
('SH', _('St. Helena')),
('SI', _('Slovenia')),
('SJ', _('Svalbard & Jan Mayen Islands')),
('SK', _('Slovakia')),
('SL', _('Sierra Leone')),
('SM', _('San Marino')),
('SN', _('Senegal')),
('SO', _('Somalia')),
('SR', _('Suriname')),
('ST', _('Sao Tome & Principe')),
('SV', _('El Salvador')),
('SY', _('Syrian Arab Republic')),
('SZ', _('Swaziland')),
('TC', _('Turks & Caicos Islands')),
('TD', _('Chad')),
('TF', _('French Southern Territories')),
('TG', _('Togo')),
('TH', _('Thailand')),
('TJ', _('Tajikistan')),
('TK', _('Tokelau')),
('TM', _('Turkmenistan')),
('TN', _('Tunisia')),
('TO', _('Tonga')),
('TP', _('East Timor')),
('TR', _('Turkey')),
('TT', _('Trinidad & Tobago')),
('TV', _('Tuvalu')),
('TW', _('Taiwan, Province of China')),
('TZ', _('Tanzania, United Republic of')),
('UA', _('Ukraine')),
('UG', _('Uganda')),
('UM', _('United States Minor Outlying Islands')),
('US', _('United States of America')),
('UY', _('Uruguay')),
('UZ', _('Uzbekistan')),
('VA', _('Vatican City State (Holy See)')),
('VC', _('St. Vincent & the Grenadines')),
('VE', _('Venezuela')),
('VG', _('British Virgin Islands')),
('VI', _('United States Virgin Islands')),
('VN', _('Viet Nam')),
('VU', _('Vanuatu')),
('WF', _('Wallis & Futuna Islands')),
('WS', _('Samoa')),
('YE', _('Yemen')),
('YT', _('Mayotte')),
('YU', _('Yugoslavia')),
('ZA', _('South Africa')),
('ZM', _('Zambia')),
('ZR', _('Zaire')),
('ZW', _('Zimbabwe')),
)
# Initialize logger. # Initialize logger.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -47,7 +289,16 @@ class RecurringPeriod(models.TextChoices):
PER_MINUTE = 'MINUTE', _('Per Minute') PER_MINUTE = 'MINUTE', _('Per Minute')
PER_SECOND = 'SECOND', _('Per Second') PER_SECOND = 'SECOND', _('Per Second')
class CountryField(models.CharField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('choices', COUNTRIES)
kwargs.setdefault('default', 'CH')
kwargs.setdefault('max_length', 2)
super(CountryField, self).__init__(*args, **kwargs)
def get_internal_type(self):
return "CharField"
def get_balance_for_user(user): def get_balance_for_user(user):
bills = reduce( bills = reduce(
@ -189,6 +440,58 @@ class PaymentMethod(models.Model):
### ###
# Bills. # Bills.
class BillingAddress(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
name = models.CharField(max_length=100)
street = models.CharField(max_length=100)
city = models.CharField(max_length=50)
postal_code = models.CharField(max_length=50)
country = CountryField(blank=True)
vat_number = models.CharField(max_length=100, default="", blank=True)
@staticmethod
def get_addresses_for(user):
return BillingAddress.objects.filter(owner=user)
@staticmethod
def get_preferred_address_for(user):
addresses = get_addresses_for(user)
if len(addresses) == 0:
return None
else:
# TODO: allow user to set primary/preferred address
return addresses[0]
def __str__(self):
return "{}, {}, {} {}, {}".format(
self.name, self.street, self.postal_code, self.city,
self.country)
# Populated with the import-vat-numbers django command.
class VATRate(models.Model):
start_date = models.DateField(blank=True, null=True)
stop_date = models.DateField(blank=True, null=True)
territory_codes = models.TextField(blank=True, default='')
currency_code = models.CharField(max_length=10)
rate = models.FloatField()
rate_type = models.TextField(blank=True, default='')
description = models.TextField(blank=True, default='')
@staticmethod
def get_for_country(country_code):
vat_rate = None
try:
vat_rate = VATRate.objects.get(
territory_codes=country_code, start_date__isnull=False, stop_date=None
)
return vat_rate.rate
except VATRate.DoesNotExist as dne:
logger.debug(str(dne))
logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0
class Bill(models.Model): class Bill(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(),
@ -225,9 +528,17 @@ class Bill(models.Model):
return bill_records return bill_records
@property @property
def total(self): def amount(self):
return reduce(lambda acc, record: acc + record.amount, self.records, 0) return reduce(lambda acc, record: acc + record.amount, self.records, 0)
@property
def vat_amount(self):
return reduce(lambda acc, record: acc + record.vat_amount, self.records, 0)
@property
def total(self):
return self.amount + self.vat_amount
@property @property
def final(self): def final(self):
# A bill is final when its ending date is passed. # A bill is final when its ending date is passed.
@ -242,6 +553,14 @@ class Bill(models.Model):
product.status = UncloudStatus.PENDING product.status = UncloudStatus.PENDING
product.save() product.save()
@property
def billing_address(self):
orders = Order.objects.filter(bill=self)
# The genrate_for method makes sure all the orders of a bill share the
# same billing address. TODO: It would be nice to enforce that somehow...
return orders[0].billing_address
# TODO: split this huuuge method!
@staticmethod @staticmethod
def generate_for(year, month, user): def generate_for(year, month, user):
# /!\ We exclusively work on the specified year and month. # /!\ We exclusively work on the specified year and month.
@ -300,26 +619,32 @@ class Bill(models.Model):
prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY
postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY
next_monthly_bill = Bill.objects.create(owner=user, # There should not be any bill linked to orders with different
# billing addresses.
per_address_orders = itertools.groupby(
unpaid_orders['monthly_or_less'],
lambda o: o.billing_address)
for addr, bill_orders in per_address_orders:
next_monthly_bill = Bill.objects.create(owner=user,
creation_date=creation_date, creation_date=creation_date,
starting_date=starting_date, # FIXME: this is a hack! starting_date=starting_date, # FIXME: this is a hack!
ending_date=ending_date, ending_date=ending_date,
due_date=postpaid_due_date) due_date=postpaid_due_date)
# It is not possible to register many-to-many relationship before # It is not possible to register many-to-many relationship before
# the two end-objects are saved in database. # the two end-objects are saved in database.
for order in unpaid_orders['monthly_or_less']: for order in bill_orders:
order.bill.add(next_monthly_bill) order.bill.add(next_monthly_bill)
logger.info("Generated monthly bill {} (amount: {}) for user {}." logger.info("Generated monthly bill {} (amount: {}) for user {}."
.format(next_monthly_bill.uuid, next_monthly_bill.total, user)) .format(next_monthly_bill.uuid, next_monthly_bill.total, user))
# Add to output. # Add to output.
generated_bills.append(next_monthly_bill) generated_bills.append(next_monthly_bill)
# Handle yearly bills starting on working month. # Handle yearly bills starting on working month.
if len(unpaid_orders['yearly']) > 0: if len(unpaid_orders['yearly']) > 0:
# For every starting date, generate new bill. # For every starting date, generate new bill.
for next_yearly_bill_start_on in unpaid_orders['yearly']: for next_yearly_bill_start_on in unpaid_orders['yearly']:
# No postpaid for yearly payments. # No postpaid for yearly payments.
@ -328,22 +653,29 @@ class Bill(models.Model):
ending_date = next_yearly_bill_start_on.replace( ending_date = next_yearly_bill_start_on.replace(
year=next_yearly_bill_start_on.year+1) - timedelta(days=1) year=next_yearly_bill_start_on.year+1) - timedelta(days=1)
next_yearly_bill = Bill.objects.create(owner=user, # There should not be any bill linked to orders with different
creation_date=creation_date, # billing addresses.
starting_date=next_yearly_bill_start_on, per_address_orders = itertools.groupby(
ending_date=ending_date, unpaid_orders['yearly'][next_yearly_bill_start_on],
due_date=prepaid_due_date) lambda o: o.billing_address)
# It is not possible to register many-to-many relationship before for addr, bill_orders in per_address_orders:
# the two end-objects are saved in database. next_yearly_bill = Bill.objects.create(owner=user,
for order in unpaid_orders['yearly'][next_yearly_bill_start_on]: creation_date=creation_date,
order.bill.add(next_yearly_bill) starting_date=next_yearly_bill_start_on,
ending_date=ending_date,
due_date=prepaid_due_date)
logger.info("Generated yearly bill {} (amount: {}) for user {}." # It is not possible to register many-to-many relationship before
.format(next_yearly_bill.uuid, next_yearly_bill.total, user)) # the two end-objects are saved in database.
for order in bill_orders:
order.bill.add(next_yearly_bill)
# Add to output. logger.info("Generated yearly bill {} (amount: {}) for user {}."
generated_bills.append(next_yearly_bill) .format(next_yearly_bill.uuid, next_yearly_bill.total, user))
# Add to output.
generated_bills.append(next_yearly_bill)
# Return generated (monthly + yearly) bills. # Return generated (monthly + yearly) bills.
return generated_bills return generated_bills
@ -449,10 +781,22 @@ class BillRecord():
raise Exception('Unsupported recurring period: {}.'. raise Exception('Unsupported recurring period: {}.'.
format(record.recurring_period)) format(record.recurring_period))
@property
def vat_rate(self):
return Decimal(VATRate.get_for_country(self.bill.billing_address.country))
@property
def vat_amount(self):
return self.amount * self.vat_rate
@property @property
def amount(self): def amount(self):
return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price
@property
def total(self):
return self.amount + self.vat_amount
### ###
# Orders. # Orders.
@ -463,6 +807,7 @@ class Order(models.Model):
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE, on_delete=models.CASCADE,
editable=False) editable=False)
billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE)
# TODO: enforce ending_date - starting_date to be larger than recurring_period. # TODO: enforce ending_date - starting_date to be larger than recurring_period.
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
@ -579,12 +924,12 @@ class Product(UncloudModel):
if being_created: if being_created:
record = OrderRecord( record = OrderRecord(
one_time_price=self.one_time_price, one_time_price=self.one_time_price,
recurring_price=self.recurring_price(recurring_period=self.recurring_period), recurring_price=self.recurring_price,
description=self.description) description=self.description)
self.order.orderrecord_set.add(record, bulk=False) self.order.orderrecord_set.add(record, bulk=False)
@property @property
def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): def recurring_price(self):
pass # To be implemented in child. pass # To be implemented in child.
@property @property
@ -595,6 +940,10 @@ class Product(UncloudModel):
def recurring_period(self): def recurring_period(self):
return self.order.recurring_period return self.order.recurring_period
@property
def billing_address(self):
return self.order.billing_address
@staticmethod @staticmethod
def allowed_recurring_periods(): def allowed_recurring_periods():
return RecurringPeriod.choices return RecurringPeriod.choices

View file

@ -57,15 +57,33 @@ class BillRecordSerializer(serializers.Serializer):
view_name='order-detail', view_name='order-detail',
read_only=True) read_only=True)
description = serializers.CharField() description = serializers.CharField()
recurring_period = serializers.CharField() one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_count = serializers.DecimalField(max_digits=10, decimal_places=2) recurring_period = serializers.ChoiceField(choices=RecurringPeriod.choices)
one_time_price = serializers.DecimalField(max_digits=10, decimal_places=2) recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
amount = serializers.DecimalField(max_digits=10, decimal_places=2) vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
class BillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
fields = ['uuid', 'name', 'street', 'city', 'postal_code', 'country', 'vat_number']
class BillSerializer(serializers.ModelSerializer): class BillSerializer(serializers.ModelSerializer):
billing_address = BillingAddressSerializer(read_only=True)
records = BillRecordSerializer(many=True, read_only=True) records = BillRecordSerializer(many=True, read_only=True)
class Meta: class Meta:
model = Bill model = Bill
fields = ['reference', 'owner', 'total', 'due_date', 'creation_date', fields = ['reference', 'owner', 'amount', 'vat_amount', 'total',
'starting_date', 'ending_date', 'records', 'final'] 'due_date', 'creation_date', 'starting_date', 'ending_date',
'records', 'final', 'billing_address']
# We do not want users to mutate the country / VAT number of an address, as it
# will change VAT on existing bills.
class UpdateBillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
fields = ['uuid', 'street', 'city', 'postal_code']

View file

@ -10,6 +10,11 @@ class BillingTestCase(TestCase):
self.user = get_user_model().objects.create( self.user = get_user_model().objects.create(
username='jdoe', username='jdoe',
email='john.doe@domain.tld') email='john.doe@domain.tld')
self.billing_address = BillingAddress.objects.create(
owner=self.user,
street="unknown",
city="unknown",
postal_code="unknown")
def test_basic_monthly_billing(self): def test_basic_monthly_billing(self):
one_time_price = 10 one_time_price = 10
@ -25,24 +30,25 @@ class BillingTestCase(TestCase):
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
ending_date=ending_date, ending_date=ending_date,
recurring_period=RecurringPeriod.PER_MONTH) recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address)
order.add_record(one_time_price, recurring_price, description) order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first month: full recurring_price + setup. # Generate & check bill for first month: full recurring_price + setup.
first_month_bills = order.bills # Initial bill generated at order creation. first_month_bills = order.bills # Initial bill generated at order creation.
self.assertEqual(len(first_month_bills), 1) self.assertEqual(len(first_month_bills), 1)
self.assertEqual(first_month_bills[0].total, one_time_price + recurring_price) self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price)
# Generate & check bill for second month: full recurring_price. # Generate & check bill for second month: full recurring_price.
second_month_bills = Bill.generate_for(2020, 4, self.user) second_month_bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(second_month_bills), 1) self.assertEqual(len(second_month_bills), 1)
self.assertEqual(second_month_bills[0].total, recurring_price) self.assertEqual(second_month_bills[0].amount, recurring_price)
# Generate & check bill for third and last month: partial recurring_price. # Generate & check bill for third and last month: partial recurring_price.
third_month_bills = Bill.generate_for(2020, 5, self.user) third_month_bills = Bill.generate_for(2020, 5, self.user)
self.assertEqual(len(third_month_bills), 1) self.assertEqual(len(third_month_bills), 1)
# 31 days in May. # 31 days in May.
self.assertEqual(float(third_month_bills[0].total), self.assertEqual(float(third_month_bills[0].amount),
round((7/31) * recurring_price, AMOUNT_DECIMALS)) round((7/31) * recurring_price, AMOUNT_DECIMALS))
# Check that running Bill.generate_for() twice does not create duplicates. # Check that running Bill.generate_for() twice does not create duplicates.
@ -59,7 +65,8 @@ class BillingTestCase(TestCase):
order = Order.objects.create( order = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_YEAR) recurring_period=RecurringPeriod.PER_YEAR,
billing_address=self.billing_address)
order.add_record(one_time_price, recurring_price, description) order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first year: recurring_price + setup. # Generate & check bill for first year: recurring_price + setup.
@ -69,7 +76,7 @@ class BillingTestCase(TestCase):
date.fromisoformat('2020-03-31')) date.fromisoformat('2020-03-31'))
self.assertEqual(first_year_bills[0].ending_date.date(), self.assertEqual(first_year_bills[0].ending_date.date(),
date.fromisoformat('2021-03-30')) date.fromisoformat('2021-03-30'))
self.assertEqual(first_year_bills[0].total, self.assertEqual(first_year_bills[0].amount,
recurring_price + one_time_price) recurring_price + one_time_price)
# Generate & check bill for second year: recurring_price. # Generate & check bill for second year: recurring_price.
@ -79,7 +86,7 @@ class BillingTestCase(TestCase):
date.fromisoformat('2021-03-31')) date.fromisoformat('2021-03-31'))
self.assertEqual(second_year_bills[0].ending_date.date(), self.assertEqual(second_year_bills[0].ending_date.date(),
date.fromisoformat('2022-03-30')) date.fromisoformat('2022-03-30'))
self.assertEqual(second_year_bills[0].total, recurring_price) self.assertEqual(second_year_bills[0].amount, recurring_price)
# Check that running Bill.generate_for() twice does not create duplicates. # Check that running Bill.generate_for() twice does not create duplicates.
self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
@ -100,19 +107,20 @@ class BillingTestCase(TestCase):
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
ending_date=ending_date, ending_date=ending_date,
recurring_period=RecurringPeriod.PER_HOUR) recurring_period=RecurringPeriod.PER_HOUR,
billing_address=self.billing_address)
order.add_record(one_time_price, recurring_price, description) order.add_record(one_time_price, recurring_price, description)
# Generate & check bill for first month: recurring_price + setup. # Generate & check bill for first month: recurring_price + setup.
first_month_bills = order.bills first_month_bills = order.bills
self.assertEqual(len(first_month_bills), 1) self.assertEqual(len(first_month_bills), 1)
self.assertEqual(float(first_month_bills[0].total), self.assertEqual(float(first_month_bills[0].amount),
round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price) round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price)
# Generate & check bill for first month: recurring_price. # Generate & check bill for first month: recurring_price.
second_month_bills = Bill.generate_for(2020, 4, self.user) second_month_bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(second_month_bills), 1) self.assertEqual(len(second_month_bills), 1)
self.assertEqual(float(second_month_bills[0].total), self.assertEqual(float(second_month_bills[0].amount),
round(12 * recurring_price, AMOUNT_DECIMALS)) round(12 * recurring_price, AMOUNT_DECIMALS))
class ProductActivationTestCase(TestCase): class ProductActivationTestCase(TestCase):
@ -121,14 +129,20 @@ class ProductActivationTestCase(TestCase):
username='jdoe', username='jdoe',
email='john.doe@domain.tld') email='john.doe@domain.tld')
self.billing_address = BillingAddress.objects.create(
owner=self.user,
street="unknown",
city="unknown",
postal_code="unknown")
def test_product_activation(self): def test_product_activation(self):
starting_date = datetime.fromisoformat('2020-03-01') starting_date = datetime.fromisoformat('2020-03-01')
order = Order.objects.create( order = Order.objects.create(
owner=self.user, owner=self.user,
starting_date=starting_date, starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH) recurring_period=RecurringPeriod.PER_MONTH,
order.save() billing_address=self.billing_address)
product = GenericServiceProduct( product = GenericServiceProduct(
custom_description="Test product", custom_description="Test product",
@ -139,16 +153,76 @@ class ProductActivationTestCase(TestCase):
product.save() product.save()
# XXX: to be automated. # XXX: to be automated.
order.add_record(product.one_time_price, product.recurring_price(), product.description) order.add_record(product.one_time_price, product.recurring_price, product.description)
# Validate initial state: must be awaiting payment. # Validate initial state: must be awaiting payment.
self.assertEqual(product.status, UncloudStatus.AWAITING_PAYMENT) self.assertEqual(product.status, UncloudStatus.AWAITING_PAYMENT)
# Pay initial bill, check that product is activated. # Pay initial bill, check that product is activated.
amount = product.order.bills[0].total amount = product.order.bills[0].amount
payment = Payment(owner=self.user, amount=amount) payment = Payment(owner=self.user, amount=amount)
payment.save() payment.save()
self.assertEqual( self.assertEqual(
GenericServiceProduct.objects.get(uuid=product.uuid).status, GenericServiceProduct.objects.get(uuid=product.uuid).status,
UncloudStatus.PENDING UncloudStatus.PENDING
) )
class BillingAddressTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='jdoe',
email='john.doe@domain.tld')
self.billing_address_01 = BillingAddress.objects.create(
owner=self.user,
street="unknown1",
city="unknown1",
postal_code="unknown1",
country="CH")
self.billing_address_02 = BillingAddress.objects.create(
owner=self.user,
street="unknown2",
city="unknown2",
postal_code="unknown2",
country="CH")
def test_billing_with_single_address(self):
# Create new orders somewhere in the past so that we do not encounter
# auto-created initial bills.
starting_date = datetime.fromisoformat('2020-03-01')
order_01 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_01)
order_02 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_01)
# We need a single bill since we work with a single address.
bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(bills), 1)
def test_billing_with_multiple_addresses(self):
# Create new orders somewhere in the past so that we do not encounter
# auto-created initial bills.
starting_date = datetime.fromisoformat('2020-03-01')
order_01 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_01)
order_02 = Order.objects.create(
owner=self.user,
starting_date=starting_date,
recurring_period=RecurringPeriod.PER_MONTH,
billing_address=self.billing_address_02)
# We need different bills since we work with different addresses.
bills = Bill.generate_for(2020, 4, self.user)
self.assertEqual(len(bills), 2)

View file

@ -1,20 +1,26 @@
from django.shortcuts import render from django.shortcuts import render
from django.db import transaction from django.db import transaction
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from rest_framework import viewsets, permissions, status, views from rest_framework import viewsets, mixins, permissions, status, views
from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.decorators import renderer_classes from rest_framework.decorators import renderer_classes
from vat_validator import validate_vat, vies
from vat_validator.countries import EU_COUNTRY_CODES
import json import json
import logging
from .models import * from .models import *
from .serializers import * from .serializers import *
from datetime import datetime from datetime import datetime
from vat_validator import sanitize_vat
import uncloud_pay.stripe as uncloud_stripe import uncloud_pay.stripe as uncloud_stripe
logger = logging.getLogger(__name__)
### ###
# Payments and Payment Methods. # Payments and Payment Methods.
@ -187,6 +193,53 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
return Order.objects.filter(owner=self.request.user) return Order.objects.filter(owner=self.request.user)
class BillingAddressViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'update':
return UpdateBillingAddressSerializer
else:
return BillingAddressSerializer
def get_queryset(self):
return self.request.user.billingaddress_set.all()
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Validate VAT numbers.
country = serializer.validated_data["country"]
vat_number = serializer.validated_data["vat_number"]
# We ignore empty VAT numbers.
if vat_number != "":
if not validate_vat(country, vat_number):
return Response(
{'error': 'Malformed VAT number.'},
status=status.HTTP_400_BAD_REQUEST)
elif country in EU_COUNTRY_CODES:
# XXX: make a synchroneous call to a third patry API here might not be a good idea..
try:
vies_state = vies.check_vat(country, vat_number)
if not vies_state.valid:
return Response(
{'error': 'European VAT number does not exist in VIES.'},
status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.warning(e)
return Response(
{'error': 'Could not validate EU VAT number against VIES. Try again later..'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
serializer.save(owner=request.user)
return Response(serializer.data)
### ###
# Old admin stuff. # Old admin stuff.

View file

@ -46,7 +46,8 @@ class GenericServiceProduct(Product):
decimal_places=AMOUNT_DECIMALS, decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)]) validators=[MinValueValidator(0)])
def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): @property
def recurring_price(self):
# FIXME: handle recurring_period somehow. # FIXME: handle recurring_period somehow.
return self.custom_recurring_price return self.custom_recurring_price

View file

@ -2,27 +2,59 @@ from rest_framework import serializers
from .models import * from .models import *
from uncloud_vm.serializers import ManagedVMProductSerializer from uncloud_vm.serializers import ManagedVMProductSerializer
from uncloud_vm.models import VMProduct from uncloud_vm.models import VMProduct
from uncloud_pay.models import RecurringPeriod from uncloud_pay.models import RecurringPeriod, BillingAddress
# XXX: the OrderSomethingSomthingProductSerializer classes add a lot of
# boilerplate: can we reduce it somehow?
class MatrixServiceProductSerializer(serializers.ModelSerializer): class MatrixServiceProductSerializer(serializers.ModelSerializer):
vm = ManagedVMProductSerializer() vm = ManagedVMProductSerializer()
# Custom field used at creation (= ordering) only. class Meta:
model = MatrixServiceProduct
fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain',
'recurring_period']
read_only_fields = ['uuid', 'order', 'owner', 'status']
class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer):
recurring_period = serializers.ChoiceField( recurring_period = serializers.ChoiceField(
choices=MatrixServiceProduct.allowed_recurring_periods()) choices=MatrixServiceProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs):
super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs)
self.fields['billing_address'] = serializers.ChoiceField(
choices=BillingAddress.get_addresses_for(
self.context['request'].user)
)
class Meta: class Meta:
model = MatrixServiceProduct model = MatrixServiceProductSerializer.Meta.model
fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain', 'recurring_period'] fields = MatrixServiceProductSerializer.Meta.fields + [
read_only_fields = ['uuid', 'order', 'owner', 'status'] 'recurring_period', 'billing_address'
]
read_only_fields = MatrixServiceProductSerializer.Meta.read_only_fields
class GenericServiceProductSerializer(serializers.ModelSerializer): class GenericServiceProductSerializer(serializers.ModelSerializer):
# Custom field used at creation (= ordering) only.
recurring_period = serializers.ChoiceField(
choices=GenericServiceProduct.allowed_recurring_periods())
class Meta: class Meta:
model = GenericServiceProduct model = GenericServiceProduct
fields = ['uuid', 'order', 'owner', 'status', 'custom_recurring_price', fields = ['uuid', 'order', 'owner', 'status', 'custom_recurring_price',
'custom_description', 'custom_one_time_price', 'recurring_period'] 'custom_description', 'custom_one_time_price']
read_only_fields = ['uuid', 'order', 'owner', 'status'] read_only_fields = ['uuid', 'order', 'owner', 'status']
class OrderGenericServiceProductSerializer(GenericServiceProductSerializer):
recurring_period = serializers.ChoiceField(
choices=GenericServiceProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs):
super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs)
self.fields['billing_address'] = serializers.ChoiceField(
choices=BillingAddress.get_addresses_for(
self.context['request'].user)
)
class Meta:
model = GenericServiceProductSerializer.Meta.model
fields = GenericServiceProductSerializer.Meta.fields + [
'recurring_period', 'billing_address'
]
read_only_fields = GenericServiceProductSerializer.Meta.read_only_fields

View file

@ -38,17 +38,25 @@ class MatrixServiceProductViewSet(ProductViewSet):
def get_queryset(self): def get_queryset(self):
return MatrixServiceProduct.objects.filter(owner=self.request.user) return MatrixServiceProduct.objects.filter(owner=self.request.user)
def get_serializer_class(self):
if self.action == 'create':
return OrderMatrixServiceProductSerializer
else:
return MatrixServiceProductSerializer
@transaction.atomic @transaction.atomic
def create(self, request): def create(self, request):
# Extract serializer data. # Extract serializer data.
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
order_recurring_period = serializer.validated_data.pop("recurring_period") order_recurring_period = serializer.validated_data.pop("recurring_period")
order_billing_address = serializer.validated_data.pop("billing_address")
# Create base order.) # Create base order.)
order = Order.objects.create( order = Order.objects.create(
recurring_period=order_recurring_period, recurring_period=order_recurring_period,
owner=request.user, owner=request.user,
billing_address=order_billing_address,
starting_date=timezone.now() starting_date=timezone.now()
) )
order.save() order.save()
@ -72,22 +80,29 @@ class MatrixServiceProductViewSet(ProductViewSet):
class GenericServiceProductViewSet(ProductViewSet): class GenericServiceProductViewSet(ProductViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = GenericServiceProductSerializer
def get_queryset(self): def get_queryset(self):
return GenericServiceProduct.objects.filter(owner=self.request.user) return GenericServiceProduct.objects.filter(owner=self.request.user)
def get_serializer_class(self):
if self.action == 'create':
return OrderGenericServiceProductSerializer
else:
return GenericServiceProductSerializer
@transaction.atomic @transaction.atomic
def create(self, request): def create(self, request):
# Extract serializer data. # Extract serializer data.
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
order_recurring_period = serializer.validated_data.pop("recurring_period") order_recurring_period = serializer.validated_data.pop("recurring_period")
order_billing_address = serializer.validated_data.pop("billing_address")
# Create base order. # Create base order.
order = Order.objects.create( order = Order.objects.create(
recurring_period=order_recurring_period, recurring_period=order_recurring_period,
owner=request.user, owner=request.user,
billing_address=order_billing_address,
starting_date=timezone.now() starting_date=timezone.now()
) )
order.save() order.save()

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.5 on 2020-04-17 05:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ungleich_service', '0004_auto_20200403_1727'),
]
operations = [
migrations.AlterField(
model_name='matrixserviceproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32),
),
]

View file

@ -1,36 +0,0 @@
# Generated by Django 3.0.5 on 2020-04-17 08:02
from django.conf import settings
import django.contrib.postgres.fields.jsonb
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0005_auto_20200417_0551'),
('ungleich_service', '0005_auto_20200417_0551'),
]
operations = [
migrations.CreateModel(
name='GenericServiceProduct',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)),
('custom_description', models.TextField()),
('custom_recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('custom_one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]