├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── code_generation_tests.py │ ├── allocation_tests.py │ ├── security_tests.py │ ├── transfer_tests.py │ ├── facade_tests.py │ └── model_tests.py ├── functional │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── rest_tests.py │ └── dashboard_tests.py ├── urls.py ├── conftest.py └── settings.py ├── sandbox ├── __init__.py ├── apps │ ├── __init__.py │ ├── checkout │ │ ├── __init__.py │ │ ├── app.py │ │ └── views.py │ ├── shipping │ │ ├── __init__.py │ │ ├── models.py │ │ └── repository.py │ └── app.py ├── settings_budgets.py ├── manage.py ├── templates │ └── checkout │ │ ├── thank_you.html │ │ ├── preview.html │ │ └── payment_details.html ├── fixtures │ └── users.json ├── urls.py └── settings.py ├── src └── oscar_accounts │ ├── api │ ├── __init__.py │ ├── errors.py │ ├── decorators.py │ ├── app.py │ └── views.py │ ├── checkout │ ├── __init__.py │ ├── allocation.py │ ├── gateway.py │ └── forms.py │ ├── dashboard │ ├── __init__.py │ ├── app.py │ ├── reports.py │ ├── forms.py │ └── views.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── close_expired_accounts.py │ │ └── oscar_accounts_init.py │ ├── migrations │ ├── __init__.py │ ├── 0002_core_accounts.py │ ├── 0003_alter_ip_address.py │ └── 0001_initial.py │ ├── config.py │ ├── core.py │ ├── exceptions.py │ ├── __init__.py │ ├── forms.py │ ├── codes.py │ ├── models.py │ ├── setup.py │ ├── templates │ └── accounts │ │ ├── dashboard │ │ ├── account_thaw.html │ │ ├── account_freeze.html │ │ ├── account_top_up.html │ │ ├── account_withdraw.html │ │ ├── transfer_detail.html │ │ ├── reports │ │ │ ├── deferred_income.html │ │ │ └── profit_loss.html │ │ ├── account_detail.html │ │ ├── transfer_list.html │ │ ├── partials │ │ │ └── account_detail.html │ │ ├── account_form.html │ │ └── account_list.html │ │ └── balance_check.html │ ├── test_factories.py │ ├── security.py │ ├── views.py │ ├── names.py │ ├── admin.py │ └── facade.py ├── requirements.testing.txt ├── requirements.sandbox.in ├── .coveragerc ├── MANIFEST.in ├── screenshots ├── dashboard-form.png ├── dashboard-detail.png ├── dashboard-accounts.png ├── dashboard-form.thumb.png ├── dashboard-transfers.png ├── dashboard-detail.thumb.png ├── dashboard-accounts.thumb.png └── dashboard-transfers.thumb.png ├── .gitignore ├── setup.cfg ├── runtests.py ├── tox.ini ├── CHANGELOG.rst ├── .editorconfig ├── .travis.yml ├── Makefile ├── requirements.sandbox.txt ├── LICENSE ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/apps/checkout/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/apps/shipping/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oscar_accounts/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.testing.txt: -------------------------------------------------------------------------------- 1 | -e .[test] 2 | -------------------------------------------------------------------------------- /src/oscar_accounts/checkout/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oscar_accounts/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oscar_accounts/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oscar_accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/oscar_accounts/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.sandbox.in: -------------------------------------------------------------------------------- 1 | Django>=1.8.11 2 | django-oscar==1.2 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = oscar_accounts 3 | omit = *migrations* 4 | -------------------------------------------------------------------------------- /sandbox/apps/shipping/models.py: -------------------------------------------------------------------------------- 1 | from oscar.apps.shipping.models import * # noqa 2 | -------------------------------------------------------------------------------- /sandbox/settings_budgets.py: -------------------------------------------------------------------------------- 1 | from settings import * 2 | 3 | ACCOUNTS_UNIT_NAME = 'Budget' 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | recursive-include src/oscar_accounts/templates *.html 4 | -------------------------------------------------------------------------------- /screenshots/dashboard-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-form.png -------------------------------------------------------------------------------- /screenshots/dashboard-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-detail.png -------------------------------------------------------------------------------- /screenshots/dashboard-accounts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-accounts.png -------------------------------------------------------------------------------- /screenshots/dashboard-form.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-form.thumb.png -------------------------------------------------------------------------------- /screenshots/dashboard-transfers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-transfers.png -------------------------------------------------------------------------------- /screenshots/dashboard-detail.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-detail.thumb.png -------------------------------------------------------------------------------- /screenshots/dashboard-accounts.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-accounts.thumb.png -------------------------------------------------------------------------------- /screenshots/dashboard-transfers.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/HEAD/screenshots/dashboard-transfers.thumb.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.sw[po] 3 | 4 | # Packaging 5 | dist/* 6 | build/* 7 | *.egg-info 8 | 9 | # Sandbox 10 | sandbox/db.sqlite 11 | settings_local.py 12 | 13 | # Testing 14 | .coverage 15 | .coveralls.yml 16 | .tox 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [pytest] 5 | python_files=test_*.py *tests.py 6 | testpaths = tests/ 7 | 8 | [flake8] 9 | max-line-length=99 10 | exclude=./accounts/**/migrations/*.py,tests/* 11 | ignore=E731 12 | -------------------------------------------------------------------------------- /src/oscar_accounts/config.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class OscarAccountsConfig(AppConfig): 6 | label = 'oscar_accounts' 7 | name = 'oscar_accounts' 8 | verbose_name = _('Accounts') 9 | -------------------------------------------------------------------------------- /sandbox/apps/checkout/app.py: -------------------------------------------------------------------------------- 1 | from oscar.apps.checkout import app 2 | 3 | from apps.checkout import views 4 | 5 | 6 | class CheckoutApplication(app.CheckoutApplication): 7 | payment_details_view = views.PaymentDetailsView 8 | 9 | 10 | application = CheckoutApplication() 11 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import sys 4 | 5 | import pytest 6 | 7 | # No logging 8 | logging.disable(logging.CRITICAL) 9 | 10 | 11 | if __name__ == '__main__': 12 | args = sys.argv[1:] 13 | result_code = pytest.main(args) 14 | sys.exit(result_code) 15 | -------------------------------------------------------------------------------- /sandbox/apps/app.py: -------------------------------------------------------------------------------- 1 | from oscar.app import Shop 2 | 3 | from apps.checkout.app import application as checkout_app 4 | 5 | 6 | class AccountsShop(Shop): 7 | # Override the checkout app so we can use our own views 8 | checkout_app = checkout_app 9 | 10 | 11 | application = AccountsShop() 12 | -------------------------------------------------------------------------------- /sandbox/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /src/oscar_accounts/management/commands/close_expired_accounts.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from oscar_accounts import facade 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Close all inactive card-accounts' 8 | 9 | def handle(self, *args, **options): 10 | facade.close_expired_accounts() 11 | -------------------------------------------------------------------------------- /src/oscar_accounts/core.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_model 2 | 3 | from oscar_accounts import names 4 | 5 | Account = get_model('oscar_accounts', 'Account') 6 | 7 | 8 | def redemptions_account(): 9 | return Account.objects.get(name=names.REDEMPTIONS) 10 | 11 | 12 | def lapsed_account(): 13 | return Account.objects.get(name=names.LAPSED) 14 | -------------------------------------------------------------------------------- /src/oscar_accounts/management/commands/oscar_accounts_init.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from oscar_accounts.setup import create_default_accounts 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Initialize oscar accounts default structure" 8 | 9 | def handle(self, *args, **options): 10 | create_default_accounts() 11 | -------------------------------------------------------------------------------- /src/oscar_accounts/exceptions.py: -------------------------------------------------------------------------------- 1 | class AccountException(Exception): 2 | pass 3 | 4 | 5 | class AccountNotEmpty(AccountException): 6 | pass 7 | 8 | 9 | class InsufficientFunds(AccountException): 10 | pass 11 | 12 | 13 | class InvalidAmount(AccountException): 14 | pass 15 | 16 | 17 | class InactiveAccount(AccountException): 18 | pass 19 | 20 | 21 | class ClosedAccount(AccountException): 22 | pass 23 | -------------------------------------------------------------------------------- /src/oscar_accounts/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | # Setting for template directory not found by app_directories.Loader. This 5 | # allows templates to be identified by two paths which enables a template to be 6 | # extended by a template with the same identifier. 7 | TEMPLATE_DIR = os.path.join( 8 | os.path.dirname(os.path.abspath(__file__)), 9 | 'templates/accounts') 10 | 11 | 12 | default_app_config = 'oscar_accounts.config.OscarAccountsConfig' 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = {py27,py33,py34}-{1.8,1.9} 8 | 9 | [testenv] 10 | commands = python runtests.py 11 | deps = -r{toxinidir}/requirements.testing.txt 12 | 1.8: django==1.8.11 13 | 1.9: django==1.9.4 14 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include 2 | from django.contrib import admin 3 | 4 | from oscar.app import application 5 | 6 | from oscar_accounts.dashboard.app import application as accounts_app 7 | from oscar_accounts.api.app import application as api_app 8 | 9 | admin.autodiscover() 10 | 11 | urlpatterns = patterns('', 12 | (r'^dashboard/accounts/', include(accounts_app.urls)), 13 | (r'^api/', include(api_app.urls)), 14 | (r'', include(application.urls)), 15 | ) 16 | -------------------------------------------------------------------------------- /src/oscar_accounts/migrations/0002_core_accounts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | def create_core_accounts(apps, schema_editor): 8 | # Please use `manage.py oscar_accounts_init` 9 | pass 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ('oscar_accounts', '0001_initial'), 16 | ] 17 | 18 | operations = [ 19 | migrations.RunPython(create_core_accounts) 20 | ] 21 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 0.4 (2016-03-xx) 6 | ---------------- 7 | Note that this release is backwards incompatible! This is due to a renaming 8 | of the module from `accounts` to `oscar_accounts`. 9 | 10 | Features 11 | - Updated to be compatible with Oscar 1.2 (Django 1.8 / 1.9) 12 | - Updated dashboard to support Bootstrap 3 13 | - Refactored test infrastructure 14 | 15 | 16 | 0.3 (2013-08-13) 17 | ---------------- 18 | - Include HTML templates in package 19 | - Dashboard templating fixes 20 | 21 | -------------------------------------------------------------------------------- /sandbox/templates/checkout/thank_you.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/checkout/thank_you.html' %} 2 | {% load i18n %} 3 | {% load currency_filters %} 4 | 5 | {% block payment_info %} 6 |
7 |

{% trans "Payment" %}

8 |

{% trans "Account allocations" %}

9 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /sandbox/apps/shipping/repository.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from oscar.apps.shipping.methods import FixedPrice, NoShippingRequired 4 | from oscar.apps.shipping.repository import Repository as CoreRepository 5 | 6 | # Dummy shipping methods 7 | method1 = FixedPrice(D('10.00')) 8 | method1.code = 'method1' 9 | method1.description = 'Ship by van' 10 | 11 | method2 = FixedPrice(D('20.00')) 12 | method2.code = 'method2' 13 | method2.description = 'Ship by boat' 14 | 15 | 16 | class Repository(CoreRepository): 17 | methods = (method1, method2,) 18 | -------------------------------------------------------------------------------- /src/oscar_accounts/migrations/0003_alter_ip_address.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('oscar_accounts', '0002_core_accounts'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='ipaddressrecord', 16 | name='ip_address', 17 | field=models.GenericIPAddressField(unique=True, verbose_name='IP address'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{css,js}] 4 | line_length = 119 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.html] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.py] 16 | line_length = 79 17 | multi_line_output = 4 18 | balanced_wrapping = true 19 | known_first_party = oscar_accounts,tests 20 | use_parentheses = true 21 | skip_glob=src/oscar_accounts/**/migrations/*.py,*/tests/**/*.py 22 | 23 | [*.yml] 24 | indent_size = 4 25 | 26 | [Makefile] 27 | indent_style = tab 28 | indent_size = 4 29 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import django 3 | 4 | import pytest 5 | from oscar_accounts import setup 6 | 7 | 8 | 9 | # It should be possible to just set DJANGO_SETTINGS_MODULE in setup.cfg 10 | # or pytest.ini, but it doesn't work because pytest tries to do some 11 | # magic by detecting a manage.py (which we don't have for our test suite). 12 | # So we need to go the manual route here. 13 | def pytest_configure(): 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 15 | django.setup() 16 | 17 | 18 | @pytest.fixture 19 | def default_accounts(): 20 | setup.create_default_accounts() 21 | -------------------------------------------------------------------------------- /src/oscar_accounts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import ugettext_lazy as _ 3 | from oscar.core.loading import get_model 4 | 5 | Account = get_model('oscar_accounts', 'Account') 6 | 7 | 8 | class AccountForm(forms.Form): 9 | code = forms.CharField(label=_("Account code")) 10 | 11 | def clean_code(self): 12 | code = self.cleaned_data['code'].strip() 13 | try: 14 | self.account = Account.objects.get( 15 | code=code) 16 | except Account.DoesNotExist: 17 | raise forms.ValidationError(_( 18 | "No account found with this code")) 19 | return code 20 | -------------------------------------------------------------------------------- /src/oscar_accounts/api/errors.py: -------------------------------------------------------------------------------- 1 | # Account creation errors 2 | CANNOT_CREATE_ACCOUNT = 'C100' 3 | AMOUNT_TOO_LOW = 'C101' 4 | AMOUNT_TOO_HIGH = 'C102' 5 | 6 | # Redemption errors 7 | CANNOT_CREATE_TRANSFER = 'T100' 8 | INSUFFICIENT_FUNDS = 'T101' 9 | ACCOUNT_INACTIVE = 'T102' 10 | 11 | MESSAGES = { 12 | CANNOT_CREATE_ACCOUNT: "Cannot create account", 13 | AMOUNT_TOO_LOW: "Amount too low", 14 | AMOUNT_TOO_HIGH: "Amount too high", 15 | CANNOT_CREATE_TRANSFER: "Cannot create transfer", 16 | INSUFFICIENT_FUNDS: "Insufficient funds", 17 | ACCOUNT_INACTIVE: "Account inactive", 18 | } 19 | 20 | 21 | def message(code): 22 | return MESSAGES.get(code, "Unknown error") 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | python: 6 | - 2.7 7 | - 3.3 8 | - 3.4 9 | - 3.5 10 | 11 | env: 12 | - DJANGO=Django==1.8.11 13 | - DJANGO=Django==1.9.4 14 | 15 | matrix: 16 | exclude: 17 | - python: 3.5 18 | 19 | matrix: 20 | exclude: 21 | - python: 3.2 22 | env: "DJANGO=Django==1.9.4" 23 | - python: 3.3 24 | env: "DJANGO=Django==1.9.4" 25 | 26 | 27 | before_install: 28 | - pip install codecov 29 | 30 | install: 31 | - pip install --pre $DJANGO django-oscar==1.2rc1 32 | - pip install -e .[test] 33 | 34 | script: 35 | - py.test --cov=oscar_accounts 36 | 37 | after_success: 38 | - codecov 39 | -------------------------------------------------------------------------------- /sandbox/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "username": "admin", 7 | "first_name": "", 8 | "last_name": "", 9 | "is_active": true, 10 | "is_superuser": true, 11 | "is_staff": true, 12 | "last_login": "2012-09-18T14:07:36.664", 13 | "groups": [], 14 | "user_permissions": [], 15 | "password": "pbkdf2_sha256$10000$kSAP1HgP4iMX$d0VcEILa8vmkAwFOM3b8iZwyjfmySWAgcnRHFWYaYeM=", 16 | "email": "superuser@example.com", 17 | "date_joined": "2012-09-18T14:07:36.664" 18 | } 19 | } 20 | ] -------------------------------------------------------------------------------- /src/oscar_accounts/codes.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from oscar.core.loading import get_model 5 | 6 | Account = get_model('oscar_accounts', 'Account') 7 | 8 | 9 | def generate(size=12, chars=None): 10 | """ 11 | Generate a new account code 12 | 13 | :size: Length of code 14 | :chars: Character set to choose from 15 | """ 16 | if chars is None: 17 | chars = string.ascii_uppercase + string.digits 18 | code = ''.join(random.choice(chars) for x in range(size)) 19 | # Ensure code does not aleady exist 20 | try: 21 | Account.objects.get(code=code) 22 | except Account.DoesNotExist: 23 | return code 24 | return generate(size=size, chars=chars) 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install test sandbox clean update-requirements 2 | 3 | install: 4 | pip install --pre -e .[test] 5 | 6 | test: 7 | ./runtests.py 8 | 9 | sandbox: install 10 | pip install -r requirements.sandbox.txt 11 | -rm sandbox/db.sqlite 12 | sandbox/manage.py migrate 13 | sandbox/manage.py loaddata sandbox/fixtures/users.json 14 | 15 | clean: 16 | find . -type f -name "*.pyc" -delete 17 | rm -rf htmlcov *.egg-info *.pdf dist 18 | 19 | update-requirements: 20 | pip-compile --upgrade --rebuild --pre requirements.sandbox.in || echo "\n\nPlease install pip-compile: pip install pip-tools" 21 | 22 | release: 23 | pip install twine wheel 24 | rm -rf dist/* 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /sandbox/templates/checkout/preview.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/checkout/preview.html' %} 2 | {% load url from future %} 3 | {% load currency_filters %} 4 | {% load i18n %} 5 | 6 | {% block payment_method %} 7 |
8 |
9 |

{% trans "Payment" %}

10 |
11 |
12 |

{% trans "Account allocations" %}

