Error
+{{ error }}
+diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e7e1ae9..758f435 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,8 +1,22 @@ -image: python:3 +stages: + - lint + - test -before_script: - - python setup.py install - -python_tests: - script: - - python -m unittest -v test/test_mac_local.py +run-tests: + stage: test + image: fedora:latest + services: + - postgres:latest + variables: + DATABASE_HOST: postgres + DATABASE_USER: postgres + POSTGRES_HOST_AUTH_METHOD: trust + coverage: /^TOTAL.+?(\d+\%)$/ + before_script: + - dnf install -y python3-devel python3-pip libpq-devel openldap-devel gcc + script: + - cd uncloud_django_based/uncloud + - pip install -r requirements.txt + - cp uncloud/secrets_sample.py uncloud/secrets.py + - coverage run --source='.' ./manage.py test + - coverage report diff --git a/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org index 4b2b361..7217e1f 100644 --- a/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org +++ b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org @@ -1,5 +1,21 @@ * What is a remote uncloud client? ** Systems that configure themselves for the use with uncloud -** Examples are VMHosts, VPN Servers, etc. +** Examples are VMHosts, VPN Servers, cdist control server, etc. * Which access do these clients need? ** They need read / write access to the database +* Possible methods +** Overview +| | pros | cons | +| SSL based | Once setup, can access all django parts natively, locally | X.509 infrastructure | +| SSH -L tunnel | All nodes can use [::1]:5432 | SSH setup can be fragile | +| ssh djangohost manage.py | All DB ops locally | Code is only executed on django host | +| https + token | Rest alike / consistent access | Code is only executed on django host | +** remote vs. local Django code execution + - If manage.py is executed locally (= on the client), it can + check/modify local configs + - However local execution requires a pyvenv + packages + db access + - Local execution also *could* make use of postgresql notify for + triggering actions (which is quite neat) + - Remote execution (= on the primary django host) can acess the db + via unix socket + - However remote execution cannot check local state diff --git a/uncloud_django_based/uncloud/uncloud/secrets_sample.py b/uncloud_django_based/uncloud/uncloud/secrets_sample.py index 464662f..150fefb 100644 --- a/uncloud_django_based/uncloud/uncloud/secrets_sample.py +++ b/uncloud_django_based/uncloud/uncloud/secrets_sample.py @@ -1,5 +1,4 @@ -# Live/test key from stripe -STRIPE_KEY = '' +from django.core.management.utils import get_random_secret_key # XML-RPC interface of opennebula OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2' @@ -15,6 +14,8 @@ LDAP_ADMIN_PASSWORD="" LDAP_SERVER_URI = "" # Stripe (Credit Card payments) -STRIPE_API_key="" +STRIPE_KEY="" +STRIPE_PUBLIC_KEY="" -SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf" +# The django secret key +SECRET_KEY=get_random_secret_key() diff --git a/uncloud_django_based/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py index 9089f91..c1eaab2 100644 --- a/uncloud_django_based/uncloud/uncloud/settings.py +++ b/uncloud_django_based/uncloud/uncloud/settings.py @@ -27,8 +27,9 @@ except ModuleNotFoundError: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'HOST': '::1', # connecting via tcp, v6, to allow ssh forwarding to work 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME, + 'HOST': os.environ.get('DATABASE_HOST', '::1'), + 'USER': os.environ.get('DATABASE_USER', 'postgres'), } } diff --git a/uncloud_django_based/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py index 54f4d36..07c538d 100644 --- a/uncloud_django_based/uncloud/uncloud/urls.py +++ b/uncloud_django_based/uncloud/uncloud/urls.py @@ -54,7 +54,6 @@ router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='paym router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') -router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods') # admin/staff urls diff --git a/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200409_1225.py b/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200409_1225.py new file mode 100644 index 0000000..fcc2374 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200409_1225.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.5 on 2020-04-09 12:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vpnnetworkreservation', + name='status', + field=models.CharField(choices=[('used', 'used'), ('free', 'free')], default='used', max_length=256), + ), + migrations.AlterField( + model_name='vpnnetwork', + name='network', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNNetworkReservation'), + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py index 734c9e9..ba7adfc 100644 --- a/uncloud_django_based/uncloud/uncloud_net/models.py +++ b/uncloud_django_based/uncloud/uncloud_net/models.py @@ -112,6 +112,7 @@ class VPNNetworkReservation(UncloudModel): address = models.GenericIPAddressField(primary_key=True) status = models.CharField(max_length=256, + default='used', choices = ( ('used', 'used'), ('free', 'free') diff --git a/uncloud_django_based/uncloud/uncloud_pay/migrations/0002_auto_20200305_1524.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0002_auto_20200305_1524.py new file mode 100644 index 0000000..0768dd0 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/migrations/0002_auto_20200305_1524.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.3 on 2020-03-05 15:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='paymentmethod', + old_name='stripe_card_id', + new_name='stripe_payment_method_id', + ), + migrations.AddField( + model_name='paymentmethod', + name='stripe_setup_intent_id', + field=models.CharField(blank=True, max_length=32, null=True), + ), + migrations.AlterUniqueTogether( + name='paymentmethod', + unique_together=set(), + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py new file mode 100644 index 0000000..4157732 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-05 13:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20200305_1524'), + ] + + operations = [ + migrations.AlterField( + model_name='paymentmethod', + name='primary', + field=models.BooleanField(default=False, editable=False), + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_pay/migrations/0004_auto_20200409_1225.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0004_auto_20200409_1225.py new file mode 100644 index 0000000..32aac87 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/migrations/0004_auto_20200409_1225.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.5 on 2020-04-09 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0003_auto_20200305_1354'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='recurring_period', + field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32), + ), + migrations.AlterField( + model_name='order', + name='starting_date', + field=models.DateTimeField(), + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_pay/models.py b/uncloud_django_based/uncloud/uncloud_pay/models.py index 945187b..e751334 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/models.py +++ b/uncloud_django_based/uncloud/uncloud_pay/models.py @@ -4,11 +4,10 @@ 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 +import logging from functools import reduce from math import ceil from datetime import timedelta @@ -21,16 +20,29 @@ from uncloud_pay.helpers import beginning_of_month, end_of_month from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud.models import UncloudModel, UncloudStatus +from decimal import Decimal +import decimal + +# Define DecimalField properties, used to represent amounts of money. +AMOUNT_MAX_DIGITS=10 +AMOUNT_DECIMALS=2 + +# FIXME: check why we need +1 here. +decimal.getcontext().prec = AMOUNT_DECIMALS + 1 # Used to generate bill due dates. BILL_PAYMENT_DELAY=timedelta(days=10) +# Initialize logger. +logger = logging.getLogger(__name__) + # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types class RecurringPeriod(models.TextChoices): ONE_TIME = 'ONCE', _('Onetime') PER_YEAR = 'YEAR', _('Per Year') PER_MONTH = 'MONTH', _('Per Month') PER_MINUTE = 'MINUTE', _('Per Minute') + PER_WEEK = 'WEEK', _('Per Week') PER_DAY = 'DAY', _('Per Day') PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') @@ -106,57 +118,76 @@ class PaymentMethod(models.Model): ), default='stripe') description = models.TextField() - primary = models.BooleanField(default=True) + primary = models.BooleanField(default=False, editable=False) # Only used for "Stripe" source - stripe_card_id = models.CharField(max_length=32, blank=True, null=True) + stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) + stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) @property def stripe_card_last4(self): - if self.source == 'stripe': - card_request = uncloud_pay.stripe.get_card( - StripeCustomer.objects.get(owner=self.owner).stripe_id, - self.stripe_card_id) - if card_request['error'] == None: - return card_request['response_object']['last4'] - else: - return None + if self.source == 'stripe' and self.active: + payment_method = uncloud_pay.stripe.get_payment_method( + self.stripe_payment_method_id) + return payment_method.card.last4 else: return None + @property + def active(self): + if self.source == 'stripe' and self.stripe_payment_method_id != None: + return True + else: + return False def charge(self, amount): - if amount > 0: # Make sure we don't charge negative amount by errors... - if self.source == 'stripe': - stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id - charge_request = uncloud_pay.stripe.charge_customer(amount, stripe_customer, self.stripe_card_id) - if charge_request['error'] == None: - payment = Payment(owner=self.owner, source=self.source, amount=amount) - payment.save() # TODO: Check return status + if not self.active: + raise Exception('This payment method is inactive.') - return payment - else: - raise Exception('Stripe error: {}'.format(charge_request['error'])) - else: - raise Exception('This payment method is unsupported/cannot be charged.') - else: + if amount < 0: # Make sure we don't charge negative amount by errors... raise Exception('Cannot charge negative amount.') + if self.source == 'stripe': + stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id + stripe_payment = uncloud_pay.stripe.charge_customer( + amount, stripe_customer, self.stripe_payment_method_id) + if 'paid' in stripe_payment and stripe_payment['paid'] == False: + raise Exception(stripe_payment['error']) + else: + payment = Payment.objects.create( + owner=self.owner, source=self.source, amount=amount) + + return payment + else: + raise Exception('This payment method is unsupported/cannot be charged.') + + def set_as_primary_for(self, user): + methods = PaymentMethod.objects.filter(owner=user, primary=True) + for method in methods: + print(method) + method.primary = False + method.save() + + self.primary = True + self.save() 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: + if method.active and method.primary: return method return None class Meta: - unique_together = [['owner', 'primary']] + # TODO: limit to one primary method per user. + # unique_together is no good since it won't allow more than one + # non-primary method. + pass ### -# Bills & Payments. +# Bills. class Bill(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -199,51 +230,108 @@ class Bill(models.Model): @staticmethod def generate_for(year, month, user): # /!\ We exclusively work on the specified year and month. + generated_bills = [] - # 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() + BILL_PAYMENT_DELAY) + # Default values for next bill (if any). + starting_date=beginning_of_month(year, month) + ending_date=end_of_month(year, month) + creation_date=timezone.now() - # Select all orders active on the request period. + # Select all orders active on the request period (i.e. starting on or after starting_date). orders = Order.objects.filter( - Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True), + Q(ending_date__gte=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 monthly bills: if previous_bill.ending_date is before + # (next_bill) ending_date, a new bill has to be generated. + # * For yearly bill: if previous_bill.ending_date is on working + # month, generate new bill. + unpaid_orders = { 'monthly_or_less': [], 'yearly': {}} 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) + # FIXME: control flow is confusing in this block. + if order.recurring_period == RecurringPeriod.PER_YEAR: + # We ignore anything smaller than a day in here. + next_yearly_bill_start_on = None + if previous_bill == None: + next_yearly_bill_start_on = order.starting_date + elif previous_bill.ending_date <= ending_date: + next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1)) - # Commit next_bill if it there are 'unpaid' orders. - if len(unpaid_orders) > 0: - next_bill.save() + # Store for bill generation. One bucket per day of month with a starting bill. + # bucket is a reference here, no need to reassign. + if next_yearly_bill_start_on: + # We want to group orders by date but keep using datetimes. + next_yearly_bill_start_on = next_yearly_bill_start_on.replace( + minute=0, hour=0, second=0, microsecond=0) + bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on) + if bucket == None: + unpaid_orders['yearly'][next_yearly_bill_start_on] = [order] + else: + unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order] + else: + if previous_bill == None or previous_bill.ending_date <= ending_date: + unpaid_orders['monthly_or_less'].append(order) + + # Handle working month's billing. + if len(unpaid_orders['monthly_or_less']) > 0: + # TODO: PREPAID billing is not supported yet. + prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY + postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY + + next_monthly_bill = Bill.objects.create(owner=user, + creation_date=creation_date, + starting_date=starting_date, # FIXME: this is a hack! + ending_date=ending_date, + due_date=postpaid_due_date) # 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) + for order in unpaid_orders['monthly_or_less']: + order.bill.add(next_monthly_bill) - # TODO: use logger. - print("Generated bill {} (amount: {}) for user {}." - .format(next_bill.uuid, next_bill.total, user)) + logger.info("Generated monthly bill {} (amount: {}) for user {}." + .format(next_monthly_bill.uuid, next_monthly_bill.total, user)) - return next_bill + # Add to output. + generated_bills.append(next_monthly_bill) - # Return None if no bill was created. - return None + # Handle yearly bills starting on working month. + if len(unpaid_orders['yearly']) > 0: + + # For every starting date, generate new bill. + for next_yearly_bill_start_on in unpaid_orders['yearly']: + # No postpaid for yearly payments. + prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY + # Bump by one year, remove one day. + ending_date = next_yearly_bill_start_on.replace( + year=next_yearly_bill_start_on.year+1) - timedelta(days=1) + + next_yearly_bill = Bill.objects.create(owner=user, + creation_date=creation_date, + starting_date=next_yearly_bill_start_on, + ending_date=ending_date, + due_date=prepaid_due_date) + + # It is not possible to register many-to-many relationship before + # the two end-objects are saved in database. + for order in unpaid_orders['yearly'][next_yearly_bill_start_on]: + order.bill.add(next_yearly_bill) + + logger.info("Generated yearly bill {} (amount: {}) for user {}." + .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_bills @staticmethod def get_unpaid_for(user): @@ -286,7 +374,7 @@ class BillRecord(): self.recurring_period = order_record.recurring_period self.description = order_record.description - if self.order.starting_date > self.bill.starting_date: + if self.order.starting_date >= self.bill.starting_date: self.one_time_price = order_record.one_time_price else: self.one_time_price = 0 @@ -295,7 +383,7 @@ class BillRecord(): def recurring_count(self): # Compute billing delta. billed_until = self.bill.ending_date - if self.order.ending_date != None and self.order.ending_date < self.order.ending_date: + if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date: billed_until = self.order.ending_date billed_from = self.bill.starting_date @@ -303,7 +391,7 @@ class BillRecord(): billed_from = self.order.starting_date if billed_from > billed_until: - # TODO: think about and check edges cases. This should not be + # TODO: think about and check edge cases. This should not be # possible. raise Exception('Impossible billing delta!') @@ -311,11 +399,14 @@ class BillRecord(): # TODO: refactor this thing? # TODO: weekly - # TODO: yearly - if self.recurring_period == RecurringPeriod.PER_MONTH: + if self.recurring_period == RecurringPeriod.PER_YEAR: + # XXX: Should always be one => we do not bill for more than one year. + # TODO: check billed_delta is ~365 days. + return 1 + elif self.recurring_period == RecurringPeriod.PER_MONTH: days = ceil(billed_delta / timedelta(days=1)) - # XXX: we assume monthly bills for now. + # Monthly bills always cover one single month. if (self.bill.starting_date.year != self.bill.starting_date.year or self.bill.starting_date.month != self.bill.ending_date.month): raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'. @@ -325,25 +416,28 @@ class BillRecord(): (_, days_in_month) = monthrange( self.bill.starting_date.year, self.bill.starting_date.month) - return Decimal(days / days_in_month) + return days / days_in_month + elif self.recurring_period == RecurringPeriod.PER_WEEK: + weeks = ceil(billed_delta / timedelta(week=1)) + return weeks elif self.recurring_period == RecurringPeriod.PER_DAY: days = ceil(billed_delta / timedelta(days=1)) - return Decimal(days) + return days elif self.recurring_period == RecurringPeriod.PER_HOUR: hours = ceil(billed_delta / timedelta(hours=1)) - return Decimal(hours) + return hours elif self.recurring_period == RecurringPeriod.PER_SECOND: seconds = ceil(billed_delta / timedelta(seconds=1)) - return Decimal(seconds) + return seconds elif self.recurring_period == RecurringPeriod.ONE_TIME: - return Decimal(0) + return 0 else: raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) @property def amount(self): - return self.recurring_price * self.recurring_count + self.one_time_price + return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price ### # Orders. @@ -358,7 +452,7 @@ class Order(models.Model): # TODO: enforce ending_date - starting_date to be larger than recurring_period. creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField(auto_now_add=True) + starting_date = models.DateTimeField() ending_date = models.DateTimeField(blank=True, null=True) diff --git a/uncloud_django_based/uncloud/uncloud_pay/serializers.py b/uncloud_django_based/uncloud/uncloud_pay/serializers.py index a0a8635..f408d1b 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/serializers.py +++ b/uncloud_django_based/uncloud/uncloud_pay/serializers.py @@ -8,30 +8,28 @@ from .models import * class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment - fields = ['owner', 'amount', 'source', 'timestamp'] + fields = '__all__' class PaymentMethodSerializer(serializers.ModelSerializer): stripe_card_last4 = serializers.IntegerField() class Meta: model = PaymentMethod - fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4'] + fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active'] + +class UpdatePaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMethod + fields = ['description', 'primary'] class ChargePaymentMethodSerializer(serializers.Serializer): amount = serializers.DecimalField(max_digits=10, decimal_places=2) -class CreditCardSerializer(serializers.Serializer): - number = serializers.IntegerField() - exp_month = serializers.IntegerField() - exp_year = serializers.IntegerField() - cvc = serializers.IntegerField() - class CreatePaymentMethodSerializer(serializers.ModelSerializer): - credit_card = CreditCardSerializer() - + please_visit = serializers.CharField(read_only=True) class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary', 'credit_card'] + fields = ['source', 'description', 'primary', 'please_visit'] ### # Orders & Products. diff --git a/uncloud_django_based/uncloud/uncloud_pay/stripe.py b/uncloud_django_based/uncloud/uncloud_pay/stripe.py index 4f28d94..f23002b 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/stripe.py +++ b/uncloud_django_based/uncloud/uncloud_pay/stripe.py @@ -10,9 +10,14 @@ import uncloud.secrets # Static stripe configuration used below. CURRENCY = 'chf' +# README: We use the Payment Intent API as described on +# https://stripe.com/docs/payments/save-and-reuse + +# For internal use only. stripe.api_key = uncloud.secrets.STRIPE_KEY # Helper (decorator) used to catch errors raised by stripe logic. +# Catch errors that should not be displayed to the end user, raise again. def handle_stripe_error(f): def handle_problems(*args, **kwargs): response = { @@ -21,108 +26,84 @@ def handle_stripe_error(f): 'error': None } - common_message = "Currently it is not possible to make payments." + common_message = "Currently it is not possible to make payments. Please try agin later." try: response_object = f(*args, **kwargs) - response = { - 'response_object': response_object, - 'error': None - } - return response + return response_object except stripe.error.CardError as e: # Since it's a decline, stripe.error.CardError will be caught body = e.json_body - err = body['error'] - response.update({'error': err['message']}) logging.error(str(e)) - return response + + raise e # For error handling. except stripe.error.RateLimitError: - response.update( - {'error': "Too many requests made to the API too quickly"}) - return response + logging.error("Too many requests made to the API too quickly.") + raise Exception(common_message) except stripe.error.InvalidRequestError as e: logging.error(str(e)) - response.update({'error': "Invalid parameters"}) - return response + raise Exception('Invalid parameters.') except stripe.error.AuthenticationError as e: # Authentication with Stripe's API failed # (maybe you changed API keys recently) logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) except stripe.error.APIConnectionError as e: logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) except stripe.error.StripeError as e: - # maybe send email + # XXX: maybe send email logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) except Exception as e: # maybe send email logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) return handle_problems -# Convenience CC container, also used for serialization. -class CreditCard(): - number = None - exp_year = None - exp_month = None - cvc = None - - def __init__(self, number, exp_month, exp_year, cvc): - self.number=number - self.exp_year = exp_year - self.exp_month = exp_month - self.cvc = cvc - # Actual Stripe logic. +def public_api_key(): + return uncloud.secrets.STRIPE_PUBLIC_KEY + def get_customer_id_for(user): try: # .get() raise if there is no matching entry. return uncloud_pay.models.StripeCustomer.objects.get(owner=user).stripe_id except ObjectDoesNotExist: # No entry yet - making a new one. - customer_request = create_customer(user.username, user.email) - if customer_request['error'] == None: - mapping = uncloud_pay.models.StripeCustomer.objects.create( - owner=user, - stripe_id=customer_request['response_object']['id'] - ) - return mapping.stripe_id - else: + try: + customer = create_customer(user.username, user.email) + uncloud_stripe_mapping = uncloud_pay.models.StripeCustomer.objects.create( + owner=user, stripe_id=customer.id) + return uncloud_stripe_mapping.stripe_id + except Exception as e: return None @handle_stripe_error -def create_card(customer_id, credit_card): - return stripe.Customer.create_source( - customer_id, - card={ - 'number': credit_card.number, - 'exp_month': credit_card.exp_month, - 'exp_year': credit_card.exp_year, - 'cvc': credit_card.cvc - }) +def create_setup_intent(customer_id): + return stripe.SetupIntent.create(customer=customer_id) @handle_stripe_error -def get_card(customer_id, card_id): - return stripe.Customer.retrieve_source(customer_id, card_id) +def get_setup_intent(setup_intent_id): + return stripe.SetupIntent.retrieve(setup_intent_id) + +def get_payment_method(payment_method_id): + return stripe.PaymentMethod.retrieve(payment_method_id) @handle_stripe_error def charge_customer(amount, customer_id, card_id): # Amount is in CHF but stripes requires smallest possible unit. - # See https://stripe.com/docs/api/charges/create + # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount adjusted_amount = int(amount * 100) - return stripe.Charge.create( - amount=adjusted_amount, - currency=CURRENCY, - customer=customer_id, - source=card_id) + return stripe.PaymentIntent.create( + amount=adjusted_amount, + currency=CURRENCY, + customer=customer_id, + payment_method=card_id, + off_session=True, + confirm=True, + ) @handle_stripe_error def create_customer(name, email): diff --git a/uncloud_django_based/uncloud/uncloud_pay/templates/error.html.j2 b/uncloud_django_based/uncloud/uncloud_pay/templates/error.html.j2 new file mode 100644 index 0000000..ba9209c --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/templates/error.html.j2 @@ -0,0 +1,18 @@ + + +
+{{ error }}
+