├── 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-transfers.png ├── dashboard-form.thumb.png ├── dashboard-accounts.thumb.png ├── dashboard-detail.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/master/screenshots/dashboard-form.png -------------------------------------------------------------------------------- /screenshots/dashboard-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/screenshots/dashboard-detail.png -------------------------------------------------------------------------------- /screenshots/dashboard-accounts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/screenshots/dashboard-accounts.png -------------------------------------------------------------------------------- /screenshots/dashboard-transfers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/screenshots/dashboard-transfers.png -------------------------------------------------------------------------------- /screenshots/dashboard-form.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/screenshots/dashboard-form.thumb.png -------------------------------------------------------------------------------- /screenshots/dashboard-accounts.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/screenshots/dashboard-accounts.thumb.png -------------------------------------------------------------------------------- /screenshots/dashboard-detail.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/screenshots/dashboard-detail.thumb.png -------------------------------------------------------------------------------- /screenshots/dashboard-transfers.thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ATA/django-oscar-accounts/master/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 |
{% trans "Account allocations" %}
9 |{% trans "Account allocations" %}
13 |{% trans "This account cannot be topped-up." %}
44 | {% endif %} 45 |[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 |
26 | {% trans "Withdraw funds from account?" %}
27 |
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 |
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 |
31 | {% endif %}
32 |
33 | {% if account %}
34 |
35 |
36 |
37 | {% trans "Code" %}
38 | {{ account.code }}
39 |
40 |
41 | {% trans "Balance" %}
42 | {{ account.balance|currency }}
43 |
44 |
45 | {% trans "Status" %}
46 | {{ account.status }}
47 |
48 | {% if account.start_date %}
49 |
50 | {% trans "Start date" %}
51 | {{ account.start_date }}
52 |
53 | {% endif %}
54 | {% if account.end_date %}
55 |
56 | {% trans "End date" %}
57 | {{ account.end_date }}
58 |
59 | {% endif %}
60 |
61 |
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 | {% trans "Reference" %}
36 | {{ transfer.reference }}
37 |
38 |
39 | {% trans "Source" %}
40 | {{ transfer.source }}
41 |
42 |
43 | {% trans "Destination" %}
44 | {{ transfer.destination }}
45 |
46 |
47 | {% trans "Amount" %}
48 | {{ transfer.amount|currency }}
49 |
50 |
51 | {% trans "Merchant reference" %}
52 | {{ transfer.merchant_reference|default:"-" }}
53 |
54 |
55 | {% trans "Description" %}
56 | {{ transfer.description|default:"-" }}
57 |
58 |
59 | {% trans "Authorised by" %}
60 | {{ transfer.user }}
61 |
62 |
63 | {% trans "Date" %}
64 | {{ transfer.date_created }}
65 |
66 |
67 |
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 |
32 |
33 |
34 |
35 | {% if rows %}
36 | {% trans "Position at" %} {{ report_date }}
37 |
38 |
39 |
40 | {% trans "Account type" %}
41 | {% trans "Total balance" %}
42 | {% trans "Num accounts" %}
43 | {% trans "Expiring" %}
44 |
45 |
46 |
47 |
48 |
49 | {% trans "< 30 days" %}
50 | {% trans "30 - 60 days" %}
51 | {% trans "60 - 90 days" %}
52 | {% trans "> 90 days" %}
53 | {% trans "No end date" %}
54 |
55 |
56 |
57 | {% for row in rows %}
58 |
59 | {{ row.name }}
60 | {{ row.total|currency }}
61 | {{ row.num_accounts }}
62 |
63 | {{ row.total_expiring_within_30|currency }}
64 | ({{ row.num_expiring_within_30 }})
65 |
66 |
67 | {{ row.total_expiring_within_60|currency }}
68 | ({{ row.num_expiring_within_60 }})
69 |
70 |
71 | {{ row.total_expiring_within_90|currency }}
72 | ({{ row.num_expiring_within_90 }})
73 |
74 |
75 | {{ row.total_expiring_outside_90|currency }}
76 | ({{ row.num_expiring_outside_90 }})
77 |
78 |
79 | {{ row.total_open_ended|currency }}
80 | ({{ row.num_open_ended }})
81 |
82 |
83 | {% endfor %}
84 |
85 |
86 | {{ totals.total|currency }}
87 | {{ totals.num_accounts }}
88 |
89 |
90 |
91 |
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 |
23 |
24 | {% trans "Edit" %}
25 | {% trans "Top-up" %}
26 | {% trans "Withdraw" %}
27 | {% if not account.is_frozen %}
28 | {% trans "Freeze" %}
29 | {% else %}
30 | {% trans "Thaw" %}
31 | {% endif %}
32 |
33 | {{ account }}
34 |
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 | {% trans "Transfer" %}
49 | {% trans "Amount" %}
50 | {% trans "Description" %}
51 | {% trans "Authorised by" %}
52 | {% trans "Date" %}
53 |
54 |
55 |
56 | {% for txn in transactions %}
57 |
58 | {{ txn.transfer }}
59 | {{ txn.amount|currency }}
60 | {{ txn.transfer.description|default:"-" }}
61 | {{ txn.transfer.user|default:"-" }}
62 | {{ txn.date_created }}
63 |
64 | {% endfor %}
65 |
66 |
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 |
33 |
34 |
35 |
36 |
37 | {{ queryset_description }}
38 | {% if transfers %}
39 |
40 |
41 | {% trans "Reference" %}
42 | {% trans "Source" %}
43 | {% trans "Destination" %}
44 | {% trans "Amount" %}
45 | {% trans "Order number" %}
46 | {% trans "Description" %}
47 | {% trans "Authorised by" %}
48 | {% trans "Date created" %}
49 |
50 | {% for transfer in transfers %}
51 |
52 | {{ transfer.reference }}
53 | {{ transfer.source }}
54 | {{ transfer.destination }}
55 | {{ transfer.amount|currency }}
56 | {{ transfer.merchant_reference|default:"-" }}
57 | {{ transfer.description|default:"-" }}
58 | {{ transfer.user|default:"-" }}
59 | {{ transfer.date_created }}
60 |
61 | {% endfor %}
62 |
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 | {% trans "Name" %}
10 | {{ account.name }}
11 |
12 |
13 | {% trans "Description" %}
14 | {{ account.description|default:"-" }}
15 |
16 |
17 | {% trans "Status" %}
18 | {{ account.status }}
19 |
20 |
21 | {% trans "Code" %}
22 | {{ account.code|default:"-" }}
23 |
24 |
25 | {% trans "Account type" %}
26 | {{ account.account_type.full_name }}
27 |
28 |
29 | {% trans "Balance" %}
30 | {{ account.balance|currency }}
31 |
32 |
33 | {% trans "Credit limit" %}
34 |
35 | {% if not account.has_credit_limit %}
36 | {% trans "No limit" %}
37 | {% else %}
38 | {{ account.credit_limit|currency }}
39 | {% endif %}
40 |
41 |
42 | {% if account.start_date %}
43 |
44 | {% trans "Start date" %}
45 | {{ account.start_date|default:"-" }}
46 |
47 | {% endif %}
48 | {% if account.end_date %}
49 |
50 | {% trans "End date" %}
51 | {{ account.end_date|default:"-" }}
52 |
53 | {% endif %}
54 | {% if account.primary_user %}
55 |
56 | {% trans "Primary user" %}
57 | {{ account.primary_user }}
58 |
59 | {% endif %}
60 | {% with num_users=account.secondary_users.all.count %}
61 | {% if num_users %}
62 |
63 | {% trans "Num secondary users" %}
64 | {{ num_users}}
65 |
66 | {% endif %}
67 | {% endwith %}
68 | {% if account.product_range %}
69 |
70 | {% trans "Can be spent on products from range" %}
71 | {{ account.product_range }}
72 |
73 | {% endif %}
74 |
75 |
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 |
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 |
40 | {% endif %}
41 |
42 | {% trans "Look up an account" %}
43 |
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 | {% trans "Name" %}
57 | {{ account.name }}
58 |
59 | {% if account.description %}
60 |
61 | {% trans "Description" %}
62 | {{ account.description }}
63 |
64 | {% endif %}
65 | {% if account.end_date %}
66 |
67 | {% trans "Expiry date" %}
68 | {{ account.end_date }}
69 |
70 | {% endif %}
71 |
72 | {% trans "Balance" %}
73 | {{ account.balance|currency }}
74 |
75 |
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 |
91 | {% endif %}
92 |
93 | {% if account_allocations %}
94 | {% trans "Allocations" %}
95 |
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 |
23 | {% trans "Create a new " %} {{ unit_name|lower }}
24 | {{ title }}
25 |
26 | {% endblock header %}
27 |
28 | {% block dashboard_content %}
29 |
30 | {% trans "Search" %}
31 |
32 |
37 |
38 |
39 |
40 |
41 |
42 | {{ queryset_description }}
43 | {% if accounts.count %}
44 |
45 |
46 | {% trans "Name" %}
47 | {% trans "Code" %}
48 | {% trans "Status" %}
49 | {% trans "Start date" %}
50 | {% trans "End date" %}
51 | {% trans "Balance" %}
52 | {% trans "Num transactions" %}
53 | {% trans "Date created" %}
54 |
55 |
56 | {% for account in accounts %}
57 | {# When we're using bootstrap 2.1, we can use table row colors #}
58 |
59 | {{ account.name|default:"-" }}
60 | {{ account.code|default:"-" }}
61 | {{ account.status }}
62 | {{ account.start_date|default:"-" }}
63 | {{ account.end_date|default:"-" }}
64 | {{ account.balance|currency }}
65 | {{ account.num_transactions }}
66 | {{ account.date_created }}
67 |
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 |
86 |
87 | {% endfor %}
88 |
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 |
32 |
33 |
34 |
35 | {% if show_report %}
36 |
37 |
38 | {% trans "Transactions between" %} {{ start_date }} {% trans "and" %} {{ end_date }}
39 |
40 |
41 |
42 | {% trans "INCREASES IN DEFERRED INCOME LIABILITY" %}
43 |
44 |
45 | {% trans "Sales" %}
46 |
47 | {% for row in cash_rows %}
48 |
49 | {{ row.name }}
50 | {{ row.total|currency }}
51 |
52 | {% endfor %}
53 |
54 |
55 | {{ cash_total|currency }}
56 |
57 |
58 | {% trans "Unpaid sources" %}
59 |
60 | {% for row in unpaid_rows %}
61 |
62 | {{ row.name }}
63 | {{ row.total|currency }}
64 |
65 |
66 |
67 | {{ unpaid_total|currency }}
68 |
69 | {% endfor %}
70 |
71 | {% trans "Refunds" %}
72 |
73 | {% for row in refund_rows %}
74 |
75 | {{ row.name }}
76 | {{ row.total|currency }}
77 |
78 | {% endfor %}
79 |
80 |
81 | {{ refund_total|currency }}
82 |
83 |
84 | {% trans "TOTAL" %}
85 | {{ increase_total|currency }}
86 |
87 |
88 |
89 |
90 |
91 |
92 | {% trans "REDUCTIONS IN DEFERRED INCOME LIABILITY" %}
93 |
94 |
95 | {% trans "Redemptions" %}
96 |
97 | {% for row in redeem_rows %}
98 |
99 | {{ row.name }}
100 | {{ row.total|currency }}
101 |
102 | {% endfor %}
103 |
104 |
105 | {{ redeem_total|currency }}
106 |
107 |
108 | {% trans "Expired" %}
109 |
110 | {% for row in closure_rows %}
111 |
112 | {{ row.name }}
113 | {{ row.total|currency }}
114 |
115 | {% endfor %}
116 |
117 |
118 | {{ closure_total|currency }}
119 |
120 |
121 | {% trans "TOTAL" %}
122 | {{ reduction_total|currency }}
123 |
124 |
125 |
126 |
127 |
128 |
129 | {% trans "DIFFERENCE IN POSITION" %}
130 | {{ position_difference|currency }}
131 |
132 |
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 |
--------------------------------------------------------------------------------