13 | 18 | 21 |
22 |
23 | {% endblock payment_method %} 24 | -------------------------------------------------------------------------------- /src/oscar_accounts/models.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import is_model_registered 2 | 3 | from oscar_accounts import abstract_models 4 | 5 | if not is_model_registered('oscar_accounts', 'AccountType'): 6 | class AccountType(abstract_models.AccountType): 7 | pass 8 | 9 | 10 | if not is_model_registered('oscar_accounts', 'Account'): 11 | class Account(abstract_models.Account): 12 | pass 13 | 14 | 15 | if not is_model_registered('oscar_accounts', 'Transfer'): 16 | class Transfer(abstract_models.Transfer): 17 | pass 18 | 19 | 20 | if not is_model_registered('oscar_accounts', 'Transaction'): 21 | class Transaction(abstract_models.Transaction): 22 | pass 23 | 24 | 25 | if not is_model_registered('oscar_accounts', 'IPAddressRecord'): 26 | class IPAddressRecord(abstract_models.IPAddressRecord): 27 | pass 28 | -------------------------------------------------------------------------------- /tests/unit/code_generation_tests.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | from django.test import TestCase 4 | 5 | from oscar_accounts import codes 6 | 7 | 8 | class TestCodeGeneration(TestCase): 9 | 10 | def test_create_codes_of_correct_length(self): 11 | for size in [4, 6, 8]: 12 | code = codes.generate(size=size) 13 | self.assertTrue(size, len(code)) 14 | 15 | def test_create_codes_using_correct_default_character_set(self): 16 | code = codes.generate() 17 | chars = string.ascii_uppercase + string.digits 18 | for char in code: 19 | self.assertTrue(char in chars) 20 | 21 | def test_can_create_codes_using_custom_character_set(self): 22 | chars = string.ascii_uppercase 23 | code = codes.generate(chars=chars) 24 | for char in code: 25 | self.assertTrue(char in chars) 26 | -------------------------------------------------------------------------------- /src/oscar_accounts/checkout/allocation.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | 4 | class Allocations(object): 5 | 6 | def __init__(self): 7 | self._allocations = {} 8 | 9 | def add(self, code, amount): 10 | if self.contains(code): 11 | self._allocations[code] += amount 12 | else: 13 | self._allocations[code] = amount 14 | 15 | def remove(self, code): 16 | if self.contains(code): 17 | del self._allocations[code] 18 | 19 | @property 20 | def total(self): 21 | total = D('0.00') 22 | for allocation in self._allocations.values(): 23 | total += allocation 24 | return total 25 | 26 | def contains(self, code): 27 | return code in self._allocations 28 | 29 | def __len__(self): 30 | return len(self._allocations) 31 | 32 | def items(self): 33 | return self._allocations.items() 34 | -------------------------------------------------------------------------------- /tests/unit/allocation_tests.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django.test import TestCase 4 | 5 | from oscar_accounts.checkout.allocation import Allocations 6 | 7 | 8 | class TestAllocations(TestCase): 9 | 10 | def setUp(self): 11 | self.allocations = Allocations() 12 | 13 | def test_have_default_total_of_zero(self): 14 | self.assertEqual(D('0.00'), self.allocations.total) 15 | 16 | def test_has_items_interface(self): 17 | self.allocations.add('A', D('10.00')) 18 | 19 | for code, amount in self.allocations.items(): 20 | self.assertEqual('A', code) 21 | self.assertEqual(D('10.00'), amount) 22 | 23 | def test_allow_items_to_be_removed(self): 24 | self.allocations.add('A', D('10.00')) 25 | self.assertEqual(D('10.00'), self.allocations.total) 26 | self.allocations.remove('A') 27 | self.assertEqual(D('0.00'), self.allocations.total) 28 | -------------------------------------------------------------------------------- /tests/unit/security_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test.client import RequestFactory 3 | 4 | from oscar_accounts import security 5 | 6 | 7 | class TestBruteForceAPI(TestCase): 8 | """Brute force API""" 9 | 10 | def setUp(self): 11 | factory = RequestFactory() 12 | self.request = factory.post('/') 13 | 14 | def test_does_not_block_by_default(self): 15 | self.assertFalse(security.is_blocked(self.request)) 16 | 17 | def test_blocks_after_freeze_threshold(self): 18 | for __ in range(3): 19 | security.record_failed_request(self.request) 20 | self.assertTrue(security.is_blocked(self.request)) 21 | 22 | def test_resets_after_success(self): 23 | for __ in range(2): 24 | security.record_failed_request(self.request) 25 | security.record_successful_request(self.request) 26 | security.record_failed_request(self.request) 27 | self.assertFalse(security.is_blocked(self.request)) 28 | -------------------------------------------------------------------------------- /src/oscar_accounts/setup.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_model 2 | 3 | 4 | def create_default_accounts(): 5 | """Create the default structure""" 6 | from oscar_accounts import names 7 | AccountType = get_model('oscar_accounts', 'AccountType') 8 | 9 | assets = AccountType.add_root(name=names.ASSETS) 10 | sales = assets.add_child(name=names.SALES) 11 | 12 | sales.accounts.create(name=names.REDEMPTIONS) 13 | sales.accounts.create(name=names.LAPSED) 14 | 15 | cash = assets.add_child(name=names.CASH) 16 | cash.accounts.create(name=names.BANK, credit_limit=None) 17 | 18 | unpaid = assets.add_child(name=names.UNPAID_ACCOUNT_TYPE) 19 | for name in names.UNPAID_ACCOUNTS: 20 | unpaid.accounts.create(name=name, credit_limit=None) 21 | 22 | # Create liability accounts 23 | liabilities = AccountType.add_root(name=names.LIABILITIES) 24 | income = liabilities.add_child(name=names.DEFERRED_INCOME) 25 | for i, name in enumerate(names.DEFERRED_INCOME_ACCOUNT_TYPES): 26 | income.add_child(name=name) 27 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_thaw.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans "Thaw account" %} #{{ account.id }} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 19 | {% endblock %} 20 | 21 | {% block headertext %}{% trans "Thaw account?" %}{% endblock %} 22 | 23 | {% block dashboard_content %} 24 | {% include 'accounts/dashboard/partials/account_detail.html' %} 25 |
26 | {% csrf_token %} 27 | {{ form.as_p }} 28 | 29 |
30 | {% endblock dashboard_content %} 31 | -------------------------------------------------------------------------------- /src/oscar_accounts/test_factories.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | import factory 4 | from oscar.core.loading import get_model 5 | 6 | 7 | class AccountFactory(factory.DjangoModelFactory): 8 | start_date = None 9 | end_date = None 10 | 11 | class Meta: 12 | model = get_model('oscar_accounts', 'Account') 13 | 14 | 15 | class TransferFactory(factory.DjangoModelFactory): 16 | source = factory.SubFactory(AccountFactory) 17 | destination = factory.SubFactory(AccountFactory) 18 | 19 | class Meta: 20 | model = get_model('oscar_accounts', 'Transfer') 21 | 22 | @classmethod 23 | def _create(cls, model_class, *args, **kwargs): 24 | instance = model_class(**kwargs) 25 | instance.save() 26 | return instance 27 | 28 | 29 | class TransactionFactory(factory.DjangoModelFactory): 30 | amount = D('10.00') 31 | transfer = factory.SubFactory( 32 | TransferFactory, amount=factory.SelfAttribute('..amount')) 33 | account = factory.SubFactory(AccountFactory) 34 | 35 | class Meta: 36 | model = get_model('oscar_accounts', 'Transaction') 37 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls import include, url 3 | from django.conf.urls.static import static 4 | from django.contrib import admin 5 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 6 | from django.views.generic import TemplateView 7 | 8 | from apps.app import application 9 | from oscar_accounts.dashboard.app import application as accounts_app 10 | from oscar_accounts.views import AccountBalanceView 11 | 12 | admin.autodiscover() 13 | 14 | urlpatterns = [ 15 | url(r'^admin/', include(admin.site.urls)), 16 | url(r'^i18n/', include('django.conf.urls.i18n')), 17 | url(r'^giftcard-balance/', AccountBalanceView.as_view(), 18 | name="account-balance"), 19 | url(r'^dashboard/accounts/', include(accounts_app.urls)), 20 | url(r'', include(application.urls)), 21 | ] 22 | 23 | if settings.DEBUG: 24 | urlpatterns += staticfiles_urlpatterns() 25 | urlpatterns += static(settings.MEDIA_URL, 26 | document_root=settings.MEDIA_ROOT) 27 | urlpatterns += [ 28 | url(r'^404$', TemplateView.as_view(template_name='404.html')), 29 | url(r'^500$', TemplateView.as_view(template_name='500.html')) 30 | ] 31 | -------------------------------------------------------------------------------- /src/oscar_accounts/security.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_model 2 | 3 | IPAddressRecord = get_model('oscar_accounts', 'IPAddressRecord') 4 | 5 | 6 | def record_failed_request(request): 7 | record, __ = IPAddressRecord.objects.get_or_create( 8 | ip_address=request.META['REMOTE_ADDR']) 9 | record.increment_failures() 10 | 11 | 12 | def record_successful_request(request): 13 | try: 14 | record, __ = IPAddressRecord.objects.get_or_create( 15 | ip_address=request.META['REMOTE_ADDR']) 16 | except IPAddressRecord.DoesNotExist: 17 | return 18 | record.reset() 19 | 20 | 21 | def record_blocked_request(request): 22 | try: 23 | record, __ = IPAddressRecord.objects.get_or_create( 24 | ip_address=request.META['REMOTE_ADDR']) 25 | except IPAddressRecord.DoesNotExist: 26 | return 27 | record.increment_blocks() 28 | 29 | 30 | def is_blocked(request): 31 | try: 32 | record = IPAddressRecord.objects.get( 33 | ip_address=request.META['REMOTE_ADDR']) 34 | except IPAddressRecord.DoesNotExist: 35 | record = IPAddressRecord( 36 | ip_address=request.META['REMOTE_ADDR']) 37 | return record.is_blocked() 38 | -------------------------------------------------------------------------------- /src/oscar_accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.views import generic 2 | 3 | from oscar_accounts import security 4 | from oscar_accounts.forms import AccountForm 5 | 6 | 7 | class AccountBalanceView(generic.FormView): 8 | form_class = AccountForm 9 | template_name = 'accounts/balance_check.html' 10 | 11 | def get_context_data(self, **kwargs): 12 | ctx = super(AccountBalanceView, self).get_context_data(**kwargs) 13 | ctx['is_blocked'] = security.is_blocked(self.request) 14 | return ctx 15 | 16 | def post(self, request, *args, **kwargs): 17 | # Check for blocked users before trying to validate form 18 | if security.is_blocked(request): 19 | return self.get(request, *args, **kwargs) 20 | return super(AccountBalanceView, self).post(request, *args, **kwargs) 21 | 22 | def form_invalid(self, form): 23 | security.record_failed_request(self.request) 24 | return super(AccountBalanceView, self).form_invalid(form) 25 | 26 | def form_valid(self, form): 27 | security.record_successful_request(self.request) 28 | ctx = self.get_context_data(form=form) 29 | ctx['account'] = form.account 30 | return self.render_to_response(ctx) 31 | -------------------------------------------------------------------------------- /tests/functional/dashboard_tests.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django_webtest import WebTest 4 | from django.core.urlresolvers import reverse 5 | from django.contrib.auth.models import User 6 | from oscar.test.factories import UserFactory 7 | 8 | from oscar_accounts import models 9 | from tests.conftest import default_accounts 10 | 11 | 12 | class TestAStaffMember(WebTest): 13 | 14 | def setUp(self): 15 | default_accounts() 16 | self.staff = UserFactory(is_staff=True) 17 | 18 | def test_can_browse_accounts(self): 19 | list_page = self.app.get(reverse('accounts-list'), user=self.staff) 20 | self.assertEqual(200, list_page.status_code) 21 | 22 | def test_can_create_a_new_account(self): 23 | list_page = self.app.get(reverse('accounts-list'), user=self.staff) 24 | create_page = list_page.click(linkid="create_new_account") 25 | create_page.form['name'] = 'Test account' 26 | create_page.form['initial_amount'] = '120.00' 27 | response = create_page.form.submit() 28 | self.assertEqual(302, response.status_code) 29 | 30 | acc = models.Account.objects.get(name='Test account') 31 | self.assertEqual(D('120.00'), acc.balance) 32 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_freeze.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% blocktrans with id=account.id %}Freeze account {{ id }}{% endblocktrans %} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 22 | {% endblock %} 23 | 24 | {% block headertext %}{% trans "Freeze account?" %}{% endblock %} 25 | 26 | {% block dashboard_content %} 27 | {% include 'accounts/dashboard/partials/account_detail.html' %} 28 | 29 |
30 | {% csrf_token %} 31 | {{ form.as_p }} 32 | 33 | {% trans "or" %} {% trans "cancel" %}. 34 |
35 | {% endblock dashboard_content %} 36 | -------------------------------------------------------------------------------- /requirements.sandbox.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # Make changes in requirements.sandbox.in, then run this to update: 4 | # 5 | # pip-compile requirements.sandbox.in 6 | # 7 | Babel==1.3 # via django-oscar 8 | django-extra-views==0.6.4 # via django-oscar 9 | django-haystack==2.4.1 # via django-oscar 10 | django-oscar==1.2 11 | django-tables2==1.0.7 # via django-oscar 12 | django-treebeard==4.0 # via django-oscar 13 | django-widget-tweaks==1.4.1 # via django-oscar 14 | Django==1.8.11 # via django-extra-views, django-haystack, django-oscar, django-tables2, django-treebeard 15 | factory-boy==2.6.1 # via django-oscar 16 | fake-factory==0.5.7 # via factory-boy 17 | funcsigs==0.4 # via mock 18 | ipaddress==1.0.16 # via fake-factory 19 | mock==1.3.0 # via django-oscar 20 | pbr==1.8.1 # via mock 21 | phonenumbers==7.2.7 # via django-oscar 22 | pillow==3.1.1 # via django-oscar 23 | purl==1.2 # via django-oscar 24 | python-dateutil==2.5.1 # via fake-factory 25 | pytz==2016.1 # via babel 26 | six==1.10.0 # via django-tables2, fake-factory, mock, purl, python-dateutil 27 | sorl-thumbnail==12.3 # via django-oscar 28 | Unidecode==0.4.19 # via django-oscar 29 | -------------------------------------------------------------------------------- /src/oscar_accounts/names.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | # The basic name of the account. Some projects will refer to them as 4 | # giftcards, budgets or credit allocations 5 | UNIT_NAME = getattr(settings, 'ACCOUNTS_UNIT_NAME', 'Account') 6 | UNIT_NAME_PLURAL = getattr(settings, 'ACCOUNTS_UNIT_NAME_PLURAL', 7 | "%ss" % UNIT_NAME) 8 | 9 | # Account where money is transferred to when an account is used to pay for an 10 | # order. 11 | REDEMPTIONS = getattr(settings, 'ACCOUNTS_REDEMPTIONS_NAME', 'Redemptions') 12 | 13 | # Account where money is transferred to when an account expires and is 14 | # automatically closed 15 | LAPSED = getattr(settings, 'ACCOUNTS_LAPSED_NAME', 'Lapsed accounts') 16 | 17 | UNPAID_ACCOUNTS = getattr(settings, 'ACCOUNTS_UNPAID_SOURCES', 18 | ('Unpaid source',)) 19 | 20 | # Account where money is transferred from when creating a giftcard 21 | BANK = getattr(settings, 'ACCOUNTS_BANK_NAME', "Bank") 22 | 23 | # Account types 24 | # ============= 25 | 26 | ASSETS = 'Assets' 27 | SALES = 'Sales' 28 | CASH = 'Cash' 29 | 30 | # Accounts that hold money waiting to be spent 31 | LIABILITIES = 'Liabilities' 32 | 33 | UNPAID_ACCOUNT_TYPE = "Unpaid accounts" 34 | 35 | DEFERRED_INCOME = "Deferred income" 36 | 37 | DEFERRED_INCOME_ACCOUNT_TYPES = getattr( 38 | settings, 'ACCOUNTS_DEFERRED_INCOME_ACCOUNT_TYPES', 39 | ('Dashboard created accounts',)) 40 | -------------------------------------------------------------------------------- /src/oscar_accounts/api/decorators.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import authenticate 5 | from django.http import HttpResponse 6 | 7 | 8 | def view_or_basicauth(view, request, *args, **kwargs): 9 | # Check for valid basic auth header 10 | if 'HTTP_AUTHORIZATION' in request.META: 11 | auth = request.META['HTTP_AUTHORIZATION'].split() 12 | if len(auth) == 2: 13 | if auth[0].lower() == b"basic": 14 | uname, passwd = base64.b64decode(auth[1]).split(b':') 15 | user = authenticate(username=uname, password=passwd) 16 | if user is not None and user.is_active: 17 | request.user = user 18 | return view(request, *args, **kwargs) 19 | 20 | # Either they did not provide an authorization header or 21 | # something in the authorization attempt failed. Send a 401 22 | # back to them to ask them to authenticate. 23 | response = HttpResponse() 24 | response.status_code = 401 25 | realm = getattr(settings, 'BASIC_AUTH_REALM', 'Forbidden') 26 | response['WWW-Authenticate'] = 'Basic realm="%s"' % realm 27 | return response 28 | 29 | 30 | def basicauth(view_func): 31 | """ 32 | Basic auth decorator 33 | """ 34 | def wrapper(request, *args, **kwargs): 35 | return view_or_basicauth(view_func, request, *args, **kwargs) 36 | return wrapper 37 | -------------------------------------------------------------------------------- /src/oscar_accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from treebeard.admin import TreeAdmin 3 | 4 | from oscar_accounts import models 5 | 6 | 7 | class AccountAdmin(admin.ModelAdmin): 8 | list_display = ['id', 'code', 'name', 'balance', 'credit_limit', 'primary_user', 9 | 'start_date', 'end_date', 'is_active', 10 | 'date_created'] 11 | readonly_fields = ('balance', 'code',) 12 | 13 | 14 | class TransferAdmin(admin.ModelAdmin): 15 | list_display = ['reference', 'amount', 'source', 'destination', 16 | 'user', 'description', 'date_created'] 17 | readonly_fields = ('amount', 'source', 'destination', 'description', 18 | 'user', 'username', 'date_created') 19 | 20 | 21 | class TransactionAdmin(admin.ModelAdmin): 22 | list_display = ['id', 'transfer', 'account', 'amount', 'date_created'] 23 | readonly_fields = ('transfer', 'account', 'amount', 'date_created') 24 | 25 | 26 | class IPAddressAdmin(admin.ModelAdmin): 27 | list_display = ['ip_address', 'total_failures', 'consecutive_failures', 28 | 'is_temporarily_blocked', 'is_permanently_blocked', 29 | 'date_last_failure'] 30 | readonly_fields = ('ip_address', 'total_failures', 'date_last_failure') 31 | 32 | 33 | admin.site.register(models.AccountType, TreeAdmin) 34 | admin.site.register(models.Account, AccountAdmin) 35 | admin.site.register(models.Transfer, TransferAdmin) 36 | admin.site.register(models.Transaction, TransactionAdmin) 37 | admin.site.register(models.IPAddressRecord, IPAddressAdmin) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Tangent Communications PLC and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Tangent Communications PLC nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_top_up.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans "Top-up account" %} #{{ account.id }} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 22 | {% endblock %} 23 | 24 | {% block headertext %}{% trans "Top up account?" %}{% endblock %} 25 | 26 | {% block dashboard_content %} 27 | {% include 'accounts/dashboard/partials/account_detail.html' %} 28 | 29 |
30 |
31 | {% if account.is_open %} 32 |
33 | {% csrf_token %} 34 | {% trans "Transaction" %} 35 | {% if form.source_account %} 36 | {% include 'partials/form_field.html' with field=form.source_account %} 37 | {% endif %} 38 | {% include 'partials/form_field.html' with field=form.amount %} 39 | 40 | or {% trans "cancel" %}. 41 |
42 | {% else %} 43 |

{% trans "This account cannot be topped-up." %}

