├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── changelog.rst ├── conf.py ├── index.rst ├── installation.rst ├── make.bat ├── payments.rst ├── processing.rst ├── requirements.txt ├── stripe-customers.rst └── subscriptions.rst ├── setup.cfg ├── setup.py ├── tests ├── .gitignore ├── manage.py └── testapp │ ├── __init__.py │ ├── customer.json │ ├── moochers.py │ ├── processing.py │ ├── settings.py │ ├── test_moocher.py │ ├── test_payments.py │ ├── test_processing.py │ ├── test_stripe.py │ ├── test_subscription_utils.py │ ├── test_subscriptions.py │ └── urls.py ├── tox.ini └── user_payments ├── __init__.py ├── admin.py ├── apps.py ├── exceptions.py ├── locale └── de │ └── LC_MESSAGES │ ├── django.mo │ └── django.po ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── processing.py ├── stripe_customers ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── moochers.py ├── static │ └── stripe_customers │ │ └── cards.js └── templates │ └── stripe_customers │ └── payment_form.html └── user_subscriptions ├── __init__.py ├── admin.py ├── apps.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py └── utils.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@babel/eslint-parser", 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | extends: ["eslint:recommended", "prettier"], 9 | globals: { 10 | Atomics: "readonly", 11 | SharedArrayBuffer: "readonly", 12 | __API_HOST: "readonly", 13 | }, 14 | parserOptions: { 15 | ecmaFeatures: { 16 | experimentalObjectRestSpread: true, 17 | jsx: true, 18 | }, 19 | ecmaVersion: 2021, 20 | requireConfigFile: false, 21 | sourceType: "module", 22 | }, 23 | rules: { 24 | "no-unused-vars": [ 25 | "error", 26 | { 27 | argsIgnorePattern: "^_", 28 | varsIgnorePattern: "^_", 29 | }, 30 | ], 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | name: Python ${{ matrix.python-version }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - "3.8" 18 | - "3.9" 19 | - "3.10" 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip wheel setuptools tox 30 | - name: Run tox targets for ${{ matrix.python-version }} 31 | run: | 32 | ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}") 33 | TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') python -m tox 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py? 2 | *.sw? 3 | *~ 4 | .coverage 5 | .tox 6 | /*.egg-info 7 | /MANIFEST 8 | build 9 | dist 10 | htmlcov 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.0.1 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-merge-conflict 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/asottile/pyupgrade 11 | rev: v2.29.1 12 | hooks: 13 | - id: pyupgrade 14 | args: [--py38-plus] 15 | - repo: https://github.com/adamchainz/django-upgrade 16 | rev: 1.4.0 17 | hooks: 18 | - id: django-upgrade 19 | args: [--target-version, "3.2"] 20 | - repo: https://github.com/pycqa/isort 21 | rev: 5.10.1 22 | hooks: 23 | - id: isort 24 | args: [--profile=black, --lines-after-imports=2, --combine-as] 25 | - repo: https://github.com/psf/black 26 | rev: 21.11b1 27 | hooks: 28 | - id: black 29 | - repo: https://github.com/pycqa/flake8 30 | rev: 4.0.1 31 | hooks: 32 | - id: flake8 33 | args: ["--ignore=E203,E501,W503"] 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: v2.5.0 36 | hooks: 37 | - id: prettier 38 | args: [--list-different, --no-semi] 39 | exclude: "^conf/|.*\\.html$" 40 | - repo: https://github.com/pre-commit/mirrors-eslint 41 | rev: v8.3.0 42 | hooks: 43 | - id: eslint 44 | args: [--fix] 45 | verbose: true 46 | additional_dependencies: 47 | - eslint@8.3.0 48 | - eslint-config-prettier@8.3.0 49 | - "@babel/core" 50 | - "@babel/eslint-parser" 51 | - "@babel/preset-env" 52 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | Change log 4 | ========== 5 | 6 | `Next version`_ 7 | ~~~~~~~~~~~~~~~ 8 | 9 | - Ensured that the username is part of ``search_fields`` for all models 10 | registered with the admin interface. 11 | - Added a new subscription periodicity, ``quarterly``. 12 | - Added a ``SubscriptionPeriod.objects.zeroize_pending_periods()`` 13 | helper for zeroizing past periods so that when users (finally) provide 14 | payment methods they do not have to pay past periods too (if you 15 | choose so). 16 | - Started applying pre-commit to the project. 17 | - From Travis CI to GitHub actions. 18 | 19 | 20 | `0.3`_ (2018-09-21) 21 | ~~~~~~~~~~~~~~~~~~~ 22 | 23 | - Fixed the case where two consecutive ``Subscription.objects.ensure()`` 24 | calls would lead to the subscription being restarted and a second 25 | period being added right away. Also, fix a bunch of other edge cases 26 | in ``ensure()`` and add a few additional tests while at it. 27 | - Made it impossible to inadvertently delete subscription periods by 28 | cascading deletions when removing line items. 29 | - Changed the subscription admin to only show the period inline when 30 | updating a subscription. 31 | - Added ``Payment.undo()`` to undo payments which have already been 32 | marked as paid. 33 | - Fixed an edge case where setting ``Subscription.paid_until`` would 34 | produce incorrect results when no period was paid for yet. 35 | 36 | 37 | `0.2`_ (2018-08-05) 38 | ~~~~~~~~~~~~~~~~~~~ 39 | 40 | - Changed ``SubscriptionPeriod.objects.create_line_items()`` to only 41 | create line items for periods that start no later than today by 42 | default. A new ``until`` keyword argument allows overriding this. 43 | - Fixed ``MANIFEST.in`` to include package data of ``stripe_customers``. 44 | - Changed the code for the updated Stripe Python library. Updated the 45 | requirement for ``django-user-payments[stripe]`` to ``>=2``. 46 | - Fixed a crash when creating a subscription with a periodicity of 47 | "manually" through the admin interface. 48 | 49 | 50 | `0.1`_ (2018-06-05) 51 | ~~~~~~~~~~~~~~~~~~~ 52 | 53 | - First release that should be fit for public consumption. 54 | 55 | 56 | .. _0.1: https://github.com/matthiask/django-user-payments/commit/c6dc9474 57 | .. _0.2: https://github.com/matthiask/django-user-payments/compare/0.1...0.2 58 | .. _0.3: https://github.com/matthiask/django-user-payments/compare/0.2...0.3 59 | .. _Next version: https://github.com/matthiask/django-user-payments/compare/0.3...master 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Feinheit AG and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Feinheit AG nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include MANIFEST.in 3 | include README.rst 4 | recursive-include user_payments/static * 5 | recursive-include user_payments/locale * 6 | recursive-include user_payments/templates * 7 | recursive-include user_payments/stripe_customers/static * 8 | recursive-include user_payments/stripe_customers/locale * 9 | recursive-include user_payments/stripe_customers/templates * 10 | recursive-include user_payments/user_subscriptions/static * 11 | recursive-include user_payments/user_subscriptions/locale * 12 | recursive-include user_payments/user_subscriptions/templates * 13 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | django-user-payments 3 | ==================== 4 | 5 | .. image:: https://travis-ci.org/matthiask/django-user-payments.svg?branch=master 6 | :target: https://travis-ci.org/matthiask/django-user-payments 7 | 8 | .. image:: https://readthedocs.org/projects/django-user-payments/badge/?version=latest 9 | :target: https://django-user-payments.readthedocs.io/en/latest/?badge=latest 10 | :alt: Documentation Status 11 | 12 | Links 13 | ===== 14 | 15 | - `Documentation on readthedocs.io `_ 16 | - `Code on Github `_ 17 | - `Package on PyPI `_ 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = test 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import sys 5 | 6 | 7 | sys.path.append(os.path.abspath("..")) 8 | 9 | project = "django-user-payments" 10 | author = "Feinheit AG" 11 | copyright = "2018, " + author 12 | version = __import__("user_payments").__version__ 13 | release = subprocess.check_output( 14 | "git fetch --tags; git describe --always --tags", 15 | shell=True, 16 | universal_newlines=True, 17 | ).strip() 18 | # language = 'en' 19 | 20 | ####################################### 21 | project_slug = re.sub(r"[^a-z]+", "", project) 22 | 23 | extensions = [ 24 | # 'sphinx.ext.autodoc', 25 | # 'sphinx.ext.viewcode', 26 | ] 27 | templates_path = ["_templates"] 28 | source_suffix = ".rst" 29 | master_doc = "index" 30 | 31 | exclude_patterns = ["build", "Thumbs.db", ".DS_Store"] 32 | pygments_style = "sphinx" 33 | todo_include_todos = False 34 | 35 | html_theme = "sphinx_rtd_theme" 36 | html_static_path = ["_static"] 37 | htmlhelp_basename = project_slug + "doc" 38 | 39 | latex_elements = { 40 | "papersize": "a4", 41 | } 42 | latex_documents = [ 43 | ( 44 | master_doc, 45 | project_slug + ".tex", 46 | project + " Documentation", 47 | author, 48 | "manual", 49 | ) 50 | ] 51 | man_pages = [ 52 | ( 53 | master_doc, 54 | project_slug, 55 | project + " Documentation", 56 | [author], 57 | 1, 58 | ) 59 | ] 60 | texinfo_documents = [ 61 | ( 62 | master_doc, 63 | project_slug, 64 | project + " Documentation", 65 | author, 66 | project_slug, 67 | "", # Description 68 | "Miscellaneous", 69 | ) 70 | ] 71 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | django-user-payments 3 | ==================== 4 | 5 | Version |release| 6 | 7 | Create, track and settle payments by users. 8 | 9 | django-user-payments consists of a few modules which help with managing 10 | payments and subscriptions by users on a Django-based site. 11 | 12 | Table of Contents 13 | ================= 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | installation 19 | payments 20 | subscriptions 21 | stripe-customers 22 | processing 23 | changelog 24 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Prerequisites and installation 2 | ============================== 3 | 4 | The prerequisites for django-user-payments are: 5 | 6 | - Django 2.2 or better 7 | - Python 3.5 or better 8 | - `django-mooch `_ 9 | (installed as a dependency) 10 | 11 | To install the package, start with installing the package using pip: 12 | 13 | .. code-block:: bash 14 | 15 | pip install django-user-payments 16 | 17 | Add the apps to ``INSTALLED_APPS`` and override settings if you want to 18 | change the defaults: 19 | 20 | .. code-block:: python 21 | 22 | INSTALLED_APPS = [ 23 | ... 24 | 25 | # Required: 26 | "user_payments", 27 | 28 | # Optional, if you want those features: 29 | # "user_payments.user_subscriptions", 30 | # "user_payments.stripe_customers", 31 | 32 | ... 33 | ] 34 | 35 | # Also optional, defaults: 36 | from datetime import timedelta # noqa 37 | 38 | USER_PAYMENTS = { 39 | "currency": "CHF", 40 | "grace_period": timedelta(days=7), 41 | "disable_autorenewal_after": timedelta(days=15), 42 | } 43 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=build 12 | set SPHINXPROJ=test 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/payments.rst: -------------------------------------------------------------------------------- 1 | Payments 2 | ======== 3 | 4 | django-user-payments allows quickly adding line items for a user and 5 | paying later for those. 6 | 7 | For example, if some functionality is really expensive you might want to 8 | add a line item each time the user requests the functionality: 9 | 10 | .. code-block:: python 11 | 12 | @login_required 13 | def expensive_view(request): 14 | LineItem.objects.create( 15 | user=request.user, 16 | amount=Decimal("0.05"), 17 | title="expensive view at %s" % timezone.now(), 18 | ) 19 | 20 | # .. further processing and response generation 21 | 22 | At the time the user wants to pay the costs that have run up you create 23 | a pending payment and maybe process it using a moocher. 24 | 25 | .. admonition:: A quick introduction to moochers 26 | 27 | Moochers (provided by `django-mooch 28 | `_) take a request and a 29 | payment instance, show a form or a button, and handle interaction 30 | with and responses from payment service providers. They allow 31 | processing individual payments one at a time. 32 | 33 | django-user-payments' ``Payment`` model extends the abstract 34 | ``mooch.Payment`` so that moochers may be readily used. 35 | 36 | 37 | The first view below, ``pay`` creates the pending payment and redirects 38 | the user to the next step. ``pay_payment`` fetches the pending payment 39 | from the database and allows selecting a payment method. Further 40 | processing is the responsibility of the selected moocher. 41 | 42 | 43 | .. code-block:: python 44 | 45 | @login_required 46 | def pay(request): 47 | payment = Payment.objects.create_pending(user=request.user) 48 | if not payment: 49 | # No line items, redirect somewhere else! 50 | return ... 51 | 52 | # django-mooch's Payment uses UUID4 fields: 53 | return redirect('pay_payment', id=payment.id.hex) 54 | 55 | 56 | @login_required 57 | def pay_payment(request, id): 58 | payment = get_object_or_404( 59 | request.user.user_payments.pending(), 60 | id=id, 61 | ) 62 | return render(request, "pay_payment.html", { 63 | "payment": payment, 64 | "moochers": [ 65 | moocher.payment_form(request, payment) 66 | for moocher in moochers.values() 67 | ], 68 | }) 69 | 70 | 71 | A payment life cycle 72 | ~~~~~~~~~~~~~~~~~~~~ 73 | 74 | Payments will most often be created by calling 75 | ``Payment.objects.create_pending(user=)``. This creates an unpaid 76 | payment instance and binds all unbound line items to the payment 77 | instance by updating their ``payment`` foreign key field. The ``amount`` 78 | fields of all line items are summed up and assigned to the payments' 79 | ``amount`` field. If there were no unbound line items, no payment 80 | instance is created and the manager method returns ``None``. 81 | 82 | Next, the instance is hopefully processed by a moocher or 83 | django-user-payment's processing which will be discussed later. A 84 | paid-for payment has its nullable ``charged_at`` field (among some other 85 | fields) set to the date and time of payment. 86 | 87 | If payment or processing failed for some reason, the payment instance is 88 | in most cases not very useful anymore. Deleting the instance directly 89 | fails because the line items' ``payment`` foreign key protects against 90 | cascading deletion. Instead, ``payment.cancel_pending()`` unbinds the 91 | line items from the payment and deletes the payment instance. 92 | 93 | 94 | Undoing payments 95 | ~~~~~~~~~~~~~~~~ 96 | 97 | In rare cases it may even be necessary to undo a payment which has 98 | already been marked as paid, respectively has its ``charged_at`` field 99 | set to a truthy value. In this case, the ``payment.undo()`` method sets 100 | ``charged_at`` back to ``None`` and unbinds all the payments' line 101 | items. 102 | -------------------------------------------------------------------------------- /docs/processing.rst: -------------------------------------------------------------------------------- 1 | Processing 2 | ========== 3 | 4 | django-user-payments comes with a framework for processing payments 5 | outside moochers. 6 | 7 | The general structure of an individual processor is as follows: 8 | 9 | .. code-block:: python 10 | 11 | from user_payments.processing import Result 12 | 13 | def psp_process(payment): 14 | # Check prerequisites 15 | if not : 16 | return Result.FAILURE 17 | 18 | # Try settling the payment 19 | if : 20 | return Result.SUCCESS 21 | 22 | return Result.FAILURE 23 | 24 | The processor **must** return a ``Result`` enum value. Individual 25 | processor results **must** not be evaluated in a boolean context. 26 | 27 | The following ``Result`` values exist: 28 | 29 | - ``Result.SUCCESS``: Payment was successfully charged for. 30 | - ``Result.FAILURE``: This processor failed, try the next. 31 | - ``Result.TERMINATE``: Terminate processing for this payment, do not 32 | run any further processors. 33 | 34 | When using ``process_payment()`` as you should (see below) and an 35 | individual processor raises exceptions the exception is logged, the 36 | payment is canceled if ``cancel_on_failure`` is ``True`` (the default) 37 | and the exception is reraised. In other words: Processors should **not** 38 | raise exceptions. 39 | 40 | 41 | Writing your processors 42 | ~~~~~~~~~~~~~~~~~~~~~~~ 43 | 44 | django-user-payments does not bundle any processors, but makes it 45 | relatively straightforward to write your own. 46 | 47 | 48 | The Stripe customers processor 49 | ------------------------------ 50 | 51 | This processors' prerequisites are a Stripe customer instance. If the 52 | prerequisites are fulfilled, this processor tries charging the user, and 53 | if this fails, sends an error mail to the user and terminates further 54 | processing: 55 | 56 | .. code-block:: python 57 | 58 | import json 59 | import logging 60 | 61 | from django.apps import apps 62 | from django.core.mail import EmailMessage 63 | from django.db.models import ObjectDoesNotExist 64 | from django.utils import timezone 65 | 66 | import stripe 67 | 68 | from user_payments.processing import Result 69 | 70 | 71 | logger = logging.getLogger(__name__) 72 | 73 | 74 | def with_stripe_customer(payment): 75 | try: 76 | customer = payment.user.stripe_customer 77 | except ObjectDoesNotExist: 78 | return Result.FAILURE 79 | 80 | s = apps.get_app_config("user_payments").settings 81 | try: 82 | charge = stripe.Charge.create( 83 | customer=customer.customer_id, 84 | amount=payment.amount_cents, 85 | currency=s.currency, 86 | description=payment.description, 87 | idempotency_key="charge-%s-%s" % (payment.id.hex, payment.amount_cents), 88 | ) 89 | 90 | except stripe.error.CardError as exc: 91 | logger.exception("Failure charging the customers' card") 92 | EmailMessage(str(payment), str(exc), to=[payment.email]).send( 93 | fail_silently=True 94 | ) 95 | return Result.TERMINATE 96 | 97 | else: 98 | payment.payment_service_provider = "stripe" 99 | payment.charged_at = timezone.now() 100 | payment.transaction = json.dumps(charge) 101 | payment.save() 102 | 103 | return Result.SUCCESS 104 | 105 | 106 | A processor which sends a "Please pay" mail 107 | ------------------------------------------- 108 | 109 | This processor always fails, but sends a mail to the user first that 110 | they should please pay soon-ish: 111 | 112 | .. code-block:: python 113 | 114 | from django.core.mail import EmailMessage 115 | 116 | from user_payments.processing import Result 117 | 118 | 119 | def please_pay_mail(payment): 120 | # Each time? Each time! 121 | EmailMessage(str(payment), "", to=[payment.email]).send(fail_silently=True) 122 | return Result.FAILURE 123 | 124 | Since this processor runs its action before returning a failure state, 125 | it only makes sense to run this one last. 126 | 127 | 128 | Processing individual payments 129 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 130 | 131 | The work horse of processing is the 132 | ``user_payments.processing.process_payment`` function. The function 133 | expects a payment instance and a list of processors and returns ``True`` 134 | if one of the individual processors returned a ``Result.SUCCESS`` state. 135 | 136 | If all processors fail the payment is automatically canceled and the 137 | payments' line items returned to the pool of unbound line items. This 138 | can be changed by passing ``cancel_on_failure=False`` in case this 139 | behavior is undesirable. 140 | 141 | 142 | Bulk processing 143 | ~~~~~~~~~~~~~~~ 144 | 145 | The ``user_payments.processing`` module offers the following functions 146 | to bulk process payments: 147 | 148 | - ``process_unbound_items(processors=[...])``: Creates pending payments 149 | for all users with unbound line items and calls ``process_payment`` on 150 | them. Cancels payments if no processor succeeds. 151 | - ``process_pending_payments(processors=[...])``: Runs all unpaid 152 | payments through ``process_payment``, but does not cancel a payment 153 | upon failure. When you're only using processors and no moochers this 154 | function *should* have nothing to do since ``process_unbound_items`` 155 | always cleans up on failure. Still, it's better to be safe than sorry 156 | and run this function too. 157 | 158 | 159 | Management command 160 | ~~~~~~~~~~~~~~~~~~ 161 | 162 | My recommendation is to write a management command that is run daily and 163 | which processes unbound line items and unpaid payments. An example 164 | management command follows: 165 | 166 | .. code-block:: python 167 | 168 | from django.core.management.base import BaseCommand 169 | 170 | from user_payments.processing import process_unbound_items, process_pending_payments 171 | # Remove this line if you're not using subscriptions: 172 | from user_payments.user_subscriptions.models import Subscription, SubscriptionPeriod 173 | 174 | # Import the processors defined above 175 | from yourapp.processing import with_stripe_customer, please_pay_mail 176 | 177 | 178 | processors = [with_stripe_customer, please_pay_mail] 179 | 180 | 181 | class Command(BaseCommand): 182 | help = "Create pending payments from line items and try settling them" 183 | 184 | def handle(self, **options): 185 | # Remove those three lines if you're not using subscriptions: 186 | Subscription.objects.disable_autorenewal() 187 | Subscription.objects.create_periods() 188 | SubscriptionPeriod.objects.create_line_items() 189 | 190 | # Process payments 191 | process_unbound_items(processors=processors) 192 | process_pending_payments(processors=processors) 193 | 194 | If you're using `Sentry `_ you probably want 195 | to wrap all commands in a ``try..except`` block: 196 | 197 | .. code-block:: python 198 | 199 | ... 200 | 201 | from raven.contrib.django.raven_compat.models import client 202 | 203 | class Command(BaseCommand): 204 | ... 205 | 206 | def handle(self, **options): 207 | try: 208 | ... 209 | except Exception: 210 | client.captureException() 211 | raise # Reraise, for good measure 212 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | Pillow 3 | -------------------------------------------------------------------------------- /docs/stripe-customers.rst: -------------------------------------------------------------------------------- 1 | Stripe customers 2 | ================ 3 | 4 | The Stripe customers module offers a moocher which automatically creates 5 | a `Stripe customer `_, a 6 | model which binds Stripe customers to user instances and a processor for 7 | payments. 8 | 9 | .. note:: 10 | 11 | Stripe supports more than one source (that is, credit card) per 12 | customer, but our ``user_payments.stripe_customers`` module does not. 13 | 14 | The Stripe customers app requires ``STRIPE_PUBLISHABLE_KEY`` and 15 | ``STRIPE_SECRET_KEY`` settings. 16 | 17 | 18 | The moocher 19 | ~~~~~~~~~~~ 20 | 21 | The ``user_payments.stripe_customers.moochers.StripeMoocher`` is 22 | basically a drop-in replacement for django-mooch's 23 | ``mooch.stripe.StripeMoocher``, except that: 24 | 25 | - Instead of only charging the user once, our moocher creates a Stripe 26 | customer and binds it to a local Django user (in case the user is 27 | authenticated) to make future payments less cumbersome. 28 | - If an authenticated user already has a Stripe customer, the moocher 29 | only shows basic credit card information (e.g. the brand and expiry 30 | date) and a "Pay" button instead of requiring entry of all numbers 31 | again. 32 | -------------------------------------------------------------------------------- /docs/subscriptions.rst: -------------------------------------------------------------------------------- 1 | Subscriptions 2 | ============= 3 | 4 | Active subscriptions periodically create periods, which in turn create 5 | line items. 6 | 7 | Creating a subscription for an user looks like this: 8 | 9 | .. code-block:: python 10 | 11 | subscription = Subscription.objects.ensure( 12 | user=request.user, 13 | code="the-membership", 14 | periodicity="monthly", 15 | amount=Decimal("12"), 16 | 17 | # Optional: 18 | title="The Membership", 19 | ) 20 | 21 | This example would also work with ``Subscription.objects.create()``, but 22 | ``Subscription.objects.ensure()`` knows to update a users' subscription 23 | with the same ``code`` and also is smart when a subscription is updated 24 | -- it does not only set fields, but also remove now invalid periods and 25 | delay the new start date if the subscription is still paid for. 26 | 27 | django-user-payments' subscriptions have no concept of a plan or a 28 | product -- this is purely your responsibility to add (if needed). 29 | 30 | 31 | Periods and periodicity 32 | ~~~~~~~~~~~~~~~~~~~~~~~ 33 | 34 | Next, let's add some periods and create some line items for them: 35 | 36 | .. code-block:: python 37 | 38 | for period in subscription.create_periods(): 39 | period.create_line_item() 40 | 41 | Subscriptions are anchored on the ``starts_on`` day. Available 42 | periodicities are: 43 | 44 | - ``yearly`` 45 | - ``monthly`` 46 | - ``weekly`` 47 | - ``manually`` 48 | 49 | Simply incrementing the month and year will not always work in the case 50 | of ``yearly`` and ``monthly`` periodicity. If the naively calculated 51 | date does not exist, the algorithm returns later dates. 52 | 53 | .. admonition:: Specifics of recurring date calculation 54 | 55 | For example, if a subscription starts on 2016-02-29 (a leap year), 56 | the next three years' periods will start on March 1st. However, the 57 | period stays anchored at the start date, therefore in 2020 the period 58 | starts on February 29th again. Same with months: The next two period 59 | starts for a monthly subscription starting on 2018-03-31 will be 60 | 2018-05-01 and 2018-05-31. As you can see, since 2018-04-31 does not 61 | exist, no period starts in April, and two periods start in May. 62 | 63 | Periods end one day before the next period starts. Respectively, 64 | subscriptions do not only offer date fields -- all date fields have a 65 | corresponding property returning a date time in the default timezone. 66 | Periods always start at 00:00:00 and end at 23:59:59.999999. 67 | 68 | ``subscription.create_periods()`` only creates periods that start no 69 | later than today. This can be overridden by passing a date using the 70 | ``until`` keyword argument. 71 | 72 | 73 | Subscription status and grace periods 74 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 75 | 76 | Once line items created by subscription periods are bound to a payment 77 | and the payment is paid for, the subscription automatically runs its 78 | ``update_paid_until()`` method. The method sets the subscriptions' 79 | ``paid_until`` date field to the date when the latest subscription 80 | period ends. 81 | 82 | However, the subscription status is not only determined by 83 | ``paid_until``. By default, subscriptions have a grace period of 7 days 84 | during which the subscription is still ``subscription.is_active``, but 85 | also ``subscription.in_grace_period``. The date time when the grace 86 | period ends is available as ``subscription.grace_period_ends_at``. 87 | 88 | Take note that the grace period also applies to subscriptions that have 89 | been newly created, that is, never been paid for. 90 | 91 | Subscriptions should be canceled by calling ``subscription.cancel()``. 92 | This method disabled automatic renewal and removes periods and their 93 | line items in case they haven't been paid for yet. 94 | 95 | 96 | Periodical tasks and maintenance 97 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 98 | 99 | The following commands would make sense to run periodically in a 100 | management command: 101 | 102 | - ``Subscription.objects.disable_autorenewal()``: Cancel subscriptions 103 | that are past due by ``disable_autorenewal_after`` days, by default 15 104 | days. 105 | - ``Subscription.objects.create_periods()``: Run 106 | ``subscription.create_periods()`` on all subscriptions that should 107 | renew automatically. 108 | - ``SubscriptionPeriod.objects.create_line_items()``: Make periods 109 | create their line items in case they haven't done so already. By 110 | default only periods that start no later than today are considered. 111 | This can be changed by providing another date using the ``until`` 112 | keyword argument. 113 | 114 | The processing documentation contains a management command where those 115 | functions are called in the recommended way and order. 116 | 117 | 118 | Closing notes 119 | ~~~~~~~~~~~~~ 120 | 121 | As you can see subscriptions do not concern themselves with payment 122 | processing, only with creating line items. Subscriptions only use 123 | payments to automatically update their ``paid_until`` date field. 124 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django_user_payments 3 | version = attr: user_payments.__version__ 4 | description = User payments and subscriptions for Django 5 | long_description = file: README.rst 6 | long_description_content_type = text/x-rst 7 | url = https://github.com/matthiask/django-user-payments/ 8 | author = Matthias Kestenholz 9 | author_email = mk@feinheit.ch 10 | license = BSD-3-Clause 11 | license_file = LICENSE 12 | platforms = OS Independent 13 | classifiers = 14 | Environment :: Web Environment 15 | Framework :: Django 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: BSD License 18 | Operating System :: OS Independent 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3 :: Only 22 | Programming Language :: Python :: 3.8 23 | Programming Language :: Python :: 3.9 24 | Programming Language :: Python :: 3.10 25 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 26 | Topic :: Software Development 27 | Topic :: Software Development :: Libraries :: Application Frameworks 28 | 29 | [options] 30 | packages = find: 31 | install_requires = 32 | django-mooch>=0.6 33 | python_requires = >=3.8 34 | include_package_data = True 35 | zip_safe = False 36 | 37 | [options.packages.find] 38 | exclude = 39 | tests 40 | tests.* 41 | 42 | [options.extras_require] 43 | tests = 44 | coverage 45 | stripe>=2 46 | stripe = 47 | stripe>=2 48 | 49 | [coverage:run] 50 | branch = True 51 | include = 52 | *user_payments* 53 | *tests* 54 | omit = 55 | *migrations* 56 | *.tox* 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /.coverage 2 | /htmlcov 3 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from os.path import abspath, dirname 5 | 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 9 | 10 | sys.path.insert(0, dirname(dirname(abspath(__file__)))) 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-user-payments/b14887a3d274dc164ef9a36f05a45b6bdac3afe5/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/customer.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "cus_BdO5X6Bj123456", 3 | "object": "customer", 4 | "account_balance": 0, 5 | "created": 1508770456, 6 | "currency": "chf", 7 | "default_source": { 8 | "id": "card_1BG7JjCY7Kd3FoaUJM123456", 9 | "object": "card", 10 | "address_city": null, 11 | "address_country": null, 12 | "address_line1": null, 13 | "address_line1_check": null, 14 | "address_line2": null, 15 | "address_state": null, 16 | "address_zip": "42422", 17 | "address_zip_check": "pass", 18 | "brand": "Visa", 19 | "country": "US", 20 | "customer": "cus_BdO5X6Bj123456", 21 | "cvc_check": "pass", 22 | "dynamic_last4": null, 23 | "exp_month": 4, 24 | "exp_year": 2024, 25 | "fingerprint": "NGPXWv8UTDzF1234", 26 | "funding": "credit", 27 | "last4": "4242", 28 | "metadata": {}, 29 | "name": null, 30 | "tokenization_method": null 31 | }, 32 | "delinquent": false, 33 | "description": null, 34 | "discount": null, 35 | "email": "test4@feinheit.ch", 36 | "invoice_prefix": "F544123", 37 | "livemode": false, 38 | "metadata": {}, 39 | "shipping": null, 40 | "sources": { 41 | "object": "list", 42 | "data": [ 43 | { 44 | "id": "card_1BG7JjCY7Kd3FoaUJM123456", 45 | "object": "card", 46 | "address_city": null, 47 | "address_country": null, 48 | "address_line1": null, 49 | "address_line1_check": null, 50 | "address_line2": null, 51 | "address_state": null, 52 | "address_zip": "42422", 53 | "address_zip_check": "pass", 54 | "brand": "Visa", 55 | "country": "US", 56 | "customer": "cus_BdO5X6Bj123456", 57 | "cvc_check": "pass", 58 | "dynamic_last4": null, 59 | "exp_month": 4, 60 | "exp_year": 2024, 61 | "fingerprint": "NGPXWv8UTDzF1234", 62 | "funding": "credit", 63 | "last4": "4242", 64 | "metadata": {}, 65 | "name": null, 66 | "tokenization_method": null 67 | } 68 | ], 69 | "has_more": false, 70 | "total_count": 1, 71 | "url": "/v1/customers/cus_BdO5X6Bj123456/sources" 72 | }, 73 | "subscriptions": { 74 | "object": "list", 75 | "data": [ 76 | { 77 | "id": "sub_BdO5oNGcTF1234", 78 | "object": "subscription", 79 | "application_fee_percent": null, 80 | "billing": "charge_automatically", 81 | "billing_cycle_anchor": 1508770456, 82 | "cancel_at_period_end": false, 83 | "canceled_at": null, 84 | "created": 1508770456, 85 | "current_period_end": 1529765656, 86 | "current_period_start": 1527087256, 87 | "customer": "cus_BdO5X6Bj123456", 88 | "days_until_due": null, 89 | "discount": null, 90 | "ended_at": null, 91 | "items": { 92 | "object": "list", 93 | "data": [ 94 | { 95 | "id": "si_1BG7JkCY7Kd3FoaUhW2VDTDi", 96 | "object": "subscription_item", 97 | "created": 1508770457, 98 | "metadata": {}, 99 | "plan": { 100 | "id": "stadtbuergerin", 101 | "object": "plan", 102 | "aggregate_usage": null, 103 | "amount": 500, 104 | "billing_scheme": "per_unit", 105 | "created": 1481102874, 106 | "currency": "chf", 107 | "interval": "month", 108 | "interval_count": 1, 109 | "livemode": false, 110 | "metadata": {}, 111 | "name": "asdfglksjadlkasjldkasjdlkajdasd", 112 | "nickname": null, 113 | "product": "prod_BUdhWQSKtA1234", 114 | "statement_descriptor": null, 115 | "tiers": null, 116 | "tiers_mode": null, 117 | "transform_usage": null, 118 | "trial_period_days": null, 119 | "usage_type": "licensed" 120 | }, 121 | "quantity": 1, 122 | "subscription": "sub_BdO5oNGcTF1234" 123 | } 124 | ], 125 | "has_more": false, 126 | "total_count": 1, 127 | "url": "/v1/subscription_items?subscription=sub_BdO5oNGcTF1234" 128 | }, 129 | "livemode": false, 130 | "metadata": {}, 131 | "plan": { 132 | "id": "stadtbuergerin", 133 | "object": "plan", 134 | "aggregate_usage": null, 135 | "amount": 500, 136 | "billing_scheme": "per_unit", 137 | "created": 1481102874, 138 | "currency": "chf", 139 | "interval": "month", 140 | "interval_count": 1, 141 | "livemode": false, 142 | "metadata": {}, 143 | "name": "asdfglksjadlkasjldkasjdlkajdasd", 144 | "nickname": null, 145 | "product": "prod_BUdhWQSKtA1234", 146 | "statement_descriptor": null, 147 | "tiers": null, 148 | "tiers_mode": null, 149 | "transform_usage": null, 150 | "trial_period_days": null, 151 | "usage_type": "licensed" 152 | }, 153 | "quantity": 1, 154 | "start": 1508770456, 155 | "status": "active", 156 | "tax_percent": null, 157 | "trial_end": null, 158 | "trial_start": null 159 | } 160 | ], 161 | "has_more": false, 162 | "total_count": 1, 163 | "url": "/v1/customers/cus_BdO5X6Bj123456/subscriptions" 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /tests/testapp/moochers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.urls import include, path 4 | 5 | from user_payments.models import Payment 6 | from user_payments.stripe_customers.moochers import StripeMoocher 7 | 8 | 9 | app_name = "mooch" 10 | moochers = OrderedDict([("stripe", StripeMoocher(model=Payment, app_name=app_name))]) 11 | urlpatterns = [path("", include(moocher.urls)) for moocher in moochers.values()] 12 | -------------------------------------------------------------------------------- /tests/testapp/processing.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import stripe 5 | from django.apps import apps 6 | from django.core.mail import EmailMessage 7 | from django.db.models import ObjectDoesNotExist 8 | from django.utils import timezone 9 | from mooch.signals import post_charge 10 | 11 | from user_payments.processing import Result 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def with_stripe_customer(payment): 18 | try: 19 | customer = payment.user.stripe_customer 20 | except ObjectDoesNotExist: 21 | return Result.FAILURE 22 | 23 | if (timezone.now() - customer.updated_at).total_seconds() > 30 * 86400: 24 | customer.refresh() 25 | 26 | s = apps.get_app_config("user_payments").settings 27 | 28 | try: 29 | charge = stripe.Charge.create( 30 | customer=customer.customer_id, 31 | amount=payment.amount_cents, 32 | currency=s.currency, 33 | description=payment.description, 34 | idempotency_key=f"charge-{payment.id.hex}-{payment.amount_cents}", 35 | ) 36 | 37 | except stripe.error.CardError as exc: 38 | logger.exception("Failure charging the customers' card") 39 | EmailMessage(str(payment), str(exc), to=[payment.email]).send( 40 | fail_silently=True 41 | ) 42 | return Result.TERMINATE 43 | 44 | else: 45 | payment.payment_service_provider = "stripe" 46 | payment.charged_at = timezone.now() 47 | payment.transaction = json.dumps(charge) 48 | payment.save() 49 | 50 | # FIXME sender? 51 | post_charge.send(sender=with_stripe_customer, payment=payment, request=None) 52 | return Result.SUCCESS 53 | 54 | 55 | def please_pay_mail(payment): 56 | # Each time? Each time! 57 | EmailMessage(str(payment), "", to=[payment.email]).send(fail_silently=True) 58 | # No success, but do not terminate processing. 59 | return Result.FAILURE 60 | 61 | 62 | processors = [with_stripe_customer, please_pay_mail] 63 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | 5 | DEBUG = True 6 | BASE_DIR = os.path.dirname(__file__) 7 | 8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 9 | 10 | INSTALLED_APPS = [ 11 | "django.contrib.auth", 12 | "django.contrib.admin", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.staticfiles", 16 | "django.contrib.messages", 17 | "testapp", 18 | # Libraries 19 | "user_payments", 20 | "user_payments.stripe_customers", 21 | "user_payments.user_subscriptions", 22 | ] 23 | 24 | STATIC_URL = "/static/" 25 | BASEDIR = os.path.dirname(__file__) 26 | MEDIA_ROOT = os.path.join(BASEDIR, "media/") 27 | STATIC_ROOT = os.path.join(BASEDIR, "static/") 28 | MEDIA_URL = "/media/" 29 | STATIC_URL = "/static/" 30 | SECRET_KEY = "supersikret" 31 | LOGIN_REDIRECT_URL = "/?login=1" 32 | 33 | ROOT_URLCONF = "testapp.urls" 34 | LANGUAGES = (("en", "English"), ("de", "German")) 35 | 36 | USE_TZ = True 37 | USE_I18N = True 38 | USE_L10N = True 39 | 40 | TEMPLATES = [ 41 | { 42 | "BACKEND": "django.template.backends.django.DjangoTemplates", 43 | "DIRS": [], 44 | "APP_DIRS": True, 45 | "OPTIONS": { 46 | "context_processors": [ 47 | "django.template.context_processors.debug", 48 | "django.template.context_processors.request", 49 | "django.contrib.auth.context_processors.auth", 50 | "django.contrib.messages.context_processors.messages", 51 | ] 52 | }, 53 | } 54 | ] 55 | 56 | MIDDLEWARE = [ 57 | "django.middleware.security.SecurityMiddleware", 58 | "django.contrib.sessions.middleware.SessionMiddleware", 59 | "django.middleware.common.CommonMiddleware", 60 | "django.middleware.locale.LocaleMiddleware", 61 | "django.middleware.csrf.CsrfViewMiddleware", 62 | "django.contrib.auth.middleware.AuthenticationMiddleware", 63 | "django.contrib.messages.middleware.MessageMiddleware", 64 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 65 | ] 66 | 67 | STRIPE_SECRET_KEY = "none" 68 | STRIPE_PUBLISHABLE_KEY = "none" 69 | 70 | USER_PAYMENTS = { 71 | "processors": [ 72 | "user_payments.stripe_customers.processing.with_stripe_customer", 73 | "user_payments.processing.please_pay_mail", 74 | ] 75 | } 76 | 77 | if os.environ.get("LOG"): 78 | logger = logging.getLogger("user_payments") 79 | logger.addHandler(logging.StreamHandler()) 80 | logger.setLevel(logging.DEBUG) 81 | -------------------------------------------------------------------------------- /tests/testapp/test_moocher.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import stripe 4 | from django.contrib.auth.models import AnonymousUser, User 5 | from django.test import RequestFactory, TestCase 6 | from django.utils.translation import deactivate_all 7 | from testapp.moochers import moochers 8 | 9 | from user_payments.models import Payment 10 | from user_payments.stripe_customers.models import Customer 11 | 12 | 13 | class AttrDict(dict): 14 | """Dictionary which also allows attribute access to its items""" 15 | 16 | def __getattr__(self, key): 17 | return self[key] 18 | 19 | 20 | class Test(TestCase): 21 | def setUp(self): 22 | self.user = User.objects.create_superuser("admin", "admin@test.ch", "blabla") 23 | self.moocher = moochers["stripe"] 24 | self.rf = RequestFactory() 25 | deactivate_all() 26 | 27 | def test_moocher_form(self): 28 | payment = Payment.objects.create(user=self.user, amount=10) 29 | payment_form = self.moocher.payment_form(self.rf.get("/"), payment) 30 | self.assertIn(payment.id.hex, payment_form) 31 | 32 | def test_moocher(self): 33 | payment = Payment.objects.create(user=self.user, amount=10) 34 | request = self.rf.post("/", {"id": payment.id.hex, "token": "test"}) 35 | request.user = self.user 36 | 37 | with mock.patch.object( 38 | stripe.Customer, "create", return_value=AttrDict(id="cus_id") 39 | ): 40 | with mock.patch.object(stripe.Charge, "create", return_value={}): 41 | response = self.moocher.charge_view(request) 42 | 43 | self.assertEqual(response.status_code, 302) 44 | 45 | # A customer has been automagically created 46 | customer = Customer.objects.get() 47 | self.assertEqual(customer, self.user.stripe_customer) 48 | self.assertEqual(customer.customer_id, "cus_id") 49 | 50 | payment.refresh_from_db() 51 | self.assertTrue(payment.charged_at is not None) 52 | 53 | def test_individual_payment(self): 54 | payment = Payment.objects.create(user=self.user, amount=10) 55 | request = self.rf.post("/", {"id": payment.id.hex, "token": "test"}) 56 | request.user = AnonymousUser() 57 | 58 | with mock.patch.object(stripe.Charge, "create", return_value={}): 59 | response = self.moocher.charge_view(request) 60 | 61 | self.assertEqual(response.status_code, 302) 62 | 63 | # No customer, user not logged in... (maybe makes not much sense for 64 | # user payments .-D 65 | self.assertEqual(Customer.objects.count(), 0) 66 | 67 | payment.refresh_from_db() 68 | self.assertTrue(payment.charged_at is not None) 69 | 70 | def test_failing_charge(self): 71 | payment = Payment.objects.create(user=self.user, amount=10) 72 | request = self.rf.post("/", {"id": payment.id.hex, "token": "test"}) 73 | request.user = AnonymousUser() 74 | 75 | class Messages(list): 76 | def add(self, *args): 77 | self.append(args) 78 | 79 | request._messages = Messages() 80 | 81 | with mock.patch.object( 82 | stripe.Charge, 83 | "create", 84 | side_effect=stripe.error.CardError("problem", "param", "code"), 85 | ): 86 | response = self.moocher.charge_view(request) 87 | 88 | self.assertEqual(response.status_code, 302) 89 | 90 | payment.refresh_from_db() 91 | self.assertTrue(payment.charged_at is None) 92 | self.assertEqual(request._messages, [(40, "Card error: problem", "")]) 93 | -------------------------------------------------------------------------------- /tests/testapp/test_payments.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import Client, TestCase 3 | from django.utils import timezone 4 | from django.utils.translation import deactivate_all 5 | 6 | from user_payments.models import LineItem, Payment 7 | 8 | 9 | class Test(TestCase): 10 | def setUp(self): 11 | self.user = User.objects.create_superuser("admin", "admin@test.ch", "blabla") 12 | deactivate_all() 13 | 14 | def login(self): 15 | client = Client() 16 | client.force_login(self.user) 17 | return client 18 | 19 | def test_model(self): 20 | self.assertEqual(Payment.objects.create_pending(user=self.user), None) 21 | 22 | LineItem.objects.create(user=self.user, amount=5, title="Something") 23 | 24 | payment = Payment.objects.create_pending(user=self.user) 25 | 26 | self.assertEqual(payment.email, "admin@test.ch") 27 | self.assertEqual(payment.amount, 5) 28 | self.assertEqual( 29 | payment.description, "Payment of 5.00 by admin@test.ch: Something" 30 | ) 31 | self.assertEqual(str(payment), "Pending payment of 5.00") 32 | 33 | self.assertEqual(LineItem.objects.unbound().count(), 0) 34 | self.assertEqual(LineItem.objects.unpaid().count(), 1) 35 | 36 | payment.cancel_pending() 37 | 38 | self.assertEqual(Payment.objects.count(), 0) 39 | self.assertEqual(LineItem.objects.unbound().count(), 1) 40 | self.assertEqual(LineItem.objects.unpaid().count(), 1) 41 | 42 | payment = Payment.objects.create_pending( 43 | user=self.user, email="test@example.org" 44 | ) 45 | 46 | # Overridden email address: 47 | self.assertEqual(payment.email, "test@example.org") 48 | 49 | payment.charged_at = timezone.now() 50 | payment.save() 51 | 52 | self.assertEqual(LineItem.objects.unbound().count(), 0) 53 | self.assertEqual(LineItem.objects.unpaid().count(), 0) 54 | self.assertEqual(str(payment), "Payment of 5.00") 55 | 56 | def test_explicit_lineitems(self): 57 | LineItem.objects.create(user=self.user, amount=5, title="Something") 58 | item = LineItem.objects.create(user=self.user, amount=5, title="Something") 59 | 60 | payment = Payment.objects.create_pending(user=self.user, lineitems=[item]) 61 | self.assertEqual(payment.amount, 5) 62 | self.assertEqual(LineItem.objects.unbound().count(), 1) 63 | -------------------------------------------------------------------------------- /tests/testapp/test_processing.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest import mock 3 | 4 | import stripe 5 | from django.contrib.auth.models import User 6 | from django.core import mail 7 | from django.test import TestCase 8 | from django.utils import timezone 9 | from django.utils.translation import deactivate_all 10 | from testapp.processing import processors 11 | 12 | from user_payments.models import LineItem, Payment 13 | from user_payments.processing import ( 14 | Result, 15 | ResultError, 16 | process_payment, 17 | process_pending_payments, 18 | process_unbound_items, 19 | ) 20 | from user_payments.stripe_customers.models import Customer 21 | 22 | 23 | class Test(TestCase): 24 | def setUp(self): 25 | deactivate_all() 26 | 27 | def test_processing(self): 28 | item = LineItem.objects.create( 29 | user=User.objects.create(username="test1", email="test1@example.com"), 30 | amount=5, 31 | title="Stuff", 32 | ) 33 | 34 | Customer.objects.create( 35 | user=item.user, customer_id="cus_example", customer_data="{}" 36 | ) 37 | 38 | LineItem.objects.create( 39 | user=User.objects.create(username="test2", email="test2@example.com"), 40 | amount=10, 41 | title="Other stuff", 42 | ) 43 | 44 | with mock.patch.object(stripe.Charge, "create", return_value={"success": True}): 45 | process_unbound_items(processors=processors) 46 | 47 | self.assertEqual(len(mail.outbox), 1) 48 | 49 | self.assertEqual(LineItem.objects.unbound().count(), 1) 50 | self.assertEqual(LineItem.objects.unpaid().count(), 1) 51 | 52 | payment = Payment.objects.get() 53 | self.assertTrue(payment.charged_at is not None) 54 | self.assertEqual(payment.user, item.user) 55 | 56 | def test_refresh(self): 57 | item = LineItem.objects.create( 58 | user=User.objects.create(username="test1", email="test1@example.com"), 59 | amount=5, 60 | title="Stuff", 61 | ) 62 | 63 | Customer.objects.create( 64 | user=item.user, customer_id="cus_example", customer_data="{}" 65 | ) 66 | 67 | Customer.objects.update(updated_at=timezone.now() - timedelta(days=60)) 68 | 69 | with mock.patch.object(stripe.Charge, "create", return_value={"success": True}): 70 | with mock.patch.object( 71 | stripe.Customer, "retrieve", return_value={"marker": True} 72 | ): 73 | process_unbound_items(processors=processors) 74 | 75 | customer = Customer.objects.get() 76 | self.assertEqual(customer.customer, {"marker": True}) 77 | 78 | def test_card_error(self): 79 | item = LineItem.objects.create( 80 | user=User.objects.create(username="test1", email="test1@example.com"), 81 | amount=5, 82 | title="Stuff", 83 | ) 84 | Customer.objects.create( 85 | user=item.user, customer_id="cus_example", customer_data="{}" 86 | ) 87 | 88 | with mock.patch.object( 89 | stripe.Charge, 90 | "create", 91 | side_effect=stripe.error.CardError("problem", "param", "code"), 92 | ): 93 | process_unbound_items(processors=processors) 94 | 95 | item = LineItem.objects.get() 96 | 97 | self.assertTrue(item.payment is None) 98 | self.assertEqual(Payment.objects.count(), 0) 99 | 100 | # Card error mail and nothing else (no please pay mail, processing 101 | # should have stopped after the card failure) 102 | self.assertEqual(len(mail.outbox), 1) 103 | 104 | def test_pending_payments(self): 105 | item = LineItem.objects.create( 106 | user=User.objects.create(username="test1", email="test1@example.com"), 107 | amount=5, 108 | title="Stuff", 109 | ) 110 | 111 | Payment.objects.create_pending(user=item.user) 112 | 113 | process_pending_payments(processors=processors) 114 | 115 | self.assertEqual(len(mail.outbox), 1) 116 | 117 | self.assertEqual(LineItem.objects.unbound().count(), 0) 118 | self.assertEqual(LineItem.objects.unpaid().count(), 1) 119 | 120 | payment = Payment.objects.get() 121 | self.assertTrue(payment.charged_at is None) 122 | 123 | def test_process_payment_exception(self): 124 | item = LineItem.objects.create( 125 | user=User.objects.create(username="test1", email="test1@example.com"), 126 | amount=5, 127 | title="Stuff", 128 | ) 129 | Customer.objects.create( 130 | user=item.user, customer_id="cus_example", customer_data="{}" 131 | ) 132 | Payment.objects.create_pending(user=item.user) 133 | 134 | class SomeException(Exception): 135 | pass 136 | 137 | with self.assertRaises(SomeException): 138 | with mock.patch.object( 139 | stripe.Charge, 140 | "create", 141 | side_effect=SomeException(), # Just not a stripe.error.CardError 142 | ): 143 | process_pending_payments(processors=processors) 144 | 145 | def test_custom_processor(self): 146 | def fail(payment): 147 | return None # Invalid return value 148 | 149 | with self.assertRaises(ResultError): 150 | process_payment(Payment(), processors=[fail]) 151 | 152 | def test_result_bool(self): 153 | with self.assertRaises(ResultError): 154 | bool(Result.FAILURE) 155 | 156 | with self.assertRaises(ResultError): 157 | if Result.SUCCESS: 158 | pass 159 | -------------------------------------------------------------------------------- /tests/testapp/test_stripe.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest import mock 4 | 5 | import stripe 6 | from django.conf import settings 7 | from django.contrib.auth.models import User 8 | from django.test import Client, TestCase 9 | from django.utils.translation import deactivate_all 10 | 11 | from user_payments.stripe_customers.models import Customer 12 | 13 | 14 | class AttrDict(dict): 15 | """Dictionary which also allows attribute access to its items""" 16 | 17 | def __getattr__(self, key): 18 | return self[key] 19 | 20 | def save(self): 21 | # stripe.Customer.save() 22 | pass 23 | 24 | 25 | class Test(TestCase): 26 | def setUp(self): 27 | self.user = User.objects.create_superuser("admin", "admin@test.ch", "blabla") 28 | deactivate_all() 29 | 30 | @property 31 | def data(self): 32 | with open( 33 | os.path.join(settings.BASE_DIR, "customer.json"), encoding="utf-8" 34 | ) as f: 35 | return AttrDict(json.load(f)) 36 | 37 | def login(self): 38 | client = Client() 39 | client.force_login(self.user) 40 | return client 41 | 42 | def test_model(self): 43 | with mock.patch.object(stripe.Customer, "retrieve", return_value=self.data): 44 | customer = Customer.objects.create( 45 | customer_id="cus_BdO5X6Bj123456", user=self.user 46 | ) 47 | 48 | self.assertEqual(str(customer), "cus_BdO5X6********") 49 | 50 | client = self.login() 51 | 52 | response = client.get( 53 | "/admin/stripe_customers/customer/%s/change/" % customer.pk 54 | ) 55 | self.assertContains( 56 | response, ""id": "card_1BG7Jj******************"" 57 | ) 58 | self.assertNotContains(response, "cus_BdO5X6Bj123456") 59 | 60 | def test_property(self): 61 | 62 | with mock.patch.object(stripe.Customer, "retrieve", return_value={"bla": 3}): 63 | customer = Customer.objects.create( 64 | customer_id="cus_1234567890", user=self.user 65 | ) 66 | 67 | self.assertEqual(customer.customer_data, '{"bla": 3}') 68 | self.assertEqual(str(customer), "cus_123456****") 69 | 70 | customer = Customer.objects.get() 71 | self.assertEqual(customer.customer_data, '{"bla": 3}') 72 | self.assertEqual(customer.customer, {"bla": 3}) 73 | 74 | cache = customer.customer 75 | cache["test"] = 5 76 | 77 | self.assertEqual(customer.customer_data, '{"bla": 3}') 78 | self.assertEqual(customer.customer, {"bla": 3, "test": 5}) 79 | 80 | # Change is serialized when calling .save() 81 | customer.save() 82 | customer = Customer.objects.get() 83 | self.assertEqual(customer.customer, {"bla": 3, "test": 5}) 84 | 85 | def test_refresh(self): 86 | with mock.patch.object(stripe.Customer, "retrieve", return_value={"bla": 3}): 87 | Customer(customer_id="cus_1234567890", user=self.user).refresh() 88 | 89 | customer = Customer.objects.get() 90 | self.assertEqual(customer.customer_data, '{"bla": 3}') 91 | self.assertEqual(customer.customer, {"bla": 3}) 92 | 93 | def test_with_token_create(self): 94 | with mock.patch.object(stripe.Customer, "create", return_value=self.data): 95 | customer = Customer.objects.with_token(user=self.user, token="bla") 96 | self.assertEqual(customer.customer_id, "cus_BdO5X6Bj123456") 97 | 98 | def test_with_token_update(self): 99 | with mock.patch.object(stripe.Customer, "retrieve", return_value=self.data): 100 | c1 = Customer.objects.create( 101 | user=self.user, customer_id="cus_BdO5X6Bj123456" 102 | ) 103 | customer = Customer.objects.with_token(user=self.user, token="bla") 104 | self.assertEqual(customer.customer_id, "cus_BdO5X6Bj123456") 105 | self.assertEqual(c1.pk, customer.pk) 106 | -------------------------------------------------------------------------------- /tests/testapp/test_subscription_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from itertools import islice 3 | 4 | from django.test import TestCase 5 | 6 | from user_payments.exceptions import UnknownPeriodicity 7 | from user_payments.user_subscriptions.utils import next_valid_day, recurring 8 | 9 | 10 | class Test(TestCase): 11 | def test_next_valid_day(self): 12 | self.assertEqual(next_valid_day(2016, 2, 28), date(2016, 2, 28)) 13 | self.assertEqual(next_valid_day(2016, 2, 29), date(2016, 2, 29)) 14 | 15 | self.assertEqual(next_valid_day(2017, 2, 28), date(2017, 2, 28)) 16 | self.assertEqual(next_valid_day(2017, 2, 29), date(2017, 3, 1)) 17 | 18 | # Days>31 may not increment months by more than 1 19 | self.assertEqual(next_valid_day(2018, 1, 65), date(2018, 2, 1)) 20 | 21 | # Months>12 may increment years by more than 1 22 | self.assertEqual(next_valid_day(2018, 27, 1), date(2020, 3, 1)) 23 | 24 | def test_recurring(self): 25 | self.assertEqual( 26 | list(islice(recurring(date(2016, 2, 29), "yearly"), 5)), 27 | [ 28 | date(2016, 2, 29), 29 | date(2017, 3, 1), 30 | date(2018, 3, 1), 31 | date(2019, 3, 1), 32 | date(2020, 2, 29), 33 | ], 34 | ) 35 | 36 | self.assertEqual( 37 | list(islice(recurring(date(2016, 1, 31), "quarterly"), 5)), 38 | [ 39 | date(2016, 1, 31), 40 | date(2016, 5, 1), 41 | date(2016, 7, 31), 42 | date(2016, 10, 31), 43 | date(2017, 1, 31), 44 | ], 45 | ) 46 | 47 | self.assertEqual( 48 | list(islice(recurring(date(2016, 1, 31), "monthly"), 5)), 49 | [ 50 | date(2016, 1, 31), 51 | date(2016, 3, 1), 52 | date(2016, 3, 31), 53 | date(2016, 5, 1), 54 | date(2016, 5, 31), 55 | ], 56 | ) 57 | 58 | self.assertEqual( 59 | list(islice(recurring(date(2016, 1, 1), "weekly"), 5)), 60 | [ 61 | date(2016, 1, 1), 62 | date(2016, 1, 8), 63 | date(2016, 1, 15), 64 | date(2016, 1, 22), 65 | date(2016, 1, 29), 66 | ], 67 | ) 68 | 69 | with self.assertRaises(UnknownPeriodicity): 70 | list(islice(recurring(date(2016, 1, 1), "unknown"), 5)) 71 | -------------------------------------------------------------------------------- /tests/testapp/test_subscriptions.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import Client, TestCase 5 | from django.utils import timezone 6 | from django.utils.translation import deactivate_all 7 | 8 | from user_payments.models import LineItem, Payment 9 | from user_payments.user_subscriptions.models import Subscription, SubscriptionPeriod 10 | 11 | 12 | def zero_management_form_data(prefix): 13 | return { 14 | "%s-TOTAL_FORMS" % prefix: 0, 15 | "%s-INITIAL_FORMS" % prefix: 0, 16 | "%s-MIN_NUM_FORMS" % prefix: 0, 17 | "%s-MAX_NUM_FORMS" % prefix: 1000, 18 | } 19 | 20 | 21 | def merge_dicts(*dicts): 22 | res = {} 23 | for d in dicts: 24 | res.update(d) 25 | return res 26 | 27 | 28 | class Test(TestCase): 29 | def setUp(self): 30 | self.user = User.objects.create_superuser("admin", "admin@test.ch", "blabla") 31 | deactivate_all() 32 | 33 | def login(self): 34 | client = Client() 35 | client.force_login(self.user) 36 | return client 37 | 38 | def pay_period(self, period, user=None): 39 | period.create_line_item() 40 | payment = Payment.objects.create_pending(user=user or self.user) 41 | payment.charged_at = timezone.now() 42 | payment.save() 43 | 44 | def test_model(self): 45 | subscription = Subscription.objects.create( 46 | user=self.user, 47 | code="test1", 48 | title="Test subscription 1", 49 | periodicity="monthly", 50 | amount=60, 51 | starts_on=date(2040, 1, 1), 52 | ) 53 | self.assertEqual(subscription.starts_at.date(), date(2040, 1, 1)) 54 | self.assertEqual(subscription.ends_at, None) 55 | 56 | self.assertEqual(SubscriptionPeriod.objects.count(), 0) 57 | Subscription.objects.create_periods() 58 | self.assertEqual(SubscriptionPeriod.objects.count(), 0) 59 | 60 | subscription.create_periods(until=date(2040, 3, 31)) 61 | 62 | self.assertEqual(SubscriptionPeriod.objects.count(), 3) 63 | self.assertEqual(LineItem.objects.count(), 0) 64 | 65 | SubscriptionPeriod.objects.create_line_items() 66 | self.assertEqual(LineItem.objects.count(), 0) 67 | 68 | SubscriptionPeriod.objects.create_line_items(until=date(2040, 3, 31)) 69 | self.assertEqual(LineItem.objects.count(), 3) 70 | 71 | payment = Payment.objects.create_pending(user=self.user) 72 | self.assertEqual(payment.amount, 180) 73 | 74 | subscription.create_periods(until=date(2040, 4, 1)) 75 | self.assertEqual(SubscriptionPeriod.objects.count(), 4) 76 | 77 | subscription.refresh_from_db() 78 | # Not paid yet. 79 | self.assertEqual(subscription.paid_until, date(2039, 12, 31)) 80 | 81 | payment.charged_at = timezone.now() 82 | payment.save() 83 | 84 | subscription.refresh_from_db() 85 | self.assertEqual(subscription.paid_until, date(2040, 3, 31)) 86 | 87 | self.assertEqual(self.user.user_subscriptions.for_code("test1"), subscription) 88 | self.assertEqual(self.user.user_subscriptions.for_code("something"), None) 89 | 90 | def test_starts_on(self): 91 | subscription = Subscription.objects.create( 92 | user=self.user, 93 | code="test2", 94 | title="Test subscription 2", 95 | periodicity="yearly", 96 | amount=0, 97 | ) 98 | self.assertEqual(subscription.starts_on, date.today()) 99 | 100 | self.assertTrue(subscription.is_active) 101 | self.assertTrue(subscription.in_grace_period) 102 | 103 | self.pay_period(subscription.create_periods()[-1]) 104 | 105 | subscription.refresh_from_db() 106 | 107 | self.assertTrue(subscription.is_active) 108 | self.assertFalse(subscription.in_grace_period) 109 | 110 | def test_ends_on(self): 111 | subscription = Subscription.objects.create( 112 | user=self.user, 113 | code="test3", 114 | title="Test subscription 3", 115 | periodicity="weekly", 116 | amount=0, 117 | starts_on=date(2016, 1, 1), 118 | ends_on=date(2016, 1, 31), 119 | ) 120 | 121 | periods = subscription.create_periods() 122 | self.assertEqual( 123 | [p.ends_on for p in periods], 124 | [ 125 | date(2016, 1, 7), 126 | date(2016, 1, 14), 127 | date(2016, 1, 21), 128 | date(2016, 1, 28), 129 | date(2016, 2, 4), 130 | ], 131 | ) 132 | 133 | self.assertTrue( 134 | tuple(subscription.ends_at.timetuple())[:6], (2016, 1, 31, 23, 59, 59) 135 | ) 136 | 137 | self.assertEqual( 138 | tuple(periods[0].starts_at.timetuple())[:6], (2016, 1, 1, 0, 0, 0) 139 | ) 140 | self.assertEqual( 141 | tuple(periods[0].ends_at.timetuple())[:6], (2016, 1, 7, 23, 59, 59) 142 | ) 143 | 144 | def test_autorenewal(self): 145 | subscription = Subscription.objects.create( 146 | user=self.user, 147 | code="test4", 148 | title="Test subscription 4", 149 | periodicity="weekly", 150 | amount=0, 151 | starts_on=date.today() - timedelta(days=30), 152 | ) 153 | 154 | # Exactly one period 155 | (period,) = subscription.create_periods(until=subscription.starts_on) 156 | self.assertEqual(LineItem.objects.count(), 0) 157 | period.create_line_item() 158 | self.assertEqual(LineItem.objects.count(), 1) 159 | period.create_line_item() 160 | self.assertEqual(LineItem.objects.count(), 1) 161 | payment = Payment.objects.create_pending(user=self.user) 162 | payment.charged_at = timezone.now() 163 | payment.save() 164 | 165 | self.assertEqual( 166 | Subscription.objects.filter(renew_automatically=True).count(), 1 167 | ) 168 | Subscription.objects.disable_autorenewal() 169 | self.assertEqual( 170 | Subscription.objects.filter(renew_automatically=True).count(), 0 171 | ) 172 | 173 | # Restart the subscription 174 | Subscription.objects.ensure( 175 | user=self.user, code="test4", starts_on=date.today(), ends_on=None 176 | ) 177 | subscription.refresh_from_db() 178 | 179 | periods = subscription.create_periods() 180 | self.assertEqual([p.starts_on for p in periods], [date.today()]) 181 | self.assertEqual( 182 | [p.starts_on for p in subscription.periods.all()], 183 | [date.today() - timedelta(days=30), date.today()], 184 | ) 185 | 186 | def test_manager_create_periods(self): 187 | Subscription.objects.ensure( 188 | user=self.user, 189 | code="sub1", 190 | periodicity="weekly", 191 | amount=10, 192 | renew_automatically=True, # The default, therefore redundant 193 | ) 194 | Subscription.objects.ensure( 195 | user=self.user, 196 | code="sub2", 197 | periodicity="weekly", 198 | amount=10, 199 | renew_automatically=False, 200 | ) 201 | 202 | Subscription.objects.create_periods() 203 | 204 | self.assertEqual( 205 | list( 206 | SubscriptionPeriod.objects.values_list("subscription__code", flat=True) 207 | ), 208 | ["sub1"], 209 | ) 210 | 211 | def test_admin_create(self): 212 | client = self.login() 213 | response = client.post( 214 | "/admin/user_subscriptions/subscription/add/", 215 | merge_dicts( 216 | { 217 | "user": self.user.pk, 218 | "code": "yay", 219 | "title": "yay", 220 | "starts_on": date.today().strftime("%Y-%m-%d"), 221 | "periodicity": "yearly", 222 | "amount": 10, 223 | "created_at_0": date.today().strftime("%Y-%m-%d"), 224 | "created_at_1": "12:00", 225 | }, 226 | zero_management_form_data("periods"), 227 | ), 228 | ) 229 | self.assertRedirects(response, "/admin/user_subscriptions/subscription/") 230 | 231 | self.assertEqual(Subscription.objects.count(), 1) 232 | self.assertEqual(SubscriptionPeriod.objects.count(), 1) 233 | 234 | def test_admin_update(self): 235 | values = {"code": "yay", "title": "yay", "periodicity": "yearly", "amount": 10} 236 | 237 | subscription = Subscription.objects.create(user=self.user, **values) 238 | 239 | client = self.login() 240 | response = client.post( 241 | "/admin/user_subscriptions/subscription/%s/change/" % subscription.pk, 242 | merge_dicts( 243 | { 244 | "created_at_0": date.today().strftime("%Y-%m-%d"), 245 | "created_at_1": "12:00", 246 | "starts_on": date.today().strftime("%Y-%m-%d"), 247 | "user": self.user.pk, 248 | **values, 249 | }, 250 | zero_management_form_data("periods"), 251 | ), 252 | ) 253 | self.assertRedirects(response, "/admin/user_subscriptions/subscription/") 254 | 255 | self.assertEqual(Subscription.objects.count(), 1) 256 | # Periods are NOT automatically created when updating subscriptions 257 | self.assertEqual(SubscriptionPeriod.objects.count(), 0) 258 | 259 | def test_unbound_amount_change(self): 260 | subscription = Subscription.objects.create( 261 | user=self.user, 262 | code="test1", 263 | title="Test subscription 1", 264 | periodicity="monthly", 265 | amount=60, 266 | ) 267 | period = subscription.create_periods()[-1] 268 | period.create_line_item() 269 | 270 | self.assertEqual(period.line_item.amount, 60) 271 | 272 | subscription.amount = 100 273 | subscription.save() 274 | 275 | period.line_item.refresh_from_db() 276 | self.assertEqual(period.line_item.amount, 100) 277 | 278 | def test_ensure(self): 279 | subscription = Subscription.objects.ensure( 280 | user=self.user, 281 | code="test1", 282 | title="Test subscription 1", 283 | periodicity="monthly", 284 | amount=60, 285 | starts_on=date(2018, 1, 1), 286 | ) 287 | 288 | self.assertEqual(subscription.starts_on, date(2018, 1, 1)) 289 | self.assertEqual(subscription.paid_until, date(2017, 12, 31)) 290 | 291 | periods = subscription.create_periods() 292 | self.assertNotEqual(subscription.periods.count(), 0) 293 | 294 | # Unpaid 295 | periods[0].create_line_item() 296 | Payment.objects.create_pending(user=self.user, lineitems=[periods[0].line_item]) 297 | # Unbound 298 | periods[1].create_line_item() 299 | 300 | subscription = Subscription.objects.ensure( 301 | user=self.user, 302 | code="test1", 303 | title="Test subscription 1", 304 | periodicity="monthly", 305 | amount=60, 306 | starts_on=date.today(), 307 | ) 308 | 309 | # Pending periods have been removed 310 | self.assertEqual(subscription.periods.count(), 0) 311 | 312 | # Pay for a period... 313 | self.pay_period(subscription.create_periods()[-1]) 314 | 315 | subscription.refresh_from_db() 316 | paid_until = subscription.paid_until 317 | self.assertTrue(paid_until > date.today() + timedelta(days=25)) 318 | 319 | subscription = Subscription.objects.ensure( 320 | user=self.user, 321 | code="test1", 322 | title="Test subscription 1", 323 | periodicity="yearly", 324 | amount=720, 325 | starts_on=date.today() + timedelta(days=10), 326 | ) 327 | 328 | self.assertEqual(subscription.periodicity, "yearly") 329 | # starts_on is automatically moved after the paid_until value 330 | self.assertTrue(subscription.starts_on, paid_until + timedelta(days=1)) 331 | 332 | def test_ensure_twice(self): 333 | subscription = Subscription.objects.ensure( 334 | user=self.user, 335 | code="test1", 336 | title="Test subscription 1", 337 | periodicity="monthly", 338 | amount=60, 339 | ) 340 | self.pay_period(subscription.create_periods()[0]) 341 | 342 | # And second request comes in... 343 | subscription = Subscription.objects.ensure( 344 | user=self.user, 345 | code="test1", 346 | title="Test subscription 1", 347 | periodicity="monthly", 348 | amount=60, 349 | ) 350 | 351 | # Test that the second, equivalent request didn't move the start, and 352 | # that -- for now -- only one period exists 353 | self.assertEqual( 354 | list(subscription.create_periods(until=subscription.starts_on)), [] 355 | ) 356 | self.assertEqual(subscription.periods.count(), 1) 357 | self.assertEqual(subscription.starts_on, date.today()) 358 | 359 | def test_ensure_with_change(self): 360 | subscription = Subscription.objects.ensure( 361 | user=self.user, 362 | code="test1", 363 | title="Test subscription 1", 364 | periodicity="monthly", 365 | amount=60, 366 | starts_on=date(2040, 1, 1), 367 | ) 368 | period = subscription.create_periods(until=subscription.starts_on)[0] 369 | self.pay_period(period) 370 | 371 | period.ends_on = date(2040, 1, 15) 372 | period.save() 373 | 374 | subscription = Subscription.objects.ensure( 375 | user=self.user, 376 | code="test1", 377 | title="Test subscription 1", 378 | periodicity="yearly", 379 | amount=900, 380 | starts_on=period.ends_on, 381 | ) 382 | 383 | # Without updating subscription.paid_until, simply editing a period 384 | # does not do anything 385 | self.assertEqual(subscription.starts_on, date(2040, 2, 1)) 386 | self.assertEqual(subscription.periodicity, "yearly") 387 | self.assertEqual(subscription.amount, 900) 388 | 389 | # Create the next period and pay for it... 390 | period = subscription.create_periods(until=subscription.starts_on)[0] 391 | self.pay_period(period) 392 | 393 | period.ends_on = date(2040, 2, 15) 394 | period.save() 395 | 396 | # ... but this makes it different! 397 | subscription.update_paid_until() 398 | self.assertEqual(subscription.paid_until, date(2040, 2, 15)) 399 | 400 | subscription = Subscription.objects.ensure( 401 | user=self.user, 402 | code="test1", 403 | title="Test subscription 1", 404 | periodicity="yearly", 405 | amount=900, 406 | starts_on=period.ends_on, 407 | ) 408 | 409 | self.assertEqual(subscription.starts_on, date(2040, 2, 16)) 410 | self.assertEqual(subscription.periodicity, "yearly") 411 | self.assertEqual(subscription.amount, 900) 412 | 413 | def test_ensure_direct_change(self): 414 | Subscription.objects.ensure( 415 | user=self.user, 416 | code="test1", 417 | title="Test subscription 1", 418 | periodicity="monthly", 419 | amount=60, 420 | ) 421 | subscription = Subscription.objects.ensure( 422 | user=self.user, 423 | code="test1", 424 | title="Test subscription 1", 425 | periodicity="yearly", 426 | amount=900, 427 | ) 428 | 429 | # Second one wins, but starting point is still today (as no periods 430 | # were paid for) 431 | self.assertEqual(subscription.starts_on, date.today()) 432 | self.assertEqual(subscription.periodicity, "yearly") 433 | self.assertEqual(subscription.amount, 900) 434 | 435 | def test_ensure_future(self): 436 | subscription = Subscription.objects.ensure( 437 | user=self.user, 438 | code="test1", 439 | title="Test subscription 1", 440 | periodicity="monthly", 441 | amount=60, 442 | starts_on=date(2040, 1, 1), 443 | ) 444 | self.assertEqual(subscription.paid_until, date(2039, 12, 31)) 445 | 446 | subscription = Subscription.objects.ensure( 447 | user=self.user, 448 | code="test1", 449 | title="Test subscription 1", 450 | periodicity="yearly", 451 | amount=900, 452 | starts_on=date(2040, 1, 1), 453 | ) 454 | 455 | self.assertEqual(subscription.starts_on, date(2040, 1, 1)) 456 | self.assertEqual(subscription.periodicity, "yearly") 457 | 458 | def test_update_paid_until(self): 459 | subscription = Subscription.objects.ensure( 460 | user=self.user, 461 | code="test1", 462 | title="Test subscription 1", 463 | periodicity="monthly", 464 | amount=60, 465 | starts_on=date(2018, 1, 1), 466 | ) 467 | 468 | period = subscription.create_periods()[0] 469 | period.create_line_item() 470 | Payment.objects.create_pending(user=self.user) 471 | 472 | # No post_save signal: 473 | Payment.objects.update(charged_at=timezone.now()) 474 | 475 | subscription.update_paid_until(save=False) 476 | self.assertEqual(subscription.paid_until, date(2018, 1, 31)) 477 | subscription.refresh_from_db() 478 | self.assertEqual(subscription.paid_until, date(2017, 12, 31)) 479 | 480 | Payment.objects.get().save() 481 | subscription.refresh_from_db() 482 | self.assertEqual(subscription.paid_until, date(2018, 1, 31)) 483 | 484 | def test_admin_create_manual_periodicity(self): 485 | client = self.login() 486 | response = client.post( 487 | "/admin/user_subscriptions/subscription/add/", 488 | merge_dicts( 489 | { 490 | "user": self.user.pk, 491 | "code": "yay", 492 | "title": "yay", 493 | "starts_on": date.today().strftime("%Y-%m-%d"), 494 | "periodicity": "manually", 495 | "amount": 10, 496 | "created_at_0": date.today().strftime("%Y-%m-%d"), 497 | "created_at_1": "12:00", 498 | } 499 | ), 500 | follow=True, 501 | ) 502 | # self.assertRedirects(response, "/admin/user_subscriptions/subscription/") 503 | 504 | messages = [str(m) for m in response.context["messages"]] 505 | self.assertEqual(messages[0], "Unknown periodicity 'manually'") 506 | self.assertEqual(len(messages), 2) 507 | 508 | self.assertEqual(Subscription.objects.count(), 1) 509 | self.assertEqual(SubscriptionPeriod.objects.count(), 0) 510 | 511 | def test_cancel(self): 512 | subscription = Subscription.objects.ensure( 513 | user=self.user, 514 | code="test1", 515 | title="Test subscription 1", 516 | periodicity="monthly", 517 | amount=60, 518 | starts_on=date(2018, 1, 1), 519 | ) 520 | self.assertEqual(len(subscription.create_periods(until=date(2018, 4, 1))), 4) 521 | subscription.cancel() 522 | self.assertEqual(subscription.periods.count(), 0) 523 | self.assertEqual(len(subscription.create_periods(until=date(2018, 4, 1))), 0) 524 | 525 | def test_banktransfer_failed(self): 526 | subscription = Subscription.objects.ensure( 527 | user=self.user, 528 | code="test1", 529 | title="Test subscription 1", 530 | periodicity="monthly", 531 | amount=60, 532 | starts_on=date(2018, 1, 1), 533 | ) 534 | (period,) = subscription.create_periods(until=subscription.starts_on) 535 | self.assertEqual(subscription.paid_until, date(2017, 12, 31)) 536 | 537 | self.pay_period(period) 538 | subscription.refresh_from_db() 539 | self.assertEqual(subscription.paid_until, date(2018, 1, 31)) 540 | 541 | payment = Payment.objects.get() 542 | payment.undo() 543 | 544 | subscription.refresh_from_db() 545 | self.assertEqual(subscription.paid_until, date(2017, 12, 31)) 546 | 547 | self.assertEqual(payment.lineitems.count(), 0) 548 | 549 | def test_create_subscription_start_paying_later(self): 550 | subscription = Subscription.objects.ensure( 551 | user=self.user, 552 | code="test1", 553 | title="Test subscription 1", 554 | periodicity="monthly", 555 | amount=60, 556 | starts_on=date(2018, 1, 1), 557 | ) 558 | today = date(2019, 1, 15) 559 | subscription.create_periods(until=today) 560 | self.assertEqual(subscription.paid_until, date(2017, 12, 31)) 561 | 562 | SubscriptionPeriod.objects.create_line_items() 563 | 564 | payment = Payment.objects.create_pending(user=self.user) 565 | self.assertEqual(payment.amount, 780) 566 | payment.cancel_pending() 567 | 568 | SubscriptionPeriod.objects.zeroize_pending_periods(lasting_until=today) 569 | 570 | payment = Payment.objects.create_pending(user=self.user) 571 | self.assertEqual(payment.amount, 60) 572 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | 6 | 7 | # from testapp import views 8 | 9 | 10 | urlpatterns = [ 11 | path("admin/", admin.site.urls), 12 | path("moochers/", include("testapp.moochers")), 13 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310}-dj{32,40,main} 4 | 5 | [testenv] 6 | usedevelop = true 7 | extras = tests 8 | commands = 9 | python -Wd {envbindir}/coverage run tests/manage.py test -v2 --keepdb {posargs:testapp} 10 | coverage report -m 11 | deps = 12 | dj32: Django>=3.2,<4.0 13 | dj40: Django>=4.0,<4.1 14 | djmain: https://github.com/django/django/archive/main.tar.gz 15 | 16 | # [testenv:docs] 17 | # deps = 18 | # Sphinx 19 | # sphinx-rtd-theme 20 | # Django 21 | # changedir = docs 22 | # commands = make html 23 | # skip_install = true 24 | # allowlist_externals = make 25 | -------------------------------------------------------------------------------- /user_payments/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "user_payments.apps.UserPayments" 2 | 3 | VERSION = (0, 3, 3) 4 | __version__ = ".".join(map(str, VERSION)) 5 | -------------------------------------------------------------------------------- /user_payments/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_user_model 3 | 4 | from . import models 5 | 6 | 7 | class LineItemInline(admin.TabularInline): 8 | model = models.LineItem 9 | raw_id_fields = ("user",) 10 | extra = 0 11 | 12 | 13 | @admin.register(models.Payment) 14 | class PaymentAdmin(admin.ModelAdmin): 15 | date_hierarchy = "created_at" 16 | inlines = [LineItemInline] 17 | list_display = ( 18 | "user", 19 | "created_at", 20 | "charged_at", 21 | "amount", 22 | "payment_service_provider", 23 | "email", 24 | ) 25 | list_filter = ("charged_at", "payment_service_provider") 26 | raw_id_fields = ("user",) 27 | search_fields = ( 28 | "email", 29 | "transaction", 30 | f"user__{get_user_model().USERNAME_FIELD}", 31 | ) 32 | 33 | 34 | @admin.register(models.LineItem) 35 | class LineItemAdmin(admin.ModelAdmin): 36 | date_hierarchy = "created_at" 37 | list_display = ("user", "payment", "created_at", "title", "amount") 38 | raw_id_fields = ("user", "payment") 39 | search_fields = ("title", f"user__{get_user_model().USERNAME_FIELD}") 40 | -------------------------------------------------------------------------------- /user_payments/apps.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from types import SimpleNamespace 3 | 4 | from django.apps import AppConfig 5 | from django.utils.text import capfirst 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class UserPayments(AppConfig): 10 | name = "user_payments" 11 | verbose_name = capfirst(_("user payments")) 12 | default_settings = { 13 | "currency": "CHF", 14 | "grace_period": timedelta(days=7), 15 | "disable_autorenewal_after": timedelta(days=15), 16 | } 17 | 18 | def ready(self): 19 | from django.conf import settings 20 | 21 | self.settings = SimpleNamespace( 22 | **{**self.default_settings, **getattr(settings, "USER_PAYMENTS", {})} 23 | ) 24 | -------------------------------------------------------------------------------- /user_payments/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnknownPeriodicity(Exception): 2 | """The periodicity is unknown""" 3 | 4 | pass 5 | -------------------------------------------------------------------------------- /user_payments/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-user-payments/b14887a3d274dc164ef9a36f05a45b6bdac3afe5/user_payments/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_payments/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-05-31 09:23+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: apps.py:11 22 | msgid "user payments" 23 | msgstr "Benutzer-Zahlungen" 24 | 25 | #: models.py:38 models.py:103 stripe_customers/models.py:41 26 | #: user_subscriptions/models.py:85 27 | msgid "user" 28 | msgstr "Benutzer" 29 | 30 | #: models.py:45 31 | #, python-format 32 | msgid "Payment of %s" 33 | msgstr "Zahlung über %s" 34 | 35 | #: models.py:46 36 | #, python-format 37 | msgid "Pending payment of %s" 38 | msgstr "Pendente Zahlung über %s" 39 | 40 | #: models.py:105 stripe_customers/models.py:44 user_subscriptions/models.py:94 41 | msgid "created at" 42 | msgstr "erstellt um" 43 | 44 | #: models.py:106 user_subscriptions/models.py:95 45 | msgid "title" 46 | msgstr "Titel" 47 | 48 | #: models.py:107 user_subscriptions/models.py:108 49 | msgid "amount" 50 | msgstr "Betrag" 51 | 52 | #: models.py:115 53 | msgid "payment" 54 | msgstr "Zahlung" 55 | 56 | #: models.py:122 user_subscriptions/models.py:273 57 | msgid "line item" 58 | msgstr "Einzelposten" 59 | 60 | #: models.py:123 61 | msgid "line items" 62 | msgstr "Einzelposten" 63 | 64 | #: stripe_customers/admin.py:44 stripe_customers/models.py:46 65 | msgid "customer ID" 66 | msgstr "Kunden-Nr." 67 | 68 | #: stripe_customers/admin.py:52 stripe_customers/models.py:52 69 | msgid "customer" 70 | msgstr "Kunde" 71 | 72 | #: stripe_customers/apps.py:12 73 | msgid "stripe customers" 74 | msgstr "Stripe-Kunden" 75 | 76 | #: stripe_customers/models.py:45 77 | msgid "updated at" 78 | msgstr "aktualisiert um" 79 | 80 | #: stripe_customers/models.py:47 81 | msgid "customer data" 82 | msgstr "Kundendaten" 83 | 84 | #: stripe_customers/models.py:53 85 | msgid "customers" 86 | msgstr "Kunden" 87 | 88 | #: stripe_customers/moochers.py:21 89 | msgid "Pay with Stripe" 90 | msgstr "Mit Stripe bezahlen" 91 | 92 | #: stripe_customers/moochers.py:127 93 | #, python-format 94 | msgid "Card error: %s" 95 | msgstr "Kartenfehler: %s" 96 | 97 | #: stripe_customers/moochers.py:127 98 | msgid "No details" 99 | msgstr "Keine Details" 100 | 101 | #: user_subscriptions/apps.py:8 102 | msgid "user subscriptions" 103 | msgstr "Benutzer-Abonnemente" 104 | 105 | #: user_subscriptions/models.py:88 106 | msgid "code" 107 | msgstr "Code" 108 | 109 | #: user_subscriptions/models.py:91 110 | msgid "Codes must be unique per user. Allows identifying the subscription." 111 | msgstr "" 112 | "Code muss pro Benutzer eindeutig sein. Erlaubt es, das Abonnement zu " 113 | "identifizieren." 114 | 115 | #: user_subscriptions/models.py:96 user_subscriptions/models.py:266 116 | msgid "starts on" 117 | msgstr "startet am" 118 | 119 | #: user_subscriptions/models.py:97 user_subscriptions/models.py:267 120 | msgid "ends on" 121 | msgstr "endet am" 122 | 123 | #: user_subscriptions/models.py:99 124 | msgid "periodicity" 125 | msgstr "Periodizität" 126 | 127 | #: user_subscriptions/models.py:102 128 | msgid "yearly" 129 | msgstr "jährlich" 130 | 131 | #: user_subscriptions/models.py:103 132 | msgid "monthly" 133 | msgstr "monatlich" 134 | 135 | #: user_subscriptions/models.py:104 136 | msgid "weekly" 137 | msgstr "wöchentlich" 138 | 139 | #: user_subscriptions/models.py:105 140 | msgid "manually" 141 | msgstr "manuell" 142 | 143 | #: user_subscriptions/models.py:110 144 | msgid "renew automatically" 145 | msgstr "automatisch erneuern" 146 | 147 | #: user_subscriptions/models.py:111 148 | msgid "paid until" 149 | msgstr "bezahlt bis" 150 | 151 | #: user_subscriptions/models.py:117 user_subscriptions/models.py:264 152 | msgid "subscription" 153 | msgstr "Abonnement" 154 | 155 | #: user_subscriptions/models.py:118 156 | msgid "subscriptions" 157 | msgstr "Abonnemente" 158 | 159 | #: user_subscriptions/models.py:280 160 | msgid "subscription period" 161 | msgstr "Abonnementsperiode" 162 | 163 | #: user_subscriptions/models.py:281 164 | msgid "subscription periods" 165 | msgstr "Abonnementsperioden" 166 | 167 | #~ msgid "Subscribe using credit card" 168 | #~ msgstr "Mit Kreditkarte abonnieren" 169 | 170 | #~ msgid "Card has been added successfully." 171 | #~ msgstr "Kreditkarte wurde erfolgreich hinzugefügt. " 172 | 173 | #~ msgid "Card has been replaced successfully." 174 | #~ msgstr "Kreditkarte wurde erfolgreich ersetzt." 175 | 176 | #~ msgid "Subscription will be canceled at period end." 177 | #~ msgstr "Abonnement wird nach Periodenende gekündigt." 178 | 179 | #~ msgid "Subscription could not be found!" 180 | #~ msgstr "Abonnement konnte nicht gefunden werden!" 181 | -------------------------------------------------------------------------------- /user_payments/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-05-21 05:11 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="LineItem", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "created_at", 32 | models.DateTimeField( 33 | default=django.utils.timezone.now, verbose_name="created at" 34 | ), 35 | ), 36 | ("title", models.CharField(max_length=200, verbose_name="title")), 37 | ( 38 | "amount", 39 | models.DecimalField( 40 | decimal_places=2, max_digits=10, verbose_name="amount" 41 | ), 42 | ), 43 | ], 44 | options={ 45 | "verbose_name": "line item", 46 | "verbose_name_plural": "line items", 47 | "ordering": ["-created_at"], 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name="Payment", 52 | fields=[ 53 | ( 54 | "id", 55 | models.UUIDField( 56 | default=uuid.uuid4, 57 | editable=False, 58 | primary_key=True, 59 | serialize=False, 60 | ), 61 | ), 62 | ( 63 | "created_at", 64 | models.DateTimeField( 65 | default=django.utils.timezone.now, verbose_name="created at" 66 | ), 67 | ), 68 | ( 69 | "charged_at", 70 | models.DateTimeField( 71 | blank=True, null=True, verbose_name="charged at" 72 | ), 73 | ), 74 | ( 75 | "amount", 76 | models.DecimalField( 77 | decimal_places=2, max_digits=10, verbose_name="amount" 78 | ), 79 | ), 80 | ( 81 | "payment_service_provider", 82 | models.CharField( 83 | blank=True, 84 | max_length=100, 85 | verbose_name="payment service provider", 86 | ), 87 | ), 88 | ("email", models.EmailField(max_length=254, verbose_name="email")), 89 | ( 90 | "transaction", 91 | models.TextField(blank=True, verbose_name="transaction"), 92 | ), 93 | ( 94 | "user", 95 | models.ForeignKey( 96 | on_delete=django.db.models.deletion.PROTECT, 97 | related_name="user_payments", 98 | to=settings.AUTH_USER_MODEL, 99 | verbose_name="user", 100 | ), 101 | ), 102 | ], 103 | options={ 104 | "verbose_name": "payment", 105 | "verbose_name_plural": "payments", 106 | "ordering": ("-created_at",), 107 | "abstract": False, 108 | }, 109 | ), 110 | migrations.AddField( 111 | model_name="lineitem", 112 | name="payment", 113 | field=models.ForeignKey( 114 | blank=True, 115 | null=True, 116 | on_delete=django.db.models.deletion.PROTECT, 117 | related_name="lineitems", 118 | to="user_payments.Payment", 119 | verbose_name="payment", 120 | ), 121 | ), 122 | migrations.AddField( 123 | model_name="lineitem", 124 | name="user", 125 | field=models.ForeignKey( 126 | on_delete=django.db.models.deletion.PROTECT, 127 | related_name="user_lineitems", 128 | to=settings.AUTH_USER_MODEL, 129 | verbose_name="user", 130 | ), 131 | ), 132 | ] 133 | -------------------------------------------------------------------------------- /user_payments/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-user-payments/b14887a3d274dc164ef9a36f05a45b6bdac3afe5/user_payments/migrations/__init__.py -------------------------------------------------------------------------------- /user_payments/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models, transaction 3 | from django.db.models import Q 4 | from django.utils import timezone 5 | from django.utils.translation import gettext, gettext_lazy as _ 6 | from mooch.models import Payment as AbstractPayment 7 | 8 | 9 | class PaymentQuerySet(models.QuerySet): 10 | def pending(self): 11 | return self.filter(charged_at__isnull=True) 12 | 13 | 14 | class PaymentManager(models.Manager): 15 | def create_pending(self, *, user, lineitems=None, **kwargs): 16 | """ 17 | Create an unpaid payment instance with all line items for the given 18 | user that have not been bound to a payment instance yet. 19 | 20 | Returns ``None`` if there are no unbound line items for the given user. 21 | """ 22 | with transaction.atomic(): 23 | items = user.user_lineitems.unbound() 24 | if lineitems is not None: 25 | items = items.filter(pk__in=[i.pk for i in lineitems]) 26 | if not len(items): 27 | return None 28 | 29 | payment = self.create( 30 | user=user, amount=sum((item.amount for item in items), 0), **kwargs 31 | ) 32 | items.update(payment=payment) 33 | return payment 34 | 35 | 36 | class Payment(AbstractPayment): 37 | user = models.ForeignKey( 38 | settings.AUTH_USER_MODEL, 39 | on_delete=models.PROTECT, 40 | related_name="user_payments", 41 | verbose_name=_("user"), 42 | ) 43 | 44 | objects = PaymentManager.from_queryset(PaymentQuerySet)() 45 | 46 | def __str__(self): 47 | if self.charged_at: 48 | return gettext("Payment of %s") % self.amount 49 | return gettext("Pending payment of %s") % self.amount 50 | 51 | def save(self, *args, **kwargs): 52 | if not self.email: 53 | self.email = self.user.email 54 | super().save(*args, **kwargs) 55 | 56 | save.alters_data = True 57 | 58 | def cancel_pending(self): 59 | assert not self.charged_at 60 | self.lineitems.update(payment=None) 61 | self.delete() 62 | 63 | cancel_pending.alters_data = True 64 | 65 | def undo(self): 66 | assert self.charged_at 67 | self.charged_at = None 68 | self.save() 69 | # Unbind line items after post_save signal handling 70 | self.lineitems.update(payment=None) 71 | 72 | undo.alters_data = True 73 | 74 | @property 75 | def description(self): 76 | return "Payment of {} by {}: {}".format( 77 | self.amount, 78 | self.email, 79 | ", ".join(str(item) for item in self.lineitems.all()), 80 | ) 81 | 82 | 83 | class LineItemQuerySet(models.QuerySet): 84 | def unbound(self): 85 | return self.filter(payment__isnull=True) 86 | 87 | def unpaid(self): 88 | return self.filter( 89 | Q(payment__isnull=True) | Q(payment__charged_at__isnull=True) 90 | ) 91 | 92 | 93 | class LineItem(models.Model): 94 | """ 95 | Individual line items may be created directly using the manager method. A 96 | minimal example follows:: 97 | 98 | @login_required 99 | def some_view(request): 100 | # This request costs 5 cents! 101 | LineItem.objects.create( 102 | user=request.user, 103 | amount=Decimal('0.05'), 104 | title='Request to some_view', 105 | ) 106 | # Rest of view 107 | 108 | If you already have a payment instance at hand you may pass it as well. 109 | """ 110 | 111 | user = models.ForeignKey( 112 | settings.AUTH_USER_MODEL, 113 | on_delete=models.PROTECT, 114 | related_name="user_lineitems", 115 | verbose_name=_("user"), 116 | ) 117 | created_at = models.DateTimeField(_("created at"), default=timezone.now) 118 | title = models.CharField(_("title"), max_length=200) 119 | amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2) 120 | 121 | payment = models.ForeignKey( 122 | Payment, 123 | on_delete=models.PROTECT, 124 | blank=True, 125 | null=True, 126 | related_name="lineitems", 127 | verbose_name=_("payment"), 128 | ) 129 | 130 | objects = LineItemQuerySet.as_manager() 131 | 132 | class Meta: 133 | ordering = ["-created_at"] 134 | verbose_name = _("line item") 135 | verbose_name_plural = _("line items") 136 | 137 | def __str__(self): 138 | return self.title 139 | -------------------------------------------------------------------------------- /user_payments/processing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | 4 | from django.contrib.auth import get_user_model 5 | 6 | from user_payments.models import LineItem, Payment 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Result(Enum): 13 | #: Processor has successfully handled the payment 14 | SUCCESS = 1 15 | #: Preconditions of the processor are not met (e.g. no credit card 16 | #: information). Try next processor. 17 | FAILURE = 2 18 | #: Terminates processing for this payment, do not run other processors 19 | TERMINATE = 3 20 | 21 | def __bool__(self): 22 | raise ResultError("Results may not be interpreted as bools") 23 | 24 | 25 | class ResultError(Exception): 26 | pass 27 | 28 | 29 | def process_payment(payment, *, processors, cancel_on_failure=True): 30 | logger.info( 31 | "Processing: %(payment)s by %(email)s", 32 | {"payment": payment, "email": payment.email}, 33 | ) 34 | success = False 35 | 36 | try: 37 | for processor in processors: 38 | # Success processing the payment? 39 | result = processor(payment) 40 | if result == Result.SUCCESS: 41 | logger.info( 42 | "Success: %(payment)s by %(email)s with %(processor)s", 43 | { 44 | "payment": payment, 45 | "email": payment.email, 46 | "processor": processor.__name__, 47 | }, 48 | ) 49 | success = True 50 | return True 51 | 52 | elif result == Result.TERMINATE: 53 | logger.info( 54 | "Warning: Processor %(processor)s terminates processing of" 55 | " %(payment)s by %(email)s", 56 | { 57 | "payment": payment, 58 | "email": payment.email, 59 | "processor": processor.__name__, 60 | }, 61 | ) 62 | break 63 | 64 | elif result == Result.FAILURE: 65 | # It's fine, do nothing. 66 | pass 67 | 68 | else: 69 | raise ResultError( 70 | f"Invalid result {result!r} from {processor.__name__}" 71 | ) 72 | 73 | else: 74 | logger.warning( 75 | "Warning: No success processing %(payment)s by %(email)s", 76 | {"payment": payment, "email": payment.email}, 77 | ) 78 | 79 | except Exception: 80 | logger.exception("Exception while processing %(payment)s by %(email)s") 81 | raise 82 | 83 | finally: 84 | if not success and cancel_on_failure: 85 | payment.cancel_pending() 86 | 87 | 88 | def process_unbound_items(*, processors): 89 | for user in ( 90 | get_user_model() 91 | .objects.filter(id__in=LineItem.objects.unbound().values("user")) 92 | .select_related("stripe_customer") 93 | ): 94 | payment = Payment.objects.create_pending(user=user) 95 | if payment: # pragma: no branch (very unlikely) 96 | process_payment(payment, processors=processors) 97 | 98 | 99 | def process_pending_payments(*, processors): 100 | for payment in Payment.objects.pending(): 101 | process_payment(payment, cancel_on_failure=False, processors=processors) 102 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "user_payments.stripe_customers.apps.StripeCustomersConfig" 2 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/admin.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from django.contrib import admin 5 | from django.utils.html import format_html 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from . import models 9 | 10 | 11 | def sanitize_value(matchobj): 12 | return "{}{}".format(matchobj.group(1), "*" * len(matchobj.group(3))) 13 | 14 | 15 | def sanitize(data, *, key=None): 16 | if isinstance(data, dict): 17 | return {key: sanitize(value, key=key) for key, value in data.items()} 18 | elif isinstance(data, list): 19 | return [sanitize(item) for item in data] 20 | elif key in ("fingerprint", "last4"): 21 | return "*" * len(data) 22 | elif isinstance(data, str): 23 | return re.sub(r"((cus_|sub_|card_)\w{6})(\w+)", sanitize_value, data) 24 | else: 25 | # Bools, ints, etc. 26 | return data 27 | 28 | 29 | @admin.register(models.Customer) 30 | class CustomerAdmin(admin.ModelAdmin): 31 | list_display = ("user", "customer_id_admin", "created_at", "updated_at") 32 | raw_id_fields = ("user",) 33 | search_fields = ("user__email",) 34 | 35 | def customer_id_admin(self, instance): 36 | return str(instance) 37 | 38 | customer_id_admin.short_description = _("customer ID") 39 | 40 | def customer_admin(self, instance): 41 | return format_html( 42 | "
{}
", 43 | json.dumps(sanitize(instance.customer), sort_keys=True, indent=4), 44 | ) 45 | 46 | customer_admin.short_description = _("customer") 47 | 48 | def get_fields(self, request, obj=None): 49 | return ( 50 | ("user", "created_at", "customer_id_admin", "customer_admin") 51 | if obj 52 | else ("user", "customer_id") 53 | ) 54 | 55 | def get_readonly_fields(self, request, obj=None): 56 | return ("customer_id_admin", "customer_admin") if obj else () 57 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/apps.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | import stripe 4 | from django.apps import AppConfig 5 | from django.utils.text import capfirst 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class StripeCustomersConfig(AppConfig): 10 | name = "user_payments.stripe_customers" 11 | verbose_name = capfirst(_("stripe customers")) 12 | 13 | def ready(self): 14 | from django.conf import settings 15 | 16 | stripe.api_key = settings.STRIPE_SECRET_KEY 17 | self.settings = SimpleNamespace( 18 | publishable_key=settings.STRIPE_PUBLISHABLE_KEY, 19 | secret_key=settings.STRIPE_SECRET_KEY, 20 | ) 21 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-05-21 19:26 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Customer", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ( 29 | "created_at", 30 | models.DateTimeField( 31 | default=django.utils.timezone.now, verbose_name="created at" 32 | ), 33 | ), 34 | ( 35 | "updated_at", 36 | models.DateTimeField(auto_now=True, verbose_name="updated at"), 37 | ), 38 | ( 39 | "customer_id", 40 | models.CharField( 41 | max_length=50, unique=True, verbose_name="customer ID" 42 | ), 43 | ), 44 | ( 45 | "customer_data", 46 | models.TextField(blank=True, verbose_name="customer data"), 47 | ), 48 | ( 49 | "user", 50 | models.OneToOneField( 51 | on_delete=django.db.models.deletion.CASCADE, 52 | related_name="stripe_customer", 53 | to=settings.AUTH_USER_MODEL, 54 | verbose_name="user", 55 | ), 56 | ), 57 | ], 58 | options={"verbose_name": "customer", "verbose_name_plural": "customers"}, 59 | ) 60 | ] 61 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-user-payments/b14887a3d274dc164ef9a36f05a45b6bdac3afe5/user_payments/stripe_customers/migrations/__init__.py -------------------------------------------------------------------------------- /user_payments/stripe_customers/models.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | 4 | import stripe 5 | from django.conf import settings 6 | from django.db import models 7 | from django.utils import timezone 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | 11 | class CustomerManager(models.Manager): 12 | def with_token(self, *, user, token): 13 | """ 14 | Add or replace a credit card for a given user 15 | """ 16 | 17 | try: 18 | user.stripe_customer 19 | except Customer.DoesNotExist: 20 | return self._create_with_token(user, token) 21 | else: 22 | return self._update_token(user, token) 23 | 24 | def _create_with_token(self, user, token): 25 | obj = stripe.Customer.create( 26 | email=user.email, 27 | source=token, 28 | expand=["default_source"], 29 | idempotency_key="create-with-token-%s" 30 | % (hashlib.sha1(token.encode("utf-8")).hexdigest(),), 31 | ) 32 | customer, created = self.update_or_create( 33 | user=user, 34 | defaults={"customer_id": obj.id, "customer_data": json.dumps(obj)}, 35 | ) 36 | customer._customer_data_cache = obj 37 | return customer 38 | 39 | def _update_token(self, user, token): 40 | obj = stripe.Customer.retrieve(user.stripe_customer.customer_id) 41 | obj.source = token 42 | obj.save() 43 | user.stripe_customer.refresh() 44 | return user.stripe_customer 45 | 46 | 47 | class Customer(models.Model): 48 | user = models.OneToOneField( 49 | settings.AUTH_USER_MODEL, 50 | on_delete=models.CASCADE, 51 | verbose_name=_("user"), 52 | related_name="stripe_customer", 53 | ) 54 | created_at = models.DateTimeField(_("created at"), default=timezone.now) 55 | updated_at = models.DateTimeField(_("updated at"), auto_now=True) 56 | customer_id = models.CharField(_("customer ID"), max_length=50, unique=True) 57 | customer_data = models.TextField(_("customer data"), blank=True) 58 | 59 | objects = CustomerManager() 60 | 61 | class Meta: 62 | verbose_name = _("customer") 63 | verbose_name_plural = _("customers") 64 | 65 | def __str__(self): 66 | return "{}{}".format(self.customer_id[:10], "*" * (len(self.customer_id) - 10)) 67 | 68 | def save(self, *args, **kwargs): 69 | if hasattr(self, "_customer_data_cache"): 70 | self.customer_data = json.dumps(self._customer_data_cache) 71 | if not self.customer_data: 72 | self.refresh(save=False) 73 | super().save(*args, **kwargs) 74 | 75 | save.alters_data = True 76 | 77 | @property 78 | def customer(self): 79 | """ 80 | Return the parsed version of the JSON blob or ``None`` if there is no 81 | data around to be parsed. 82 | 83 | After calling ``Customer.refresh()`` this property even returns the 84 | objects returned by the Stripe library as they come. 85 | 86 | Does NOT work with ``instance.refresh_from_db()`` -- you have to fetch 87 | a completely new object from the database. 88 | """ 89 | if not hasattr(self, "_customer_data_cache"): 90 | self._customer_data_cache = json.loads(self.customer_data or "{}") 91 | return self._customer_data_cache 92 | 93 | @customer.setter 94 | def customer(self, value): 95 | self._customer_data_cache = value 96 | self.customer_data = json.dumps(self._customer_data_cache) 97 | 98 | def refresh(self, save=True): 99 | self.customer = stripe.Customer.retrieve( 100 | self.customer_id, expand=["default_source"] 101 | ) 102 | if save: 103 | self.save() 104 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/moochers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import stripe 4 | from django import http 5 | from django.apps import apps 6 | from django.contrib import messages 7 | from django.shortcuts import get_object_or_404, redirect 8 | from django.template.loader import render_to_string 9 | from django.urls import path, reverse 10 | from django.utils import timezone 11 | from django.utils.translation import gettext_lazy as _ 12 | from mooch.base import BaseMoocher, csrf_exempt_m, require_POST_m 13 | from mooch.signals import post_charge 14 | 15 | from .models import Customer 16 | 17 | 18 | class StripeMoocher(BaseMoocher): 19 | identifier = "stripe" 20 | title = _("Pay with Stripe") 21 | 22 | def get_urls(self): 23 | return [path("stripe_charge/", self.charge_view, name="stripe_charge")] 24 | 25 | def payment_form(self, request, payment): 26 | try: 27 | customer = request.user.stripe_customer 28 | except (AttributeError, Customer.DoesNotExist): 29 | customer = None 30 | 31 | s = apps.get_app_config("stripe_customers").settings 32 | 33 | return render_to_string( 34 | "stripe_customers/payment_form.html", 35 | { 36 | "moocher": self, 37 | "payment": payment, 38 | "publishable_key": s.publishable_key, 39 | "customer": customer, 40 | "charge_url": reverse("%s:stripe_charge" % self.app_name), 41 | "LANGUAGE_CODE": getattr(request, "LANGUAGE_CODE", "auto"), 42 | }, 43 | request=request, 44 | ) 45 | 46 | @csrf_exempt_m 47 | @require_POST_m 48 | def charge_view(self, request): 49 | s = apps.get_app_config("user_payments").settings 50 | 51 | try: 52 | customer = request.user.stripe_customer 53 | except (AttributeError, Customer.DoesNotExist): 54 | customer = None 55 | 56 | instance = get_object_or_404(self.model, id=request.POST.get("id")) 57 | instance.payment_service_provider = self.identifier 58 | instance.transaction = repr( 59 | {key: values for key, values in request.POST.lists() if key != "token"} 60 | ) 61 | instance.save() 62 | 63 | try: 64 | if ( 65 | not customer 66 | and request.POST.get("token") 67 | and request.user.is_authenticated 68 | ): 69 | customer = Customer.objects.with_token( 70 | user=request.user, token=request.POST["token"] 71 | ) 72 | 73 | if customer: 74 | # FIXME Only with valid default source 75 | charge = stripe.Charge.create( 76 | customer=customer.customer_id, 77 | amount=instance.amount_cents, 78 | currency=s.currency, 79 | idempotency_key="charge-%s-%s" 80 | % (instance.id.hex, instance.amount_cents), 81 | ) 82 | else: 83 | # TODO create customer anyway, and stash away the customer ID 84 | # for associating with a user account after succesful payment? 85 | charge = stripe.Charge.create( 86 | source=request.POST["token"], 87 | amount=instance.amount_cents, 88 | currency=s.currency, 89 | idempotency_key="charge-%s-%s" 90 | % (instance.id.hex, instance.amount_cents), 91 | ) 92 | 93 | # TODO Error handling 94 | 95 | instance.charged_at = timezone.now() 96 | instance.transaction = json.dumps(charge) 97 | instance.save() 98 | 99 | post_charge.send(sender=self, payment=instance, request=request) 100 | 101 | return http.HttpResponseRedirect(self.success_url) 102 | 103 | except stripe.error.CardError as exc: 104 | messages.error( 105 | request, _("Card error: %s") % (exc._message or _("No details")) 106 | ) 107 | return redirect(self.failure_url) 108 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/static/stripe_customers/cards.js: -------------------------------------------------------------------------------- 1 | /* global Stripe */ 2 | document.addEventListener("DOMContentLoaded", function () { 3 | var form = document.querySelector("form[data-publishable-key]") 4 | if (!form) return 5 | 6 | var style = { 7 | base: { 8 | color: "#32325d", 9 | lineHeight: "24px", 10 | fontFamily: '"Helvetica Neue", Helvetica, sans-serif', 11 | fontSmoothing: "antialiased", 12 | fontSize: "16px", 13 | "::placeholder": { 14 | color: "#aab7c4", 15 | }, 16 | }, 17 | invalid: { 18 | color: "#fa755a", 19 | iconColor: "#fa755a", 20 | }, 21 | } 22 | 23 | var stripe = Stripe(form.getAttribute("data-publishable-key")) 24 | var elements = stripe.elements() 25 | var card = elements.create("card", { style: style, hidePostalCode: true }) 26 | card.mount("#card-element") 27 | card.addEventListener("change", function (event) { 28 | document.getElementById("card-errors").textContent = event.error 29 | ? event.error.message 30 | : "\u00A0" 31 | }) 32 | 33 | form.addEventListener("submit", function (event) { 34 | event.preventDefault() 35 | form.querySelector('button[type="submit"]').disabled = true 36 | 37 | stripe.createToken(card).then(function (result) { 38 | if (result.error) { 39 | document.getElementById("card-errors").textContent = 40 | result.error.message 41 | form.querySelector('button[type="submit"]').disabled = false 42 | } else { 43 | var input = document.createElement("input") 44 | input.setAttribute("type", "hidden") 45 | input.setAttribute("name", "token") 46 | input.setAttribute("value", result.token.id) 47 | form.appendChild(input) 48 | form.submit() 49 | } 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /user_payments/stripe_customers/templates/stripe_customers/payment_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 | 3 | {% if customer.customer.default_source %} 4 | 5 | {% with source=customer.customer.default_source %} 6 | {{ source.brand }}, 7 | endend auf {{ source.last4 }}, 8 | gültig bis {{ source.exp_month }}/{{ source.exp_year }}. 9 | {% endwith %} 10 |
11 | {% csrf_token %} 12 | 13 | 14 |
15 | 16 | {% else %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {{ moocher.title }} 22 |
23 |
24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 | 32 | {% endif %} 33 | -------------------------------------------------------------------------------- /user_payments/user_subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "user_payments.user_subscriptions.apps.UserSubscriptions" 2 | -------------------------------------------------------------------------------- /user_payments/user_subscriptions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_user_model 3 | 4 | from user_payments.exceptions import UnknownPeriodicity 5 | 6 | from . import models 7 | 8 | 9 | class SubscriptionPeriodInline(admin.TabularInline): 10 | model = models.SubscriptionPeriod 11 | raw_id_fields = ("line_item",) 12 | extra = 0 13 | 14 | 15 | @admin.register(models.Subscription) 16 | class SubscriptionAdmin(admin.ModelAdmin): 17 | inlines = [SubscriptionPeriodInline] 18 | list_display = ( 19 | "user", 20 | "title", 21 | "code", 22 | "starts_on", 23 | "ends_on", 24 | "periodicity", 25 | "amount", 26 | "paid_until", 27 | "renew_automatically", 28 | ) 29 | list_filter = ("renew_automatically", "code") 30 | radio_fields = {"periodicity": admin.HORIZONTAL} 31 | raw_id_fields = ("user",) 32 | search_fields = ( 33 | "title", 34 | "code", 35 | f"user__{get_user_model().USERNAME_FIELD}", 36 | ) 37 | 38 | def get_inline_instances(self, request, obj=None): 39 | if obj is None: 40 | return [] 41 | return super().get_inline_instances(request, obj=obj) 42 | 43 | def save_model(self, request, obj, form, change): 44 | super().save_model(request, obj, form, change) 45 | if not change: 46 | try: 47 | obj.create_periods() 48 | except UnknownPeriodicity as exc: 49 | self.message_user(request, exc) 50 | 51 | 52 | @admin.register(models.SubscriptionPeriod) 53 | class SubscriptionPeriodAdmin(admin.ModelAdmin): 54 | list_display = ("subscription", "starts_on", "ends_on", "line_item") 55 | raw_id_fields = ("subscription", "line_item") 56 | search_fields = [ 57 | f"subscription__{field}" for field in SubscriptionAdmin.search_fields 58 | ] 59 | -------------------------------------------------------------------------------- /user_payments/user_subscriptions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.text import capfirst 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class UserSubscriptions(AppConfig): 7 | name = "user_payments.user_subscriptions" 8 | verbose_name = capfirst(_("user subscriptions")) 9 | -------------------------------------------------------------------------------- /user_payments/user_subscriptions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-05-21 05:11 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ("user_payments", "0001_initial"), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="Subscription", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ( 32 | "code", 33 | models.CharField( 34 | help_text="Codes must be unique per user. Allows identifying the subscription.", 35 | max_length=20, 36 | verbose_name="code", 37 | ), 38 | ), 39 | ( 40 | "created_at", 41 | models.DateTimeField( 42 | default=django.utils.timezone.now, verbose_name="created at" 43 | ), 44 | ), 45 | ("title", models.CharField(max_length=200, verbose_name="title")), 46 | ("starts_on", models.DateField(verbose_name="starts on")), 47 | ( 48 | "ends_on", 49 | models.DateField(blank=True, null=True, verbose_name="ends on"), 50 | ), 51 | ( 52 | "periodicity", 53 | models.CharField( 54 | choices=[ 55 | ("yearly", "yearly"), 56 | ("quarterly", "quarterly"), 57 | ("monthly", "monthly"), 58 | ("weekly", "weekly"), 59 | ("manually", "manually"), 60 | ], 61 | max_length=20, 62 | verbose_name="periodicity", 63 | ), 64 | ), 65 | ( 66 | "amount", 67 | models.DecimalField( 68 | decimal_places=2, max_digits=10, verbose_name="amount" 69 | ), 70 | ), 71 | ( 72 | "renew_automatically", 73 | models.BooleanField( 74 | default=True, verbose_name="renew automatically" 75 | ), 76 | ), 77 | ("paid_until", models.DateField(blank=True, verbose_name="paid until")), 78 | ( 79 | "user", 80 | models.ForeignKey( 81 | on_delete=django.db.models.deletion.PROTECT, 82 | related_name="user_subscriptions", 83 | to=settings.AUTH_USER_MODEL, 84 | verbose_name="user", 85 | ), 86 | ), 87 | ], 88 | options={ 89 | "verbose_name": "subscription", 90 | "verbose_name_plural": "subscriptions", 91 | }, 92 | ), 93 | migrations.CreateModel( 94 | name="SubscriptionPeriod", 95 | fields=[ 96 | ( 97 | "id", 98 | models.AutoField( 99 | auto_created=True, 100 | primary_key=True, 101 | serialize=False, 102 | verbose_name="ID", 103 | ), 104 | ), 105 | ("starts_on", models.DateField(verbose_name="starts on")), 106 | ("ends_on", models.DateField(verbose_name="ends on")), 107 | ( 108 | "line_item", 109 | models.OneToOneField( 110 | blank=True, 111 | null=True, 112 | on_delete=django.db.models.deletion.PROTECT, 113 | to="user_payments.LineItem", 114 | verbose_name="line item", 115 | ), 116 | ), 117 | ( 118 | "subscription", 119 | models.ForeignKey( 120 | on_delete=django.db.models.deletion.CASCADE, 121 | related_name="periods", 122 | to="user_subscriptions.Subscription", 123 | verbose_name="subscription", 124 | ), 125 | ), 126 | ], 127 | options={ 128 | "verbose_name": "subscription period", 129 | "verbose_name_plural": "subscription periods", 130 | }, 131 | ), 132 | migrations.AlterUniqueTogether( 133 | name="subscriptionperiod", unique_together={("subscription", "starts_on")} 134 | ), 135 | migrations.AlterUniqueTogether( 136 | name="subscription", unique_together={("user", "code")} 137 | ), 138 | ] 139 | -------------------------------------------------------------------------------- /user_payments/user_subscriptions/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-user-payments/b14887a3d274dc164ef9a36f05a45b6bdac3afe5/user_payments/user_subscriptions/migrations/__init__.py -------------------------------------------------------------------------------- /user_payments/user_subscriptions/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time, timedelta 2 | 3 | from django.apps import apps 4 | from django.conf import settings 5 | from django.db import models, transaction 6 | from django.db.models import Max, Q, signals 7 | from django.utils import timezone 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from user_payments.models import LineItem, Payment 11 | 12 | from .utils import recurring 13 | 14 | 15 | class SubscriptionQuerySet(models.QuerySet): 16 | def for_code(self, code): 17 | try: 18 | return self.get(code=code) 19 | except self.model.DoesNotExist: 20 | return None 21 | 22 | 23 | class SubscriptionManager(models.Manager): 24 | def create(self, *, user, code, periodicity, amount, **kwargs): 25 | """ 26 | Make it a ``TypeError`` to forget fields. 27 | """ 28 | return super().create( 29 | user=user, code=code, periodicity=periodicity, amount=amount, **kwargs 30 | ) 31 | 32 | def ensure(self, *, user, code, **kwargs): 33 | """ 34 | Ensure that the user is subscribed to the subscription specified by 35 | ``code``. Pass additional fields for the subscription as kwargs. 36 | 37 | If the subscription is still in a paid period this also ensures that 38 | new subscription periods aren't created too early. 39 | """ 40 | with transaction.atomic(): 41 | changed = False 42 | 43 | try: 44 | subscription = self.get(user=user, code=code) 45 | except Subscription.DoesNotExist: 46 | subscription = self.create(user=user, code=code, **kwargs) 47 | else: 48 | for key, value in kwargs.items(): 49 | if getattr(subscription, key) != value: 50 | changed = True 51 | setattr(subscription, key, value) 52 | 53 | subscription.save() 54 | 55 | if not changed: 56 | return subscription 57 | 58 | subscription.delete_pending_periods() 59 | 60 | if subscription.paid_until > date.today(): 61 | # paid_until might already have been changed in the save() 62 | # call above. So look at paid_until and not at starts_on 63 | subscription.starts_on = subscription.paid_until + timedelta(days=1) 64 | 65 | subscription.save() 66 | return subscription 67 | 68 | def create_periods(self): 69 | for subscription in self.filter(renew_automatically=True): 70 | subscription.create_periods() 71 | 72 | def disable_autorenewal(self): 73 | """ 74 | Disable autorenewal for subscriptions that are past due 75 | 76 | Uses the ``USER_PAYMENTS['disable_autorenewal_after']`` timedelta to 77 | determine the timespan after which autorenewal is disabled for unpaid 78 | subscriptions. Defaults to 15 days. 79 | """ 80 | s = apps.get_app_config("user_payments").settings 81 | for subscription in self.filter( 82 | renew_automatically=True, 83 | paid_until__lt=timezone.now() - s.disable_autorenewal_after, 84 | ): 85 | subscription.cancel() 86 | 87 | 88 | class Subscription(models.Model): 89 | """ 90 | How to quickly generate a new subscription and fetch the first payment:: 91 | 92 | user = request.user # Fetch the user from somewhere 93 | 94 | subscription, created = Subscription.objects.get_or_create( 95 | user=user, 96 | code='plan', # Or whatever makes sense for you 97 | defaults={ 98 | 'title': 'You should want to provide this', 99 | 'periodicity': 'yearly', # or monthly, or weekly. NOT manually. 100 | 'amount': 60, 101 | }, 102 | ) 103 | for period in subscription.create_periods(): 104 | period.create_line_item() 105 | first_payment = Payment.objects.create_pending(user=user) 106 | """ 107 | 108 | user = models.ForeignKey( 109 | settings.AUTH_USER_MODEL, 110 | on_delete=models.PROTECT, 111 | related_name="user_subscriptions", 112 | verbose_name=_("user"), 113 | ) 114 | code = models.CharField( 115 | _("code"), 116 | max_length=20, 117 | help_text=_( 118 | "Codes must be unique per user. Allows identifying the subscription." 119 | ), 120 | ) 121 | created_at = models.DateTimeField(_("created at"), default=timezone.now) 122 | title = models.CharField(_("title"), max_length=200) 123 | starts_on = models.DateField(_("starts on")) 124 | ends_on = models.DateField(_("ends on"), blank=True, null=True) 125 | periodicity = models.CharField( 126 | _("periodicity"), 127 | max_length=20, 128 | choices=[ 129 | ("yearly", _("yearly")), 130 | ("quarterly", _("quarterly")), 131 | ("monthly", _("monthly")), 132 | ("weekly", _("weekly")), 133 | ("manually", _("manually")), 134 | ], 135 | ) 136 | amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2) 137 | 138 | renew_automatically = models.BooleanField(_("renew automatically"), default=True) 139 | paid_until = models.DateField(_("paid until"), blank=True) 140 | 141 | objects = SubscriptionManager.from_queryset(SubscriptionQuerySet)() 142 | 143 | class Meta: 144 | unique_together = (("user", "code"),) 145 | verbose_name = _("subscription") 146 | verbose_name_plural = _("subscriptions") 147 | 148 | def __str__(self): 149 | return self.title 150 | 151 | def save(self, *args, **kwargs): 152 | if not self.starts_on: 153 | self.starts_on = date.today() 154 | if not self.paid_until or self.paid_until < self.starts_on: 155 | # New subscription instance or restarted subscription with 156 | # inactivity period. 157 | self.paid_until = self.starts_on - timedelta(days=1) 158 | super().save(*args, **kwargs) 159 | 160 | # Update unbound line items with new amount. 161 | LineItem.objects.unbound().filter(subscriptionperiod__subscription=self).update( 162 | amount=self.amount 163 | ) 164 | 165 | save.alters_data = True 166 | 167 | def update_paid_until(self, save=True): 168 | self.paid_until = self.periods.paid().aggregate(m=Max("ends_on"))["m"] 169 | if save: 170 | self.save() 171 | 172 | update_paid_until.alters_data = True 173 | 174 | @property 175 | def starts_at(self): 176 | return timezone.make_aware( 177 | datetime.combine(self.starts_on, time.min), timezone.get_default_timezone() 178 | ) 179 | 180 | @property 181 | def ends_at(self): 182 | return ( 183 | timezone.make_aware( 184 | datetime.combine(self.ends_on, time.max), 185 | timezone.get_default_timezone(), 186 | ) 187 | if self.ends_on 188 | else None 189 | ) 190 | 191 | @property 192 | def paid_until_at(self): 193 | return timezone.make_aware( 194 | datetime.combine(self.paid_until, time.max), timezone.get_default_timezone() 195 | ) 196 | 197 | @property 198 | def grace_period_ends_at(self): 199 | s = apps.get_app_config("user_payments").settings 200 | return self.paid_until_at + s.grace_period 201 | 202 | @property 203 | def is_active(self): 204 | s = apps.get_app_config("user_payments").settings 205 | return timezone.now() <= self.paid_until_at + s.grace_period 206 | 207 | @property 208 | def in_grace_period(self): 209 | return self.paid_until_at <= timezone.now() <= self.grace_period_ends_at 210 | 211 | def create_periods(self, *, until=None): 212 | """ 213 | Create period instances for this subscription, up to either today or 214 | the end of the subscription, whichever date is earlier. 215 | 216 | ``until`` is interpreted as "up to and including". 217 | """ 218 | end = until or date.today() 219 | if self.ends_on: 220 | end = min(self.ends_on, end) 221 | days = recurring(self.starts_on, self.periodicity) 222 | this_start = next(days) 223 | 224 | latest_ends_on = self.periods.aggregate(m=Max("ends_on"))["m"] 225 | periods = [] 226 | 227 | if this_start <= end: 228 | while True: 229 | next_start = next(days) 230 | if latest_ends_on is None or this_start > latest_ends_on: 231 | periods.append( 232 | self.periods.create( 233 | starts_on=this_start, ends_on=next_start - timedelta(days=1) 234 | ) 235 | ) 236 | this_start = next_start 237 | 238 | if this_start > end: 239 | break 240 | 241 | return periods 242 | 243 | create_periods.alters_data = True 244 | 245 | def delete_pending_periods(self): 246 | for period in self.periods.select_related("line_item__payment"): 247 | line_item = period.line_item 248 | if line_item: 249 | if line_item.payment: 250 | if line_item.payment.charged_at: 251 | continue 252 | line_item.payment.cancel_pending() 253 | period.delete() 254 | if line_item: 255 | line_item.delete() 256 | 257 | delete_pending_periods.alters_data = True 258 | 259 | def cancel(self): 260 | self.ends_on = self.paid_until 261 | self.renew_automatically = False 262 | self.save() 263 | 264 | self.delete_pending_periods() 265 | 266 | cancel.alters_data = True 267 | 268 | 269 | def payment_changed(sender, instance, **kwargs): 270 | affected = SubscriptionPeriod.objects.filter(line_item__payment=instance.pk).values( 271 | "subscription" 272 | ) 273 | for subscription in Subscription.objects.filter(pk__in=affected): 274 | subscription.update_paid_until() 275 | 276 | 277 | signals.post_save.connect(payment_changed, sender=Payment) 278 | signals.post_delete.connect(payment_changed, sender=Payment) 279 | 280 | 281 | class SubscriptionPeriodManager(models.Manager): 282 | def paid(self): 283 | """ 284 | Return subscription periods that have been paid for. 285 | """ 286 | return self.filter(line_item__payment__charged_at__isnull=False) 287 | 288 | def create_line_items(self, *, until=None): 289 | for period in self.filter( 290 | line_item__isnull=True, starts_on__lte=until or date.today() 291 | ): 292 | period.create_line_item() 293 | 294 | def zeroize_pending_periods(self, *, lasting_until=None): 295 | LineItem.objects.filter( 296 | id__in=self.filter( 297 | ~Q(id__in=self.paid()), Q(ends_on__lt=lasting_until or date.today()) 298 | ).values("line_item") 299 | ).update(amount=0) 300 | 301 | 302 | class SubscriptionPeriod(models.Model): 303 | subscription = models.ForeignKey( 304 | Subscription, 305 | on_delete=models.CASCADE, 306 | related_name="periods", 307 | verbose_name=_("subscription"), 308 | ) 309 | starts_on = models.DateField(_("starts on")) 310 | ends_on = models.DateField(_("ends on")) 311 | line_item = models.OneToOneField( 312 | LineItem, 313 | on_delete=models.PROTECT, 314 | blank=True, 315 | null=True, 316 | verbose_name=_("line item"), 317 | ) 318 | 319 | objects = SubscriptionPeriodManager() 320 | 321 | class Meta: 322 | get_latest_by = "starts_on" 323 | unique_together = (("subscription", "starts_on"),) 324 | verbose_name = _("subscription period") 325 | verbose_name_plural = _("subscription periods") 326 | 327 | def __str__(self): 328 | return f"{self.subscription} ({self.starts_on} - {self.ends_on})" 329 | 330 | def create_line_item(self): 331 | """ 332 | Create a user payments line item for this subscription period. 333 | """ 334 | if not self.line_item: 335 | self.line_item = LineItem.objects.create( 336 | user=self.subscription.user, 337 | title=str(self), 338 | amount=self.subscription.amount, 339 | ) 340 | self.save() 341 | 342 | create_line_item.alters_data = True 343 | 344 | @property 345 | def starts_at(self): 346 | return timezone.make_aware( 347 | datetime.combine(self.starts_on, time.min), timezone.get_default_timezone() 348 | ) 349 | 350 | @property 351 | def ends_at(self): 352 | return ( 353 | timezone.make_aware( 354 | datetime.combine(self.ends_on, time.max), 355 | timezone.get_default_timezone(), 356 | ) 357 | if self.ends_on 358 | else None 359 | ) 360 | -------------------------------------------------------------------------------- /user_payments/user_subscriptions/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from datetime import date, timedelta 3 | 4 | from user_payments.exceptions import UnknownPeriodicity 5 | 6 | 7 | def next_valid_day(year, month, day): 8 | """ 9 | Return the next valid date for the given year, month and day combination. 10 | 11 | Used by ``recurring`` below. 12 | """ 13 | while True: 14 | try: 15 | return date(year, month, day) 16 | except ValueError: 17 | if month > 12: 18 | month -= 12 19 | year += 1 20 | continue 21 | 22 | day += 1 23 | if day > 31: 24 | day = 1 25 | month += 1 26 | 27 | 28 | def recurring(start, periodicity): 29 | """ 30 | This generator yields valid dates with the given start date and 31 | periodicity. 32 | 33 | Returns later dates if calculated dates do not exist, e.g. for a yearly 34 | periodicity and a starting date of 2016-02-29 (a leap year), dates for 35 | years that aren't leap years will be 20xx-03-01, not 20xx-02-28. However, 36 | leap year dates will stay on 20xx-02-29 and not be delayed. 37 | """ 38 | if periodicity == "yearly": 39 | return ( # pragma: no branch 40 | next_valid_day(start.year + i, start.month, start.day) 41 | for i in itertools.count() 42 | ) 43 | 44 | elif periodicity == "quarterly": 45 | return ( # pragma: no branch 46 | next_valid_day(start.year, start.month + i * 3, start.day) 47 | for i in itertools.count() 48 | ) 49 | 50 | elif periodicity == "monthly": 51 | return ( # pragma: no branch 52 | next_valid_day(start.year, start.month + i, start.day) 53 | for i in itertools.count() 54 | ) 55 | 56 | elif periodicity == "weekly": 57 | return ( # pragma: no branch 58 | start + timedelta(days=i * 7) for i in itertools.count() 59 | ) 60 | 61 | else: 62 | raise UnknownPeriodicity("Unknown periodicity %r" % periodicity) 63 | 64 | 65 | if __name__ == "__main__": # pragma: no cover 66 | from pprint import pprint 67 | 68 | def twenty(start, periodicity): 69 | pprint(list(itertools.islice(recurring(start, periodicity), 20))) 70 | print() 71 | 72 | twenty(date.today(), "yearly") 73 | twenty(date(2016, 2, 29), "yearly") 74 | 75 | twenty(date.today(), "monthly") 76 | twenty(date(2016, 2, 29), "monthly") 77 | twenty(date(2015, 7, 31), "monthly") 78 | 79 | twenty(date.today(), "weekly") 80 | --------------------------------------------------------------------------------