Credits cannot be changed once created.
+ +diff --git a/.gitignore b/.gitignore index 14ad287..a7ce173 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ nosetests.xml coverage.xml stats.dat .DS_Store +.tox/ diff --git a/.travis.yml b/.travis.yml new file mode 100755 index 0000000..fd76209 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: python + +python: + - "2.7" + +env: + - DJANGO=">=1.6,<1.7" + - DJANGO=">=1.5,<1.6" + - DJANGO=">=1.4,<1.5" + - DJANGO=">=1.3,<1.4" + +install: + - pip install -q -r requirements.txt + - pip install -q -r test-requirements.txt + - pip install -q -I "Django $DJANGO" + - pip install python-coveralls + - python setup.py -q install + +script: ./run-tests.sh + +after_success: coveralls diff --git a/README.md b/README.md index d4a042a..c82158f 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,90 @@ -# django-balanced +django-balanced +=============== -## How to send ACH payments in 10 minutes +[](http://travis-ci.org/balanced/django-balanced) +[](https://coveralls.io/r/balanced/django-balanced) +[](https://pypi.python.org/pypi/django-balanced) + +Django integration for [Balanced Payments](https://www.balancedpayments.com/). + +* **Version**: 0.1.10 +* **License**: BSD + +This version is compatible with the +[Balanced API v1.0](https://docs.balancedpayments.com/1.0/overview/) using +[balanced-python 0.11.15](https://pypi.python.org/pypi/balanced/0.11.15). + +How to send ACH payments in 10 minutes +-------------------------------------- 1. Visit www.balancedpayments.com and get yourself an API key + 2. `pip install django-balanced` + 3. Edit your `settings.py` and add the API key like so: - import os + ``` + import os - BALANCED = { - 'API_KEY': os.environ.get('BALANCED_API_KEY'), - } + BALANCED = { + 'API_KEY': os.environ.get('BALANCED_API_KEY'), + } + ``` 4. Add `django_balanced` to your `INSTALLED_APPS` in `settings.py` - INSTALLED_APPS = ( - ... - 'django.contrib.admin', # if you want to use the admin interface - 'django_balanced', - ... - ) + ``` + INSTALLED_APPS = ( + ... + 'django.contrib.admin', # if you want to use the admin interface + 'django_balanced', + ... + ) + ``` 5. Run `BALANCED_API_KEY=YOUR_API_KEY django-admin.py syncdb` + 6. Run `BALANCED_API_KEY=YOUR_API_KEY python manage.py runserver` + 7. Visit `http://127.0.0.1:8000/admin` and pay some people! + +Testing +------- + +Continuous integration provided by [Travis CI](https://travis-ci.org/). + +### Running the tests + +1. Install all requirements: + + ``` + $ pip install Django -r requirements.txt -r test-requirements.txt + ``` + +2. Run the tests: + + ``` + $ ./run-tests.py + + ... + + =============== 2 passed, 17 skipped in 15.95 seconds =============== + ``` + +### Testing with Tox + +For quickly testing against different Django versions, we use +[Tox](http://tox.readthedocs.org/). + +``` +$ tox + +... + +_______________ summary _______________ + py27-django17: commands succeeded + py27-django16: commands succeeded + py27-django15: commands succeeded + py27-django14: commands succeeded + congratulations :) +``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..bd9d5d7 --- /dev/null +++ b/conftest.py @@ -0,0 +1,7 @@ +import os +from django.conf import settings + + +def pytest_configure(): + if not settings.configured: + os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' diff --git a/django_balanced/__init__.py b/django_balanced/__init__.py index 33d45d8..447b2c7 100644 --- a/django_balanced/__init__.py +++ b/django_balanced/__init__.py @@ -1,3 +1,3 @@ __version__ = '0.1.10' -from . import settings +default_app_config = 'django_balanced.apps.DjangoBalancedConfig' diff --git a/django_balanced/admin.py b/django_balanced/admin.py index 95734e4..fe58ba6 100644 --- a/django_balanced/admin.py +++ b/django_balanced/admin.py @@ -1,172 +1,83 @@ from __future__ import unicode_literals -import balanced -from django import forms from django.conf.urls import patterns, url from django.contrib import admin -from django.contrib.auth.models import User -from django.core import urlresolvers -from django.shortcuts import render, redirect +from django.shortcuts import redirect -from django_balanced.models import BankAccount, Credit +from . import views +from .forms import BankAccountAddForm, BankAccountChangeForm, CreditAddForm +from .models import BankAccount, Credit """ TODO: - Generate merchant dashboard login links - Bulk pay a set of bank accounts Add account URI onto django users """ class BalancedAdmin(admin.ModelAdmin): - add_fields = () - edit_fields = () - - def add_view(self, *args, **kwargs): - self.fields = getattr(self, 'add_fields', self.fields) - return super(BalancedAdmin, self).add_view(*args, **kwargs) - - def change_view(self, *args, **kwargs): - self.fields = getattr(self, 'edit_fields', self.fields) - return super(BalancedAdmin, self).change_view(*args, **kwargs) - - -class BankAccountAdminForm(forms.ModelForm): - name = forms.CharField(max_length=255) - account_number = forms.CharField(max_length=255) - routing_number = forms.CharField(max_length=255) - type = forms.ChoiceField(choices=( - ('savings', 'savings'), ('checking', 'checking') - )) - user = forms.ModelChoiceField(queryset=User.objects, required=False) - - class Meta: - model = BankAccount - -# -# def clean(self): -# data = self.cleaned_data -# # TODO: validate routing number -# # routing_number = balanced.BankAccount( -# # routing_number=data['routing_number'], -# # ) -# # try: -# # routing_number.validate() -# # except balanced.exc.HTTPError as ex: -# # if 'routing_number' in ex.message: -# # raise forms.ValidationError(ex.message) -# # raise -# return data + add_form = None + + def get_form(self, request, obj=None, **kwargs): + defaults = {} + if obj is None and self.add_form: + defaults.update({ + 'form': self.add_form, + }) + defaults.update(kwargs) + return super(BalancedAdmin, self).get_form(request, obj, **defaults) class BankAccountAdmin(BalancedAdmin): - add_fields = ('name', 'account_number', 'routing_number', 'type', 'user') - edit_fields = ('user',) - list_display = ['account_number', 'created_at', 'user', 'name', - 'bank_name', 'type', 'dashboard_link'] - list_filter = ['type', 'bank_name', 'user'] - search_fields = ['name', 'account_number'] - form = BankAccountAdminForm - actions = ['bulk_pay_action'] + actions = ('bulk_pay_action',) + list_display = ('account_number', 'created_at', 'user', 'name', + 'bank_name', 'type', 'dashboard_link') + list_filter = ('type', 'bank_name', 'user') + add_form = BankAccountAddForm + form = BankAccountChangeForm + raw_id_fields = ('user',) + search_fields = ('name', 'account_number') def bulk_pay_action(self, request, queryset): - return render(request, 'django_balanced/admin_confirm_bulk_pay.html', { - 'bank_accounts': enumerate(queryset), - }, current_app=self.admin_site.name) - + request.session['bank_account_bulk_pay'] = [bank_account.pk for + bank_account in queryset] + return redirect('admin:bank_account_bulk_pay') bulk_pay_action.short_description = 'Credit selected accounts' def get_urls(self): - urls = super(BankAccountAdmin, self).get_urls() - my_urls = patterns('django_balanced.admin', - url(r'^bulk_pay/$', - self.admin_site.admin_view(self.bulk_pay_view), - name='bank_account_bulk_pay') - ) - return my_urls + urls - - def bulk_pay_view(self, request): - charges = [] - index = 0 - total = 0 - while True: - prefix = 'bank_account_%s' % index - uri = request.POST.get(prefix) - if not uri: - break - description = request.POST.get('%s_description' % prefix) - amount = float(request.POST.get('%s_amount' % prefix)) - amount = int(amount * 100) - bank_account = BankAccount.objects.get(pk=uri) - total += amount - charges.append( - (bank_account, amount, description) - ) - index += 1 - balanced.bust_cache() - escrow = balanced.Marketplace.my_marketplace.in_escrow - if total > escrow: - raise Exception('You have insufficient funds.') - for bank_account, amount, description in charges: - bank_account.credit(amount, description) - return redirect(urlresolvers.reverse('admin:index')) + urlpatterns = patterns('django_balanced.admin', + url(r'^bulk_pay/$', views.AdminBulkPay.as_view(), + name='bank_account_bulk_pay') + ) + super(BankAccountAdmin, self).get_urls() + return urlpatterns def save_model(self, request, obj, form, change): - data = form.data - obj.name = data['name'] - obj.account_number = data['account_number'] - obj.routing_number = data['routing_number'] - obj.type = data['type'] - if data['user']: - obj.user = User.objects.get(pk=data['user']) - super(BalancedAdmin, self).save_model(request, obj, form, change) - - -class CreditAdminForm(forms.ModelForm): - amount = forms.DecimalField(max_digits=10, required=True) - description = forms.CharField(max_length=255, required=False) - bank_account = forms.ModelChoiceField(queryset=BankAccount.objects) - - class Meta: - model = Credit - - def clean(self): - if not self.is_valid(): - return self.cleaned_data - data = self.cleaned_data - balanced.bust_cache() - escrow = balanced.Marketplace.my_marketplace.in_escrow - amount = int(float(data['amount']) * 100) - if amount > escrow: - raise forms.ValidationError('You have insufficient funds to cover ' - 'this transfer.') - return data + if isinstance(form, self.add_form): + data = form.cleaned_data + obj.name = data['name'] + obj.account_number = data['account_number'] + obj.routing_number = data['routing_number'] + obj.type = data['type'] + if data['user']: + obj.user = data['user'] + super(BankAccountAdmin, self).save_model(request, obj, form, change) +admin.site.register(BankAccount, BankAccountAdmin) -class CreditAdmin(BalancedAdmin): - add_fields = ('amount', 'bank_account', 'description') - edit_fields = (None) - list_display = ['user', 'bank_account', 'amount', - 'description', 'status', 'dashboard_link'] - search_fields = ['amount', 'description', 'status'] - list_filter = ['user', 'status'] - form = CreditAdminForm - def get_form(self, request, obj=None, **kwargs): - if obj: - self.exclude = ('amount',) - return super(CreditAdmin, self).get_form(request, obj=None, **kwargs) +class CreditAdmin(BalancedAdmin): + add_form = CreditAddForm + list_display = ('user', 'bank_account', 'amount', 'description', + 'status', 'dashboard_link') + list_filter = ('user', 'status') + search_fields = ('amount', 'description', 'status') def save_model(self, request, obj, form, change): - data = form.data - amount = int(float(data['amount']) * 100) - bank_account = BankAccount.objects.get(pk=data['bank_account']) - obj.amount = amount - obj.bank_account = bank_account - obj.user = bank_account.user - obj.description = data['description'] - obj.save() + if isinstance(form, self.add_form): + data = form.cleaned_data + obj.amount = int(data['amount'] * 100) + obj.bank_account = data['bank_account'] + obj.user = data['bank_account'].user + obj.description = data['description'] + return super(CreditAdmin, self).save_model(request, obj, form, change) - -admin.site.register(BankAccount, BankAccountAdmin) admin.site.register(Credit, CreditAdmin) diff --git a/django_balanced/apps.py b/django_balanced/apps.py new file mode 100644 index 0000000..da3ab9e --- /dev/null +++ b/django_balanced/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class DjangoBalancedConfig(AppConfig): + name = 'django_balanced' + + def ready(self): + print 'HELLO' + from . import listeners diff --git a/django_balanced/context_processors.py b/django_balanced/context_processors.py index 5dc8509..6ac3f86 100644 --- a/django_balanced/context_processors.py +++ b/django_balanced/context_processors.py @@ -9,7 +9,7 @@ def balanced_settings(request): 'BALANCED': { 'MARKETPLACE_URI': balanced.Marketplace.my_marketplace.uri, 'DASHBOARD_URL': settings.BALANCED['DASHBOARD_URL'], - 'API_URL': settings.BALANCED['DASHBOARD_URL'], + 'API_URL': settings.BALANCED['API_URL'], }, } diff --git a/django_balanced/forms.py b/django_balanced/forms.py new file mode 100644 index 0000000..89733bd --- /dev/null +++ b/django_balanced/forms.py @@ -0,0 +1,59 @@ +import balanced + +from decimal import Decimal +from django import forms + +from .models import BankAccount, Credit + + +class BankAccountAddForm(forms.ModelForm): + name = forms.CharField(max_length=255) + account_number = forms.CharField(max_length=255) + routing_number = forms.CharField(max_length=255) + type = forms.ChoiceField(choices=( + ('savings', 'savings'), ('checking', 'checking') + )) + + class Meta: + fields = ('user',) + model = BankAccount + + +class BankAccountChangeForm(forms.ModelForm): + class Meta: + fields = ('user',) + model = BankAccount + + +class CreditAddForm(forms.ModelForm): + amount = forms.DecimalField(max_digits=10, decimal_places=2, required=True) + description = forms.CharField(max_length=255, required=False) + bank_account = forms.ModelChoiceField(queryset=BankAccount.objects) + + class Meta: + model = Credit + + def clean(self): + if not self.is_valid(): + return self.cleaned_data + data = self.cleaned_data + escrow = balanced.Marketplace.my_marketplace.in_escrow + amount = int(data['amount'] * 100) + if amount > escrow: + error = 'You have insufficient funds to cover this transfer.' + raise forms.ValidationError(error) + return data + + +class PayoutForm(forms.Form): + bank_account = forms.ModelChoiceField(queryset=BankAccount.objects, + widget=forms.HiddenInput) + amount = forms.DecimalField(decimal_places=2, min_value=Decimal('.50')) + description = forms.CharField() + + def save(self): + assert self.is_valid() + return self.cleaned_data['bank_account'].credit( + self.cleaned_data['amount'] * 100, + self.cleaned_data['description'] + ) diff --git a/django_balanced/formsets.py b/django_balanced/formsets.py new file mode 100644 index 0000000..140e29f --- /dev/null +++ b/django_balanced/formsets.py @@ -0,0 +1,20 @@ +import balanced + +from django import forms +from django.forms.models import BaseFormSet, formset_factory + +from .forms import PayoutForm + + +class BaseBulkPayoutFormSet(BaseFormSet): + def clean(self): + if any(self.errors): + return + escrow = balanced.Marketplace.my_marketplace.in_escrow + total = sum([form.cleaned_data['amount'] for form in self.forms]) * 100 + if total > escrow: + error = 'You have insufficient funds to cover this payout.' + raise forms.ValidationError(error) + +BulkPayoutFormSet = formset_factory(PayoutForm, + formset=BaseBulkPayoutFormSet, extra=0) diff --git a/django_balanced/listeners.py b/django_balanced/listeners.py new file mode 100644 index 0000000..02b3135 --- /dev/null +++ b/django_balanced/listeners.py @@ -0,0 +1,31 @@ +from __future__ import unicode_literals + +from django.dispatch import receiver +from django.db.models import signals + +try: + from django.contrib.auth import get_user_model +except ImportError: + from django.contrib.auth.models import User + get_user_model = lambda: User + +try: + post_sync = signals.post_migrate +except AttributeError: + post_sync = signals.post_syncdb + + +# This will create an account per user when they are next saved. Subsequent +# saves will not make a network call. +@receiver(signals.post_save, sender=get_user_model()) +def create_user_profile(sender, instance, created, **kwargs): + from .models import Account + Account.objects.get_or_create(user=instance) + + +# Sync with Balanced when the database is synced +@receiver(post_sync, dispatch_uid='django_balanced.listeners.sync_balanced') +def sync_balanced(sender, **kwargs): + from .models import BankAccount, Credit + BankAccount.sync() + Credit.sync() diff --git a/django_balanced/management/__init__.py b/django_balanced/management/__init__.py index 27809ed..fdd3485 100644 --- a/django_balanced/management/__init__.py +++ b/django_balanced/management/__init__.py @@ -19,16 +19,16 @@ def sync_balanced(app, created_models, verbosity, db, **kwargs): Credit.sync() signals.post_syncdb.connect( - sync_balanced, - sender=models, + sync_balanced, + sender=models, dispatch_uid="django_balanced.management.sync_balanced" ) # the pre_syncdb signal is supported in a later version # only connect to it when it exists if hasattr(signals, 'pre_syncdb'): signals.pre_syncdb.connect( - configure_balanced, - sender=models, + configure_balanced, + sender=models, dispatch_uid="django_balanced.management.pre_syncdb" ) else: diff --git a/django_balanced/middleware.py b/django_balanced/middleware.py deleted file mode 100644 index eac0932..0000000 --- a/django_balanced/middleware.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import unicode_literals - -import balanced -from django.conf import settings - - -class BalancedMiddleware(object): - - def process_request(*_): - balanced.configure(settings.BALANCED['API_KEY']) diff --git a/django_balanced/models.py b/django_balanced/models.py index 05f3d94..13a02c2 100644 --- a/django_balanced/models.py +++ b/django_balanced/models.py @@ -4,10 +4,22 @@ import balanced from django.conf import settings -from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.db import models -from django.db.models.signals import post_save + +from .settings import BALANCED + +try: + from django import apps +except ImportError: + # We're in Django <1.7 and can't rely on the apps registry. + # Fall back to importing listeners here. + from . import listeners + +if BALANCED.get('API_KEY'): + balanced.configure(BALANCED['API_KEY']) + +AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') class BalancedException(Exception): @@ -25,7 +37,7 @@ class Meta: def dashboard_link(self): return 'View on Balanced' % ( - settings.BALANCED['DASHBOARD_URL'], + BALANCED['DASHBOARD_URL'], self.uri[3:] ) dashboard_link.allow_tags = True @@ -54,7 +66,7 @@ def _sync(self, obj): class BankAccount(BalancedResource): _resource = balanced.BankAccount - user = models.ForeignKey(User, + user = models.ForeignKey(AUTH_USER_MODEL, related_name='bank_accounts', null=True) account_number = models.CharField(editable=False, max_length=255) @@ -64,7 +76,7 @@ class BankAccount(BalancedResource): type = models.CharField(editable=False, max_length=255) class Meta: -# app_label = 'Balanced' + # app_label = 'Balanced' db_table = 'balanced_bank_accounts' def __unicode__(self): @@ -112,7 +124,7 @@ def credit(self, amount, description=None): class Card(BalancedResource): _resource = balanced.Card - user = models.ForeignKey(User, + user = models.ForeignKey(AUTH_USER_MODEL, related_name='cards', null=False) name = models.CharField(editable=False, max_length=255) @@ -122,7 +134,7 @@ class Card(BalancedResource): brand = models.CharField(editable=False, max_length=255) class Meta: - # app_label = 'Balanced' + # app_label = 'Balanced' db_table = 'balanced_cards' @classmethod @@ -161,7 +173,7 @@ def debit(self, amount, description): class Credit(BalancedResource): _resource = balanced.Credit - user = models.ForeignKey(User, + user = models.ForeignKey(AUTH_USER_MODEL, related_name='credits', editable=False, null=True) @@ -175,7 +187,7 @@ class Credit(BalancedResource): status = models.CharField(editable=False, max_length=255) class Meta: -# app_label = 'Balanced' + # app_label = 'Balanced' db_table = 'balanced_credits' def save(self, **kwargs): @@ -208,7 +220,7 @@ def delete(self, using=None): class Debit(BalancedResource): _resource = balanced.Debit - user = models.ForeignKey(User, + user = models.ForeignKey(AUTH_USER_MODEL, related_name='debits', null=False) amount = models.DecimalField(editable=False, @@ -220,7 +232,7 @@ class Debit(BalancedResource): editable=False) class Meta: - # app_label = 'Balanced' + # app_label = 'Balanced' db_table = 'balanced_debits' def save(self, **kwargs): @@ -252,7 +264,8 @@ def delete(self, using=None): class Account(BalancedResource): _resource = balanced.Account - user = models.OneToOneField(User, related_name='balanced_account') + user = models.OneToOneField(AUTH_USER_MODEL, + related_name='balanced_account') class Meta: db_table = 'balanced_accounts' @@ -283,12 +296,3 @@ def debit(self, amount, description, card=None): def delete(self, using=None): raise NotImplemented - - -# this will create an account per user when they are next saved. subsequent -# saves will not make a network call. -def create_user_profile(sender, instance, created, **kwargs): - Account.objects.get_or_create(user=instance) - - -post_save.connect(create_user_profile, sender=User) diff --git a/django_balanced/settings.py b/django_balanced/settings.py index 290ae47..bd099f5 100644 --- a/django_balanced/settings.py +++ b/django_balanced/settings.py @@ -6,34 +6,15 @@ LOGGER = logging.getLogger(__name__) -BALANCED = getattr(settings, 'BALANCED', {}) -BALANCED.setdefault('DASHBOARD_URL', 'https://www.balancedpayments.com') -BALANCED.setdefault('API_URL', 'https://api.balancedpayments.com') +BALANCED = { + 'DASHBOARD_URL': os.environ.get('BALANCED_DASHBOARD_URL', + 'https://dashboard.balancedpayments.com'), + 'API_URL': os.environ.get('BALANCED_API_URL', + 'https://api.balancedpayments.com'), + 'API_KEY': os.environ.get('BALANCED_API_KEY'), +} -installed_apps = getattr(settings, 'INSTALLED_APPS', ()) -ctx_processors = getattr(settings, 'TEMPLATE_CONTEXT_PROCESSORS', []) -middlware_clss = getattr(settings, 'MIDDLEWARE_CLASSES', ()) +BALANCED.update(getattr(settings, 'BALANCED', {})) -installed_apps += ( - 'django_balanced', -) -ctx_processors = [ - 'django_balanced.context_processors.balanced_library', - 'django_balanced.context_processors.balanced_settings', - 'django.contrib.auth.context_processors.auth', -] -middlware_clss += ( - 'django_balanced.middleware.BalancedMiddleware', -) - -settings.INSTALLED_APPS = installed_apps -settings.TEMPLATE_CONTEXT_PROCESSORS = ctx_processors -settings.MIDDLEWARE_CLASSES = middlware_clss - -PROJECT_PATH = os.path.realpath(os.path.dirname(__file__)) -settings.TEMPLATE_DIRS += ( - PROJECT_PATH + '/' + 'templates', -) - -if not BALANCED.get('API_KEY'): +if BALANCED['API_KEY'] is None: LOGGER.error('You must set the BALANCED_API_KEY environment variable.') diff --git a/django_balanced/templates/admin/django_balanced/credit/change_form.html b/django_balanced/templates/admin/django_balanced/credit/change_form.html new file mode 100644 index 0000000..9038a12 --- /dev/null +++ b/django_balanced/templates/admin/django_balanced/credit/change_form.html @@ -0,0 +1,13 @@ +{% extends "admin/change_form.html" %} + +{% block content %} + {% if add %} + {{ block.super }} + {% else %} + {# You cannot make changes to Credit objects #} +
Credits cannot be changed once created.
+ +How much do you want to pay to the following bank accounts:
+ +