Rename / prepare for merge with uncloud repo

This commit is contained in:
Nico Schottelius 2020-04-02 19:31:03 +02:00
commit 7a6c8739f6
118 changed files with 1499 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UncloudPayConfig(AppConfig):
name = 'uncloud_pay'

View file

@ -0,0 +1,26 @@
from functools import reduce
from datetime import datetime
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from django.utils import timezone
from calendar import monthrange
def beginning_of_month(year, month):
tz = timezone.get_current_timezone()
return datetime(year=year, month=month, day=1, tzinfo=tz)
def end_of_month(year, month):
(_, days) = monthrange(year, month)
tz = timezone.get_current_timezone()
return datetime(year=year, month=month, day=days,
hour=23, minute=59, second=59, tzinfo=tz)
class ProductViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
GenericViewSet):
"""
A customer-facing viewset that provides default `create()`, `retrieve()`
and `list()`.
"""
pass

View file

@ -0,0 +1,31 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
balance = get_balance_for(user)
if balance < 0:
print("User {} has negative balance ({}), charging.".format(user.username, balance))
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)
if not charge_ok:
print("ERR: charging {} with method {} failed"
.format(user.username, payment_method.uuid)
)
else:
print("ERR: no payment method registered for {}".format(user.username))
print("=> Done.")

View file

@ -0,0 +1,35 @@
import logging
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill
from django.core.exceptions import ObjectDoesNotExist
from datetime import timedelta, date
from django.utils import timezone
from uncloud_pay.models import Bill
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser):
pass
# TODO: use logger.*
def handle(self, *args, **options):
# Iterate over all 'active' users.
# TODO: filter out inactive users.
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
now = timezone.now()
Bill.generate_for(
year=now.year,
month=now.month,
user=user)
# We're done for this round :-)
print("=> Done.")

View file

@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Bill
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Take action on overdue bills.'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
for bill in Bill.get_overdue_for(user):
print("/!\ Overdue bill for {}, {} with amount {}"
.format(user.username, bill.uuid, bill.amount))
# TODO: take action?
print("=> Done.")

View file

@ -0,0 +1,85 @@
# Generated by Django 3.0.3 on 2020-03-05 10:17
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_auth', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Bill',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField()),
('ending_date', models.DateTimeField()),
('due_date', models.DateField()),
('valid', models.BooleanField(default=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Order',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField(auto_now_add=True)),
('ending_date', models.DateTimeField(blank=True, null=True)),
('recurring_period', models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32)),
('bill', models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='StripeCustomer',
fields=[
('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('stripe_id', models.CharField(max_length=32)),
],
),
migrations.CreateModel(
name='Payment',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('description', models.TextField()),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
],
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)),
('description', models.TextField()),
('primary', models.BooleanField(default=True)),
('stripe_card_id', models.CharField(blank=True, max_length=32, null=True)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('owner', 'primary')},
},
),
]

View file