44 | {% endif %} 45 |
46 |
47 | {% endblock dashboard_content %} 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from setuptools import find_packages, setup 6 | 7 | install_requires=[ 8 | 'django-oscar>=1.1.1', 9 | 'python-dateutil>=2.4,<3.0', 10 | ] 11 | 12 | tests_require = [ 13 | 'django-webtest==1.7.8', 14 | 'pytest==2.9.0', 15 | 'pytest-cov==2.1.0', 16 | 'pytest-django==2.8.0', 17 | ] 18 | 19 | setup_requires = [ 20 | 'setuptools_scm==1.10.1' 21 | ] 22 | 23 | 24 | setup( 25 | name='django-oscar-accounts', 26 | author="David Winterbottom", 27 | author_email="david.winterbottom@tangentlabs.co.uk", 28 | description="Managed accounts for django-oscar", 29 | long_description=open('README.rst').read(), 30 | license='BSD', 31 | package_dir={'': 'src'}, 32 | packages=find_packages('src'), 33 | include_package_data=True, 34 | classifiers=[ 35 | 'Development Status :: 4 - Beta', 36 | 'Environment :: Web Environment', 37 | 'Framework :: Django', 38 | 'Framework :: Django :: 1.7', 39 | 'Framework :: Django :: 1.8', 40 | 'Intended Audience :: Developers', 41 | 'License :: OSI Approved :: BSD License', 42 | 'Operating System :: Unix', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 2', 45 | 'Programming Language :: Python :: 2.7', 46 | 'Programming Language :: Python :: 3', 47 | 'Programming Language :: Python :: 3.3', 48 | 'Programming Language :: Python :: 3.4', 49 | 'Programming Language :: Python :: 3.5', 50 | ], 51 | install_requires=install_requires, 52 | tests_require=tests_require, 53 | setup_requires=setup_requires, 54 | extras_require={ 55 | 'test': tests_require, 56 | }, 57 | use_scm_version=True, 58 | ) 59 | -------------------------------------------------------------------------------- /src/oscar_accounts/api/app.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | from django.views.decorators.csrf import csrf_exempt 3 | from oscar.core.application import Application 4 | 5 | from oscar_accounts.api import decorators, views 6 | 7 | 8 | class APIApplication(Application): 9 | name = None 10 | 11 | accounts_view = views.AccountsView 12 | account_view = views.AccountView 13 | account_redemptions_view = views.AccountRedemptionsView 14 | account_refunds_view = views.AccountRefundsView 15 | 16 | transfer_view = views.TransferView 17 | transfer_reverse_view = views.TransferReverseView 18 | transfer_refunds_view = views.TransferRefundsView 19 | 20 | def get_urls(self): 21 | urlpatterns = patterns('', 22 | url(r'^accounts/$', 23 | self.accounts_view.as_view(), 24 | name='accounts'), 25 | url(r'^accounts/(?P[A-Z0-9]+)/$', 26 | self.account_view.as_view(), 27 | name='account'), 28 | url(r'^accounts/(?P[A-Z0-9]+)/redemptions/$', 29 | self.account_redemptions_view.as_view(), 30 | name='account-redemptions'), 31 | url(r'^accounts/(?P[A-Z0-9]+)/refunds/$', 32 | self.account_refunds_view.as_view(), 33 | name='account-refunds'), 34 | url(r'^transfers/(?P[A-Z0-9]{32})/$', 35 | self.transfer_view.as_view(), 36 | name='transfer'), 37 | url(r'^transfers/(?P[A-Z0-9]{32})/reverse/$', 38 | self.transfer_reverse_view.as_view(), 39 | name='transfer-reverse'), 40 | url(r'^transfers/(?P[A-Z0-9]{32})/refunds/$', 41 | self.transfer_refunds_view.as_view(), 42 | name='transfer-refunds'), 43 | ) 44 | return self.post_process_urls(urlpatterns) 45 | 46 | def get_url_decorator(self, url_name): 47 | return lambda x: csrf_exempt(decorators.basicauth(x)) 48 | 49 | 50 | application = APIApplication() 51 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_withdraw.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans "Return funds to source account" %} #{{ account.id }} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 22 | {% endblock %} 23 | 24 | {% block header %} 25 | 28 | {% endblock header %} 29 | 30 | {% block dashboard_content %} 31 | {% include 'accounts/dashboard/partials/account_detail.html' %} 32 | 33 |
34 |
35 | 36 | {% if account.is_open %} 37 |
38 | {% csrf_token %} 39 | {% trans "Transaction" %} 40 | {% if form.source_account %} 41 | {% include 'partials/form_field.html' with field=form.source_account %} 42 | {% endif %} 43 | {% include 'partials/form_field.html' with field=form.amount %} 44 | 45 | or {% trans "cancel" %}. 46 |
47 | {% else %} 48 |
49 |

{% trans "This account's funds cannot be withdrawn." %}

50 |
51 | {% endif %} 52 |
53 |
54 | 55 | {% endblock dashboard_content %} 56 | -------------------------------------------------------------------------------- /src/oscar_accounts/checkout/gateway.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext_lazy as _ 2 | from oscar.apps.payment.exceptions import UnableToTakePayment 3 | from oscar.core.loading import get_model 4 | 5 | from oscar_accounts import codes, core, exceptions, facade 6 | 7 | Account = get_model('oscar_accounts', 'Account') 8 | Transfer = get_model('oscar_accounts', 'Transfer') 9 | 10 | 11 | def user_accounts(user): 12 | """ 13 | Return accounts available to the passed user 14 | """ 15 | return Account.active.filter(primary_user=user) 16 | 17 | 18 | def redeem(order_number, user, allocations): 19 | """ 20 | Settle payment for the passed set of account allocations 21 | 22 | Will raise UnableToTakePayment if any of the transfers is invalid 23 | """ 24 | # First, we need to check if the allocations are still valid. The accounts 25 | # may have changed status since the allocations were written to the 26 | # session. 27 | transfers = [] 28 | destination = core.redemptions_account() 29 | for code, amount in allocations.items(): 30 | try: 31 | account = Account.active.get(code=code) 32 | except Account.DoesNotExist: 33 | raise UnableToTakePayment( 34 | _("No active account found with code %s") % code) 35 | 36 | # We verify each transaction 37 | try: 38 | Transfer.objects.verify_transfer( 39 | account, destination, amount, user) 40 | except exceptions.AccountException as e: 41 | raise UnableToTakePayment(str(e)) 42 | 43 | transfers.append((account, destination, amount)) 44 | 45 | # All transfers verified, now redeem 46 | for account, destination, amount in transfers: 47 | facade.transfer(account, destination, amount, 48 | user=user, merchant_reference=order_number, 49 | description="Redeemed to pay for order %s" % order_number) 50 | 51 | 52 | def create_giftcard(order_number, user, amount): 53 | source = core.paid_source_account() 54 | code = codes.generate() 55 | destination = Account.objects.create( 56 | code=code 57 | ) 58 | facade.transfer(source, destination, amount, user, 59 | "Create new account") 60 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/balance_check.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans 'Check my balance' %} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 17 | {% endblock %} 18 | 19 | {% block headertext %}{% trans 'Check balance of giftcard' %}{% endblock %} 20 | 21 | {% block content %} 22 | 23 | {% if is_blocked %} 24 |

{% trans "Your IP address is currently blocked. Please try again later." %}

25 | {% else %} 26 |
27 | {% csrf_token %} 28 | {% include 'partials/form_fields.html' %} 29 | 30 |
31 | {% endif %} 32 | 33 | {% if account %} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% if account.start_date %} 49 | 50 | 51 | 52 | 53 | {% endif %} 54 | {% if account.end_date %} 55 | 56 | 57 | 58 | 59 | {% endif %} 60 | 61 |
{% trans "Code" %}{{ account.code }}
{% trans "Balance" %}{{ account.balance|currency }}
{% trans "Status" %}{{ account.status }}
{% trans "Start date" %}{{ account.start_date }}
{% trans "End date" %}{{ account.end_date }}
62 | {% endif %} 63 | 64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import os.path 3 | 4 | from django.conf import global_settings, settings 5 | from oscar import OSCAR_MAIN_TEMPLATE_DIR, get_core_apps 6 | from oscar.defaults import * # noqa 7 | 8 | from oscar_accounts import TEMPLATE_DIR as ACCOUNTS_TEMPLATE_DIR 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | } 14 | } 15 | 16 | STATICFILES_FINDERS = [ 17 | 'django.contrib.staticfiles.finders.FileSystemFinder', 18 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 19 | ] 20 | 21 | SECRET_KEY = str(uuid.uuid4()) 22 | 23 | INSTALLED_APPS=[ 24 | 'django.contrib.auth', 25 | 'django.contrib.admin', 26 | 'django.contrib.contenttypes', 27 | 'django.contrib.staticfiles', 28 | 'django.contrib.sessions', 29 | 'django.contrib.sites', 30 | 'django.contrib.flatpages', 31 | 'oscar_accounts', 32 | 'widget_tweaks', 33 | ] + get_core_apps() 34 | 35 | MIDDLEWARE_CLASSES=global_settings.MIDDLEWARE_CLASSES + ( 36 | 'django.contrib.sessions.middleware.SessionMiddleware', 37 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 38 | 'django.contrib.messages.middleware.MessageMiddleware', 39 | 'oscar.apps.basket.middleware.BasketMiddleware', 40 | ) 41 | 42 | TEMPLATE_CONTEXT_PROCESSORS=global_settings.TEMPLATE_CONTEXT_PROCESSORS + ( 43 | 'django.core.context_processors.request', 44 | 'oscar.apps.search.context_processors.search_form', 45 | 'oscar.apps.promotions.context_processors.promotions', 46 | 'oscar.apps.checkout.context_processors.checkout', 47 | 'oscar.core.context_processors.metadata', 48 | ) 49 | 50 | DEBUG=False 51 | 52 | HAYSTACK_CONNECTIONS = { 53 | 'default': { 54 | 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine' 55 | } 56 | } 57 | 58 | ROOT_URLCONF = 'tests.urls' 59 | 60 | TEMPLATE_DIRS = ( 61 | OSCAR_MAIN_TEMPLATE_DIR, 62 | os.path.join(OSCAR_MAIN_TEMPLATE_DIR, 'templates'), 63 | ACCOUNTS_TEMPLATE_DIR, 64 | # Include sandbox templates as they patch from templates that 65 | # are in Oscar 0.4 but not 0.3 66 | 'sandbox/templates', 67 | ) 68 | 69 | STATIC_URL='/static/' 70 | 71 | SITE_ID=1 72 | ACCOUNTS_UNIT_NAME='Giftcard' 73 | USE_TZ=True 74 | 75 | DDF_FILL_NULLABLE_FIELDS=False 76 | ACCOUNTS_DEFERRED_INCOME_ACCOUNT_TYPES=('Test accounts',) 77 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/transfer_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans "Transfer" %} {{ transfer.reference }} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 22 | {% endblock %} 23 | 24 | {% block headertext %}Transfer {{ transfer.reference }}{% endblock %} 25 | 26 | {% block dashboard_content %} 27 | 28 | 29 |
30 |
{% trans "Transfers" %}
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
{% trans "Reference" %}{{ transfer.reference }}
{% trans "Source" %}{{ transfer.source }}
{% trans "Destination" %}{{ transfer.destination }}
{% trans "Amount" %}{{ transfer.amount|currency }}
{% trans "Merchant reference" %}{{ transfer.merchant_reference|default:"-" }}
{% trans "Description" %}{{ transfer.description|default:"-" }}
{% trans "Authorised by" %}{{ transfer.user }}
{% trans "Date" %}{{ transfer.date_created }}
68 |
69 |
70 | 71 | {% endblock dashboard_content %} 72 | -------------------------------------------------------------------------------- /src/oscar_accounts/dashboard/app.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | from django.contrib.admin.views.decorators import staff_member_required 3 | from oscar.core.application import Application 4 | 5 | from oscar_accounts.dashboard import views 6 | 7 | 8 | class AccountsDashboardApplication(Application): 9 | name = None 10 | default_permissions = ['is_staff', ] 11 | 12 | account_list_view = views.AccountListView 13 | account_create_view = views.AccountCreateView 14 | account_update_view = views.AccountUpdateView 15 | account_transactions_view = views.AccountTransactionsView 16 | account_freeze_view = views.AccountFreezeView 17 | account_thaw_view = views.AccountThawView 18 | account_top_up_view = views.AccountTopUpView 19 | account_withdraw_view = views.AccountWithdrawView 20 | 21 | transfer_list_view = views.TransferListView 22 | transfer_detail_view = views.TransferDetailView 23 | 24 | report_deferred_income = views.DeferredIncomeReportView 25 | report_profit_loss = views.ProfitLossReportView 26 | 27 | def get_urls(self): 28 | urlpatterns = [ 29 | url(r'^$', 30 | self.account_list_view.as_view(), 31 | name='accounts-list'), 32 | url(r'^create/$', self.account_create_view.as_view(), 33 | name='accounts-create'), 34 | url(r'^(?P\d+)/update/$', self.account_update_view.as_view(), 35 | name='accounts-update'), 36 | url(r'^(?P\d+)/$', self.account_transactions_view.as_view(), 37 | name='accounts-detail'), 38 | url(r'^(?P\d+)/freeze/$', self.account_freeze_view.as_view(), 39 | name='accounts-freeze'), 40 | url(r'^(?P\d+)/thaw/$', self.account_thaw_view.as_view(), 41 | name='accounts-thaw'), 42 | url(r'^(?P\d+)/top-up/$', self.account_top_up_view.as_view(), 43 | name='accounts-top-up'), 44 | url(r'^(?P\d+)/withdraw/$', self.account_withdraw_view.as_view(), 45 | name='accounts-withdraw'), 46 | url(r'^transfers/$', self.transfer_list_view.as_view(), 47 | name='transfers-list'), 48 | url(r'^transfers/(?P[A-Z0-9]{32})/$', 49 | self.transfer_detail_view.as_view(), 50 | name='transfers-detail'), 51 | url(r'^reports/deferred-income/$', 52 | self.report_deferred_income.as_view(), 53 | name='report-deferred-income'), 54 | url(r'^reports/profit-loss/$', 55 | self.report_profit_loss.as_view(), 56 | name='report-profit-loss'), 57 | ] 58 | return self.post_process_urls(urlpatterns) 59 | 60 | 61 | application = AccountsDashboardApplication() 62 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/reports/deferred_income.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {{ title }} | {% trans "Accounts" %} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 19 | {% endblock %} 20 | 21 | {% block headertext %}{{ title }}{% endblock %} 22 | 23 | {% block dashboard_content %} 24 | 25 |
26 |
{% trans "Search" %}
27 |
28 |
29 | {% include 'dashboard/partials/form_fields_inline.html' with form=form %} 30 | 31 |
32 |
33 |
34 | 35 | {% if rows %} 36 |

{% trans "Position at" %} {{ report_date }}

37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% for row in rows %} 58 | 59 | 60 | 61 | 62 | 66 | 70 | 74 | 78 | 82 | 83 | {% endfor %} 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
{% trans "Account type" %}{% trans "Total balance" %}{% trans "Num accounts" %}{% trans "Expiring" %}
{% trans "< 30 days" %}{% trans "30 - 60 days" %}{% trans "60 - 90 days" %}{% trans "> 90 days" %}{% trans "No end date" %}
{{ row.name }}{{ row.total|currency }}{{ row.num_accounts }} 63 | {{ row.total_expiring_within_30|currency }} 64 | ({{ row.num_expiring_within_30 }}) 65 | 67 | {{ row.total_expiring_within_60|currency }} 68 | ({{ row.num_expiring_within_60 }}) 69 | 71 | {{ row.total_expiring_within_90|currency }} 72 | ({{ row.num_expiring_within_90 }}) 73 | 75 | {{ row.total_expiring_outside_90|currency }} 76 | ({{ row.num_expiring_outside_90 }}) 77 | 79 | {{ row.total_open_ended|currency }} 80 | ({{ row.num_open_ended }}) 81 |
{{ totals.total|currency }}{{ totals.num_accounts }}
92 | {% endif %} 93 | 94 | {% endblock dashboard_content %} 95 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans "Account" %} #{{ account.id }} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 19 | {% endblock %} 20 | 21 | {% block header %} 22 | 35 | {% endblock header %} 36 | 37 | {% block dashboard_content %} 38 | 39 | {% include 'accounts/dashboard/partials/account_detail.html' %} 40 | 41 |
42 |
{% trans "Transaction overview" %}
43 |
44 | {% if transactions %} 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {% for txn in transactions %} 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {% endfor %} 65 | 66 |
{% trans "Transfer" %}{% trans "Amount" %}{% trans "Description" %}{% trans "Authorised by" %}{% trans "Date" %}
{{ txn.transfer }}{{ txn.amount|currency }}{{ txn.transfer.description|default:"-" }}{{ txn.transfer.user|default:"-" }}{{ txn.date_created }}
67 | {% include "dashboard/partials/pagination.html" %} 68 | {% else %} 69 |

{% trans "No transactions." %}

70 | {% endif %} 71 |
72 |
73 | 74 | {% endblock dashboard_content %} 75 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/transfer_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {% trans "Transfers" %} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 19 | {% endblock %} 20 | 21 | {% block headertext %}{% trans "Transfers" %}{% endblock %} 22 | 23 | {% block dashboard_content %} 24 | 25 |
26 |
{% trans "Search" %}
27 |
28 |
29 | {% include 'dashboard/partials/form_fields_inline.html' %} 30 | 31 | or {% trans "reset" %}. 32 |
33 |
34 |
35 | 36 |
37 |
{{ queryset_description }}
38 | {% if transfers %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {% for transfer in transfers %} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | {% endfor %} 62 |
{% trans "Reference" %}{% trans "Source" %}{% trans "Destination" %}{% trans "Amount" %}{% trans "Order number" %}{% trans "Description" %}{% trans "Authorised by" %}{% trans "Date created" %}
{{ transfer.reference }}{{ transfer.source }}{{ transfer.destination }}{{ transfer.amount|currency }}{{ transfer.merchant_reference|default:"-" }}{{ transfer.description|default:"-" }}{{ transfer.user|default:"-" }}{{ transfer.date_created }}
63 | {% include "partials/pagination.html" %} 64 | {% else %} 65 |
66 |

{% trans "No transfers found." %}

67 |
68 | {% endif %} 69 |
70 | 71 | {% endblock dashboard_content %} 72 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/partials/account_detail.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load currency_filters %} 3 | 4 |
5 |
{{ account.name }}
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 41 | 42 | {% if account.start_date %} 43 | 44 | 45 | 46 | 47 | {% endif %} 48 | {% if account.end_date %} 49 | 50 | 51 | 52 | 53 | {% endif %} 54 | {% if account.primary_user %} 55 | 56 | 57 | 58 | 59 | {% endif %} 60 | {% with num_users=account.secondary_users.all.count %} 61 | {% if num_users %} 62 | 63 | 64 | 65 | 66 | {% endif %} 67 | {% endwith %} 68 | {% if account.product_range %} 69 | 70 | 71 | 72 | 73 | {% endif %} 74 | 75 |
{% trans "Name" %}{{ account.name }}
{% trans "Description" %}{{ account.description|default:"-" }}
{% trans "Status" %}{{ account.status }}
{% trans "Code" %}{{ account.code|default:"-" }}
{% trans "Account type" %}{{ account.account_type.full_name }}
{% trans "Balance" %}{{ account.balance|currency }}
{% trans "Credit limit" %} 35 | {% if not account.has_credit_limit %} 36 | {% trans "No limit" %} 37 | {% else %} 38 | {{ account.credit_limit|currency }} 39 | {% endif %} 40 |
{% trans "Start date" %}{{ account.start_date|default:"-" }}
{% trans "End date" %}{{ account.end_date|default:"-" }}
{% trans "Primary user" %}{{ account.primary_user }}
{% trans "Num secondary users" %}{{ num_users}}
{% trans "Can be spent on products from range" %}{{ account.product_range }}
76 |
77 | -------------------------------------------------------------------------------- /src/oscar_accounts/checkout/forms.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django import forms 4 | from django.utils.translation import ugettext_lazy as _ 5 | from oscar.core.loading import get_model 6 | from oscar.templatetags.currency_filters import currency 7 | 8 | Account = get_model('oscar_accounts', 'Account') 9 | 10 | 11 | class ValidAccountForm(forms.Form): 12 | code = forms.CharField(label=_("Account code")) 13 | 14 | def __init__(self, user, *args, **kwargs): 15 | self.user = user 16 | super(ValidAccountForm, self).__init__(*args, **kwargs) 17 | 18 | def clean_code(self): 19 | code = self.cleaned_data['code'].strip().upper() 20 | code = code.replace('-', '') 21 | try: 22 | self.account = Account.objects.get( 23 | code=code) 24 | except Account.DoesNotExist: 25 | raise forms.ValidationError(_( 26 | "No account found with this code")) 27 | if not self.account.is_active(): 28 | raise forms.ValidationError(_( 29 | "This account is no longer active")) 30 | if not self.account.is_open(): 31 | raise forms.ValidationError(_( 32 | "This account is no longer open")) 33 | if self.account.balance == D('0.00'): 34 | raise forms.ValidationError(_( 35 | "This account is empty")) 36 | if not self.account.can_be_authorised_by(self.user): 37 | raise forms.ValidationError(_( 38 | "You can not authorised to use this account")) 39 | return code 40 | 41 | 42 | class AllocationForm(forms.Form): 43 | amount = forms.DecimalField(label=_("Allocation"), min_value=D('0.01')) 44 | 45 | def __init__(self, account, basket, shipping_total, order_total, 46 | allocations, *args, **kwargs): 47 | """ 48 | :account: Account to allocate from 49 | :basket: The basket to pay for 50 | :shipping_total: The shipping total 51 | :order_total: Order total 52 | :allocations: Allocations instance 53 | """ 54 | self.account = account 55 | self.basket = basket 56 | self.shipping_total = shipping_total 57 | self.order_total = order_total 58 | self.allocations = allocations 59 | self.max_allocation = self.get_max_amount() 60 | initial = {'amount': self.max_allocation} 61 | super(AllocationForm, self).__init__(initial=initial, *args, **kwargs) 62 | if self.account.product_range: 63 | self.fields['amount'].help_text = ( 64 | "Restrictions apply to which products can be paid for") 65 | 66 | def get_max_amount(self): 67 | max_allocation = self.account.permitted_allocation( 68 | self.basket, self.shipping_total, self.order_total) 69 | return max_allocation - self.allocations.total 70 | 71 | def clean_amount(self): 72 | amt = self.cleaned_data.get('amount') 73 | max_allocation = self.max_allocation 74 | if amt > max_allocation: 75 | raise forms.ValidationError(_( 76 | "The maximum allocation is %s") % currency( 77 | max_allocation)) 78 | return amt 79 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {{ title }} | {% trans "Accounts" %} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 26 | {% endblock %} 27 | 28 | {% block headertext %}{{ title }}{% endblock %} 29 | 30 | {% block dashboard_content %} 31 | 32 | {% if account %} 33 | {% include 'accounts/dashboard/partials/account_detail.html' %} 34 |

{% trans "Edit this account" %}

35 | {% endif %} 36 | 37 |
38 |
{% trans "Edit" %}
39 |
40 |
41 | {% csrf_token %} 42 | {{ form.non_field_errors }} 43 | {% include 'dashboard/partials/form_field.html' with field=form.name %} 44 | {% include 'dashboard/partials/form_field.html' with field=form.description %} 45 | {% if form.account_type %} 46 | {% include 'partials/form_field.html' with field=form.account_type %} 47 | {% endif %} 48 | 49 | {% if form.initial_amount %} 50 | {% trans "Initial transaction" %} 51 | {% if form.source_account %} 52 | {% include 'dashboard/partials/form_field.html' with field=form.source_account %} 53 | {% endif %} 54 | {% include 'dashboard/partials/form_field.html' with field=form.initial_amount %} 55 | {% endif %} 56 | 57 | {% trans "Restrictions" %} 58 |

{% trans "Restrict WHEN the account can be used" %}

59 | {% include 'dashboard/partials/form_field.html' with field=form.start_date %} 60 | {% include 'dashboard/partials/form_field.html' with field=form.end_date %} 61 |

{% trans "Restrict WHO can use the account" %}

62 | {% include 'dashboard/partials/form_field.html' with field=form.primary_user %} 63 | {% include 'dashboard/partials/form_field.html' with field=form.secondary_users %} 64 |

{% trans "Restrict WHAT can be bought" %}

