├── .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 |
15 |
16 | {% else %}
17 |
18 |
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 |
--------------------------------------------------------------------------------