@ -0,0 +1,466 @@
from django.db import models
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
from decimal import Decimal
import uncloud_pay.stripe
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
# Used to generate bill due dates.
BILL_PAYMENT_DELAY=timedelta(days=10)
# 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_DAY = 'DAY', _('Per Day')
PER_HOUR = 'HOUR', _('Per Hour')
PER_SECOND = 'SECOND', _('Per Second')
def get_balance_for_user(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.
class Payment(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
amount = models.DecimalField(
default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
source = models.CharField(max_length=256,
choices = (
('wire', 'Wire Transfer'),
('stripe', 'Stripe'),
('voucher', 'Voucher'),
('referral', 'Referral'),
('unknown', 'Unknown')
),
default='unknown')
timestamp = models.DateTimeField(editable=False, auto_now_add=True)
# WIP prepaid and service activation logic by fnux.
## We override save() in order to active products awaiting payment.
#def save(self, *args, **kwargs):
# # TODO: only run activation logic on creation, not on update.
# unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner)
# super(Payment, self).save(*args, **kwargs) # Save payment in DB.
# unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner)
# newly_paid_bills = list(
# set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
# for bill in newly_paid_bills:
# bill.activate_orders()
class PaymentMethod(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
source = models.CharField(max_length=256,
choices = (
('stripe', 'Stripe'),
('unknown', 'Unknown'),
),
default='stripe')
description = models.TextField()
primary = models.BooleanField(default=True)
# Only used for "Stripe" source
stripe_card_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
else:
return None
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
return payment
else:
raise Exception('Stripe error: {}'.format(charge_request['error']))
else:
raise Exception('This payment method is unsupported/cannot be charged.')
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']]
###
# Bills & Payments.
class Bill(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField()
ending_date = models.DateTimeField()
due_date = models.DateField()
valid = models.BooleanField(default=True)
@property
def reference(self):
return "{}-{}".format(
self.owner.username,
self.creation_date.strftime("%Y-%m-%d-%H%M"))
@property
def records(self):
bill_records = []
orders = Order.objects.filter(bill=self)
for order in orders:
for order_record in order.records:
bill_record = BillRecord(self, order_record)
bill_records.append(bill_record)
return bill_records
@property
def total(self):
return reduce(lambda acc, record: acc + record.amount, self.records, 0)
@property
def final(self):
# A bill is final when its ending date is passed.
return self.ending_date < timezone.now()
@staticmethod
def generate_for(year, month, user):
# /!\ 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() + BILL_PAYMENT_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
@staticmethod
def get_unpaid_for(user):
balance = get_balance_for(user)
unpaid_bills = []
# No unpaid bill if balance is positive.
if balance >= 0:
return []
else:
bills = Bill.objects.filter(
owner=user,
due_date__lt=timezone.now()
).order_by('-creation_date')
# Amount to be paid by the customer.
unpaid_balance = abs(balance)
for bill in bills:
if unpaid_balance < 0:
break
unpaid_balance -= bill.amount
unpaid_bills.append(bill)
return unpaid_bills
@staticmethod
def get_overdue_for(user):
unpaid_bills = Bill.get_unpaid_for(user)
return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills))
class BillRecord():
"""
Entry of a bill, dynamically generated from order records.
"""
def __init__(self, bill, order_record):
self.bill = bill
self.order = order_record.order
self.recurring_price = order_record.recurring_price
self.recurring_period = order_record.recurring_period
self.description = order_record.description
if self.order.starting_date > self.bill.starting_date:
self.one_time_price = order_record.one_time_price
else:
self.one_time_price = 0
@property
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:
billed_until = self.order.ending_date
billed_from = self.bill.starting_date
if self.order.starting_date > self.bill.starting_date:
billed_from = self.order.starting_date
if billed_from > billed_until:
# TODO: think about and check edges cases. This should not be
# possible.
raise Exception('Impossible billing delta!')
billed_delta = billed_until - billed_from
# TODO: refactor this thing?
# TODO: weekly
# TODO: yearly
if self.recurring_period == RecurringPeriod.PER_MONTH:
days = ceil(billed_delta / timedelta(days=1))
# XXX: we assume monthly bills for now.
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.'.
format(self.bill.uuid))
# XXX: minumal length of monthly order is to be enforced somewhere else.
(_, days_in_month) = monthrange(
self.bill.starting_date.year,
self.bill.starting_date.month)
return Decimal(days / days_in_month)
elif self.recurring_period == RecurringPeriod.PER_DAY:
days = ceil(billed_delta / timedelta(days=1))
return Decimal(days)
elif self.recurring_period == RecurringPeriod.PER_HOUR:
hours = ceil(billed_delta / timedelta(hours=1))
return Decimal(hours)
elif self.recurring_period == RecurringPeriod.PER_SECOND:
seconds = ceil(billed_delta / timedelta(seconds=1))
return Decimal(seconds)
elif self.recurring_period == RecurringPeriod.ONE_TIME:
return Decimal(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
###
# Orders.
# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
# bills. Do **NOT** mutate then!
class Order(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
# 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)
ending_date = models.DateTimeField(blank=True,
null=True)
bill = models.ManyToManyField(Bill,
editable=False,
blank=True)
recurring_period = models.CharField(max_length=32,
choices = RecurringPeriod.choices,
default = RecurringPeriod.PER_MONTH)
@property
def records(self):
return OrderRecord.objects.filter(order=self)
@property
def one_time_price(self):
return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0)
@property
def recurring_price(self):
return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0)
def add_record(self, one_time_price, recurring_price, description):
OrderRecord.objects.create(order=self,
one_time_price=one_time_price,
recurring_price=recurring_price,
description=description)
class OrderRecord(models.Model):
"""
Order records store billing informations for products: the actual product
might be mutated and/or moved to another order but we do not want to loose
the details of old orders.
Used as source of trust to dynamically generate bill entries.
"""
order = models.ForeignKey(Order, on_delete=models.CASCADE)
one_time_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
recurring_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
description = models.TextField()
@property
def recurring_period(self):
return self.order.recurring_period
@property
def starting_date(self):
return self.order.starting_date
@property
def ending_date(self):
return self.order.ending_date
###
# Products
# Abstract (= no database representation) class used as parent for products
# (e.g. uncloud_vm.models.VMProduct).
class Product(UncloudModel):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
editable=False)
description = ""
status = models.CharField(max_length=32,
choices=UncloudStatus.choices,
default=UncloudStatus.PENDING)
order = models.ForeignKey(Order,
on_delete=models.CASCADE,
editable=False,
null=True)
@property
def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
pass # To be implemented in child.
@property
def one_time_price(self):
return 0
@property
def recurring_period(self):
return self.order.recurring_period
@staticmethod
def allowed_recurring_periods():
return RecurringPeriod.choices
class Meta:
abstract = True

View file

@ -0,0 +1,73 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import *
###
# Payments and Payment Methods.
class PaymentSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = ['owner', 'amount', 'source', 'timestamp']
class PaymentMethodSerializer(serializers.ModelSerializer):
stripe_card_last4 = serializers.IntegerField()
class Meta:
model = PaymentMethod
fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4']
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()
class Meta:
model = PaymentMethod
fields = ['source', 'description', 'primary', 'credit_card']
###
# Orders & Products.
class OrderRecordSerializer(serializers.ModelSerializer):
class Meta:
model = OrderRecord
fields = ['one_time_price', 'recurring_price', 'description']
class OrderSerializer(serializers.ModelSerializer):
records = OrderRecordSerializer(many=True, read_only=True)
class Meta:
model = Order
fields = ['uuid', 'creation_date', 'starting_date', 'ending_date',
'bill', 'recurring_period', 'records', 'recurring_price', 'one_time_price']
###
# Bills
# TODO: remove magic numbers for decimal fields
class BillRecordSerializer(serializers.Serializer):
order = serializers.HyperlinkedRelatedField(
view_name='order-detail',
read_only=True)
description = serializers.CharField()
recurring_period = serializers.CharField()
recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2)
recurring_count = serializers.DecimalField(max_digits=10, decimal_places=2)
one_time_price = serializers.DecimalField(max_digits=10, decimal_places=2)
amount = serializers.DecimalField(max_digits=10, decimal_places=2)
class BillSerializer(serializers.ModelSerializer):
records = BillRecordSerializer(many=True, read_only=True)
class Meta:
model = Bill
fields = ['reference', 'owner', 'total', 'due_date', 'creation_date',
'starting_date', 'ending_date', 'records', 'final']