65 | {% include 'dashboard/partials/form_field.html' with field=form.product_range %} 66 | {% include 'dashboard/partials/form_field.html' with field=form.can_be_used_for_non_products %} 67 | 68 |
69 | 70 | {% trans "or" %} 71 | {% trans "cancel" %} 72 |
73 |
74 |
75 |
76 | 77 | {% endblock dashboard_content %} 78 | 79 | {% block onbodyload %} 80 | {{ block.super }} 81 | $('a.form-toggle').click(function(){ 82 | $($(this)[0].parentNode.nextElementSibling).toggle(); 83 | // Bind datepicker 84 | oscar.dashboard.init(); 85 | return false; 86 | }); 87 | {% endblock %} 88 | -------------------------------------------------------------------------------- /src/oscar_accounts/facade.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from oscar.core.loading import get_model 4 | 5 | from oscar_accounts import core, exceptions 6 | 7 | Account = get_model('oscar_accounts', 'Account') 8 | Transfer = get_model('oscar_accounts', 'Transfer') 9 | 10 | logger = logging.getLogger('oscar_accounts') 11 | 12 | 13 | def close_expired_accounts(): 14 | """ 15 | Close expired, open accounts and transfer any remaining balance to an 16 | expiration account. 17 | """ 18 | accounts = Account.expired.filter( 19 | status=Account.OPEN) 20 | logger.info("Found %d open accounts to close", accounts.count()) 21 | destination = core.lapsed_account() 22 | for account in accounts: 23 | balance = account.balance 24 | try: 25 | transfer(account, destination, 26 | balance, description="Closing account") 27 | except exceptions.AccountException as e: 28 | logger.error("Unable to close account #%d - %s", account.id, e) 29 | else: 30 | logger.info(("Account #%d successfully expired - %d transferred " 31 | "to sales account"), account.id, balance) 32 | account.close() 33 | 34 | 35 | def transfer(source, destination, amount, 36 | parent=None, user=None, merchant_reference=None, 37 | description=None): 38 | """ 39 | Transfer funds between source and destination accounts. 40 | 41 | Will raise a accounts.exceptions.AccountException if anything goes wrong. 42 | 43 | :source: Account to debit 44 | :destination: Account to credit 45 | :amount: Amount to transfer 46 | :parent: Parent transfer to reference 47 | :user: Authorising user 48 | :merchant_reference: An optional merchant ref associated with this transfer 49 | :description: Description of transaction 50 | """ 51 | if source.id == destination.id: 52 | raise exceptions.AccountException( 53 | "The source and destination accounts for a transfer " 54 | "must be different." 55 | ) 56 | msg = "Transfer of %.2f from account #%d to account #%d" % ( 57 | amount, source.id, destination.id) 58 | if user: 59 | msg += " authorised by user #%d (%s)" % (user.id, user.get_username()) 60 | if description: 61 | msg += " '%s'" % description 62 | try: 63 | transfer = Transfer.objects.create( 64 | source, destination, amount, parent, user, 65 | merchant_reference, description) 66 | except exceptions.AccountException as e: 67 | logger.warning("%s - failed: '%s'", msg, e) 68 | raise 69 | except Exception as e: 70 | logger.error("%s - failed: '%s'", msg, e) 71 | raise exceptions.AccountException( 72 | "Unable to complete transfer: %s" % e) 73 | else: 74 | logger.info("%s - successful, transfer: %s", msg, 75 | transfer.reference) 76 | return transfer 77 | 78 | 79 | def reverse(transfer, user=None, merchant_reference=None, description=None): 80 | """ 81 | Reverse a previous transfer, returning the money to the original source. 82 | """ 83 | msg = "Reverse of transfer #%d" % transfer.id 84 | if user: 85 | msg += " authorised by user #%d (%s)" % (user.id, user.get_username()) 86 | if description: 87 | msg += " '%s'" % description 88 | try: 89 | transfer = Transfer.objects.create( 90 | source=transfer.destination, 91 | destination=transfer.source, 92 | amount=transfer.amount, user=user, 93 | merchant_reference=merchant_reference, 94 | description=description) 95 | except exceptions.AccountException as e: 96 | logger.warning("%s - failed: '%s'", msg, e) 97 | raise 98 | except Exception as e: 99 | logger.error("%s - failed: '%s'", msg, e) 100 | raise exceptions.AccountException( 101 | "Unable to reverse transfer: %s" % e) 102 | else: 103 | logger.info("%s - successful, transfer: %s", msg, 104 | transfer.reference) 105 | return transfer 106 | -------------------------------------------------------------------------------- /sandbox/templates/checkout/payment_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/checkout/payment_details.html' %} 2 | {% load url from future %} 3 | {% load currency_filters %} 4 | {% load i18n %} 5 | 6 | {% block payment_details %} 7 |
8 |

{% trans "Enter payment details" %}

9 |
10 | 11 | {% if not allocation_form %} 12 | {# 1. Initial load of page - show form to look up account if they are not blocked #} 13 | {% if is_blocked %} 14 |

{% trans "You are blocked." %}

15 | {% else %} 16 | {% if user_accounts %} 17 |

{% trans "Choose a user account" %}

18 |
19 | {% csrf_token %} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for account in user_accounts %} 30 | 31 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
{% trans "Account" %}{% trans "Balance" %}
{{ account.name }}{{ account.balance|currency }}
38 | 39 |
40 | {% endif %} 41 | 42 |

{% trans "Look up an account" %}

43 |
44 | {% csrf_token %} 45 | 46 | {% include 'partials/form_fields.html' with form=account_form %} 47 | 48 |
49 | {% endif %} 50 | {% else %} 51 | {# 2. An account has been found - choose allocation #} 52 | {% with account=allocation_form.account %} 53 |

{% trans "Account" %}

54 | 55 | 56 | 57 | 58 | 59 | {% if account.description %} 60 | 61 | 62 | 63 | 64 | {% endif %} 65 | {% if account.end_date %} 66 | 67 | 68 | 69 | 70 | {% endif %} 71 | 72 | 73 | 74 | 75 |
{% trans "Name" %}{{ account.name }}
{% trans "Description" %}{{ account.description }}
{% trans "Expiry date" %}{{ account.end_date }}
{% trans "Balance" %}{{ account.balance|currency }}
76 | {% endwith %} 77 |

{% trans "Choose allocation" %}

78 |

{% trans "The order total is" %} {{ order_total_incl_tax|currency }}.

79 |

{% trans "The maximum allocation from this account is" %} {{ allocation_form.max_allocation|currency }}.

80 |
81 | {% csrf_token %} 82 | {# Include account form hidden #} 83 |
84 | {{ account_form.as_p }} 85 |
86 | 87 | {% include 'partials/form_fields.html' with form=allocation_form %} 88 | {% trans "or" %} 89 | {% trans "cancel" %}. 90 |
91 | {% endif %} 92 | 93 | {% if account_allocations %} 94 |

{% trans "Allocations" %}

95 |
96 | {% csrf_token %} 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | {% for code, amount in account_allocations.items %} 108 | 109 | 110 | 111 | 114 | 115 | {% endfor %} 116 | 117 |
{% trans "Account code" %}{% trans "Allocation" %}
{{ code }}{{ amount|currency }} 112 | 113 |
118 |
119 | {% endif %} 120 | 121 | 122 | {% if to_allocate == 0 %} 123 | {% trans "Continue" %} 124 | {% else %} 125 |

{% trans "Order total" %}: {{ order_total_incl_tax|currency }}. {% trans "You need to allocate another" %} 126 | {{ to_allocate|currency }}

127 | {% endif %} 128 | 129 | {% endblock %} 130 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/account_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {{ title }} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 19 | {% endblock %} 20 | 21 | {% block header %} 22 | 26 | {% endblock header %} 27 | 28 | {% block dashboard_content %} 29 |
30 |
{% trans "Search" %}
31 |
32 |
33 | {% include 'dashboard/partials/form_fields_inline.html' %} 34 | 35 | {% trans "or" %} {% trans "reset" %}. 36 |
37 |
38 |
39 | 40 | 41 |
42 |
{{ queryset_description }}
43 | {% if accounts.count %} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {% for account in accounts %} 57 | {# When we're using bootstrap 2.1, we can use table row colors #} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 86 | 87 | {% endfor %} 88 |
{% trans "Name" %}{% trans "Code" %}{% trans "Status" %}{% trans "Start date" %}{% trans "End date" %}{% trans "Balance" %}{% trans "Num transactions" %}{% trans "Date created" %}
{{ account.name|default:"-" }}{{ account.code|default:"-" }}{{ account.status }}{{ account.start_date|default:"-" }}{{ account.end_date|default:"-" }}{{ account.balance|currency }}{{ account.num_transactions }}{{ account.date_created }} 68 | {% if account.is_editable %} 69 | {% trans "Edit" %} 70 | {% else %} 71 | {% trans "Edit" %} 72 | {% endif %} 73 | {% trans "Transactions" %} 74 | {% if account.is_editable %} 75 | {% trans "Top-up" %} 76 | {% trans "Withdraw" %} 77 | {% if not account.is_frozen %} 78 | {% trans "Freeze" %} 79 | {% else %} 80 | {% trans "Thaw" %} 81 | {% endif %} 82 | {% else %} 83 | {% trans "Top-up" %} 84 | {% endif %} 85 |
89 | {% include "partials/pagination.html" %} 90 | {% else %} 91 |
92 |

{% trans "No results found." %}

93 |
94 | {% endif %} 95 |
96 | 97 | {% endblock dashboard_content %} 98 | -------------------------------------------------------------------------------- /tests/unit/transfer_tests.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | from oscar.test.factories import UserFactory 7 | 8 | from oscar_accounts import exceptions 9 | from oscar_accounts.models import Account, Transfer 10 | from oscar_accounts.test_factories import AccountFactory 11 | 12 | 13 | class TestASuccessfulTransfer(TestCase): 14 | 15 | def setUp(self): 16 | self.user = UserFactory(username="barry") 17 | source = AccountFactory(primary_user=None, credit_limit=None) 18 | destination = AccountFactory() 19 | self.transfer = Transfer.objects.create(source, destination, 20 | D('10.00'), user=self.user) 21 | 22 | def test_creates_2_transactions(self): 23 | self.assertEqual(2, self.transfer.transactions.all().count()) 24 | 25 | def test_records_the_transferred_amount(self): 26 | self.assertEqual(D('10.00'), self.transfer.amount) 27 | 28 | def test_updates_source_balance(self): 29 | self.assertEqual(-D('10.00'), self.transfer.source.balance) 30 | 31 | def test_updates_destination_balance(self): 32 | self.assertEqual(D('10.00'), self.transfer.destination.balance) 33 | 34 | def test_cannot_be_deleted(self): 35 | with self.assertRaises(RuntimeError): 36 | self.transfer.delete() 37 | 38 | def test_records_static_user_information_in_case_user_is_deleted(self): 39 | self.assertEqual('barry', self.transfer.authorisor_username) 40 | self.user.delete() 41 | transfer = Transfer.objects.get(id=self.transfer.id) 42 | self.assertEqual('barry', transfer.authorisor_username) 43 | 44 | 45 | class TestATransferToAnInactiveAccount(TestCase): 46 | 47 | def test_is_permitted(self): 48 | self.user = UserFactory() 49 | now = timezone.now() 50 | source = AccountFactory(primary_user=None, credit_limit=None) 51 | destination = AccountFactory(end_date=now - timezone.timedelta(days=1)) 52 | try: 53 | Transfer.objects.create(source, destination, 54 | D('20.00'), user=self.user) 55 | except exceptions.AccountException as e: 56 | self.fail("Transfer failed: %s" % e) 57 | 58 | 59 | class TestATransferFromAnInactiveAccount(TestCase): 60 | 61 | def test_is_permitted(self): 62 | self.user = UserFactory() 63 | now = timezone.now() 64 | source = AccountFactory( 65 | credit_limit=None, primary_user=None, 66 | end_date=now - timezone.timedelta(days=1)) 67 | destination = AccountFactory() 68 | try: 69 | Transfer.objects.create( 70 | source, destination, D('20.00'), user=self.user) 71 | except exceptions.AccountException as e: 72 | self.fail("Transfer failed: %s" % e) 73 | 74 | 75 | class TestAnAttemptedTransfer(TestCase): 76 | 77 | def setUp(self): 78 | self.user = UserFactory() 79 | 80 | def test_raises_an_exception_when_trying_to_exceed_credit_limit_of_source(self): 81 | source = AccountFactory(primary_user=None, credit_limit=D('10.00')) 82 | destination = AccountFactory() 83 | with self.assertRaises(exceptions.InsufficientFunds): 84 | Transfer.objects.create(source, destination, 85 | D('20.00'), user=self.user) 86 | 87 | def test_raises_an_exception_when_trying_to_debit_negative_value(self): 88 | source = AccountFactory(credit_limit=None) 89 | destination = AccountFactory() 90 | with self.assertRaises(exceptions.InvalidAmount): 91 | Transfer.objects.create(source, destination, 92 | D('-20.00'), user=self.user) 93 | 94 | def test_raises_an_exception_when_trying_to_use_closed_source(self): 95 | source = AccountFactory(credit_limit=None) 96 | source.close() 97 | destination = AccountFactory() 98 | with self.assertRaises(exceptions.ClosedAccount): 99 | Transfer.objects.create(source, destination, 100 | D('20.00'), user=self.user) 101 | 102 | def test_raises_an_exception_when_trying_to_use_closed_destination(self): 103 | source = AccountFactory(primary_user=None, credit_limit=None) 104 | destination = AccountFactory() 105 | destination.close() 106 | with self.assertRaises(exceptions.ClosedAccount): 107 | Transfer.objects.create( 108 | source, destination, D('20.00'), user=self.user) 109 | -------------------------------------------------------------------------------- /src/oscar_accounts/dashboard/reports.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django.db.models import Sum 4 | from oscar.core.loading import get_model 5 | 6 | from oscar_accounts import names 7 | 8 | AccountType = get_model('oscar_accounts', 'AccountType') 9 | Account = get_model('oscar_accounts', 'Account') 10 | Transfer = get_model('oscar_accounts', 'Transfer') 11 | 12 | 13 | class ProfitLossReport(object): 14 | 15 | def __init__(self, start_datetime, end_datetime): 16 | self.start = start_datetime 17 | self.end = end_datetime 18 | 19 | def run(self): 20 | ctx = {} 21 | self.get_paid_loading_data(ctx) 22 | self.get_unpaid_loading_data(ctx) 23 | self.get_deferred_income_data(ctx) 24 | 25 | # Totals 26 | ctx['increase_total'] = ( 27 | ctx['cash_total'] + ctx['unpaid_total'] + 28 | ctx['refund_total']) 29 | ctx['reduction_total'] = ctx['redeem_total'] + ctx['closure_total'] 30 | ctx['position_difference'] = ( 31 | ctx['increase_total'] - ctx['reduction_total']) 32 | 33 | return ctx 34 | 35 | def transfer_total(self, qs): 36 | filters = { 37 | 'date_created__gte': self.start, 38 | 'date_created__lt': self.end} 39 | transfers = qs.filter(**filters) 40 | total = transfers.aggregate(sum=Sum('amount'))['sum'] 41 | return total if total is not None else D('0.00') 42 | 43 | def get_paid_loading_data(self, ctx): 44 | cash = AccountType.objects.get(name=names.CASH) 45 | cash_rows = [] 46 | cash_total = D('0.00') 47 | for account in cash.accounts.all(): 48 | total = self.transfer_total(account.source_transfers) 49 | cash_rows.append({ 50 | 'name': account.name, 51 | 'total': total}) 52 | cash_total += total 53 | ctx['cash_rows'] = cash_rows 54 | ctx['cash_total'] = cash_total 55 | 56 | def get_unpaid_loading_data(self, ctx): 57 | unpaid = AccountType.objects.get(name=names.UNPAID_ACCOUNT_TYPE) 58 | unpaid_rows = [] 59 | unpaid_total = D('0.00') 60 | for account in unpaid.accounts.all(): 61 | total = self.transfer_total(account.source_transfers) 62 | unpaid_rows.append({ 63 | 'name': account.name, 64 | 'total': total}) 65 | unpaid_total += total 66 | ctx['unpaid_rows'] = unpaid_rows 67 | ctx['unpaid_total'] = unpaid_total 68 | 69 | def get_deferred_income_data(self, ctx): 70 | deferred_income = AccountType.objects.get( 71 | name=names.DEFERRED_INCOME) 72 | redeem_rows = [] 73 | closure_rows = [] 74 | refund_rows = [] 75 | redeem_total = closure_total = refund_total = D('0.00') 76 | redemptions_act = Account.objects.get(name=names.REDEMPTIONS) 77 | lapsed_act = Account.objects.get(name=names.LAPSED) 78 | for child in deferred_income.get_children(): 79 | child_redeem_total = D('0.00') 80 | child_closure_total = D('0.00') 81 | child_refund_total = D('0.00') 82 | for account in child.accounts.all(): 83 | # Look for transfers to the redemptions account 84 | qs = account.source_transfers.filter( 85 | destination=redemptions_act) 86 | total = self.transfer_total(qs) 87 | child_redeem_total += total 88 | # Look for transfers to expired account 89 | qs = account.source_transfers.filter( 90 | destination=lapsed_act) 91 | total = self.transfer_total(qs) 92 | child_closure_total += total 93 | # Look for transfers from redemptions account 94 | qs = redemptions_act.source_transfers.filter( 95 | destination=account) 96 | child_refund_total += self.transfer_total(qs) 97 | redeem_rows.append({ 98 | 'name': child.name, 99 | 'total': child_redeem_total}) 100 | closure_rows.append({ 101 | 'name': child.name, 102 | 'total': child_closure_total}) 103 | refund_rows.append({ 104 | 'name': child.name, 105 | 'total': child_refund_total}) 106 | redeem_total += child_redeem_total 107 | closure_total += child_closure_total 108 | refund_total += child_refund_total 109 | 110 | ctx['redeem_rows'] = redeem_rows 111 | ctx['redeem_total'] = redeem_total 112 | ctx['closure_rows'] = closure_rows 113 | ctx['closure_total'] = closure_total 114 | ctx['refund_rows'] = refund_rows 115 | ctx['refund_total'] = refund_total 116 | -------------------------------------------------------------------------------- /src/oscar_accounts/templates/accounts/dashboard/reports/profit_loss.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/layout.html' %} 2 | {% load currency_filters %} 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {{ title }} | {% trans "Accounts" %} | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 19 | {% endblock %} 20 | 21 | {% block headertext %}{{ title }}{% endblock %} 22 | 23 | {% block dashboard_content %} 24 | 25 |
26 |
{% trans "Search" %}
27 |
28 |
29 | {% include 'dashboard/partials/form_fields_inline.html' with form=form %} 30 | 31 |
32 |
33 |
34 | 35 | {% if show_report %} 36 |
37 |
38 |

{% trans "Transactions between" %} {{ start_date }} {% trans "and" %} {{ end_date }}

39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {% for row in cash_rows %} 48 | 49 | 50 | 51 | 52 | {% endfor %} 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {% for row in unpaid_rows %} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {% endfor %} 70 | 71 | 72 | 73 | {% for row in refund_rows %} 74 | 75 | 76 | 77 | 78 | {% endfor %} 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | {% for row in redeem_rows %} 98 | 99 | 100 | 101 | 102 | {% endfor %} 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | {% for row in closure_rows %} 111 | 112 | 113 | 114 | 115 | {% endfor %} 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |
{% trans "INCREASES IN DEFERRED INCOME LIABILITY" %}
{% trans "Sales" %}
{{ row.name }}{{ row.total|currency }}
{{ cash_total|currency }}
{% trans "Unpaid sources" %}
{{ row.name }}{{ row.total|currency }}
{{ unpaid_total|currency }}
{% trans "Refunds" %}
{{ row.name }}{{ row.total|currency }}
{{ refund_total|currency }}
{% trans "TOTAL" %}{{ increase_total|currency }}
 
{% trans "REDUCTIONS IN DEFERRED INCOME LIABILITY" %}
{% trans "Redemptions" %}
{{ row.name }}{{ row.total|currency }}
{{ redeem_total|currency }}
{% trans "Expired" %}
{{ row.name }}{{ row.total|currency }}
{{ closure_total|currency }}
{% trans "TOTAL" %}{{ reduction_total|currency }}
 
{% trans "DIFFERENCE IN POSITION" %}{{ position_difference|currency }}
133 |
134 |
135 | {% endif %} 136 | 137 | {% endblock dashboard_content %} 138 | -------------------------------------------------------------------------------- /tests/unit/facade_tests.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django.contrib.auth.models import User 4 | from django.db.models import Sum 5 | from django.test import TestCase, TransactionTestCase 6 | from oscar.test.factories import UserFactory 7 | import mock 8 | 9 | from oscar_accounts import facade, exceptions 10 | from oscar_accounts.models import Account, Transfer, Transaction 11 | from oscar_accounts.test_factories import AccountFactory 12 | 13 | 14 | class TestReversingATransfer(TestCase): 15 | 16 | def setUp(self): 17 | self.user = UserFactory() 18 | self.source = AccountFactory(primary_user=None, credit_limit=None) 19 | self.destination = AccountFactory(primary_user=None) 20 | self.transfer = facade.transfer(self.source, self.destination, 21 | D('100'), user=self.user, 22 | description="Give money to customer") 23 | self.reverse = facade.reverse(self.transfer, self.user, 24 | description="Oops! Return money") 25 | 26 | def test_creates_4_transactions(self): 27 | self.assertEqual(4, Transaction.objects.all().count()) 28 | 29 | def test_creates_2_transfers(self): 30 | self.assertEqual(2, Transfer.objects.all().count()) 31 | 32 | def test_leaves_both_balances_unchanged(self): 33 | self.assertEqual(D('0.00'), self.source.balance) 34 | self.assertEqual(D('0.00'), self.destination.balance) 35 | 36 | def test_records_the_authorising_user(self): 37 | self.assertEqual(self.user, self.reverse.user) 38 | 39 | def test_records_the_transfer_message(self): 40 | self.assertEqual("Oops! Return money", self.reverse.description) 41 | 42 | def test_records_the_correct_accounts(self): 43 | self.assertEqual(self.source, self.reverse.destination) 44 | self.assertEqual(self.destination, self.reverse.source) 45 | 46 | def test_records_the_correct_amount(self): 47 | self.assertEqual(D('100'), self.reverse.amount) 48 | 49 | 50 | class TestATransfer(TestCase): 51 | 52 | def setUp(self): 53 | self.user = UserFactory() 54 | self.source = AccountFactory(credit_limit=None, primary_user=None) 55 | self.destination = AccountFactory() 56 | self.transfer = facade.transfer( 57 | self.source, self.destination, D('100'), 58 | user=self.user, description="Give money to customer") 59 | 60 | def test_generates_an_unguessable_reference(self): 61 | self.assertTrue(len(self.transfer.reference) > 0) 62 | 63 | def test_records_the_authorising_user(self): 64 | self.assertEqual(self.user, self.transfer.user) 65 | 66 | def test_can_record_a_description(self): 67 | self.assertEqual("Give money to customer", self.transfer.description) 68 | 69 | def test_creates_two_transactions(self): 70 | self.assertEqual(2, self.transfer.transactions.all().count()) 71 | 72 | def test_preserves_zero_sum_invariant(self): 73 | aggregates = self.transfer.transactions.aggregate(sum=Sum('amount')) 74 | self.assertEqual(D('0.00'), aggregates['sum']) 75 | 76 | def test_debits_the_source_account(self): 77 | self.assertEqual(D('-100.00'), self.source.balance) 78 | 79 | def test_credits_the_destination_account(self): 80 | self.assertEqual(D('100.00'), self.destination.balance) 81 | 82 | def test_creates_a_credit_transaction(self): 83 | destination_txn = self.transfer.transactions.get( 84 | account=self.destination) 85 | self.assertEqual(D('100.00'), destination_txn.amount) 86 | 87 | def test_creates_a_debit_transaction(self): 88 | source_txn = self.transfer.transactions.get(account=self.source) 89 | self.assertEqual(D('-100.00'), source_txn.amount) 90 | 91 | 92 | class TestAnAnonymousTransaction(TestCase): 93 | 94 | def test_doesnt_explode(self): 95 | source = AccountFactory(credit_limit=None) 96 | destination = AccountFactory() 97 | facade.transfer(source, destination, D('1')) 98 | 99 | 100 | class TestErrorHandling(TransactionTestCase): 101 | 102 | def tearDown(self): 103 | Account.objects.all().delete() 104 | 105 | def test_no_transaction_created_when_exception_raised(self): 106 | user = UserFactory() 107 | source = AccountFactory(credit_limit=None) 108 | destination = AccountFactory() 109 | with mock.patch( 110 | 'oscar_accounts.abstract_models.PostingManager._wrap') as mock_method: 111 | mock_method.side_effect = RuntimeError() 112 | try: 113 | facade.transfer(source, destination, D('100'), user) 114 | except Exception: 115 | pass 116 | self.assertEqual(0, Transfer.objects.all().count()) 117 | self.assertEqual(0, Transaction.objects.all().count()) 118 | 119 | def test_account_exception_raised_for_invalid_transfer(self): 120 | user = UserFactory() 121 | source = AccountFactory(credit_limit=D('0.00')) 122 | destination = AccountFactory() 123 | with self.assertRaises(exceptions.AccountException): 124 | facade.transfer(source, destination, D('100'), user) 125 | 126 | def test_account_exception_raised_for_runtime_error(self): 127 | user = UserFactory() 128 | source = AccountFactory(credit_limit=None) 129 | destination = AccountFactory() 130 | with mock.patch( 131 | 'oscar_accounts.abstract_models.PostingManager._wrap') as mock_method: 132 | mock_method.side_effect = RuntimeError() 133 | with self.assertRaises(exceptions.AccountException): 134 | facade.transfer(source, destination, D('100'), user) 135 | -------------------------------------------------------------------------------- /tests/unit/model_tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from decimal import Decimal as D 3 | 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | from django.utils import timezone 7 | from oscar.test.factories import UserFactory 8 | 9 | from oscar_accounts import exceptions 10 | from oscar_accounts.models import Account, Transaction, Transfer 11 | from oscar_accounts.test_factories import AccountFactory, TransactionFactory 12 | 13 | 14 | class TestAnAccount(TestCase): 15 | 16 | def setUp(self): 17 | self.account = Account() 18 | 19 | def test_is_open_by_default(self): 20 | self.assertEqual(Account.OPEN, self.account.status) 21 | 22 | def test_can_be_closed(self): 23 | self.account.close() 24 | self.assertEqual(Account.CLOSED, self.account.status) 25 | 26 | def test_always_saves_the_code_as_uppercase(self): 27 | self.account.code = 'abc' 28 | self.account.save() 29 | self.assertEquals('ABC', self.account.code) 30 | 31 | def test_can_be_authorised_when_no_user_passed(self): 32 | self.assertTrue(self.account.can_be_authorised_by()) 33 | 34 | def test_can_be_authorised_by_anyone_by_default(self): 35 | self.account.save() 36 | user = UserFactory() 37 | self.assertTrue(self.account.can_be_authorised_by(user)) 38 | 39 | def test_can_only_be_authorised_by_primary_user_when_set(self): 40 | primary = UserFactory() 41 | other = UserFactory() 42 | self.account.primary_user = primary 43 | self.account.save() 44 | 45 | self.assertTrue(self.account.can_be_authorised_by(primary)) 46 | self.assertFalse(self.account.can_be_authorised_by(other)) 47 | 48 | def test_can_only_be_authorised_by_secondary_users_when_set(self): 49 | self.account.save() 50 | users = [UserFactory(), UserFactory()] 51 | other = UserFactory() 52 | for user in users: 53 | self.account.secondary_users.add(user) 54 | 55 | for user in users: 56 | self.assertTrue(self.account.can_be_authorised_by(user)) 57 | self.assertFalse(self.account.can_be_authorised_by(other)) 58 | 59 | def test_does_not_permit_an_allocation(self): 60 | amt = self.account.permitted_allocation( 61 | None, D('2.00'), D('12.00')) 62 | self.assertEqual(D('0.00'), amt) 63 | 64 | 65 | class TestAnAccountWithFunds(TestCase): 66 | 67 | def setUp(self): 68 | self.account = Account() 69 | self.account.balance = D('100.00') 70 | 71 | def test_cannot_be_closed(self): 72 | with self.assertRaises(exceptions.AccountNotEmpty): 73 | self.account.close() 74 | 75 | def test_allows_allocations_less_than_balance(self): 76 | amt = self.account.permitted_allocation( 77 | None, D('2.00'), D('12.00')) 78 | self.assertEqual(D('12.00'), amt) 79 | 80 | def test_doesnt_allow_allocations_greater_than_balance(self): 81 | amt = self.account.permitted_allocation( 82 | None, D('2.00'), D('120.00')) 83 | self.assertEqual(D('100.00'), amt) 84 | 85 | 86 | class TestAnAccountWithFundsButOnlyForProducts(TestCase): 87 | 88 | def setUp(self): 89 | self.account = Account() 90 | self.account.can_be_used_for_non_products = False 91 | self.account.balance = D('100.00') 92 | 93 | def test_doesnt_allow_shipping_in_allocation(self): 94 | amt = self.account.permitted_allocation( 95 | None, D('20.00'), D('40.00')) 96 | self.assertEqual(D('20.00'), amt) 97 | 98 | 99 | class TestANewZeroCreditLimitAccount(TestCase): 100 | 101 | def setUp(self): 102 | self.account = Account() 103 | 104 | def test_defaults_to_zero_credit_limit(self): 105 | self.assertEqual(D('0.00'), self.account.credit_limit) 106 | 107 | def test_does_not_permit_any_debits(self): 108 | self.assertFalse(self.account.is_debit_permitted(D('1.00'))) 109 | 110 | def test_has_zero_balance(self): 111 | self.assertEqual(D('0.00'), self.account.balance) 112 | 113 | def test_has_zero_transactions(self): 114 | self.assertEqual(0, self.account.num_transactions()) 115 | 116 | 117 | class TestAFixedCreditLimitAccount(TestCase): 118 | 119 | def setUp(self): 120 | self.account = AccountFactory( 121 | credit_limit=D('500'), start_date=None, end_date=None) 122 | 123 | def test_permits_smaller_and_equal_debits(self): 124 | for amt in (D('0.00'), D('1.00'), D('500')): 125 | self.assertTrue(self.account.is_debit_permitted(amt)) 126 | 127 | def test_does_not_permit_larger_amounts(self): 128 | for amt in (D('501'), D('1000')): 129 | self.assertFalse(self.account.is_debit_permitted(amt)) 130 | 131 | 132 | class TestAnUnlimitedCreditLimitAccount(TestCase): 133 | 134 | def setUp(self): 135 | self.account = AccountFactory( 136 | credit_limit=None, start_date=None, end_date=None) 137 | 138 | def test_permits_any_debit(self): 139 | for amt in (D('0.00'), D('1.00'), D('1000000')): 140 | self.assertTrue(self.account.is_debit_permitted(amt)) 141 | 142 | 143 | class TestAccountExpiredManager(TestCase): 144 | 145 | def test_includes_only_expired_accounts(self): 146 | now = timezone.now() 147 | AccountFactory(end_date=now - datetime.timedelta(days=1)) 148 | AccountFactory(end_date=now + datetime.timedelta(days=1)) 149 | accounts = Account.expired.all() 150 | self.assertEqual(1, accounts.count()) 151 | 152 | 153 | class TestAccountActiveManager(TestCase): 154 | 155 | def test_includes_only_active_accounts(self): 156 | now = timezone.now() 157 | expired = AccountFactory(end_date=now - datetime.timedelta(days=1)) 158 | AccountFactory(end_date=now + datetime.timedelta(days=1)) 159 | AccountFactory(start_date=now, end_date=now + datetime.timedelta(days=1)) 160 | accounts = Account.active.all() 161 | self.assertTrue(expired not in accounts) 162 | 163 | 164 | class TestATransaction(TestCase): 165 | 166 | def test_cannot_be_deleted(self): 167 | txn = TransactionFactory() 168 | with self.assertRaises(RuntimeError): 169 | txn.delete() 170 | 171 | def test_is_not_deleted_when_the_authorisor_is_deleted(self): 172 | user = UserFactory() 173 | source = AccountFactory(credit_limit=None, primary_user=user, start_date=None, end_date=None) 174 | destination = AccountFactory(start_date=None, end_date=None) 175 | txn = Transfer.objects.create(source, destination, 176 | D('20.00'), user=user) 177 | self.assertEqual(2, txn.transactions.all().count()) 178 | user.delete() 179 | self.assertEqual(2, txn.transactions.all().count()) 180 | -------------------------------------------------------------------------------- /src/oscar_accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from decimal import Decimal 5 | 6 | import django.db.models.deletion 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('offer', '__first__'), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Account', 21 | fields=[ 22 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 23 | ('name', models.CharField(max_length=128, unique=True, null=True, blank=True)), 24 | ('description', models.TextField(help_text='This text is shown to customers during checkout', null=True, blank=True)), 25 | ('code', models.CharField(max_length=128, unique=True, null=True, blank=True)), 26 | ('status', models.CharField(default='Open', max_length=32)), 27 | ('credit_limit', models.DecimalField(default=Decimal('0.00'), null=True, max_digits=12, decimal_places=2, blank=True)), 28 | ('balance', models.DecimalField(default=Decimal('0.00'), null=True, max_digits=12, decimal_places=2)), 29 | ('start_date', models.DateTimeField(null=True, blank=True)), 30 | ('end_date', models.DateTimeField(null=True, blank=True)), 31 | ('can_be_used_for_non_products', models.BooleanField(default=True, help_text='Whether this account can be used to pay for shipping and other charges')), 32 | ('date_created', models.DateTimeField(auto_now_add=True)), 33 | ], 34 | options={ 35 | 'abstract': False, 36 | }, 37 | bases=(models.Model,), 38 | ), 39 | migrations.CreateModel( 40 | name='AccountType', 41 | fields=[ 42 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 43 | ('path', models.CharField(unique=True, max_length=255)), 44 | ('depth', models.PositiveIntegerField()), 45 | ('numchild', models.PositiveIntegerField(default=0)), 46 | ('code', models.CharField(max_length=128, unique=True, null=True, blank=True)), 47 | ('name', models.CharField(max_length=128)), 48 | ], 49 | options={ 50 | 'abstract': False, 51 | }, 52 | bases=(models.Model,), 53 | ), 54 | migrations.CreateModel( 55 | name='IPAddressRecord', 56 | fields=[ 57 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 58 | ('ip_address', models.IPAddressField(unique=True, verbose_name='IP address')), 59 | ('total_failures', models.PositiveIntegerField(default=0)), 60 | ('consecutive_failures', models.PositiveIntegerField(default=0)), 61 | ('date_created', models.DateTimeField(auto_now_add=True)), 62 | ('date_last_failure', models.DateTimeField(null=True)), 63 | ], 64 | options={ 65 | 'abstract': False, 66 | 'verbose_name': 'IP address record', 67 | 'verbose_name_plural': 'IP address records', 68 | }, 69 | bases=(models.Model,), 70 | ), 71 | migrations.CreateModel( 72 | name='Transaction', 73 | fields=[ 74 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 75 | ('amount', models.DecimalField(max_digits=12, decimal_places=2)), 76 | ('date_created', models.DateTimeField(auto_now_add=True)), 77 | ('account', models.ForeignKey(related_name='transactions', to='oscar_accounts.Account')), 78 | ], 79 | options={ 80 | 'abstract': False, 81 | }, 82 | bases=(models.Model,), 83 | ), 84 | migrations.CreateModel( 85 | name='Transfer', 86 | fields=[ 87 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 88 | ('reference', models.CharField(max_length=64, unique=True, null=True)), 89 | ('amount', models.DecimalField(max_digits=12, decimal_places=2)), 90 | ('merchant_reference', models.CharField(max_length=128, null=True)), 91 | ('description', models.CharField(max_length=256, null=True)), 92 | ('username', models.CharField(max_length=128)), 93 | ('date_created', models.DateTimeField(auto_now_add=True)), 94 | ('destination', models.ForeignKey(related_name='destination_transfers', to='oscar_accounts.Account')), 95 | ('parent', models.ForeignKey(related_name='related_transfers', to='oscar_accounts.Transfer', null=True)), 96 | ('source', models.ForeignKey(related_name='source_transfers', to='oscar_accounts.Account')), 97 | ('user', models.ForeignKey(related_name='transfers', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)), 98 | ], 99 | options={ 100 | 'ordering': ('-date_created',), 101 | 'abstract': False, 102 | }, 103 | bases=(models.Model,), 104 | ), 105 | migrations.AddField( 106 | model_name='transaction', 107 | name='transfer', 108 | field=models.ForeignKey(related_name='transactions', to='oscar_accounts.Transfer'), 109 | preserve_default=True, 110 | ), 111 | migrations.AlterUniqueTogether( 112 | name='transaction', 113 | unique_together=set([('transfer', 'account')]), 114 | ), 115 | migrations.AddField( 116 | model_name='account', 117 | name='account_type', 118 | field=models.ForeignKey(related_name='accounts', to='oscar_accounts.AccountType', null=True), 119 | preserve_default=True, 120 | ), 121 | migrations.AddField( 122 | model_name='account', 123 | name='primary_user', 124 | field=models.ForeignKey(related_name='accounts', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True), 125 | preserve_default=True, 126 | ), 127 | migrations.AddField( 128 | model_name='account', 129 | name='product_range', 130 | field=models.ForeignKey(blank=True, to='offer.Range', null=True), 131 | preserve_default=True, 132 | ), 133 | migrations.AddField( 134 | model_name='account', 135 | name='secondary_users', 136 | field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, blank=True), 137 | preserve_default=True, 138 | ), 139 | ] 140 | -------------------------------------------------------------------------------- /sandbox/apps/checkout/views.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django import http 4 | from django.contrib import messages 5 | from django.core.urlresolvers import reverse 6 | from django.utils.translation import ugettext_lazy as _ 7 | from oscar.apps.checkout import views 8 | from oscar.apps.payment import exceptions 9 | from oscar.apps.payment.models import Source, SourceType 10 | 11 | from oscar_accounts import exceptions as act_exceptions 12 | from oscar_accounts import security 13 | from oscar_accounts.checkout import forms, gateway 14 | from oscar_accounts.checkout.allocation import Allocations 15 | 16 | 17 | class PaymentDetailsView(views.PaymentDetailsView): 18 | 19 | # Override core methods 20 | 21 | def get_context_data(self, **kwargs): 22 | ctx = super(PaymentDetailsView, self).get_context_data(**kwargs) 23 | 24 | # Add variable to indicate if the user is blocked from paying with 25 | # accounts. 26 | ctx['is_blocked'] = security.is_blocked(self.request) 27 | 28 | form = forms.ValidAccountForm(self.request.user) 29 | ctx['account_form'] = form 30 | 31 | # Add accounts that are linked to this user 32 | if self.request.user.is_authenticated(): 33 | ctx['user_accounts'] = gateway.user_accounts(self.request.user) 34 | 35 | # Add existing allocations to context 36 | allocations = self.get_account_allocations() 37 | ctx['account_allocations'] = allocations 38 | ctx['to_allocate'] = ctx['order_total_incl_tax'] - allocations.total 39 | 40 | return ctx 41 | 42 | def post(self, request, *args, **kwargs): 43 | # Intercept POST requests to look for attempts to allocate to an 44 | # account, or remove an allocation. 45 | action = self.request.POST.get('action', None) 46 | if action == 'select_account': 47 | return self.select_account(request) 48 | elif action == 'allocate': 49 | return self.add_allocation(request) 50 | elif action == 'remove_allocation': 51 | return self.remove_allocation(request) 52 | return super(PaymentDetailsView, self).post(request, *args, **kwargs) 53 | 54 | def handle_payment(self, order_number, total, **kwargs): 55 | # Override payment method to use accounts to pay for the order 56 | allocations = self.get_account_allocations() 57 | if allocations.total != total: 58 | raise exceptions.UnableToTakePayment( 59 | "Your account allocations do not cover the order total") 60 | 61 | try: 62 | gateway.redeem(order_number, self.request.user, allocations) 63 | except act_exceptions.AccountException: 64 | raise exceptions.UnableToTakePayment( 65 | "An error occurred with the account redemption") 66 | 67 | # If we get here, payment was successful. We record the payment 68 | # sources and event to complete the audit trail for this order 69 | source_type, __ = SourceType.objects.get_or_create( 70 | name="Account") 71 | for code, amount in allocations.items(): 72 | source = Source( 73 | source_type=source_type, 74 | amount_debited=amount, reference=code) 75 | self.add_payment_source(source) 76 | self.add_payment_event("Settle", total) 77 | 78 | # Custom form-handling methods 79 | 80 | def select_account(self, request): 81 | ctx = self.get_context_data() 82 | 83 | # Check for blocked users 84 | if security.is_blocked(request): 85 | messages.error(request, 86 | "You are currently blocked from using accounts") 87 | return http.HttpResponseRedirect( 88 | reverse('checkout:payment-deatils')) 89 | 90 | # If account form has been submitted, validate it and show the 91 | # allocation form if the account has non-zero balance 92 | form = forms.ValidAccountForm(self.request.user, 93 | self.request.POST) 94 | ctx['account_form'] = form 95 | if not form.is_valid(): 96 | security.record_failed_request(self.request) 97 | return self.render_to_response(ctx) 98 | 99 | security.record_successful_request(self.request) 100 | ctx['allocation_form'] = forms.AllocationForm( 101 | form.account, self.request.basket, 102 | ctx['shipping_total_incl_tax'], 103 | ctx['order_total_incl_tax'], 104 | self.get_account_allocations()) 105 | return self.render_to_response(ctx) 106 | 107 | def add_allocation(self, request): 108 | # We have two forms to validate, first check the account form 109 | account_form = forms.ValidAccountForm(request.user, 110 | self.request.POST) 111 | if not account_form.is_valid(): 112 | # Only manipulation can get us here 113 | messages.error(request, 114 | _("An error occurred allocating from your account")) 115 | return http.HttpResponseRedirect(reverse( 116 | 'checkout:payment-details')) 117 | 118 | # Account is still valid, now check requested allocation 119 | ctx = self.get_context_data() 120 | allocation_form = forms.AllocationForm( 121 | account_form.account, self.request.basket, 122 | ctx['shipping_total_incl_tax'], 123 | ctx['order_total_incl_tax'], 124 | self.get_account_allocations(), 125 | data=self.request.POST) 126 | if not allocation_form.is_valid(): 127 | ctx = self.get_context_data() 128 | ctx['allocation_form'] = allocation_form 129 | ctx['account_form'] = account_form 130 | return self.render_to_response(ctx) 131 | 132 | # Allocation is valid - record in session and reload page 133 | self.store_allocation_in_session(allocation_form) 134 | messages.success(request, _("Allocation recorded")) 135 | return http.HttpResponseRedirect(reverse( 136 | 'checkout:payment-details')) 137 | 138 | def remove_allocation(self, request): 139 | code = None 140 | for key in request.POST.keys(): 141 | if key.startswith('remove_'): 142 | code = key.replace('remove_', '') 143 | allocations = self.get_account_allocations() 144 | if not allocations.contains(code): 145 | messages.error( 146 | request, _("No allocation found with code '%s'") % code) 147 | else: 148 | allocations.remove(code) 149 | self.set_account_allocations(allocations) 150 | messages.success(request, _("Allocation removed")) 151 | return http.HttpResponseRedirect(reverse('checkout:payment-details')) 152 | 153 | def store_allocation_in_session(self, form): 154 | allocations = self.get_account_allocations() 155 | allocations.add(form.account.code, form.cleaned_data['amount']) 156 | self.set_account_allocations(allocations) 157 | 158 | # The below methods could be put onto a customised version of 159 | # oscar.apps.checkout.utils.CheckoutSessionData. They are kept here for 160 | # simplicity 161 | 162 | def get_account_allocations(self): 163 | return self.checkout_session._get('accounts', 'allocations', 164 | Allocations()) 165 | 166 | def set_account_allocations(self, allocations): 167 | return self.checkout_session._set('accounts', 168 | 'allocations', allocations) 169 | -------------------------------------------------------------------------------- /sandbox/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from decimal import Decimal as D 3 | 4 | from oscar import OSCAR_MAIN_TEMPLATE_DIR, get_core_apps 5 | from oscar.defaults import * # noqa 6 | 7 | from oscar_accounts import TEMPLATE_DIR as ACCOUNTS_TEMPLATE_DIR 8 | 9 | PROJECT_DIR = os.path.dirname(__file__) 10 | location = lambda x: os.path.join(os.path.dirname(os.path.realpath(__file__)), x) 11 | 12 | DEBUG = True 13 | TEMPLATE_DEBUG = True 14 | SQL_DEBUG = True 15 | 16 | ADMINS = ( 17 | # ('Your Name', 'your_email@domain.com'), 18 | ) 19 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 20 | 21 | MANAGERS = ADMINS 22 | 23 | DATABASES = { 24 | 'default': { 25 | 'ENGINE': 'django.db.backends.sqlite3', 26 | 'NAME': os.path.join(os.path.dirname(__file__), 'db.sqlite'), 27 | } 28 | } 29 | ATOMIC_REQUESTS = True 30 | 31 | # Local time zone for this installation. Choices can be found here: 32 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 33 | # although not all choices may be available on all operating systems. 34 | # On Unix systems, a value of None will cause Django to use the same 35 | # timezone as the operating system. 36 | # If running in a Windows environment this must be set to the same as your 37 | # system time zone. 38 | TIME_ZONE = 'Europe/London' 39 | 40 | # Language code for this installation. All choices can be found here: 41 | # http://www.i18nguy.com/unicode/language-identifiers.html 42 | LANGUAGE_CODE = 'en-us' 43 | 44 | SITE_ID = 1 45 | 46 | # If you set this to False, Django will make some optimizations so as not 47 | # to load the internationalization machinery. 48 | USE_I18N = True 49 | 50 | # If you set this to False, Django will not format dates, numbers and 51 | # calendars according to the current locale 52 | USE_L10N = True 53 | 54 | # Absolute path to the directory that holds media. 55 | # Example: "/home/media/media.lawrence.com/" 56 | MEDIA_ROOT = location("assets") 57 | 58 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 59 | # trailing slash if there is a path component (optional in other cases). 60 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 61 | MEDIA_URL = '/media/' 62 | 63 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 64 | # trailing slash. 65 | # Examples: "http://foo.com/media/", "/media/". 66 | #ADMIN_MEDIA_PREFIX = '/media/admin/' 67 | 68 | STATIC_URL = '/static/' 69 | STATIC_ROOT = location("static") 70 | STATICFILES_DIRS = () 71 | STATICFILES_FINDERS = ( 72 | 'django.contrib.staticfiles.finders.FileSystemFinder', 73 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 74 | ) 75 | 76 | # Make this unique, and don't share it with anybody. 77 | SECRET_KEY = '$)a7n&o80u!6y5t-+jrd3)3!%vh&shg$wqpjpxc!ar&p#!)n1a' 78 | 79 | # List of callables that know how to import templates from various sources. 80 | TEMPLATE_LOADERS = ( 81 | 'django.template.loaders.filesystem.Loader', 82 | 'django.template.loaders.app_directories.Loader', 83 | 'django.template.loaders.eggs.Loader', 84 | ) 85 | 86 | TEMPLATE_CONTEXT_PROCESSORS = ( 87 | "django.contrib.auth.context_processors.auth", 88 | "django.core.context_processors.request", 89 | "django.core.context_processors.debug", 90 | "django.core.context_processors.i18n", 91 | "django.core.context_processors.media", 92 | "django.core.context_processors.static", 93 | "django.contrib.messages.context_processors.messages", 94 | # Oscar specific 95 | 'oscar.apps.search.context_processors.search_form', 96 | 'oscar.apps.promotions.context_processors.promotions', 97 | 'oscar.apps.checkout.context_processors.checkout', 98 | 'oscar.core.context_processors.metadata', 99 | ) 100 | 101 | MIDDLEWARE_CLASSES = ( 102 | 'django.middleware.common.CommonMiddleware', 103 | 'django.contrib.sessions.middleware.SessionMiddleware', 104 | 'django.middleware.csrf.CsrfViewMiddleware', 105 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 106 | 'django.contrib.messages.middleware.MessageMiddleware', 107 | 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', 108 | 'oscar.apps.basket.middleware.BasketMiddleware', 109 | ) 110 | 111 | INTERNAL_IPS = ('127.0.0.1',) 112 | 113 | ROOT_URLCONF = 'urls' 114 | 115 | TEMPLATE_DIRS = ( 116 | location('templates'), 117 | OSCAR_MAIN_TEMPLATE_DIR, 118 | ACCOUNTS_TEMPLATE_DIR, 119 | ) 120 | 121 | # A sample logging configuration. The only tangible logging 122 | # performed by this configuration is to send an email to 123 | # the site admins on every HTTP 500 error. 124 | # See http://docs.djangoproject.com/en/dev/topics/logging for 125 | # more details on how to customize your logging configuration. 126 | LOGGING = { 127 | 'version': 1, 128 | 'disable_existing_loggers': False, 129 | 'formatters': { 130 | 'verbose': { 131 | 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' 132 | }, 133 | 'simple': { 134 | 'format': '%(levelname)s %(message)s' 135 | }, 136 | }, 137 | 'handlers': { 138 | 'null': { 139 | 'level': 'DEBUG', 140 | 'class': 'django.utils.log.NullHandler', 141 | }, 142 | 'console': { 143 | 'level': 'DEBUG', 144 | 'class': 'logging.StreamHandler', 145 | 'formatter': 'verbose' 146 | }, 147 | 'mail_admins': { 148 | 'level': 'ERROR', 149 | 'class': 'django.utils.log.AdminEmailHandler', 150 | }, 151 | }, 152 | 'loggers': { 153 | 'django': { 154 | 'handlers': ['null'], 155 | 'propagate': True, 156 | 'level': 'INFO', 157 | }, 158 | 'django.request': { 159 | 'handlers': ['mail_admins'], 160 | 'level': 'ERROR', 161 | 'propagate': False, 162 | }, 163 | 'oscar.checkout': { 164 | 'handlers': ['console'], 165 | 'propagate': True, 166 | 'level': 'INFO', 167 | }, 168 | 'django.db.backends': { 169 | 'handlers': ['null'], 170 | 'propagate': False, 171 | 'level': 'DEBUG', 172 | }, 173 | 'accounts': { 174 | 'handlers': ['console'], 175 | 'propagate': False, 176 | 'level': 'DEBUG', 177 | }, 178 | } 179 | } 180 | 181 | 182 | INSTALLED_APPS = [ 183 | 'django.contrib.auth', 184 | 'django.contrib.contenttypes', 185 | 'django.contrib.sessions', 186 | 'django.contrib.sites', 187 | 'django.contrib.messages', 188 | 'django.contrib.admin', 189 | 'django.contrib.flatpages', 190 | 'django.contrib.staticfiles', 191 | 192 | # External apps 193 | 'widget_tweaks', 194 | ] + get_core_apps(['apps.shipping']) + ['oscar_accounts'] 195 | 196 | AUTHENTICATION_BACKENDS = ( 197 | 'oscar.apps.customer.auth_backends.Emailbackend', 198 | 'django.contrib.auth.backends.ModelBackend', 199 | ) 200 | 201 | LOGIN_REDIRECT_URL = '/accounts/' 202 | APPEND_SLASH = True 203 | 204 | 205 | OSCAR_SHOP_TAGLINE = "Accounts" 206 | 207 | HAYSTACK_CONNECTIONS = { 208 | 'default': { 209 | 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine', 210 | }, 211 | } 212 | 213 | USE_TZ = True 214 | 215 | # Accounts settings 216 | # ================= 217 | 218 | OSCAR_DASHBOARD_NAVIGATION.append( 219 | { 220 | 'label': 'Accounts', 221 | 'icon': 'icon-globe', 222 | 'children': [ 223 | { 224 | 'label': 'Accounts', 225 | 'url_name': 'accounts-list', 226 | }, 227 | { 228 | 'label': 'Transfers', 229 | 'url_name': 'transfers-list', 230 | }, 231 | { 232 | 'label': 'Deferred income report', 233 | 'url_name': 'report-deferred-income', 234 | }, 235 | { 236 | 'label': 'Profit/loss report', 237 | 'url_name': 'report-profit-loss', 238 | }, 239 | ] 240 | }) 241 | 242 | ACCOUNTS_UNIT_NAME = 'Giftcard' 243 | ACCOUNTS_UNIT_NAME_PLURAL = 'Giftcards' 244 | ACCOUNTS_MIN_LOAD_VALUE = D('30.00') 245 | ACCOUNTS_MAX_ACCOUNT_VALUE = D('1000.00') 246 | 247 | try: 248 | from settings_local import * 249 | except ImportError: 250 | pass 251 | -------------------------------------------------------------------------------- /src/oscar_accounts/dashboard/forms.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.core import exceptions 6 | from django.utils.translation import ugettext_lazy as _ 7 | from oscar.core.loading import get_model 8 | from oscar.forms.widgets import DatePickerInput 9 | from oscar.templatetags.currency_filters import currency 10 | 11 | from oscar_accounts import codes, names 12 | 13 | Account = get_model('oscar_accounts', 'Account') 14 | AccountType = get_model('oscar_accounts', 'AccountType') 15 | 16 | 17 | class SearchForm(forms.Form): 18 | name = forms.CharField(required=False) 19 | code = forms.CharField(required=False) 20 | STATUS_CHOICES = ( 21 | ('', "------"), 22 | (Account.OPEN, _("Open")), 23 | (Account.FROZEN, _("Frozen")), 24 | (Account.CLOSED, _("Closed"))) 25 | status = forms.ChoiceField(choices=STATUS_CHOICES, required=False) 26 | 27 | 28 | class TransferSearchForm(forms.Form): 29 | reference = forms.CharField(required=False) 30 | start_date = forms.DateField(required=False, widget=DatePickerInput) 31 | end_date = forms.DateField(required=False, widget=DatePickerInput) 32 | 33 | 34 | class EditAccountForm(forms.ModelForm): 35 | name = forms.CharField(label=_("Name"), required=True) 36 | 37 | class Meta: 38 | model = Account 39 | exclude = ['status', 'code', 'credit_limit', 'balance'] 40 | widgets = { 41 | 'start_date': DatePickerInput, 42 | 'end_date': DatePickerInput, 43 | } 44 | 45 | def __init__(self, *args, **kwargs): 46 | super(EditAccountForm, self).__init__(*args, **kwargs) 47 | self.fields['product_range'].help_text = ( 48 | "You may need to create a product range first") 49 | 50 | # Add field for account type (if there is a choice) 51 | deferred_income = AccountType.objects.get(name=names.DEFERRED_INCOME) 52 | types = deferred_income.get_children() 53 | if types.count() > 1: 54 | self.fields['account_type'] = forms.ModelChoiceField( 55 | queryset=types) 56 | elif types.count() == 1: 57 | del self.fields['account_type'] 58 | self._account_type = types[0] 59 | else: 60 | raise exceptions.ImproperlyConfigured( 61 | "You need to define some 'deferred income' account types") 62 | 63 | 64 | 65 | class SourceAccountMixin(object): 66 | 67 | def __init__(self, *args, **kwargs): 68 | super(SourceAccountMixin, self).__init__(*args, **kwargs) 69 | 70 | # Add field for source account (if there is a choice) 71 | unpaid_sources = AccountType.objects.get( 72 | name=names.UNPAID_ACCOUNT_TYPE) 73 | sources = unpaid_sources.accounts.all() 74 | if sources.count() > 1: 75 | self.fields['source_account'] = forms.ModelChoiceField( 76 | queryset=unpaid_sources.accounts.all()) 77 | elif sources.count() == 1: 78 | self._source_account = sources[0] 79 | else: 80 | raise exceptions.ImproperlyConfigured( 81 | "You need to define some 'unpaid source' accounts") 82 | 83 | def get_source_account(self): 84 | if 'source_account' in self.cleaned_data: 85 | return self.cleaned_data['source_account'] 86 | return self._source_account 87 | 88 | 89 | class NewAccountForm(SourceAccountMixin, EditAccountForm): 90 | initial_amount = forms.DecimalField( 91 | min_value=getattr(settings, 'ACCOUNTS_MIN_LOAD_VALUE', D('0.00')), 92 | max_value=getattr(settings, 'ACCOUNTS_MAX_ACCOUNT_VALUE', None), 93 | decimal_places=2) 94 | 95 | def __init__(self, *args, **kwargs): 96 | super(NewAccountForm, self).__init__(*args, **kwargs) 97 | 98 | # Add field for source account (if there is a choice) 99 | unpaid_sources = AccountType.objects.get( 100 | name=names.UNPAID_ACCOUNT_TYPE) 101 | sources = unpaid_sources.accounts.all() 102 | if sources.count() > 1: 103 | self.fields['source_account'] = forms.ModelChoiceField( 104 | queryset=unpaid_sources.accounts.all()) 105 | elif sources.count() == 1: 106 | self._source_account = sources[0] 107 | else: 108 | raise exceptions.ImproperlyConfigured( 109 | "You need to define some 'unpaid source' accounts") 110 | 111 | def save(self, *args, **kwargs): 112 | kwargs['commit'] = False 113 | account = super(NewAccountForm, self).save(*args, **kwargs) 114 | account.code = codes.generate() 115 | if hasattr(self, '_account_type'): 116 | account.account_type = self._account_type 117 | account.save() 118 | self.save_m2m() 119 | return account 120 | 121 | def get_source_account(self): 122 | if 'source_account' in self.cleaned_data: 123 | return self.cleaned_data['source_account'] 124 | return self._source_account 125 | 126 | 127 | class UpdateAccountForm(EditAccountForm): 128 | pass 129 | 130 | 131 | class ChangeStatusForm(forms.ModelForm): 132 | status = forms.CharField(widget=forms.widgets.HiddenInput) 133 | new_status = None 134 | 135 | def __init__(self, *args, **kwargs): 136 | kwargs['initial']['status'] = self.new_status 137 | super(ChangeStatusForm, self).__init__(*args, **kwargs) 138 | 139 | class Meta: 140 | model = Account 141 | exclude = ['name', 'account_type', 'description', 'category', 'code', 'start_date', 142 | 'end_date', 'credit_limit', 'balance', 'product_range', 143 | 'primary_user', 'secondary_users', 144 | 'can_be_used_for_non_products'] 145 | 146 | 147 | class FreezeAccountForm(ChangeStatusForm): 148 | new_status = Account.FROZEN 149 | 150 | 151 | class ThawAccountForm(ChangeStatusForm): 152 | new_status = Account.OPEN 153 | 154 | 155 | class TopUpAccountForm(SourceAccountMixin, forms.Form): 156 | amount = forms.DecimalField( 157 | min_value=getattr(settings, 'ACCOUNTS_MIN_LOAD_VALUE', D('0.00')), 158 | max_value=getattr(settings, 'ACCOUNTS_MAX_ACCOUNT_VALUE', None), 159 | decimal_places=2) 160 | 161 | def __init__(self, *args, **kwargs): 162 | self.account = kwargs.pop('instance') 163 | super(TopUpAccountForm, self).__init__(*args, **kwargs) 164 | 165 | def clean_amount(self): 166 | amt = self.cleaned_data['amount'] 167 | if hasattr(settings, 'ACCOUNTS_MAX_ACCOUNT_VALUE'): 168 | max_amount = ( 169 | settings.ACCOUNTS_MAX_ACCOUNT_VALUE - self.account.balance) 170 | if amt > max_amount: 171 | raise forms.ValidationError(_( 172 | "The maximum permitted top-up amount is %s") % ( 173 | currency(max_amount))) 174 | return amt 175 | 176 | def clean(self): 177 | if self.account.is_closed(): 178 | raise forms.ValidationError(_("Account is closed")) 179 | elif self.account.is_frozen(): 180 | raise forms.ValidationError(_("Account is frozen")) 181 | return self.cleaned_data 182 | 183 | 184 | class WithdrawFromAccountForm(SourceAccountMixin, forms.Form): 185 | amount = forms.DecimalField( 186 | min_value=D('0.00'), 187 | max_value=None, 188 | decimal_places=2) 189 | 190 | def __init__(self, *args, **kwargs): 191 | self.account = kwargs.pop('instance') 192 | super(WithdrawFromAccountForm, self).__init__(*args, **kwargs) 193 | 194 | def clean_amount(self): 195 | amt = self.cleaned_data['amount'] 196 | max_amount = self.account.balance 197 | if amt > max_amount: 198 | raise forms.ValidationError(_( 199 | "The account has only %s") % ( 200 | currency(max_amount))) 201 | return amt 202 | 203 | def clean(self): 204 | if self.account.is_closed(): 205 | raise forms.ValidationError(_("Account is closed")) 206 | elif self.account.is_frozen(): 207 | raise forms.ValidationError(_("Account is frozen")) 208 | return self.cleaned_data 209 | 210 | 211 | class DateForm(forms.Form): 212 | date = forms.DateField(widget=DatePickerInput) 213 | 214 | 215 | class DateRangeForm(forms.Form): 216 | start_date = forms.DateField(label=_("From"), widget=DatePickerInput) 217 | end_date = forms.DateField(label=_("To"), widget=DatePickerInput) 218 | -------------------------------------------------------------------------------- /src/oscar_accounts/api/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from decimal import Decimal as D 3 | from decimal import InvalidOperation 4 | 5 | from dateutil import parser 6 | from django import http 7 | from django.conf import settings 8 | from django.core.urlresolvers import reverse 9 | from django.shortcuts import get_object_or_404 10 | from django.utils import timezone 11 | from django.views import generic 12 | from oscar.core.loading import get_model 13 | 14 | from oscar_accounts import codes, exceptions, facade, names 15 | from oscar_accounts.api import errors 16 | 17 | Account = get_model('oscar_accounts', 'Account') 18 | AccountType = get_model('oscar_accounts', 'AccountType') 19 | Transfer = get_model('oscar_accounts', 'Transfer') 20 | 21 | 22 | class InvalidPayload(Exception): 23 | pass 24 | 25 | 26 | class ValidationError(Exception): 27 | def __init__(self, code, *args, **kwargs): 28 | self.code = code 29 | super(ValidationError, self).__init__(*args, **kwargs) 30 | 31 | 32 | class JSONView(generic.View): 33 | required_keys = () 34 | optional_keys = () 35 | 36 | # Error handlers 37 | 38 | def forbidden(self, code=None, msg=None): 39 | # Forbidden by business logic 40 | return self.error(403, code, msg) 41 | 42 | def bad_request(self, code=None, msg=None): 43 | # Bad syntax (eg missing keys) 44 | return self.error(400, code, msg) 45 | 46 | def error(self, status_code, code, msg): 47 | data = {'code': code if code is not None else '', 48 | 'message': msg if msg is not None else errors.message(code)} 49 | return http.HttpResponse(json.dumps(data), 50 | status=status_code, 51 | content_type='application/json') 52 | 53 | # Success handlers 54 | 55 | def created(self, url, data): 56 | response = http.HttpResponse( 57 | json.dumps(data), content_type='application/json', 58 | status=201) 59 | response['Location'] = url 60 | return response 61 | 62 | def ok(self, data): 63 | return http.HttpResponse(json.dumps(data), 64 | content_type='application/json') 65 | 66 | def post(self, request, *args, **kwargs): 67 | # Only accept JSON 68 | if request.META['CONTENT_TYPE'] != 'application/json': 69 | return self.bad_request( 70 | msg="Requests must have CONTENT_TYPE 'application/json'") 71 | try: 72 | payload = json.loads(request.body.decode('utf-8')) 73 | except ValueError: 74 | return self.bad_request( 75 | msg="JSON payload could not be decoded") 76 | try: 77 | self.validate_payload(payload) 78 | except InvalidPayload as e: 79 | return self.bad_request(msg=str(e)) 80 | except ValidationError as e: 81 | return self.forbidden(code=e.code, msg=errors.message(e.code)) 82 | # We can still get a ValidationError even if the payload itself is 83 | # valid. 84 | try: 85 | return self.valid_payload(payload) 86 | except ValidationError as e: 87 | return self.forbidden(code=e.code, msg=errors.message(e.code)) 88 | 89 | def validate_payload(self, payload): 90 | # We mimic Django's forms API by using dynamic dispatch to call clean_* 91 | # methods, and use a single 'clean' method to validate relations 92 | # between fields. 93 | for key in self.required_keys: 94 | if key not in payload: 95 | raise InvalidPayload(( 96 | "Mandatory field '%s' is missing from JSON " 97 | "payload") % key) 98 | validator_method = 'clean_%s' % key 99 | if hasattr(self, validator_method): 100 | payload[key] = getattr(self, validator_method)(payload[key]) 101 | for key in self.optional_keys: 102 | validator_method = 'clean_%s' % key 103 | if hasattr(self, validator_method): 104 | payload[key] = getattr(self, validator_method)(payload[key]) 105 | if hasattr(self, 'clean'): 106 | getattr(self, 'clean')(payload) 107 | 108 | 109 | class AccountsView(JSONView): 110 | """ 111 | For creating new accounts 112 | """ 113 | required_keys = ('start_date', 'end_date', 'amount', 'account_type') 114 | 115 | def clean_amount(self, value): 116 | try: 117 | amount = D(value) 118 | except InvalidOperation: 119 | raise InvalidPayload("'%s' is not a valid amount" % value) 120 | if amount < 0: 121 | raise InvalidPayload("Amount must be positive") 122 | if amount < getattr(settings, 'ACCOUNTS_MIN_LOAD_VALUE', D('0.00')): 123 | raise ValidationError(errors.AMOUNT_TOO_LOW) 124 | if hasattr(settings, 'ACCOUNTS_MAX_ACCOUNT_VALUE'): 125 | if amount > getattr(settings, 'ACCOUNTS_MAX_ACCOUNT_VALUE'): 126 | raise ValidationError(errors.AMOUNT_TOO_HIGH) 127 | return amount 128 | 129 | def clean_start_date(self, value): 130 | start_date = parser.parse(value) 131 | if timezone.is_naive(start_date): 132 | raise InvalidPayload( 133 | 'Start date must include timezone information') 134 | return start_date 135 | 136 | def clean_end_date(self, value): 137 | end_date = parser.parse(value) 138 | if timezone.is_naive(end_date): 139 | raise InvalidPayload( 140 | 'End date must include timezone information') 141 | return end_date 142 | 143 | def clean_account_type(self, value): 144 | # Name must be one from a predefined set of values 145 | if value not in names.DEFERRED_INCOME_ACCOUNT_TYPES: 146 | raise InvalidPayload('Unrecognised account type') 147 | try: 148 | acc_type = AccountType.objects.get(name=value) 149 | except AccountType.DoesNotExist: 150 | raise InvalidPayload('Unrecognised account type') 151 | return acc_type 152 | 153 | def clean(self, payload): 154 | if payload['start_date'] > payload['end_date']: 155 | raise InvalidPayload( 156 | 'Start date must be before end date') 157 | 158 | def valid_payload(self, payload): 159 | account = self.create_account(payload) 160 | try: 161 | self.load_account(account, payload) 162 | except exceptions.AccountException as e: 163 | account.delete() 164 | return self.forbidden( 165 | code=errors.CANNOT_CREATE_ACCOUNT, 166 | msg=e.message) 167 | else: 168 | return self.created( 169 | reverse('account', kwargs={'code': account.code}), 170 | account.as_dict()) 171 | 172 | def create_account(self, payload): 173 | return Account.objects.create( 174 | account_type=payload['account_type'], 175 | start_date=payload['start_date'], 176 | end_date=payload['end_date'], 177 | code=codes.generate() 178 | ) 179 | 180 | def load_account(self, account, payload): 181 | bank = Account.objects.get(name=names.BANK) 182 | facade.transfer(bank, account, payload['amount'], 183 | description="Load from bank") 184 | 185 | 186 | class AccountView(JSONView): 187 | """ 188 | Fetch details of an account 189 | """ 190 | def get(self, request, *args, **kwargs): 191 | account = get_object_or_404(Account, code=kwargs['code']) 192 | return self.ok(account.as_dict()) 193 | 194 | 195 | class AccountRedemptionsView(JSONView): 196 | required_keys = ('amount',) 197 | optional_keys = ('merchant_reference',) 198 | 199 | def clean_amount(self, value): 200 | try: 201 | amount = D(value) 202 | except InvalidOperation: 203 | raise InvalidPayload("'%s' is not a valid amount" % value) 204 | if amount < 0: 205 | raise InvalidPayload("Amount must be positive") 206 | return amount 207 | 208 | def valid_payload(self, payload): 209 | """ 210 | Redeem an amount from the selected giftcard 211 | """ 212 | account = get_object_or_404(Account, code=self.kwargs['code']) 213 | if not account.is_active(): 214 | raise ValidationError(errors.ACCOUNT_INACTIVE) 215 | amt = payload['amount'] 216 | if not account.is_debit_permitted(amt): 217 | raise ValidationError(errors.INSUFFICIENT_FUNDS) 218 | 219 | redemptions = Account.objects.get(name=names.REDEMPTIONS) 220 | try: 221 | transfer = facade.transfer( 222 | account, redemptions, amt, 223 | merchant_reference=payload.get('merchant_reference', None)) 224 | except exceptions.AccountException as e: 225 | return self.forbidden( 226 | code=errors.CANNOT_CREATE_TRANSFER, 227 | msg=e.message) 228 | return self.created( 229 | reverse('transfer', kwargs={'reference': transfer.reference}), 230 | transfer.as_dict()) 231 | 232 | 233 | class AccountRefundsView(JSONView): 234 | required_keys = ('amount',) 235 | optional_keys = ('merchant_reference',) 236 | 237 | def clean_amount(self, value): 238 | try: 239 | amount = D(value) 240 | except InvalidOperation: 241 | raise InvalidPayload("'%s' is not a valid amount" % value) 242 | if amount < 0: 243 | raise InvalidPayload("Amount must be positive") 244 | return amount 245 | 246 | def valid_payload(self, payload): 247 | account = get_object_or_404(Account, code=self.kwargs['code']) 248 | if not account.is_active(): 249 | raise ValidationError(errors.ACCOUNT_INACTIVE) 250 | redemptions = Account.objects.get(name=names.REDEMPTIONS) 251 | try: 252 | transfer = facade.transfer( 253 | redemptions, account, payload['amount'], 254 | merchant_reference=payload.get('merchant_reference', None)) 255 | except exceptions.AccountException as e: 256 | return self.forbidden( 257 | code=errors.CANNOT_CREATE_TRANSFER, 258 | msg=e.message) 259 | return self.created( 260 | reverse('transfer', kwargs={'reference': transfer.reference}), 261 | transfer.as_dict()) 262 | 263 | 264 | class TransferView(JSONView): 265 | def get(self, request, *args, **kwargs): 266 | transfer = get_object_or_404(Transfer, reference=kwargs['reference']) 267 | return self.ok(transfer.as_dict()) 268 | 269 | 270 | class TransferReverseView(JSONView): 271 | optional_keys = ('merchant_reference',) 272 | 273 | def valid_payload(self, payload): 274 | to_reverse = get_object_or_404(Transfer, 275 | reference=self.kwargs['reference']) 276 | if not to_reverse.source.is_active(): 277 | raise ValidationError(errors.ACCOUNT_INACTIVE) 278 | merchant_reference = payload.get('merchant_reference', None) 279 | try: 280 | transfer = facade.reverse(to_reverse, 281 | merchant_reference=merchant_reference) 282 | except exceptions.AccountException as e: 283 | return self.forbidden( 284 | code=errors.CANNOT_CREATE_TRANSFER, 285 | msg=e.message) 286 | return self.created( 287 | reverse('transfer', kwargs={'reference': transfer.reference}), 288 | transfer.as_dict()) 289 | 290 | 291 | class TransferRefundsView(JSONView): 292 | required_keys = ('amount',) 293 | optional_keys = ('merchant_reference',) 294 | 295 | def clean_amount(self, value): 296 | try: 297 | amount = D(value) 298 | except InvalidOperation: 299 | raise InvalidPayload("'%s' is not a valid amount" % value) 300 | if amount < 0: 301 | raise InvalidPayload("Amount must be positive") 302 | return amount 303 | 304 | def valid_payload(self, payload): 305 | to_refund = get_object_or_404(Transfer, 306 | reference=self.kwargs['reference']) 307 | amount = payload['amount'] 308 | max_refund = to_refund.max_refund() 309 | if amount > max_refund: 310 | return self.forbidden( 311 | ("Refund not permitted: maximum refund permitted " 312 | "is %.2f") % max_refund) 313 | if not to_refund.source.is_active(): 314 | raise ValidationError(errors.ACCOUNT_INACTIVE) 315 | try: 316 | transfer = facade.transfer( 317 | to_refund.destination, to_refund.source, 318 | payload['amount'], parent=to_refund, 319 | merchant_reference=payload.get('merchant_reference', None)) 320 | except exceptions.AccountException as e: 321 | return self.forbidden( 322 | code=errors.CANNOT_CREATE_TRANSFER, 323 | msg=e.message) 324 | return self.created( 325 | reverse('transfer', kwargs={'reference': transfer.reference}), 326 | transfer.as_dict()) 327 | -------------------------------------------------------------------------------- /tests/functional/api/rest_tests.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from decimal import Decimal as D 4 | 5 | from oscar_accounts import models 6 | from django import test 7 | from django.contrib.auth.models import User 8 | from django.core.urlresolvers import reverse 9 | from django.test.client import Client 10 | from django.utils.encoding import force_bytes 11 | 12 | from tests.conftest import default_accounts 13 | 14 | USERNAME, PASSWORD = 'client', 'password' 15 | 16 | 17 | def get_headers(): 18 | # Create a user to authenticate as 19 | try: 20 | User.objects.get(username=USERNAME) 21 | except User.DoesNotExist: 22 | User.objects.create_user(USERNAME, None, PASSWORD) 23 | auth = "%s:%s" % (USERNAME, PASSWORD) 24 | auth_headers = { 25 | 'HTTP_AUTHORIZATION': b'Basic ' + base64.b64encode(auth.encode('utf-8')) 26 | } 27 | return auth_headers 28 | 29 | 30 | def get(url): 31 | return Client().get(url, **get_headers()) 32 | 33 | 34 | def post(url, payload): 35 | """ 36 | POST a JSON-encoded payload 37 | """ 38 | return Client().post( 39 | url, json.dumps(payload), 40 | content_type="application/json", 41 | **get_headers()) 42 | 43 | 44 | def to_json(response): 45 | return json.loads(response.content.decode('utf-8')) 46 | 47 | 48 | 49 | class TestCreatingAnAccountErrors(test.TestCase): 50 | 51 | def setUp(self): 52 | default_accounts() 53 | self.payload = { 54 | 'start_date': '2012-01-01T09:00:00+03:00', 55 | 'end_date': '2019-06-01T09:00:00+03:00', 56 | 'amount': '400.00', 57 | } 58 | 59 | def test_missing_dates(self): 60 | payload = self.payload.copy() 61 | del payload['start_date'] 62 | response = post(reverse('accounts'), payload) 63 | self.assertEqual(400, response.status_code) 64 | self.assertTrue('message' in to_json(response)) 65 | 66 | def test_timezone_naive_start_date(self): 67 | payload = self.payload.copy() 68 | payload['start_date'] = '2013-01-01T09:00:00' 69 | response = post(reverse('accounts'), payload) 70 | self.assertEqual(400, response.status_code) 71 | self.assertTrue('message' in to_json(response)) 72 | 73 | def test_timezone_naive_end_date(self): 74 | payload = self.payload.copy() 75 | payload['end_date'] = '2013-06-01T09:00:00' 76 | response = post(reverse('accounts'), payload) 77 | self.assertEqual(400, response.status_code) 78 | self.assertTrue('message' in to_json(response)) 79 | 80 | def test_dates_in_wrong_order(self): 81 | payload = self.payload.copy() 82 | payload['start_date'] = '2013-06-01T09:00:00+03:00' 83 | payload['end_date'] = '2013-01-01T09:00:00+03:00' 84 | response = post(reverse('accounts'), payload) 85 | self.assertEqual(400, response.status_code) 86 | self.assertTrue('message' in to_json(response)) 87 | 88 | def test_invalid_amount(self): 89 | payload = self.payload.copy() 90 | payload['amount'] = 'silly' 91 | response = post(reverse('accounts'), payload) 92 | self.assertEqual(400, response.status_code) 93 | self.assertTrue('message' in to_json(response)) 94 | 95 | def test_negative_amount(self): 96 | payload = self.payload.copy() 97 | payload['amount'] = '-100' 98 | response = post(reverse('accounts'), payload) 99 | self.assertEqual(400, response.status_code) 100 | self.assertTrue('message' in to_json(response)) 101 | 102 | def test_amount_too_low(self): 103 | payload = self.payload.copy() 104 | payload['amount'] = '1.00' 105 | with self.settings(ACCOUNTS_MIN_LOAD_VALUE=D('25.00')): 106 | response = post(reverse('accounts'), payload) 107 | self.assertEqual(403, response.status_code) 108 | data = to_json(response) 109 | self.assertEqual('C101', data['code']) 110 | 111 | def test_amount_too_high(self): 112 | payload = self.payload.copy() 113 | payload['amount'] = '5000.00' 114 | with self.settings(ACCOUNTS_MAX_ACCOUNT_VALUE=D('500.00')): 115 | response = post(reverse('accounts'), payload) 116 | self.assertEqual(403, response.status_code) 117 | data = to_json(response) 118 | self.assertEqual('C102', data['code']) 119 | 120 | 121 | class TestSuccessfullyCreatingAnAccount(test.TestCase): 122 | 123 | def setUp(self): 124 | default_accounts() 125 | self.payload = { 126 | 'start_date': '2013-01-01T09:00:00+03:00', 127 | 'end_date': '2019-06-01T09:00:00+03:00', 128 | 'amount': '400.00', 129 | 'account_type': 'Test accounts', 130 | } 131 | # Submit request to create a new account, then fetch the detail 132 | # page that is returned. 133 | self.create_response = post(reverse('accounts'), self.payload) 134 | if 'Location' in self.create_response: 135 | self.detail_response = get( 136 | self.create_response['Location']) 137 | self.payload = to_json(self.detail_response) 138 | self.account = models.Account.objects.get( 139 | code=self.payload['code']) 140 | 141 | def test_returns_201(self): 142 | self.assertEqual(201, self.create_response.status_code) 143 | 144 | def test_returns_a_valid_location(self): 145 | self.assertEqual(200, self.detail_response.status_code) 146 | 147 | def test_detail_view_returns_correct_keys(self): 148 | keys = ['code', 'start_date', 'end_date', 'balance'] 149 | for key in keys: 150 | self.assertTrue(key in self.payload) 151 | 152 | def test_returns_dates_in_utc(self): 153 | self.assertEqual('2013-01-01T06:00:00+00:00', 154 | self.payload['start_date']) 155 | self.assertEqual('2019-06-01T06:00:00+00:00', 156 | self.payload['end_date']) 157 | 158 | def test_loads_the_account_with_the_right_amount(self): 159 | self.assertEqual('400.00', self.payload['balance']) 160 | 161 | def test_detail_view_returns_redemptions_url(self): 162 | self.assertTrue('redemptions_url' in self.payload) 163 | 164 | def test_detail_view_returns_refunds_url(self): 165 | self.assertTrue('refunds_url' in self.payload) 166 | 167 | 168 | class TestMakingARedemption(test.TestCase): 169 | 170 | def setUp(self): 171 | default_accounts() 172 | self.create_payload = { 173 | 'start_date': '2012-01-01T09:00:00+03:00', 174 | 'end_date': '2019-06-01T09:00:00+03:00', 175 | 'amount': '400.00', 176 | 'account_type': 'Test accounts', 177 | } 178 | self.create_response = post(reverse('accounts'), self.create_payload) 179 | self.assertEqual(201, self.create_response.status_code) 180 | self.detail_response = get(self.create_response['Location']) 181 | redemption_url = to_json(self.detail_response)['redemptions_url'] 182 | 183 | self.redeem_payload = { 184 | 'amount': '50.00', 185 | 'merchant_reference': '1234' 186 | } 187 | self.redeem_response = post(redemption_url, self.redeem_payload) 188 | 189 | transfer_url = self.redeem_response['Location'] 190 | self.transfer_response = get( 191 | transfer_url) 192 | 193 | def test_returns_201_for_the_redeem_request(self): 194 | self.assertEqual(201, self.redeem_response.status_code) 195 | 196 | def test_returns_valid_transfer_url(self): 197 | url = self.redeem_response['Location'] 198 | response = get(url) 199 | self.assertEqual(200, response.status_code) 200 | 201 | def test_returns_the_correct_data_in_the_transfer_request(self): 202 | data = to_json(self.transfer_response) 203 | keys = ['source_code', 'source_name', 'destination_code', 204 | 'destination_name', 'amount', 'datetime', 'merchant_reference', 205 | 'description'] 206 | for key in keys: 207 | self.assertTrue(key in data, "Key '%s' not found in payload" % key) 208 | 209 | self.assertEqual('50.00', data['amount']) 210 | self.assertIsNone(data['destination_code']) 211 | 212 | def test_works_without_merchant_reference(self): 213 | self.redeem_payload = { 214 | 'amount': '10.00', 215 | } 216 | redemption_url = to_json(self.detail_response)['redemptions_url'] 217 | response = post(redemption_url, self.redeem_payload) 218 | self.assertEqual(201, response.status_code) 219 | 220 | 221 | class TestTransferView(test.TestCase): 222 | 223 | def test_returns_404_for_missing_transfer(self): 224 | url = reverse('transfer', kwargs={'reference': 225 | '12345678123456781234567812345678'}) 226 | response = get(url) 227 | self.assertEqual(404, response.status_code) 228 | 229 | 230 | class TestMakingARedemptionThenRefund(test.TestCase): 231 | 232 | def setUp(self): 233 | default_accounts() 234 | self.create_payload = { 235 | 'start_date': '2012-01-01T09:00:00+03:00', 236 | 'end_date': '2019-06-01T09:00:00+03:00', 237 | 'amount': '400.00', 238 | 'account_type': 'Test accounts', 239 | } 240 | self.create_response = post( 241 | reverse('accounts'), self.create_payload) 242 | self.detail_response = get(self.create_response['Location']) 243 | 244 | self.redeem_payload = { 245 | 'amount': '50.00', 246 | 'merchant_reference': '1234' 247 | } 248 | account_dict = to_json(self.detail_response) 249 | redemption_url = account_dict['redemptions_url'] 250 | self.redeem_response = post(redemption_url, self.redeem_payload) 251 | 252 | self.refund_payload = { 253 | 'amount': '25.00', 254 | 'merchant_reference': '1234', 255 | } 256 | refund_url = account_dict['refunds_url'] 257 | self.refund_response = post(refund_url, self.refund_payload) 258 | 259 | def test_returns_201_for_the_refund_request(self): 260 | self.assertEqual(201, self.refund_response.status_code) 261 | 262 | def test_works_without_a_merchant_reference(self): 263 | self.refund_payload = { 264 | 'amount': '25.00', 265 | } 266 | account_dict = to_json(self.detail_response) 267 | refund_url = account_dict['refunds_url'] 268 | self.refund_response = post(refund_url, self.refund_payload) 269 | self.assertEqual(201, self.refund_response.status_code) 270 | 271 | 272 | class TestMakingARedemptionThenReverse(test.TestCase): 273 | 274 | def setUp(self): 275 | default_accounts() 276 | self.create_payload = { 277 | 'start_date': '2012-01-01T09:00:00+03:00', 278 | 'end_date': '2019-06-01T09:00:00+03:00', 279 | 'amount': '400.00', 280 | 'account_type': 'Test accounts', 281 | } 282 | self.create_response = post(reverse('accounts'), self.create_payload) 283 | self.detail_response = get(self.create_response['Location']) 284 | account_dict = to_json(self.detail_response) 285 | self.redeem_payload = { 286 | 'amount': '50.00', 287 | 'merchant_reference': '1234' 288 | } 289 | redemption_url = account_dict['redemptions_url'] 290 | self.redeem_response = post(redemption_url, self.redeem_payload) 291 | 292 | transfer_response = get(self.redeem_response['Location']) 293 | transfer_dict = to_json(transfer_response) 294 | self.reverse_payload = {} 295 | reverse_url = transfer_dict['reverse_url'] 296 | self.reverse_response = post(reverse_url, self.reverse_payload) 297 | 298 | def test_returns_201_for_the_reverse_request(self): 299 | self.assertEqual(201, self.reverse_response.status_code) 300 | 301 | 302 | class TestMakingARedemptionThenTransferRefund(test.TestCase): 303 | 304 | def setUp(self): 305 | default_accounts() 306 | self.create_payload = { 307 | 'start_date': '2012-01-01T09:00:00+03:00', 308 | 'end_date': '2019-06-01T09:00:00+03:00', 309 | 'amount': '1000.00', 310 | 'account_type': 'Test accounts', 311 | } 312 | self.create_response = post( 313 | reverse('accounts'), self.create_payload) 314 | self.detail_response = get(self.create_response['Location']) 315 | account_dict = to_json(self.detail_response) 316 | 317 | self.redeem_payload = {'amount': '300.00'} 318 | redemption_url = account_dict['redemptions_url'] 319 | self.redeem_response = post(redemption_url, self.redeem_payload) 320 | self.transfer_response = get(self.redeem_response['Location']) 321 | transfer_dict = to_json(self.transfer_response) 322 | 323 | self.refund_payload = { 324 | 'amount': '25.00', 325 | } 326 | refund_url = transfer_dict['refunds_url'] 327 | self.refund_response = post(refund_url, self.refund_payload) 328 | 329 | def test_returns_201_for_the_refund_request(self): 330 | self.assertEqual(201, self.refund_response.status_code) 331 | 332 | def test_refunds_are_capped_at_value_of_redemption(self): 333 | # Make another redemption to ensure the redemptions account has enough 334 | # funds to attemp the below refund 335 | self.redeem_payload = {'amount': '300.00'} 336 | account_dict = to_json(self.detail_response) 337 | redemption_url = account_dict['redemptions_url'] 338 | post(redemption_url, self.redeem_payload) 339 | 340 | self.refund_payload = { 341 | 'amount': '280.00', 342 | } 343 | transfer_dict = to_json(self.transfer_response) 344 | refund_url = transfer_dict['refunds_url'] 345 | response = post(refund_url, self.refund_payload) 346 | self.assertEqual(403, response.status_code) 347 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Managed accounts for Django 3 | =========================== 4 | 5 | A 'managed account' is an allocation of money that can be debited and credited. 6 | This package provides managed account functionality for use with the e-commerce 7 | framework `Oscar`_. It can also be used standalone without Oscar. 8 | 9 | .. _`Oscar`: https://github.com/django-oscar/django-oscar 10 | 11 | Accounts can be used to implement a variety of interesting components, 12 | including: 13 | 14 | * Giftcards 15 | * Web accounts 16 | * Loyalty schemes 17 | 18 | Basically anything that involves tracking the movement of funds within a closed 19 | system. 20 | 21 | This package uses `double-entry bookkeeping`_ where every transaction is 22 | recorded twice (once for the source and once for the destination). This 23 | ensures the books always balance and there is full audit trail of all 24 | transactional activity. 25 | 26 | If your project manages money, you should be using a library like this. Your 27 | finance people will thank you. 28 | 29 | 30 | .. image:: https://travis-ci.org/django-oscar/django-oscar-accounts.svg?branch=master 31 | :target: https://travis-ci.org/django-oscar/django-oscar-accounts 32 | 33 | .. image:: http://codecov.io/github/django-oscar/django-oscar-accounts/coverage.svg?branch=master 34 | :alt: Coverage 35 | :target: http://codecov.io/github/django-oscar/django-oscar-accounts?branch=master 36 | 37 | .. image:: https://requires.io/github/django-oscar/django-oscar-accounts/requirements.svg?branch=master 38 | :target: https://requires.io/github/django-oscar/django-oscar-accounts/requirements/?branch=master 39 | :alt: Requirements Status 40 | 41 | .. image:: https://img.shields.io/pypi/v/django-oscar-accounts.svg 42 | :target: https://pypi.python.org/pypi/django-oscar-accounts/ 43 | 44 | 45 | .. _double-entry bookkeeping: http://en.wikipedia.org/wiki/Double-entry_bookkeeping_system 46 | 47 | 48 | Features 49 | -------- 50 | 51 | * An account has a credit limit which defaults to zero. Accounts can be set up 52 | with no credit limit so that they are a 'source' of money within the system. 53 | At least one account must be set up without a credit limit in order for money 54 | to move around the system. 55 | 56 | * Accounts can have: 57 | - No users assigned 58 | - A single "primary" user - this is the most common case 59 | - A set of users assigned 60 | 61 | * A user can have multiple accounts 62 | 63 | * An account can have a start and end date to allow its usage in a limited time 64 | window 65 | 66 | * An account can be restricted so that it can only be used to pay for a range of 67 | products. 68 | 69 | * Accounts can be categorised 70 | 71 | Screenshots 72 | ----------- 73 | 74 | .. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-accounts.thumb.png 75 | :alt: Dashboard account list 76 | :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-accounts.png 77 | 78 | .. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-form.thumb.png 79 | :alt: Create new account 80 | :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-form.png 81 | 82 | .. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-transfers.thumb.png 83 | :alt: Dashboard transfer list 84 | :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-transfers.png 85 | 86 | .. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-detail.thumb.png 87 | :alt: Dashboard account detail 88 | :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-detail.png 89 | 90 | 91 | Installation 92 | ------------ 93 | 94 | Install using pip: 95 | 96 | .. code-block:: bash 97 | 98 | pip install django-oscar-accounts 99 | 100 | and add `oscar_accounts` to `INSTALLED_APPS`. Runnning ``manage.py migrate 101 | oscar_accounts`` will create the appropriate database tables. To create initial 102 | some core accounts and account-types use ``manage.py oscar_accounts_init``. 103 | The names of these accounts can be controlled using settings (see below). 104 | 105 | If running with Oscar, add an additional path to your `TEMPLATE_DIRS`: 106 | 107 | .. code-block:: python 108 | 109 | from accounts import TEMPLATE_DIR as ACCOUNTS_TEMPLATE_DIR 110 | 111 | TEMPLATE_DIRS = ( 112 | ... 113 | ACCOUNTS_TEMPLATE_DIR) 114 | 115 | This allows the templates to be customised by overriding blocks instead of 116 | replacing the entire template. 117 | 118 | In order to make the accounts accessible via the Oscar dashboard you need to 119 | append it to your `OSCAR_DASHBOARD_NAVIGATION` 120 | 121 | .. code-block:: python 122 | 123 | from oscar.defaults import * 124 | 125 | OSCAR_DASHBOARD_NAVIGATION.append( 126 | { 127 | 'label': 'Accounts', 128 | 'icon': 'icon-globe', 129 | 'children': [ 130 | { 131 | 'label': 'Accounts', 132 | 'url_name': 'accounts-list', 133 | }, 134 | { 135 | 'label': 'Transfers', 136 | 'url_name': 'transfers-list', 137 | }, 138 | { 139 | 'label': 'Deferred income report', 140 | 'url_name': 'report-deferred-income', 141 | }, 142 | { 143 | 'label': 'Profit/loss report', 144 | 'url_name': 'report-profit-loss', 145 | }, 146 | ] 147 | }) 148 | 149 | 150 | Furthermore you need to add the url-pattern to your `urls.py` 151 | 152 | .. code-block:: python 153 | 154 | from oscar_accounts.dashboard.app import application as accounts_app 155 | 156 | # ... 157 | 158 | urlpatterns = [ 159 | ... 160 | url(r'^dashboard/accounts/', include(accounts_app.urls)), 161 | ] 162 | 163 | 164 | You should also set-up a cronjob that calls:: 165 | 166 | ./manage.py close_expired_accounts 167 | 168 | to close any expired accounts and transfer their funds to the 'expired' 169 | account. 170 | 171 | API 172 | --- 173 | 174 | Create account instances using the manager: 175 | 176 | .. code-block:: python 177 | 178 | from decimal import Decimal 179 | import datetime 180 | 181 | from django.contrib.auth.models import User 182 | 183 | from oscar_accounts import models 184 | 185 | anonymous_account = models.Account.objects.create() 186 | 187 | barry = User.objects.get(username="barry") 188 | user_account = models.Account.objects.create(primary_user=barry) 189 | 190 | no_credit_limit_account = models.Account.objects.create(credit_limit=None) 191 | credit_limit_account = models.Account.objects.create(credit_limit=Decimal('1000.00')) 192 | 193 | today = datetime.date.today() 194 | next_week = today + datetime.timedelta(days=7) 195 | date_limited_account = models.Account.objects.create( 196 | start_date=today, end_date=next_week) 197 | 198 | 199 | Transfer funds using the facade: 200 | 201 | .. code-block:: python 202 | 203 | from oscar_accounts import facade 204 | 205 | staff_member = User.objects.get(username="staff") 206 | trans = facade.transfer(source=no_credit_limit_account, 207 | destination=user_account, 208 | amount=Decimal('10.00'), 209 | user=staff_member) 210 | 211 | Reverse transfers: 212 | 213 | .. code-block:: python 214 | 215 | facade.reverse(trans, user=staff_member, 216 | description="Just an example") 217 | 218 | If the proposed transfer is invalid, an exception will be raised. All 219 | exceptions are subclasses of `oscar_accounts.exceptions.AccountException`. 220 | Your client code should look for exceptions of this type and handle them 221 | appropriately. 222 | 223 | Client code should only use the `oscar_accounts.models.Budget` class and the 224 | two functions from `oscar_accounts.facade` - nothing else should be required. 225 | 226 | Error handling 227 | -------------- 228 | 229 | Note that the transfer operation is wrapped in its own database transaction to 230 | ensure that only complete transfers are written out. When using Django's 231 | transaction middleware, you need to be careful. If you have an unhandled 232 | exception, then account transfers will still be committed even though nothing 233 | else will be. To handle this, you need to make sure that, if an exception 234 | occurs during your post-payment code, then you roll-back any transfers. 235 | 236 | Here's a toy example: 237 | 238 | 239 | .. code-block:: python 240 | 241 | from oscar_accounts import facade 242 | 243 | def submit(self, order_total): 244 | # Take payment first 245 | transfer = facade.transfer(self.get_user_account(), 246 | self.get_merchant_account(), 247 | order_total) 248 | # Create order models 249 | try: 250 | self.place_order() 251 | except Exception, e: 252 | # Something went wrong placing the order. Roll-back the previous 253 | # transfer 254 | facade.reverse(transfer) 255 | 256 | In this situation, you'll end up with two transfers being created but no order. 257 | While this isn't ideal, it's the best way of handling exceptions that occur 258 | during order placement. 259 | 260 | Multi-transfer payments 261 | ----------------------- 262 | 263 | Projects will often allow users to have multiple accounts and pay for an order 264 | using more than one. This will involve several transfers and needs some 265 | careful handling in your application code. 266 | 267 | It normally makes sense to write your own wrapper around the accounts API to 268 | encapsulate your business logic and error handling. Here's an example: 269 | 270 | 271 | .. code-block:: python 272 | 273 | from decimal import Decimal as D 274 | from oscar_accounts import models, exceptions, facade 275 | 276 | 277 | def redeem(order_number, user, amount): 278 | # Get user's non-empty accounts ordered with the first to expire first 279 | accounts = models.Account.active.filter( 280 | user=user, balance__gt=0).order_by('end_date') 281 | 282 | # Build up a list of potential transfers that cover the requested amount 283 | transfers = [] 284 | amount_to_allocate = amount 285 | for account in accounts: 286 | to_transfer = min(account.balance, amount_to_allocate) 287 | transfers.append((account, to_transfer)) 288 | amount_to_allocate -= to_transfer 289 | if amount_to_allocate == D('0.00'): 290 | break 291 | if amount_to_allocate > D('0.00'): 292 | raise exceptions.InsufficientFunds() 293 | 294 | # Execute transfers to some 'Sales' account 295 | destination = models.Account.objects.get(name="Sales") 296 | completed_transfers = [] 297 | try: 298 | for account, amount in transfers: 299 | transfer = facade.transfer( 300 | account, destination, amount, user=user, 301 | description="Order %s" % order_number) 302 | completed_transfers.append(transfer) 303 | except exceptions.AccountException, transfer_exc: 304 | # Something went wrong with one of the transfers (possibly a race condition). 305 | # We try and roll back all completed ones to get us back to a clean state. 306 | try: 307 | for transfer in completed_transfers: 308 | facade.reverse(transfer) 309 | except Exception, reverse_exc: 310 | # Uh oh: No man's land. We could be left with a partial 311 | # redemption. This will require an admin to intervene. Make 312 | # sure your logger mails admins on error. 313 | logger.error("Order %s, transfers failed (%s) and reverse failed (%s)", 314 | order_number, transfer_exc, reverse_exc) 315 | logger.exception(reverse_exc) 316 | 317 | # Raise an exception so that your client code can inform the user appropriately. 318 | raise RedemptionFailed() 319 | else: 320 | # All transfers completed ok 321 | return completed_transfers 322 | 323 | As you can see, there is some careful handling of the scenario where not all 324 | transfers can be executed. 325 | 326 | If you using Oscar then ensure that you create an `OrderSource` instance for 327 | every transfer (rather than aggregating them all into one). This will provide 328 | better audit information. Here's some example code: 329 | 330 | 331 | .. code-block:: python 332 | 333 | try: 334 | transfers = api.redeem(order_number, user, total_incl_tax) 335 | except Exception: 336 | # Inform user of failed payment 337 | else: 338 | for transfer in transfers: 339 | source_type, __ = SourceType.objects.get_or_create(name="Accounts") 340 | source = Source( 341 | source_type=source_type, 342 | amount_allocated=transfer.amount, 343 | amount_debited=transfer.amount, 344 | reference=transfer.reference) 345 | self.add_payment_source(source) 346 | 347 | 348 | Core accounts and account types 349 | ------------------------------- 350 | 351 | A post-syncdb signal will create the common structure for account types and 352 | accounts. Some names can be controlled with settings, as indicated in 353 | parentheses. 354 | 355 | - **Assets** 356 | 357 | - **Sales** 358 | 359 | - Redemptions (`ACCOUNTS_REDEMPTIONS_NAME`) - where money is 360 | transferred to when an account is used to pay for something. 361 | - Lapsed (`ACCOUNTS_LAPSED_NAME`) - where money is transferred to 362 | when an account expires. This is done by the 363 | 'close_expired_accounts' management command. The name of this 364 | account can be set using the `ACCOUNTS_LAPSED_NAME`. 365 | 366 | - **Cash** 367 | 368 | - "Bank" (`ACCOUNTS_BANK_NAME`) - the source account for creating new 369 | accounts that are paid for by the customer (eg a giftcard). This 370 | account will not have a credit limit and will normally have a 371 | negative balance as money is only transferred out. 372 | 373 | - **Unpaid** - This contains accounts that are used as sources for other 374 | accounts but aren't paid for by the customer. For instance, you might 375 | allow admins to create new accounts in the dashboard. An account of this 376 | type will be the source account for the initial transfer. 377 | 378 | - **Liabilities** 379 | 380 | - **Deferred income** - This contains customer accounts/giftcards. You may 381 | want to create additional account types within this type to categorise 382 | accounts. 383 | 384 | Example transactions 385 | -------------------- 386 | 387 | Consider the following accounts and account types: 388 | 389 | - **Assets** 390 | - **Sales** 391 | - Redemptions 392 | - Lapsed 393 | - **Cash** 394 | - Bank 395 | - **Unpaid** 396 | - Merchant funded 397 | - **Liabilities** 398 | - **Deferred income** 399 | 400 | Note that all accounts start with a balance of 0 and the sum of all balances 401 | will always be zero. 402 | 403 | *A customer purchases a £50 giftcard* 404 | 405 | - A new account is created of type 'Deferred income' with an end date - £50 is 406 | transferred from the Bank to this new account 407 | 408 | *A customer pays for a £30 order using their £50 giftcard* 409 | 410 | - £30 is transferred from the giftcard account to the redemptions account 411 | 412 | *The customer's giftcard expires with £20 still on it* 413 | 414 | - £20 is transferred from the giftcard account to the lapsed account 415 | 416 | *The customer phones up to complain and a staff member creates a new giftcard 417 | for £20* 418 | 419 | - A new account is created of type 'Deferred income' - £20 is transferred from 420 | the "Merchant funded" account to this new account 421 | 422 | Settings 423 | -------- 424 | 425 | There are settings to control the naming and initial unpaid and deferred income 426 | account types: 427 | 428 | * `ACCOUNTS_MIN_INITIAL_VALUE` The minimum value that can be used to create an 429 | account (or for a top-up) 430 | 431 | * `ACCOUNTS_MAX_INITIAL_VALUE` The maximum value that can be transferred to an 432 | account. 433 | 434 | Contributing 435 | ------------ 436 | 437 | Fork repo, set-up virtualenv and run:: 438 | 439 | make install 440 | 441 | Run tests with:: 442 | 443 | ./runtests.py 444 | -------------------------------------------------------------------------------- /src/oscar_accounts/dashboard/views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from decimal import Decimal as D 3 | 4 | from django import http 5 | from django.contrib import messages 6 | from django.core.urlresolvers import reverse 7 | from django.db.models import Sum 8 | from django.shortcuts import get_object_or_404 9 | from django.utils import timezone 10 | from django.utils.translation import ugettext_lazy as _ 11 | from django.views import generic 12 | from oscar.core.loading import get_model 13 | from oscar.templatetags.currency_filters import currency 14 | 15 | from oscar_accounts import exceptions, facade, names 16 | from oscar_accounts.dashboard import forms, reports 17 | 18 | AccountType = get_model('oscar_accounts', 'AccountType') 19 | Account = get_model('oscar_accounts', 'Account') 20 | Transfer = get_model('oscar_accounts', 'Transfer') 21 | Transaction = get_model('oscar_accounts', 'Transaction') 22 | 23 | 24 | class AccountListView(generic.ListView): 25 | model = Account 26 | context_object_name = 'accounts' 27 | template_name = 'accounts/dashboard/account_list.html' 28 | form_class = forms.SearchForm 29 | description = _("All %s") % names.UNIT_NAME_PLURAL.lower() 30 | 31 | def get_context_data(self, **kwargs): 32 | ctx = super(AccountListView, self).get_context_data(**kwargs) 33 | ctx['form'] = self.form 34 | ctx['title'] = names.UNIT_NAME_PLURAL 35 | ctx['unit_name'] = names.UNIT_NAME 36 | ctx['queryset_description'] = self.description 37 | return ctx 38 | 39 | def get_queryset(self): 40 | queryset = Account.objects.all() 41 | 42 | if 'code' not in self.request.GET: 43 | # Form not submitted 44 | self.form = self.form_class() 45 | return queryset 46 | 47 | self.form = self.form_class(self.request.GET) 48 | if not self.form.is_valid(): 49 | # Form submitted but invalid 50 | return queryset 51 | 52 | # Form valid - build queryset and description 53 | data = self.form.cleaned_data 54 | desc_template = _( 55 | "%(status)s %(unit)ss %(code_filter)s %(name_filter)s") 56 | desc_ctx = { 57 | 'unit': names.UNIT_NAME.lower(), 58 | 'status': "All", 59 | 'code_filter': "", 60 | 'name_filter': "", 61 | } 62 | if data['name']: 63 | queryset = queryset.filter(name__icontains=data['name']) 64 | desc_ctx['name_filter'] = _( 65 | " with name matching '%s'") % data['name'] 66 | if data['code']: 67 | queryset = queryset.filter(code=data['code']) 68 | desc_ctx['code_filter'] = _( 69 | " with code '%s'") % data['code'] 70 | if data['status']: 71 | queryset = queryset.filter(status=data['status']) 72 | desc_ctx['status'] = data['status'] 73 | 74 | self.description = desc_template % desc_ctx 75 | 76 | return queryset 77 | 78 | 79 | class AccountCreateView(generic.CreateView): 80 | model = Account 81 | context_object_name = 'account' 82 | template_name = 'accounts/dashboard/account_form.html' 83 | form_class = forms.NewAccountForm 84 | 85 | def get_context_data(self, **kwargs): 86 | ctx = super(AccountCreateView, self).get_context_data(**kwargs) 87 | ctx['title'] = _("Create a new %s") % names.UNIT_NAME.lower() 88 | return ctx 89 | 90 | def form_valid(self, form): 91 | account = form.save() 92 | 93 | # Load transaction 94 | source = form.get_source_account() 95 | amount = form.cleaned_data['initial_amount'] 96 | try: 97 | facade.transfer(source, account, amount, 98 | user=self.request.user, 99 | description=_("Creation of account")) 100 | except exceptions.AccountException as e: 101 | messages.error( 102 | self.request, 103 | _("Account created but unable to load funds onto new " 104 | "account: %s") % e) 105 | else: 106 | messages.success( 107 | self.request, 108 | _("New account created with code '%s'") % account.code) 109 | return http.HttpResponseRedirect( 110 | reverse('accounts-detail', kwargs={'pk': account.id})) 111 | 112 | 113 | class AccountUpdateView(generic.UpdateView): 114 | model = Account 115 | context_object_name = 'account' 116 | template_name = 'accounts/dashboard/account_form.html' 117 | form_class = forms.UpdateAccountForm 118 | 119 | def get_context_data(self, **kwargs): 120 | ctx = super(AccountUpdateView, self).get_context_data(**kwargs) 121 | ctx['title'] = _("Update '%s' account") % self.object.name 122 | return ctx 123 | 124 | def form_valid(self, form): 125 | account = form.save() 126 | messages.success(self.request, _("Account saved")) 127 | return http.HttpResponseRedirect( 128 | reverse('accounts-detail', kwargs={'pk': account.id})) 129 | 130 | 131 | class AccountFreezeView(generic.UpdateView): 132 | model = Account 133 | template_name = 'accounts/dashboard/account_freeze.html' 134 | form_class = forms.FreezeAccountForm 135 | 136 | def get_success_url(self): 137 | messages.success(self.request, _("Account frozen")) 138 | return reverse('accounts-list') 139 | 140 | 141 | class AccountThawView(generic.UpdateView): 142 | model = Account 143 | template_name = 'accounts/dashboard/account_thaw.html' 144 | form_class = forms.ThawAccountForm 145 | 146 | def get_success_url(self): 147 | messages.success(self.request, _("Account thawed")) 148 | return reverse('accounts-list') 149 | 150 | 151 | class AccountTopUpView(generic.UpdateView): 152 | model = Account 153 | template_name = 'accounts/dashboard/account_top_up.html' 154 | form_class = forms.TopUpAccountForm 155 | 156 | def form_valid(self, form): 157 | account = self.object 158 | amount = form.cleaned_data['amount'] 159 | try: 160 | facade.transfer(form.get_source_account(), account, amount, 161 | user=self.request.user, 162 | description=_("Top-up account")) 163 | except exceptions.AccountException as e: 164 | messages.error(self.request, 165 | _("Unable to top-up account: %s") % e) 166 | else: 167 | messages.success( 168 | self.request, _("%s added to account") % currency(amount)) 169 | return http.HttpResponseRedirect(reverse('accounts-detail', 170 | kwargs={'pk': account.id})) 171 | 172 | 173 | class AccountWithdrawView(generic.UpdateView): 174 | model = Account 175 | template_name = 'accounts/dashboard/account_withdraw.html' 176 | form_class = forms.WithdrawFromAccountForm 177 | 178 | def form_valid(self, form): 179 | account = self.object 180 | amount = form.cleaned_data['amount'] 181 | try: 182 | facade.transfer(account, form.get_source_account(), amount, 183 | user=self.request.user, 184 | description=_("Return funds to source account")) 185 | except exceptions.AccountException as e: 186 | messages.error(self.request, 187 | _("Unable to withdraw funds from account: %s") % e) 188 | else: 189 | messages.success( 190 | self.request, 191 | _("%s withdrawn from account") % currency(amount)) 192 | return http.HttpResponseRedirect(reverse('accounts-detail', 193 | kwargs={'pk': account.id})) 194 | 195 | 196 | class AccountTransactionsView(generic.ListView): 197 | model = Transaction 198 | context_object_name = 'transactions' 199 | template_name = 'accounts/dashboard/account_detail.html' 200 | 201 | def get(self, request, *args, **kwargs): 202 | self.account = get_object_or_404(Account, id=kwargs['pk']) 203 | return super(AccountTransactionsView, self).get( 204 | request, *args, **kwargs) 205 | 206 | def get_queryset(self): 207 | return self.account.transactions.all().order_by('-date_created') 208 | 209 | def get_context_data(self, **kwargs): 210 | ctx = super(AccountTransactionsView, self).get_context_data(**kwargs) 211 | ctx['account'] = self.account 212 | return ctx 213 | 214 | 215 | class TransferListView(generic.ListView): 216 | model = Transfer 217 | context_object_name = 'transfers' 218 | template_name = 'accounts/dashboard/transfer_list.html' 219 | form_class = forms.TransferSearchForm 220 | description = _("All transfers") 221 | 222 | def get_context_data(self, **kwargs): 223 | ctx = super(TransferListView, self).get_context_data(**kwargs) 224 | ctx['form'] = self.form 225 | ctx['queryset_description'] = self.description 226 | return ctx 227 | 228 | def get_queryset(self): 229 | queryset = self.model.objects.all() 230 | 231 | if 'reference' not in self.request.GET: 232 | # Form not submitted 233 | self.form = self.form_class() 234 | return queryset 235 | 236 | self.form = self.form_class(self.request.GET) 237 | if not self.form.is_valid(): 238 | # Form submitted but invalid 239 | return queryset 240 | 241 | # Form valid - build queryset and description 242 | data = self.form.cleaned_data 243 | desc_template = _( 244 | "Transfers %(reference)s %(date)s") 245 | desc_ctx = { 246 | 'reference': "", 247 | 'date': "", 248 | } 249 | if data['reference']: 250 | queryset = queryset.filter(reference=data['reference']) 251 | desc_ctx['reference'] = _( 252 | " with reference '%s'") % data['reference'] 253 | 254 | if data['start_date'] and data['end_date']: 255 | # Add 24 hours to make search inclusive 256 | date_from = data['start_date'] 257 | date_to = data['end_date'] + datetime.timedelta(days=1) 258 | queryset = queryset.filter(date_created__gte=date_from).filter(date_created__lt=date_to) 259 | desc_ctx['date'] = _(" created between %(start_date)s and %(end_date)s") % { 260 | 'start_date': data['start_date'], 261 | 'end_date': data['end_date']} 262 | elif data['start_date']: 263 | queryset = queryset.filter(date_created__gte=data['start_date']) 264 | desc_ctx['date'] = _(" created since %s") % data['start_date'] 265 | elif data['end_date']: 266 | date_to = data['end_date'] + datetime.timedelta(days=1) 267 | queryset = queryset.filter(date_created__lt=date_to) 268 | desc_ctx['date'] = _(" created before %s") % data['end_date'] 269 | 270 | self.description = desc_template % desc_ctx 271 | return queryset 272 | 273 | 274 | class TransferDetailView(generic.DetailView): 275 | model = Transfer 276 | context_object_name = 'transfer' 277 | template_name = 'accounts/dashboard/transfer_detail.html' 278 | 279 | def get_object(self, queryset=None): 280 | if queryset is None: 281 | queryset = self.get_queryset() 282 | return queryset.get(reference=self.kwargs['reference']) 283 | 284 | 285 | class DeferredIncomeReportView(generic.FormView): 286 | form_class = forms.DateForm 287 | template_name = 'accounts/dashboard/reports/deferred_income.html' 288 | 289 | def get(self, request, *args, **kwargs): 290 | if self.is_form_submitted(): 291 | return self.validate() 292 | return super(DeferredIncomeReportView, self).get(request, *args, 293 | **kwargs) 294 | 295 | def is_form_submitted(self): 296 | return 'date' in self.request.GET 297 | 298 | def get_context_data(self, **kwargs): 299 | ctx = super(DeferredIncomeReportView, self).get_context_data(**kwargs) 300 | ctx['title'] = 'Deferred income report' 301 | return ctx 302 | 303 | def get_form_kwargs(self): 304 | kwargs = {'initial': self.get_initial()} 305 | if self.is_form_submitted(): 306 | kwargs.update({ 307 | 'data': self.request.GET, 308 | }) 309 | return kwargs 310 | 311 | def validate(self): 312 | form_class = self.get_form_class() 313 | form = self.get_form(form_class) 314 | if form.is_valid(): 315 | return self.form_valid(form) 316 | else: 317 | return self.form_invalid(form) 318 | 319 | def form_valid(self, form): 320 | # Take cutoff as the first second of the following day, which we 321 | # convert to a datetime instane in UTC 322 | threshold_date = form.cleaned_data['date'] + datetime.timedelta(days=1) 323 | threshold_datetime = datetime.datetime.combine( 324 | threshold_date, datetime.time(tzinfo=timezone.utc)) 325 | 326 | # Get data 327 | rows = [] 328 | totals = {'total': D('0.00'), 329 | 'num_accounts': 0} 330 | for acc_type_name in names.DEFERRED_INCOME_ACCOUNT_TYPES: 331 | acc_type = AccountType.objects.get(name=acc_type_name) 332 | data = { 333 | 'name': acc_type_name, 334 | 'total': D('0.00'), 335 | 'num_accounts': 0, 336 | 'num_expiring_within_30': 0, 337 | 'num_expiring_within_60': 0, 338 | 'num_expiring_within_90': 0, 339 | 'num_expiring_outside_90': 0, 340 | 'num_open_ended': 0, 341 | 'total_expiring_within_30': D('0.00'), 342 | 'total_expiring_within_60': D('0.00'), 343 | 'total_expiring_within_90': D('0.00'), 344 | 'total_expiring_outside_90': D('0.00'), 345 | 'total_open_ended': D('0.00'), 346 | } 347 | for account in acc_type.accounts.all(): 348 | data['num_accounts'] += 1 349 | total = account.transactions.filter( 350 | date_created__lt=threshold_datetime).aggregate( 351 | total=Sum('amount'))['total'] 352 | if total is None: 353 | total = D('0.00') 354 | data['total'] += total 355 | days_remaining = account.days_remaining(threshold_datetime) 356 | if days_remaining is None: 357 | data['num_open_ended'] += 1 358 | data['total_open_ended'] += total 359 | else: 360 | if days_remaining <= 30: 361 | data['num_expiring_within_30'] += 1 362 | data['total_expiring_within_30'] += total 363 | elif days_remaining <= 60: 364 | data['num_expiring_within_60'] += 1 365 | data['total_expiring_within_60'] += total 366 | elif days_remaining <= 90: 367 | data['num_expiring_within_90'] += 1 368 | data['total_expiring_within_90'] += total 369 | else: 370 | data['num_expiring_outside_90'] += 1 371 | data['total_expiring_outside_90'] += total 372 | 373 | totals['total'] += data['total'] 374 | totals['num_accounts'] += data['num_accounts'] 375 | rows.append(data) 376 | ctx = self.get_context_data(form=form) 377 | ctx['rows'] = rows 378 | ctx['totals'] = totals 379 | ctx['report_date'] = form.cleaned_data['date'] 380 | return self.render_to_response(ctx) 381 | 382 | 383 | class ProfitLossReportView(generic.FormView): 384 | form_class = forms.DateRangeForm 385 | template_name = 'accounts/dashboard/reports/profit_loss.html' 386 | 387 | def get(self, request, *args, **kwargs): 388 | if self.is_form_submitted(): 389 | return self.validate() 390 | return super(ProfitLossReportView, self).get(request, *args, 391 | **kwargs) 392 | 393 | def is_form_submitted(self): 394 | return 'start_date' in self.request.GET 395 | 396 | def get_context_data(self, **kwargs): 397 | ctx = super(ProfitLossReportView, self).get_context_data(**kwargs) 398 | ctx['title'] = 'Profit and loss report' 399 | return ctx 400 | 401 | def get_form_kwargs(self): 402 | kwargs = {'initial': self.get_initial()} 403 | if self.is_form_submitted(): 404 | kwargs.update({ 405 | 'data': self.request.GET, 406 | }) 407 | return kwargs 408 | 409 | def validate(self): 410 | form_class = self.get_form_class() 411 | form = self.get_form(form_class) 412 | if form.is_valid(): 413 | return self.form_valid(form) 414 | else: 415 | return self.form_invalid(form) 416 | 417 | def form_valid(self, form): 418 | start = form.cleaned_data['start_date'] 419 | end = form.cleaned_data['end_date'] + datetime.timedelta(days=1) 420 | report = reports.ProfitLossReport(start, end) 421 | data = report.run() 422 | 423 | ctx = self.get_context_data(form=form) 424 | ctx.update(data) 425 | ctx['show_report'] = True 426 | ctx['start_date'] = start 427 | ctx['end_date'] = end 428 | 429 | return self.render_to_response(ctx) 430 | 431 | def total(self, qs): 432 | sales_amt = qs.aggregate(sum=Sum('amount'))['sum'] 433 | return sales_amt if sales_amt is not None else D('0.00') 434 | --------------------------------------------------------------------------------