View file

@ -0,0 +1,133 @@
import stripe
import stripe.error
import logging
from django.core.exceptions import ObjectDoesNotExist
import uncloud_pay.models
import uncloud.secrets
# Static stripe configuration used below.
CURRENCY = 'chf'
stripe.api_key = uncloud.secrets.STRIPE_KEY
# Helper (decorator) used to catch errors raised by stripe logic.
def handle_stripe_error(f):
def handle_problems(*args, **kwargs):
response = {
'paid': False,
'response_object': None,
'error': None
}
common_message = "Currently it is not possible to make payments."
try:
response_object = f(*args, **kwargs)
response = {
'response_object': response_object,
'error': None
}
return response
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
except stripe.error.RateLimitError:
response.update(
{'error': "Too many requests made to the API too quickly"})
return response
except stripe.error.InvalidRequestError as e:
logging.error(str(e))
response.update({'error': "Invalid parameters"})
return response
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
except stripe.error.APIConnectionError as e:
logging.error(str(e))
response.update({'error': common_message})
return response
except stripe.error.StripeError as e:
# maybe send email
logging.error(str(e))
response.update({'error': common_message})
return response
except Exception as e:
# maybe send email
logging.error(str(e))
response.update({'error': common_message})
return response
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 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:
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
})
@handle_stripe_error
def get_card(customer_id, card_id):
return stripe.Customer.retrieve_source(customer_id, card_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
adjusted_amount = int(amount * 100)
return stripe.Charge.create(
amount=adjusted_amount,
currency=CURRENCY,
customer=customer_id,
source=card_id)
@handle_stripe_error
def create_customer(name, email):
return stripe.Customer.create(name=name, email=email)
@handle_stripe_error
def get_customer(customer_id):
return stripe.Customer.retrieve(customer_id)

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,150 @@
from django.shortcuts import render
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework import viewsets, permissions, status
from rest_framework.response import Response
from rest_framework.decorators import action
import json
from .models import *
from .serializers import *
from datetime import datetime
import uncloud_pay.stripe as uncloud_stripe
###
# Standard user views:
class BalanceViewSet(viewsets.ViewSet):
# here we return a number
# number = sum(payments) - sum(bills)
#bills = Bill.objects.filter(owner=self.request.user)
#payments = Payment.objects.filter(owner=self.request.user)
# sum_paid = sum([ amount for amount payments..,. ]) # you get the picture
# sum_to_be_paid = sum([ amount for amount bills..,. ]) # you get the picture
pass
class BillViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = BillSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Bill.objects.filter(owner=self.request.user)
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = PaymentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Payment.objects.filter(owner=self.request.user)
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
class PaymentMethodViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'create':
return CreatePaymentMethodSerializer
elif self.action == 'charge':
return ChargePaymentMethodSerializer
else:
return PaymentMethodSerializer
def get_queryset(self):
return PaymentMethod.objects.filter(owner=self.request.user)
# XXX: Handling of errors is far from great down there.
@transaction.atomic
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Retrieve Stripe customer ID for user.
customer_id = uncloud_stripe.get_customer_id_for(request.user)
if customer_id == None:
return Response(
{'error': 'Could not resolve customer stripe ID.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Register card under stripe customer.
credit_card = uncloud_stripe.CreditCard(**serializer.validated_data.pop('credit_card'))
card_request = uncloud_stripe.create_card(customer_id, credit_card)
if card_request['error']:
return Response({'stripe_error': card_request['error']}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
card_id = card_request['response_object']['id']
# Save payment method locally.
serializer.validated_data['stripe_card_id'] = card_request['response_object']['id']
payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data)
# We do not want to return the credit card details sent with the POST
# request.
output_serializer = PaymentMethodSerializer(payment_method)
return Response(output_serializer.data)
@action(detail=True, methods=['post'])
def charge(self, request, pk=None):
payment_method = self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
amount = serializer.validated_data['amount']
try:
payment = payment_method.charge(amount)
output_serializer = PaymentSerializer(payment)
return Response(output_serializer.data)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
###
# Admin views.
class AdminPaymentViewSet(viewsets.ModelViewSet):
serializer_class = PaymentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Payment.objects.all()
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(timestamp=datetime.now())
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class AdminBillViewSet(viewsets.ModelViewSet):
serializer_class = BillSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Bill.objects.all()
def unpaid(self, request):
return Bill.objects.filter(owner=self.request.user, paid=False)
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(creation_date=datetime.now())
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class AdminOrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Order.objects.all()