diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..94840b3 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +layout python3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1895af1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +.direnv +*.pyc +__pycache__ +*.egg-info +/dist/ +/build/ +.tox +.coverage +.cache +test.db +.mypy_cache +.pytest_cache/ + +# Intellij idea +.idea +*.iml + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..bea8854 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +dist: xenial + +sudo: false + +language: python + +python: + - 3.6 + - 3.7 + +install: pip install tox-travis coveralls + +script: tox + +stages: + - test + - name: deploy + if: tag IS present + +jobs: + include: + - stage: deploy + install: skip + script: skip + deploy: + provider: pypi + user: skioo + password: + secure: "bMMhRbHn4ZPdoGAuMoHIZdpt0Op73IKc1bi0xo6kHONJXbGFli2xJXYZNz9DjiqKn2WzvQkkxnjv4yMH1jdda01XlIdqKJPxlxzEYvInMBbrbKWv7mktbneJVDYw9cX9NcXkT0sFqH8r2htoVksBg0q4nQFp2fxjELDRYmdA3QjjTEVX8nw/eNElc8sSCdC5HmVaIpPN7yIrLO3Qe+f0RFEgkV8nTvrDQ3Nsg+SbQPWf78iBj7+3+8c6HvhKvDX+Wc01NCZSsM7NPtNqpv3907SGN56mPGVdCmW7aTLNWyf3zZeD2w5TRVNoUehcx7mMahJbCW0rBSAECVHVXUGJm35cbSk4OP5NPqJNCxlEQcfoqrEpvUayyLBsVK5cAjWS0k6A2l/TqXOzJ8UPIR/94aiD6X4FKxydFhhXf1ywevyi71mz/H2nESc18sOUgmPjjCrKAIxv/7phEYJ2FPB53Tg8zWQoLfB1ixu3kh/LoZmbmhnk27NfosXflnEsii/E1pSQ+QSnyDH4SvKeQCrSbmZvIc1MWoTCdMmtizew9MgzHbI/aG7TbttOzHjYYGG+iBMZTr0yMf+M/bej3sDglK7GJksJjYurpmR86B2f/s4gKdXFZ2Dl3/xmgLpHE/O/RYVWXqbv+1zuVtSQ9TZSHTW8HDVDb3ab56Jv6HoRb60=" + distribution: sdist bdist_wheel + on: + tags: true + +after_success: + - coveralls diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7956dfa --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) 2017, Skioo SA and individual contributors. + + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e58b8c8 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# django-payment + +[![Build Status](https://travis-ci.org/skioo/django-payment.svg?branch=master)](https://travis-ci.org/skioo/django-payment) +[![PyPI version](https://badge.fury.io/py/django-payment.svg)](https://badge.fury.io/py/django-payment) +[![Requirements Status](https://requires.io/github/skioo/django-payment/requirements.svg?branch=master)](https://requires.io/github/skioo/django-payment/requirements/?branch=master) +[![Coverage Status](https://coveralls.io/repos/github/skioo/django-payment/badge.svg?branch=master)](https://coveralls.io/github/skioo/django-payment?branch=master) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) + + +## Requirements +* Python: 3.6 and over +* Django: 2.1 and over + + +## Usage +To use django-payments in your existing django project: + +Add payment to your `INSTALLED_APPS`: + + INSTALLED_APPS = ( + ... + 'payment.apps.PaymentConfig, + ... + ) + + +Run the migrations: + + ./manage.py migrate + + +Configure the CHECKOUT_PAYMENT_GATEWAYS and PAYMENT_GATEWAYS settings. See [example settings.py](example_project/settings.py) + + +## Payment gateways +This module provides implementations for the following payment-gateways: + +### Stripe +Implemented features: +- Simple Payment (authorization and capture at once) +- Separate authorization and capture +- Refunds +- Split Payment with stripe connect +- Adding metadata to the stripe payment, for easy sorting in stripe + + +## The example project +The source distribution includes an example project that lets one exercise +the different gateway implementations without having to write any code. + +Install the django-payment dependencies (the example project has identical dependencies): + + pip install -e . + + Create the database and admin user: + + cd example_project + ./manage.py migrate + ./manage.py createsuperuser + + Start the dev server: + + ./manage.py runserver + +Then point your browser to: + + http://127.0.0.1:8000/admin + +Create a new payment. + +Then operate on that payment with: + + http://127.0.0.1:8000/payment/ + +## Development + +To install all dependencies: + + pip install -e . + +To run unit tests: + + pip install pytest-django + pytest + +To lint, typecheck, test, and verify you didn't forget to create a migration: + + pip install tox + tox + +To install the version being developed into another django project: + + pip install -e + + +## More information + +* [The design of this application](docs/design.md) diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..067b63b --- /dev/null +++ b/docs/design.md @@ -0,0 +1,41 @@ +Rationale +--------- + +The idea is to profit from the design and implementation of the payment gateways in saleor, +without pulling in the rest of saleor. Saleor wants to separate the payment part from the rest of saleor, +but they're not done yet: https://github.com/mirumee/saleor/issues/3422 . It also seems like it's not really in progress. + +I last retrieved the saleor code on 2019-06-24, commit hash: 509cb4530 + +Changing as little as possible in the copied code so that next time I retrieve the saleor code I can easily apply the changes. + + +General architecture +-------------------- +Client code interacts with a generic interface. +In interface.py Payment, the billing and shipping addresses are for optional fraud checking. + +Payment-gateway implementors implement the + +![payment with stripe sequence diagram](payment-with-stripe.png) + + +Manifest +-------- +This package contains: +- A copy of the interfaces of sealor payments. +- A copy of the ```stripe``` and ```dummy``` saleor gateways package and tests (we're not interested in braintree or razorpay) +- A modified copy of the models (to remove references to saleor orders and to the saleor custom money) +- A modified copy of utils.py (again to remove references to saleor orders and to the saleor custom money) +- A modified copy of stripe/__init__.py (to fix a bug when we just want to authorize) + + +Our changes +----------- +- Don't depend on saleor orders from within our code +- Don't depend on postgres, so we replace jsonfield by textfield + (there is a database-independent jsonfield but it's not very useful and also unmaintained) +- Don't depend on the saleor homegrown money class. +- Remove use the DEFAULT_CURRENCY settings, the way it's used in the saleor code looks like a very bad idea. +- Use the django admin instead of a handcrafted UI. + diff --git a/docs/payment-with-stripe.png b/docs/payment-with-stripe.png new file mode 100644 index 0000000..a1e1d4d Binary files /dev/null and b/docs/payment-with-stripe.png differ diff --git a/example_project/__init__.py b/example_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_project/manage.py b/example_project/manage.py new file mode 100755 index 0000000..b5a2943 --- /dev/null +++ b/example_project/manage.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +import os +import sys + + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/example_project/settings.py b/example_project/settings.py new file mode 100644 index 0000000..2efc2f0 --- /dev/null +++ b/example_project/settings.py @@ -0,0 +1,123 @@ +# flake8: noqa + +import os +import sys + + +def abspath(*args): + return os.path.abspath(os.path.join(*args)) + + +PROJECT_ROOT = abspath(os.path.dirname(__file__)) +PAYMENT_MODULE_PATH = abspath(PROJECT_ROOT, '..') +sys.path.insert(0, PAYMENT_MODULE_PATH) + +DEBUG = True + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test.db' + }, +} + +SECRET_KEY = 'not_so_secret' + +USE_TZ = True + +# Use a fast hasher to speed up tests. +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.contenttypes', + 'django.contrib.staticfiles', + 'django_fsm', + 'djmoney', + 'tests', + 'payment.apps.PaymentConfig', +] + +MIDDLEWARE = [ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +] + +STATIC_URL = '/static/' + +STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static/') + +STATICFILES_DIRS = [os.path.join(PROJECT_ROOT, 'project/static')] + +MEDIA_URL = '/media/' + +ROOT_URLCONF = 'urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': ['templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.request', + 'django.template.context_processors.static', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +# Enable specific currencies (djmoney) +CURRENCIES = ['USD', 'EUR', 'JPY', 'GBP', 'CAD', 'CHF'] + + +DUMMY = "dummy" +STRIPE = "stripe" + +CHECKOUT_PAYMENT_GATEWAYS = { + DUMMY: "Dummy gateway", + STRIPE: "Stripe", +} + +PAYMENT_GATEWAYS = { + DUMMY: { + "module": "payment.gateways.dummy", + "config": { + "auto_capture": True, + "connection_params": {}, + "template_path": "payment/dummy.html", + }, + }, + STRIPE: { + "module": "payment.gateways.stripe", + "config": { + "auto_capture": True, + "template_path": "payment/stripe.html", + "connection_params": { + "public_key": os.environ.get("STRIPE_PUBLIC_KEY"), + "secret_key": os.environ.get("STRIPE_SECRET_KEY"), + "store_name": os.environ.get("STRIPE_STORE_NAME", "skioo shop"), + "store_image": os.environ.get("STRIPE_STORE_IMAGE", None), + "prefill": os.environ.get("STRIPE_PREFILL", True), + "remember_me": os.environ.get("STRIPE_REMEMBER_ME", False), + "locale": os.environ.get("STRIPE_LOCALE", "auto"), + "enable_billing_address": os.environ.get( + "STRIPE_ENABLE_BILLING_ADDRESS", False + ), + "enable_shipping_address": os.environ.get( + "STRIPE_ENABLE_SHIPPING_ADDRESS", False + ), + }, + }, + }, +} diff --git a/example_project/templates/operation_list.html b/example_project/templates/operation_list.html new file mode 100644 index 0000000..2d20746 --- /dev/null +++ b/example_project/templates/operation_list.html @@ -0,0 +1,20 @@ +

Payment

+ + +See payment in admin + +

Stripe Operations

+ + diff --git a/example_project/templates/stripe/checkout.html b/example_project/templates/stripe/checkout.html new file mode 100644 index 0000000..881ba84 --- /dev/null +++ b/example_project/templates/stripe/checkout.html @@ -0,0 +1,25 @@ + + + + +

Redirecting to stripe checkout

+ + + + + + + + + \ No newline at end of file diff --git a/example_project/templates/stripe/elements_token.html b/example_project/templates/stripe/elements_token.html new file mode 100644 index 0000000..7c03983 --- /dev/null +++ b/example_project/templates/stripe/elements_token.html @@ -0,0 +1,144 @@ + + + + Stripe elements + + + + + + + +
+
+ +
+ +
+ + + +
+
+ +
+ + + + + + + + + \ No newline at end of file diff --git a/example_project/templates/stripe/old_checkout_ajax.html b/example_project/templates/stripe/old_checkout_ajax.html new file mode 100644 index 0000000..688f19a --- /dev/null +++ b/example_project/templates/stripe/old_checkout_ajax.html @@ -0,0 +1,43 @@ + + + + + + + + + \ No newline at end of file diff --git a/example_project/templates/stripe/payment_intents_manual_flow.html b/example_project/templates/stripe/payment_intents_manual_flow.html new file mode 100644 index 0000000..1ae3792 --- /dev/null +++ b/example_project/templates/stripe/payment_intents_manual_flow.html @@ -0,0 +1,85 @@ + + + + payment intents + + + + + +

Payment intents manual flow

+ + + +
+
+ + + + + + + diff --git a/example_project/urls.py b/example_project/urls.py new file mode 100644 index 0000000..e0e74fb --- /dev/null +++ b/example_project/urls.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path, include + +from views import stripe +from views import view_payment + +urlpatterns = [ + path('admin/', admin.site.urls), + path('payment/', view_payment, name='view_payment'), +] + +stripe_urls = [ + path('/stripe/checkout', stripe.checkout, name='stripe_checkout'), + path('/stripe/elements_token', stripe.elements_token, name='stripe_elements_token'), + + path('/stripe/payment_intents_manual_flow', stripe.payment_intents_manual_flow, + name='stripe_payment_intents_manual_flow'), + path('/stripe/payment_intents_confirm_payment', stripe.payment_intents_confirm_payment, + name='stripe_payment_intents_confirm_payment'), + path('/stripe/capture', stripe.capture, name='stripe_capture'), +] + + +urlpatterns += [path('stripe', include(stripe_urls))] + + +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/example_project/views/__init__.py b/example_project/views/__init__.py new file mode 100644 index 0000000..ff58d15 --- /dev/null +++ b/example_project/views/__init__.py @@ -0,0 +1,10 @@ +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse + +from payment.models import Payment + + +def view_payment(request: HttpRequest, payment_id: int) -> HttpResponse: + payment = get_object_or_404(Payment, id=payment_id) + return TemplateResponse(request, 'operation_list.html', {'payment': payment}) diff --git a/example_project/views/stripe.py b/example_project/views/stripe.py new file mode 100644 index 0000000..b24cd2a --- /dev/null +++ b/example_project/views/stripe.py @@ -0,0 +1,134 @@ +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view +from rest_framework.status import HTTP_500_INTERNAL_SERVER_ERROR +from structlog import get_logger + +from payment import get_payment_gateway +from payment.gateways.stripe import get_amount_for_stripe, get_currency_for_stripe +from payment.models import Payment +from payment.utils import gateway_authorize, gateway_capture + +logger = get_logger() + + +def capture(request: HttpRequest, payment_id: int) -> HttpResponse: + payment = get_object_or_404(Payment, id=payment_id) + capture_result = gateway_capture(payment=payment) + logger.info('stripe capture', payment=payment, capture_result=capture_result) + return redirect('view_payment', payment_id=payment_id) + + +def checkout(request, payment_id: int) -> HttpResponse: + """ + Takes the user to the stripe checkout page. + This is not part of the gateway abstraction, so we implement it directly using the stripe API + """ + payment = get_object_or_404(Payment, id=payment_id) + + import stripe + stripe.api_key = 'sk_test_QWtEpnVswmgW9aUJkyKmEutp00dsgn2KAa' + + session = stripe.checkout.Session.create( + payment_method_types=['card'], + customer_email=payment.customer_email, + line_items=[{ + 'name': 'Your order', + 'amount': get_amount_for_stripe(payment.total.amount, payment.total.currency.code), + 'currency': get_currency_for_stripe(payment.total.currency.code), + 'quantity': 1, + }], + payment_intent_data={ + 'capture_method': 'manual', + }, + success_url='https://example.com/success', + cancel_url='https://example.com/cancel', + ) + return TemplateResponse(request, 'stripe/checkout.html', {'CHECKOUT_SESSION_ID': session.id}) + + +@csrf_exempt +def elements_token(request, payment_id: int) -> HttpResponse: + payment = get_object_or_404(Payment, id=payment_id) + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + connection_params = gateway_config.connection_params + + if request.method == 'GET': + return TemplateResponse( + request, + 'stripe/elements_token.html', + {'stripe_public_key': connection_params['public_key']}) + elif request.method == 'POST': + stripe_token = request.POST.get('stripeToken') + if stripe_token is None: + return HttpResponse('missing stripe token') + try: + logger.info('stripe authorize', payment=payment) + gateway_authorize(payment=payment, payment_token=stripe_token) + except Exception as exc: + logger.error('stripe authorize', exc_info=exc) + return HttpResponse('Error authorizing {}: {}'.format(payment_id, exc)) + else: + return redirect('view_payment', payment_id=payment.pk) + + +def payment_intents_manual_flow(request, payment_id: int) -> HttpResponse: + payment = get_object_or_404(Payment, id=payment_id) + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + connection_params = gateway_config.connection_params + + stripe_public_key = connection_params['public_key'] + confirm_payment_endpoint = reverse('stripe_payment_intents_confirm_payment', args=[payment_id]) + + return TemplateResponse( + request, + 'stripe/payment_intents_manual_flow.html', + { + 'stripe_public_key': stripe_public_key, + 'confirm_payment_endpoint': confirm_payment_endpoint}) + + +@api_view(['POST']) +def payment_intents_confirm_payment(request, payment_id): + # XXX: Update the payment with the info + payment = get_object_or_404(Payment, id=payment_id) + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + connection_params = gateway_config.connection_params + stripe_public_key = connection_params['public_key'] + + import stripe + stripe.api_key = stripe_public_key + + data = request.data + + try: + if 'payment_method_id' in data: + # Create the PaymentIntent + intent = stripe.PaymentIntent.create( + payment_method=data['payment_method_id'], + amount=1099, + currency='chf', + confirmation_method='manual', + confirm=True, + ) + elif 'payment_intent_id' in data: + intent = stripe.PaymentIntent.confirm(data['payment_intent_id']) + except stripe.error.CardError as e: + # Display error on client + return JsonResponse({'error': e.user_message}) + + if intent.status == 'requires_action' and intent.next_action.type == 'use_stripe_sdk': + # Tell the client to handle the action + return JsonResponse({ + 'requires_action': True, + 'payment_intent_client_secret': intent.client_secret}) + elif intent.status == 'succeeded': + # The payment didn’t need any additional actions and completed! + # Handle post-payment fulfillment + return JsonResponse({'success': True}) + else: + # Invalid status + return JsonResponse({'error': 'Invalid PaymentIntent status'}, status=HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/example_project/views/stripe_deprecated.py b/example_project/views/stripe_deprecated.py new file mode 100644 index 0000000..1b4d0db --- /dev/null +++ b/example_project/views/stripe_deprecated.py @@ -0,0 +1,128 @@ +from dataclasses import asdict +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view +from structlog import get_logger + +from payment import get_payment_gateway +from payment.gateways.stripe import get_amount_for_stripe +from payment.models import Payment +from payment.utils import create_payment_information, gateway_authorize +from payment.utils import gateway_process_payment + +logger = get_logger() + + +def authorize_and_capture_old_checkout(request: HttpRequest, payment_id: int) -> HttpResponse: + return _pay(request, payment_id, True) + + +def authorize_old_checkout(request: HttpRequest, payment_id: int) -> HttpResponse: + return _pay(request, payment_id, False) + + +def _pay(request: HttpRequest, payment_id: int, also_capture: bool) -> HttpResponse: + payment = get_object_or_404(Payment, id=payment_id) + payment_data = create_payment_information(payment) + + logger.debug('stripe _pay payment-data', **asdict(payment_data)) + + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + + connection_params = gateway_config.connection_params + form = payment_gateway.create_form( + request.POST or None, + payment_information=payment_data, + connection_params=connection_params, + ) + if form.is_valid(): + try: + if also_capture: + logger.info('stripe gateway-process-payment', payment=payment) + gateway_process_payment(payment=payment, payment_token=form.get_payment_token()) + else: + logger.info('stripe authorize', payment=payment) + gateway_authorize(payment=payment, payment_token=form.get_payment_token()) + except Exception as exc: + form.add_error(None, str(exc)) + else: + return redirect('view_payment', payment_id=payment.pk) + + client_token = payment_gateway.get_client_token(connection_params=connection_params) + ctx = { + "form": form, + "payment": payment, + "client_token": client_token, + } + return TemplateResponse(request, gateway_config.template_path, ctx) + + +@csrf_exempt +def authorize_old_checkout_ajax(request, payment_id: int) -> HttpResponse: + if request.method == 'GET': + payment_params_endpoint = reverse('stripe_payment_params', args=[payment_id]) + return TemplateResponse( + request, + 'stripe/old_checkout_ajax.html', + {'payment_params_endpoint': payment_params_endpoint}) + elif request.method == 'POST': + payment = get_object_or_404(Payment, id=payment_id) + payment_data = create_payment_information(payment) + logger.debug('stripe payment-data', **asdict(payment_data)) + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + connection_params = gateway_config.connection_params + form = payment_gateway.create_form( + request.POST, + payment_information=payment_data, + connection_params=connection_params) + if form.is_valid(): + try: + logger.info('stripe authorize', payment=payment) + gateway_authorize(payment=payment, payment_token=form.get_payment_token()) + except Exception as exc: + form.add_error(None, str(exc)) + logger.error('stripe authorize', exc_info=exc) + return HttpResponse('Error authorizing {}: {}'.format(payment_id, exc)) + else: + return redirect('view_payment', payment_id=payment.pk) + + +@api_view(['GET']) +def payment_params(request, payment_id: int) -> HttpResponse: + """ + Returns a data representation of the parameters that are needed to initiate a stripe payment. + This is not part of the gateway abstraction, so we implement it directly using the stripe API + """ + payment = get_object_or_404(Payment, id=payment_id) + + _, gateway_config = get_payment_gateway(payment.gateway) + gateway_params = gateway_config.connection_params + + amount = payment.total.amount + currency = payment.total.currency.code + + stripe_payment_params = { + "key": gateway_params.get("public_key"), + "amount": get_amount_for_stripe(amount, currency), + "name": gateway_params.get("store_name"), + "currency": currency, + "locale": "auto", + "allow-remember-me": "false", + "billing-address": "false", + "zip-code": "false", + "email": payment.customer_email + } + + image = gateway_params.get("store_image") + if image: + payment_params["image"] = image + + result = { + "gateway": payment.gateway, + "params": stripe_payment_params, + } + + return JsonResponse(result) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..e9e7fdd --- /dev/null +++ b/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/payment/__init__.py b/payment/__init__.py new file mode 100644 index 0000000..b9089f1 --- /dev/null +++ b/payment/__init__.py @@ -0,0 +1,134 @@ +import importlib +from enum import Enum + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.utils.translation import pgettext_lazy + +from .interface import GatewayConfig + + +class PaymentError(Exception): + def __init__(self, message): + super(PaymentError, self).__init__(message) + self.message = message + + +class GatewayError(IOError): + pass + + +class CustomPaymentChoices: + MANUAL = "manual" + + CHOICES = [(MANUAL, pgettext_lazy("Custom payment choice type", "Manual"))] + + +class OperationType(Enum): + PROCESS_PAYMENT = "process_payment" + AUTH = "authorize" + CAPTURE = "capture" + VOID = "void" + REFUND = "refund" + + +class TransactionError(Enum): + """Represents a transaction error.""" + + INCORRECT_NUMBER = "incorrect_number" + INVALID_NUMBER = "invalid_number" + INCORRECT_CVV = "incorrect_cvv" + INVALID_CVV = "invalid_cvv" + INCORRECT_ZIP = "incorrect_zip" + INCORRECT_ADDRESS = "incorrect_address" + INVALID_EXPIRY_DATE = "invalid_expiry_date" + EXPIRED = "expired" + PROCESSING_ERROR = "processing_error" + DECLINED = "declined" + + +class TransactionKind: + """Represents the type of a transaction. + + The following transactions types are possible: + - AUTH - an amount reserved against the customer's funding source. Money + does not change hands until the authorization is captured. + - VOID - a cancellation of a pending authorization or capture. + - CAPTURE - a transfer of the money that was reserved during the + authorization stage. + - REFUND - full or partial return of captured funds to the customer. + """ + + AUTH = "auth" + CAPTURE = "capture" + VOID = "void" + REFUND = "refund" + # FIXME we could use another status like WAITING_FOR_AUTH for transactions + # Which were authorized, but needs to be confirmed manually by staff + # eg. Braintree with "submit_for_settlement" enabled + CHOICES = [ + (AUTH, pgettext_lazy("transaction kind", "Authorization")), + (REFUND, pgettext_lazy("transaction kind", "Refund")), + (CAPTURE, pgettext_lazy("transaction kind", "Capture")), + (VOID, pgettext_lazy("transaction kind", "Void")), + ] + + +class ChargeStatus: + """Represents possible statuses of a payment. + + The following statuses are possible: + - NOT_CHARGED - no funds were take off the customer founding source yet. + - PARTIALLY_CHARGED - funds were taken off the customer's funding source, + partly covering the payment amount. + - FULLY_CHARGED - funds were taken off the customer founding source, + partly or completely covering the payment amount. + - PARTIALLY_REFUNDED - part of charged funds were returned to the customer. + - FULLY_REFUNDED - all charged funds were returned to the customer. + """ + + NOT_CHARGED = "not-charged" + PARTIALLY_CHARGED = "partially-charged" + FULLY_CHARGED = "fully-charged" + PARTIALLY_REFUNDED = "partially-refunded" + FULLY_REFUNDED = "fully-refunded" + + CHOICES = [ + (NOT_CHARGED, pgettext_lazy("payment status", "Not charged")), + (PARTIALLY_CHARGED, pgettext_lazy("payment status", "Partially charged")), + (FULLY_CHARGED, pgettext_lazy("payment status", "Fully charged")), + (PARTIALLY_REFUNDED, pgettext_lazy("payment status", "Partially refunded")), + (FULLY_REFUNDED, pgettext_lazy("payment status", "Fully refunded")), + ] + + +GATEWAYS_ENUM = Enum( # type:ignore + "GatewaysEnum", {key.upper(): key.lower() for key in settings.PAYMENT_GATEWAYS} +) + + +def get_payment_gateway(gateway_name): + if gateway_name not in settings.CHECKOUT_PAYMENT_GATEWAYS: + raise ValueError("%s is not allowed gateway" % gateway_name) + if gateway_name not in settings.PAYMENT_GATEWAYS: + raise ImproperlyConfigured( + "Payment gateway %s is not configured." % gateway_name + ) + + gateway_module = importlib.import_module( + settings.PAYMENT_GATEWAYS[gateway_name]["module"] + ) + + if "config" not in settings.PAYMENT_GATEWAYS[gateway_name]: + raise ImproperlyConfigured( + "Payment gateway %s should have own configuration" % gateway_name + ) + + gateway_config = settings.PAYMENT_GATEWAYS[gateway_name]["config"] + config = GatewayConfig( + auto_capture=gateway_config["auto_capture"], + template_path=gateway_config["template_path"], + connection_params=gateway_config["connection_params"], + ) + + return gateway_module, config diff --git a/payment/admin.py b/payment/admin.py new file mode 100644 index 0000000..fc7e532 --- /dev/null +++ b/payment/admin.py @@ -0,0 +1,149 @@ +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ +from moneyed.localization import format_money + +from .models import Payment, Transaction + + +############################################################## +# Shared utilities + + +def amount(obj): + return format_money(obj.amount) + + +amount.admin_order_field = 'amount' # type: ignore +amount.short_description = _('amount') # type: ignore + + +def created_on(obj): + return obj.created.date() + + +created_on.admin_order_field = 'created' # type: ignore +created_on.short_description = _('created') # type: ignore + + +def modified_on(obj): + return obj.modified.date() + + +modified_on.admin_order_field = 'modified' # type: ignore +modified_on.short_description = _('modified') # type: ignore + +############################################################## +# Credit Cards + +''' +class CreditCardValidFilter(admin.SimpleListFilter): + title = _('Valid') + parameter_name = 'valid' + + def lookups(self, request, model_admin): + return ( + ('yes', _('Yes')), + ('no', _('No')), + ('all', _('All')), + ) + + def queryset(self, request, queryset): + today = datetime.now().date() + if self.value() == 'yes': + return queryset.filter(expiry_date__gte=today) + if self.value() == 'no': + return queryset.filter(expiry_date__lt=today) + + +def credit_card_expiry(obj): + return format_html('{}/{}', obj.expiry_month, obj.expiry_year) + + +credit_card_expiry.admin_order_field = 'expiry_date' # type: ignore + + +def credit_card_is_valid(obj): + return obj.is_valid() + + +credit_card_is_valid.boolean = True # type: ignore + +credit_card_is_valid.short_description = 'valid' # type: ignore + + +@admin.register(CreditCard) +class CreditCardAdmin(admin.ModelAdmin): + date_hierarchy = 'created' + list_display = ['number', created_on, 'type', 'status', credit_card_expiry, credit_card_is_valid] + search_fields = ['number'] + list_filter = ['type', 'status', CreditCardValidFilter] + ordering = ['-created'] + list_select_related = True + + readonly_fields = ['created', 'modified', 'expiry_date'] + + +class CreditCardInline(admin.TabularInline): + model = CreditCard + fields = readonly_fields = ['type', 'number', 'status', credit_card_expiry, created_on] + show_change_link = True + can_delete = False + extra = 0 + ordering = ['-created'] + +''' + + +############################################################## +# Transactions +# + +class TransactionInline(admin.TabularInline): + model = Transaction + ordering = ['-created'] + show_change_link = True + + # The gateway response is a huge field so we don't show it in the inline. + # We let the user click on the inline change link to see all the details of the transaction. + fields = readonly_fields = ['created', 'token', 'kind', amount, 'is_success', 'error'] + + def has_add_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): + + def has_module_permission(self, request): + # Prevent TransactionAdmin from appearing in the admin menu, + # to view a transaction detail users should start navigation from a Payment. + return False + + +############################################################## +# Payments + +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + date_hierarchy = 'created' + ordering = ['-created'] + list_filter = ['gateway', 'is_active', 'charge_status'] + list_display = ['created', 'gateway', 'is_active', 'charge_status', 'formatted_total', 'formatted_captured_amount', + 'customer_email'] + search_fields = ['customer_email', 'token', 'total', 'id'] + + inlines = [TransactionInline] + + def formatted_total(self, obj): + return format_money(obj.total) + + formatted_total.short_description = _('total') # type: ignore + + def formatted_captured_amount(self, obj): + if obj.captured_amount is not None: + return format_money(obj.captured_amount) + + formatted_captured_amount.short_description = _('captured amount') # type: ignore diff --git a/payment/apps.py b/payment/apps.py new file mode 100644 index 0000000..89758cf --- /dev/null +++ b/payment/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class PaymentConfig(AppConfig): + name = 'payment' + verbose_name = _("Payment") diff --git a/payment/gateways/__init__.py b/payment/gateways/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/gateways/dummy/__init__.py b/payment/gateways/dummy/__init__.py new file mode 100644 index 0000000..d3ba929 --- /dev/null +++ b/payment/gateways/dummy/__init__.py @@ -0,0 +1,107 @@ +import uuid + +from ... import ChargeStatus, TransactionKind +from ...interface import GatewayConfig, GatewayResponse, PaymentData +from .forms import DummyPaymentForm + + +def dummy_success(): + return True + + +def get_client_token(**_): + return str(uuid.uuid4()) + + +def create_form(data, payment_information, connection_params): + return DummyPaymentForm(data=data) + + +def authorize( + payment_information: PaymentData, config: GatewayConfig +) -> GatewayResponse: + success = dummy_success() + error = None + if not success: + error = "Unable to authorize transaction" + return GatewayResponse( + is_success=success, + kind=TransactionKind.AUTH, + amount=payment_information.amount, + currency=payment_information.currency, + transaction_id=payment_information.token, + error=error, + ) + + +def void(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + error = None + success = dummy_success() + if not success: + error = "Unable to void the transaction." + return GatewayResponse( + is_success=success, + kind=TransactionKind.VOID, + amount=payment_information.amount, + currency=payment_information.currency, + transaction_id=payment_information.token, + error=error, + ) + + +def capture(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + """Perform capture transaction.""" + error = None + success = dummy_success() + if not success: + error = "Unable to process capture" + + return GatewayResponse( + is_success=success, + kind=TransactionKind.CAPTURE, + amount=payment_information.amount, + currency=payment_information.currency, + transaction_id=payment_information.token, + error=error, + ) + + +def refund(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + error = None + success = dummy_success() + if not success: + error = "Unable to process refund" + return GatewayResponse( + is_success=success, + kind=TransactionKind.REFUND, + amount=payment_information.amount, + currency=payment_information.currency, + transaction_id=payment_information.token, + error=error, + ) + + +def process_payment( + payment_information: PaymentData, config: GatewayConfig +) -> GatewayResponse: + """Process the payment.""" + token = payment_information.token + + # Process payment normally if payment token is valid + if token not in dict(ChargeStatus.CHOICES): + return capture(payment_information, config) + + # Process payment by charge status which is selected in the payment form + # Note that is for testing by dummy gateway only + charge_status = token + authorize_response = authorize(payment_information, config) + if charge_status == ChargeStatus.NOT_CHARGED: + return authorize_response + + if not config.auto_capture: + return authorize_response + + capture_response = capture(payment_information, config) + if charge_status == ChargeStatus.FULLY_REFUNDED: + return refund(payment_information, config) + return capture_response diff --git a/payment/gateways/dummy/forms.py b/payment/gateways/dummy/forms.py new file mode 100644 index 0000000..4aaa438 --- /dev/null +++ b/payment/gateways/dummy/forms.py @@ -0,0 +1,40 @@ +from django import forms +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ + +from ... import ChargeStatus + + +class DummyPaymentForm(forms.Form): + charge_status = forms.ChoiceField( + label=pgettext_lazy("Payment status form field", "Payment status"), + choices=ChargeStatus.CHOICES, + initial=ChargeStatus.NOT_CHARGED, + widget=forms.RadioSelect, + ) + + def clean(self): + cleaned_data = super(DummyPaymentForm, self).clean() + + # Partially refunded is not supported directly + # since only last transaction of call_gateway will be processed + charge_status = cleaned_data["charge_status"] + if charge_status in [ + ChargeStatus.PARTIALLY_CHARGED, + ChargeStatus.PARTIALLY_REFUNDED, + ]: + raise forms.ValidationError( + _( + "Setting charge status to {} directly " + "is not supported. Please use the dashboard to " + "refund partially.".format(charge_status) + ), + code="invalid_charge_status", + ) + + return cleaned_data + + def get_payment_token(self): + """Return selected charge status instead of token for testing only. + Gateways used for production should return an actual token instead.""" + charge_status = self.cleaned_data["charge_status"] + return charge_status diff --git a/payment/gateways/stripe/__init__.py b/payment/gateways/stripe/__init__.py new file mode 100644 index 0000000..a5528b7 --- /dev/null +++ b/payment/gateways/stripe/__init__.py @@ -0,0 +1,227 @@ +import stripe +from typing import Dict + +from . import connect +from .forms import StripePaymentModalForm +from .utils import ( + get_amount_for_stripe, + get_amount_from_stripe, + get_currency_for_stripe, + get_currency_from_stripe, + get_payment_billing_fullname, + shipping_to_stripe_dict, +) +from ... import TransactionKind +from ...interface import GatewayConfig, GatewayResponse, PaymentData + + +def get_client_token(**_): + """Not implemented for stripe gateway currently. The client token can be + generated by Stripe's checkout.js or stripe.js automatically. + """ + return + + +def authorize( + payment_information: PaymentData, config: GatewayConfig, should_capture: bool = False +) -> GatewayResponse: + client, error = _get_client(**config.connection_params), None + + try: + # Authorize with/without capture + response = _create_stripe_charge( + client=client, + payment_information=payment_information, + should_capture=should_capture, + ) + except stripe.error.StripeError as exc: + response = _get_error_response_from_exc(exc) + error = exc.user_message + + kind = TransactionKind.CAPTURE if should_capture else TransactionKind.AUTH + + # Create response + return _create_response( # type: ignore + payment_information=payment_information, + kind=kind, + response=response, + error=error, + ) + + +def capture(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + client, error = _get_client(**config.connection_params), None + + # Get amount from argument or payment, and convert to stripe's amount + amount = payment_information.amount + stripe_amount = get_amount_for_stripe(amount, payment_information.currency) + + try: + # Retrieve stripe charge and capture specific amount + stripe_charge = client.Charge.retrieve(payment_information.token) + response = stripe_charge.capture(amount=stripe_amount) + except stripe.error.StripeError as exc: + response = _get_error_response_from_exc(exc) + error = exc.user_message + + # Create response + return _create_response( # type: ignore + payment_information=payment_information, + kind=TransactionKind.CAPTURE, + response=response, + error=error + ) + + +def refund(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + client, error = _get_client(**config.connection_params), None + + # Get amount from payment, and convert to stripe's amount + amount = payment_information.amount + stripe_amount = get_amount_for_stripe(amount, payment_information.currency) + + try: + # Retrieve stripe charge and refund specific amount + stripe_charge = client.Charge.retrieve(payment_information.token) + response = client.Refund.create(charge=stripe_charge.id, amount=stripe_amount) + except stripe.error.StripeError as exc: + response = _get_error_response_from_exc(exc) + error = exc.user_message + + # Create response + return _create_response( # type:ignore + payment_information=payment_information, + kind=TransactionKind.REFUND, + response=response, + error=error, + ) + + +def void(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + client, error = _get_client(**config.connection_params), None + + try: + # Retrieve stripe charge and refund all + stripe_charge = client.Charge.retrieve(payment_information.token) + response = client.Refund.create(charge=stripe_charge.id) + except stripe.error.StripeError as exc: + response = _get_error_response_from_exc(exc) + error = exc.user_message + + # Create response + return _create_response( # type:ignore + payment_information=payment_information, + kind=TransactionKind.VOID, + response=response, + error=error, + ) + + +def process_payment( + payment_information: PaymentData, config: GatewayConfig +) -> GatewayResponse: + # Stripe supports capture during authorize process. No need to run other steps. + return authorize(payment_information=payment_information, + config=config, + should_capture=True) + + +def create_form( + data: Dict, payment_information: PaymentData, connection_params: Dict +) -> StripePaymentModalForm: + return StripePaymentModalForm( + data=data, + payment_information=payment_information, + gateway_params=connection_params, + ) + + +def _get_client(**connection_params): + stripe.api_key = connection_params.get("secret_key") + return stripe + + +def _get_stripe_charge_payload( + payment_information: PaymentData, should_capture: bool +) -> Dict: + shipping = payment_information.shipping + + # Get currency + currency = get_currency_for_stripe(payment_information.currency) + + # Get appropriate amount for stripe + stripe_amount = get_amount_for_stripe(payment_information.amount, currency) + + # Get billing name from payment + name = get_payment_billing_fullname(payment_information) + + # Construct the charge payload from the data + charge_payload = { + "capture": should_capture, + "amount": stripe_amount, + "currency": currency, + "source": payment_information.token, + "description": name, + "metadata": payment_information.metadata, + } + + if shipping: + # Update shipping address to prevent fraud in Stripe + charge_payload["shipping"] = { + "name": name, + "address": shipping_to_stripe_dict(shipping), + } + + return charge_payload + + +def _create_stripe_charge(client, payment_information, should_capture: bool): + """Create a charge with specific amount, ignoring payment's total.""" + charge_payload = _get_stripe_charge_payload(payment_information, should_capture) + connect.maybe_add_transfer_data(charge_payload) + return client.Charge.create(**charge_payload) + + +def _create_response( + payment_information: PaymentData, kind: str, response: Dict, error: str +) -> GatewayResponse: + # Get currency from response or payment + currency = get_currency_from_stripe( + response.get("currency", payment_information.currency) + ) + + amount = payment_information.amount + # Get amount from response or payment + if "amount" in response: + stripe_amount = response.get("amount") + if "amount_refunded" in response: + # This happens for partial catpure which will refund the left + # Then the actual amount should minus refunded amount + stripe_amount -= response.get("amount_refunded") # type:ignore + amount = get_amount_from_stripe(stripe_amount, currency) + + # Get token from response or use provided one + token = response.get("id", payment_information.token) + + # Check if the response's status is flagged as succeeded + is_success = response.get("status") == "succeeded" + return GatewayResponse( + is_success=is_success, + transaction_id=token, + kind=kind, + amount=amount, + currency=currency, + error=error, + raw_response=response, + ) + + +def _get_error_response_from_exc(exc): + response = exc.json_body + + # Some errors from stripe don't json_body as None + # such as stripe.error.InvalidRequestError + if response is None: + response = dict() + + return response diff --git a/payment/gateways/stripe/connect.py b/payment/gateways/stripe/connect.py new file mode 100644 index 0000000..b8e3b2d --- /dev/null +++ b/payment/gateways/stripe/connect.py @@ -0,0 +1,31 @@ +from django.conf import settings +from structlog import get_logger + +logger = get_logger() + + +def add_transfer_data(charge_payload, destination, percent): + full_amount = charge_payload['amount'] + transfer_amount = int(full_amount * percent / 100) + charge_payload['transfer_data'] = { + 'destination': destination, + 'amount': transfer_amount + } + logger.debug('stripe_connect_maybe_add_transfer_data', + full_amount=full_amount, + transfer_amount=transfer_amount, + destination=destination) + + +def maybe_add_transfer_data(charge_payload): + if hasattr(settings, 'STRIPE_CONNECT'): + _connect_settings = settings.STRIPE_CONNECT + _transfer_destination = _connect_settings['transfer_destination'] + _transfer_percent_string = _connect_settings['transfer_percent'] + try: + _transfer_percent = int(_transfer_percent_string) + except ValueError: + raise Exception("STRIPE_TRANSFER_PERCENT should be an int") + if _transfer_percent < 0 or _transfer_percent > 100: + raise Exception("STRIPE_TRANSFER_PERCENT should be between 0 and 100") + add_transfer_data(charge_payload=charge_payload, destination=_transfer_destination, percent=_transfer_percent) diff --git a/payment/gateways/stripe/errors.py b/payment/gateways/stripe/errors.py new file mode 100644 index 0000000..90d803b --- /dev/null +++ b/payment/gateways/stripe/errors.py @@ -0,0 +1,6 @@ +from django.utils.translation import pgettext_lazy + +ORDER_NOT_AUTHORIZED = pgettext_lazy( + "Stripe payment error", "Order was not authorized." +) +ORDER_NOT_CHARGED = pgettext_lazy("Stripe payment error", "Order was not charged.") diff --git a/payment/gateways/stripe/forms.py b/payment/gateways/stripe/forms.py new file mode 100644 index 0000000..e629213 --- /dev/null +++ b/payment/gateways/stripe/forms.py @@ -0,0 +1,84 @@ +from typing import Dict + +from django import forms +from django.forms.utils import flatatt +from django.forms.widgets import HiddenInput +from django.utils.html import format_html +from django.utils.translation import pgettext_lazy + +from ...interface import PaymentData +from .utils import get_amount_for_stripe + +CHECKOUT_SCRIPT_URL = "https://checkout.stripe.com/checkout.js" +CHECKOUT_DESCRIPTION = pgettext_lazy( + "Stripe payment gateway description", "Total payment" +) + + +class StripeCheckoutWidget(HiddenInput): + def __init__( + self, payment_information: PaymentData, gateway_params: Dict, *args, **kwargs + ): + attrs = kwargs.get("attrs", {}) + kwargs["attrs"] = { + "class": "stripe-button", + "src": CHECKOUT_SCRIPT_URL, + "data-key": gateway_params.get("public_key"), + "data-amount": get_amount_for_stripe( + payment_information.amount, payment_information.currency + ), + "data-name": gateway_params.get("store_name"), + "data-image": gateway_params.get("store_image"), + "data-description": CHECKOUT_DESCRIPTION, + "data-currency": payment_information.currency, + "data-locale": gateway_params.get("locale"), + "data-allow-remember-me": "true" + if gateway_params.get("remember_me") + else "false", + "data-billing-address": "true" + if gateway_params.get("enable_billing_address") + else "false", + "data-zip-code": "true" + if gateway_params.get("enable_billing_address") + else "false", + "data-shipping-address": "true" + if gateway_params.get("enable_shipping_address") + else "false", + } + + if gateway_params.get("prefill"): + kwargs["attrs"].update({"data-email": payment_information.customer_email}) + + kwargs["attrs"].update(attrs) + super(StripeCheckoutWidget, self).__init__(*args, **kwargs) + + def render(self, name=None, value=None, attrs=None, renderer=None): + attrs = attrs or {} + attrs.update(self.attrs) + attrs.pop("id", None) + return format_html("", flatatt(attrs)) + + +class StripePaymentModalForm(forms.Form): + """ + At this moment partial-payment is not supported, but there is no need to + validate amount, which may be manually adjusted in the template, + since checkout.js can do that automatically. + """ + + stripeToken = forms.CharField(required=True, widget=HiddenInput) + + def __init__( + self, payment_information: PaymentData, gateway_params: Dict, *args, **kwargs + ): + super().__init__(*args, **kwargs) + + self.fields["stripe"] = forms.CharField( + widget=StripeCheckoutWidget( + payment_information=payment_information, gateway_params=gateway_params + ), + required=False, + ) + + def get_payment_token(self): + return self.cleaned_data["stripeToken"] diff --git a/payment/gateways/stripe/utils.py b/payment/gateways/stripe/utils.py new file mode 100644 index 0000000..6aa6691 --- /dev/null +++ b/payment/gateways/stripe/utils.py @@ -0,0 +1,95 @@ +from decimal import Decimal +from typing import Dict + +from django_countries import countries + +# List of zero-decimal currencies +# Since there is no public API in Stripe backend or helper function +# in Stripe's Python library, this list is straight out of Stripe's docs +# https://stripe.com/docs/currencies#zero-decimal +from ...interface import AddressData, PaymentData + +ZERO_DECIMAL_CURRENCIES = [ + "BIF", + "CLP", + "DJF", + "GNF", + "JPY", + "KMF", + "KRW", + "MGA", + "PYG", + "RWF", + "UGX", + "VND", + "VUV", + "XAF", + "XOF", + "XPF", +] + + +def get_amount_for_stripe(amount, currency): + """Get appropriate amount for stripe. + Stripe is using currency's smallest unit such as cents for USD and + stripe requires integer instead of decimal, so multiplying by 100 + and converting to integer is required. But for zero-decimal currencies, + multiplying by 100 is not needed. + """ + # Multiply by 100 for non-zero-decimal currencies + if currency.upper() not in ZERO_DECIMAL_CURRENCIES: + amount *= 100 + + # Using int(Decimal) directly may yield wrong result + # such as int(Decimal(24.24)*100) will equal to 2423 + return int(amount.to_integral_value()) + + +def get_amount_from_stripe(amount, currency): + """Get appropriate amount from stripe.""" + amount = Decimal(amount) + + # Divide by 100 for non-zero-decimal currencies + if currency.upper() not in ZERO_DECIMAL_CURRENCIES: + # Using Decimal(amount / 100.0) will convert to decimal from float + # where precision may be lost + amount /= Decimal(100) + + return amount + + +def get_currency_for_stripe(currency): + """Convert Saleor's currency format to Stripe's currency format. + Stripe's currency is using lowercase while Saleor is using uppercase. + """ + return currency.lower() + + +def get_currency_from_stripe(currency): + """Convert Stripe's currency format to Saleor's currency format. + Stripe's currency is using lowercase while Saleor is using uppercase. + """ + return currency.upper() + + +def get_payment_billing_fullname(payment_information: PaymentData) -> str: + # Get billing name from payment + # NWO + return payment_information.customer_email + """ + return "%s %s" % ( + payment_information.billing.last_name, + payment_information.billing.first_name, + ) + """ + + +def shipping_to_stripe_dict(shipping: AddressData) -> Dict: + return { + "line1": shipping.street_address_1, + "line2": shipping.street_address_2, + "city": shipping.city, + "state": shipping.country_area, + "postal_code": shipping.postal_code, + "country": dict(countries).get(shipping.country, ""), + } diff --git a/payment/interface.py b/payment/interface.py new file mode 100644 index 0000000..4f7ae6c --- /dev/null +++ b/payment/interface.py @@ -0,0 +1,64 @@ +from decimal import Decimal +from typing import Any, Dict, Optional + +from dataclasses import dataclass + + +@dataclass +class GatewayResponse: + """Dataclass for storing gateway response. Used for unifying the + representation of gateway response. It is required to communicate between + Saleor and given payment gateway.""" + + is_success: bool + kind: str + amount: Decimal + currency: str + transaction_id: str + error: Optional[str] + raw_response: Optional[Dict[str, str]] = None + + +@dataclass +class AddressData: + first_name: str + last_name: str + company_name: str + street_address_1: str + street_address_2: str + city: str + city_area: str + postal_code: str + country: str + country_area: str + phone: str + + +@dataclass +class PaymentData: + """Dataclass for storing all payment information. Used for unifying the + representation of data. It is required to communicate between Saleor and + given payment gateway.""" + + token: str + amount: Decimal + currency: str + billing: Optional[AddressData] + shipping: Optional[AddressData] + order_id: Optional[int] + customer_ip_address: str + customer_email: str + metadata: Dict[str, str] + + +@dataclass +class GatewayConfig: + """Dataclass for storing gateway config data. Used for unifying the + representation of config data. It is required to communicate between + Saleor and given payment gateway.""" + + auto_capture: bool + template_path: str + # Each gateway has different connection data so we are not able to create + # a unified structure + connection_params: Dict[str, Any] diff --git a/payment/locale/fr/LC_MESSAGES/django.mo b/payment/locale/fr/LC_MESSAGES/django.mo new file mode 100644 index 0000000..a542755 Binary files /dev/null and b/payment/locale/fr/LC_MESSAGES/django.mo differ diff --git a/payment/locale/fr/LC_MESSAGES/django.po b/payment/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..009fd4b --- /dev/null +++ b/payment/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,208 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-07-29 07:50-0500\n" +"PO-Revision-Date: 2019-07-29 12:51+0000\n" +"Last-Translator: b' '\n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Translated-Using: django-rosetta 0.9.3\n" + +#: __init__.py:24 +msgctxt "Custom payment choice type" +msgid "Manual" +msgstr "Manuel" + +#: __init__.py:70 +msgctxt "transaction kind" +msgid "Authorization" +msgstr "Autorisation" + +#: __init__.py:71 +msgctxt "transaction kind" +msgid "Refund" +msgstr "Remboursement" + +#: __init__.py:72 +msgctxt "transaction kind" +msgid "Capture" +msgstr "Capture" + +#: __init__.py:73 +msgctxt "transaction kind" +msgid "Void" +msgstr "Annulation" + +#: __init__.py:97 +msgctxt "payment status" +msgid "Not charged" +msgstr "Non payé" + +#: __init__.py:98 +msgctxt "payment status" +msgid "Partially charged" +msgstr "Payé en partie" + +#: __init__.py:99 +msgctxt "payment status" +msgid "Fully charged" +msgstr "Payé" + +#: __init__.py:100 +msgctxt "payment status" +msgid "Partially refunded" +msgstr "Remboursé en partie " + +#: __init__.py:101 +msgctxt "payment status" +msgid "Fully refunded" +msgstr "Remboursé" + +#: admin.py:17 models.py:228 +msgid "amount" +msgstr "montant" + +#: admin.py:25 models.py:97 models.py:222 +msgid "created" +msgstr "créé" + +#: admin.py:33 models.py:98 +msgid "modified" +msgstr "modifié" + +#: admin.py:143 models.py:102 +msgid "total" +msgstr "total" + +#: admin.py:149 models.py:103 +msgid "captured amount" +msgstr "montant capturé" + +#: apps.py:7 +msgid "Payment" +msgstr "Paiement" + +#: gateways/dummy/forms.py:9 +msgctxt "Payment status form field" +msgid "Payment status" +msgstr "Status paiement" + +#: gateways/dummy/forms.py:27 +msgid "" +"Setting charge status to {} directly is not supported. Please use the " +"dashboard to refund partially." +msgstr "" +"Il n'est pas possible de mettre le status a {} directement. \n" +"Utilisez la console pour un remboursement partiel." + +#: gateways/stripe/errors.py:4 +msgctxt "Stripe payment error" +msgid "Order was not authorized." +msgstr "L'ordre n'a pas été autorisé" + +#: gateways/stripe/errors.py:6 +msgctxt "Stripe payment error" +msgid "Order was not charged." +msgstr "L'ordre n'a pas été payé" + +#: gateways/stripe/forms.py:14 +msgctxt "Stripe payment gateway description" +msgid "Total payment" +msgstr "Paiement total" + +#: models.py:95 +msgid "gateway" +msgstr "passerelle" + +#: models.py:96 +msgid "is_active" +msgstr "actif?" + +#: models.py:99 +msgid "charge status" +msgstr "Status paiement" + +#: models.py:101 models.py:225 +msgid "token" +msgstr "jeton" + +#: models.py:105 +msgid "cc first digits" +msgstr "cc premiers chiffres" + +#: models.py:106 +msgid "cc last digits" +msgstr "cc derniers chiffres" + +#: models.py:107 +msgid "cc brand" +msgstr "cc marque" + +#: models.py:108 +msgid "cc exp month" +msgstr "cc mois d'expiration" + +#: models.py:111 +msgid "cc exp year" +msgstr "cc année d'expiration" + +#: models.py:114 +msgid "customer email" +msgstr "email client" + +#: models.py:116 +msgid "customer ip address" +msgstr "adresse ip client" + +#: models.py:117 +msgid "extra data" +msgstr "données supplémentaires" + +#: models.py:120 models.py:224 +msgid "payment" +msgstr "paiement" + +#: models.py:121 +msgid "payments" +msgstr "paiements" + +#: models.py:125 +#, fuzzy +#| msgid "Payment %s (%s)" +msgid "Payment {} ({})" +msgstr "Paiement {} ({})" + +#: models.py:226 +msgid "kind" +msgstr "type" + +#: models.py:227 +msgid "is success" +msgstr "succès?" + +#: models.py:229 +msgid "error" +msgstr "erreur" + +#: models.py:230 +msgid "gateway response" +msgstr "réponse passerelle" + +#: models.py:233 +msgid "transaction" +msgstr "transaction" + +#: models.py:234 +msgid "transactions" +msgstr "transactions" diff --git a/payment/migrations/0001_initial.py b/payment/migrations/0001_initial.py new file mode 100644 index 0000000..27da7bf --- /dev/null +++ b/payment/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 2.2.3 on 2019-07-29 06:00 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('gateway', models.CharField(max_length=255, verbose_name='gateway')), + ('is_active', models.BooleanField(default=True, verbose_name='is_active')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('charge_status', models.CharField(choices=[('not-charged', 'Not charged'), ('partially-charged', 'Partially charged'), ('fully-charged', 'Fully charged'), ('partially-refunded', 'Partially refunded'), ('fully-refunded', 'Fully refunded')], default='not-charged', max_length=20, verbose_name='charge status')), + ('token', models.CharField(blank=True, default='', max_length=128, verbose_name='token')), + ('total_currency', djmoney.models.fields.CurrencyField(choices=[('XUA', 'ADB Unit of Account'), ('AFN', 'Afghani'), ('DZD', 'Algerian Dinar'), ('ARS', 'Argentine Peso'), ('AMD', 'Armenian Dram'), ('AWG', 'Aruban Guilder'), ('AUD', 'Australian Dollar'), ('AZN', 'Azerbaijanian Manat'), ('BSD', 'Bahamian Dollar'), ('BHD', 'Bahraini Dinar'), ('THB', 'Baht'), ('PAB', 'Balboa'), ('BBD', 'Barbados Dollar'), ('BYN', 'Belarussian Ruble'), ('BYR', 'Belarussian Ruble'), ('BZD', 'Belize Dollar'), ('BMD', 'Bermudian Dollar (customarily known as Bermuda Dollar)'), ('BTN', 'Bhutanese ngultrum'), ('VEF', 'Bolivar Fuerte'), ('BOB', 'Boliviano'), ('XBA', 'Bond Markets Units European Composite Unit (EURCO)'), ('BRL', 'Brazilian Real'), ('BND', 'Brunei Dollar'), ('BGN', 'Bulgarian Lev'), ('BIF', 'Burundi Franc'), ('XOF', 'CFA Franc BCEAO'), ('XAF', 'CFA franc BEAC'), ('XPF', 'CFP Franc'), ('CAD', 'Canadian Dollar'), ('CVE', 'Cape Verde Escudo'), ('KYD', 'Cayman Islands Dollar'), ('CLP', 'Chilean peso'), ('XTS', 'Codes specifically reserved for testing purposes'), ('COP', 'Colombian peso'), ('KMF', 'Comoro Franc'), ('CDF', 'Congolese franc'), ('BAM', 'Convertible Marks'), ('NIO', 'Cordoba Oro'), ('CRC', 'Costa Rican Colon'), ('HRK', 'Croatian Kuna'), ('CUP', 'Cuban Peso'), ('CUC', 'Cuban convertible peso'), ('CZK', 'Czech Koruna'), ('GMD', 'Dalasi'), ('DKK', 'Danish Krone'), ('MKD', 'Denar'), ('DJF', 'Djibouti Franc'), ('STD', 'Dobra'), ('DOP', 'Dominican Peso'), ('VND', 'Dong'), ('XCD', 'East Caribbean Dollar'), ('EGP', 'Egyptian Pound'), ('SVC', 'El Salvador Colon'), ('ETB', 'Ethiopian Birr'), ('EUR', 'Euro'), ('XBB', 'European Monetary Unit (E.M.U.-6)'), ('XBD', 'European Unit of Account 17(E.U.A.-17)'), ('XBC', 'European Unit of Account 9(E.U.A.-9)'), ('FKP', 'Falkland Islands Pound'), ('FJD', 'Fiji Dollar'), ('HUF', 'Forint'), ('GHS', 'Ghana Cedi'), ('GIP', 'Gibraltar Pound'), ('XAU', 'Gold'), ('XFO', 'Gold-Franc'), ('PYG', 'Guarani'), ('GNF', 'Guinea Franc'), ('GYD', 'Guyana Dollar'), ('HTG', 'Haitian gourde'), ('HKD', 'Hong Kong Dollar'), ('UAH', 'Hryvnia'), ('ISK', 'Iceland Krona'), ('INR', 'Indian Rupee'), ('IRR', 'Iranian Rial'), ('IQD', 'Iraqi Dinar'), ('IMP', 'Isle of Man Pound'), ('JMD', 'Jamaican Dollar'), ('JOD', 'Jordanian Dinar'), ('KES', 'Kenyan Shilling'), ('PGK', 'Kina'), ('LAK', 'Kip'), ('KWD', 'Kuwaiti Dinar'), ('AOA', 'Kwanza'), ('MMK', 'Kyat'), ('GEL', 'Lari'), ('LVL', 'Latvian Lats'), ('LBP', 'Lebanese Pound'), ('ALL', 'Lek'), ('HNL', 'Lempira'), ('SLL', 'Leone'), ('LSL', 'Lesotho loti'), ('LRD', 'Liberian Dollar'), ('LYD', 'Libyan Dinar'), ('SZL', 'Lilangeni'), ('LTL', 'Lithuanian Litas'), ('MGA', 'Malagasy Ariary'), ('MWK', 'Malawian Kwacha'), ('MYR', 'Malaysian Ringgit'), ('TMM', 'Manat'), ('MUR', 'Mauritius Rupee'), ('MZN', 'Metical'), ('MXV', 'Mexican Unidad de Inversion (UDI)'), ('MXN', 'Mexican peso'), ('MDL', 'Moldovan Leu'), ('MAD', 'Moroccan Dirham'), ('BOV', 'Mvdol'), ('NGN', 'Naira'), ('ERN', 'Nakfa'), ('NAD', 'Namibian Dollar'), ('NPR', 'Nepalese Rupee'), ('ANG', 'Netherlands Antillian Guilder'), ('ILS', 'New Israeli Sheqel'), ('RON', 'New Leu'), ('TWD', 'New Taiwan Dollar'), ('NZD', 'New Zealand Dollar'), ('KPW', 'North Korean Won'), ('NOK', 'Norwegian Krone'), ('PEN', 'Nuevo Sol'), ('MRO', 'Ouguiya'), ('TOP', 'Paanga'), ('PKR', 'Pakistan Rupee'), ('XPD', 'Palladium'), ('MOP', 'Pataca'), ('PHP', 'Philippine Peso'), ('XPT', 'Platinum'), ('GBP', 'Pound Sterling'), ('BWP', 'Pula'), ('QAR', 'Qatari Rial'), ('GTQ', 'Quetzal'), ('ZAR', 'Rand'), ('OMR', 'Rial Omani'), ('KHR', 'Riel'), ('MVR', 'Rufiyaa'), ('IDR', 'Rupiah'), ('RUB', 'Russian Ruble'), ('RWF', 'Rwanda Franc'), ('XDR', 'SDR'), ('SHP', 'Saint Helena Pound'), ('SAR', 'Saudi Riyal'), ('RSD', 'Serbian Dinar'), ('SCR', 'Seychelles Rupee'), ('XAG', 'Silver'), ('SGD', 'Singapore Dollar'), ('SBD', 'Solomon Islands Dollar'), ('KGS', 'Som'), ('SOS', 'Somali Shilling'), ('TJS', 'Somoni'), ('SSP', 'South Sudanese Pound'), ('LKR', 'Sri Lanka Rupee'), ('XSU', 'Sucre'), ('SDG', 'Sudanese Pound'), ('SRD', 'Surinam Dollar'), ('SEK', 'Swedish Krona'), ('CHF', 'Swiss Franc'), ('SYP', 'Syrian Pound'), ('BDT', 'Taka'), ('WST', 'Tala'), ('TZS', 'Tanzanian Shilling'), ('KZT', 'Tenge'), ('XXX', 'The codes assigned for transactions where no currency is involved'), ('TTD', 'Trinidad and Tobago Dollar'), ('MNT', 'Tugrik'), ('TND', 'Tunisian Dinar'), ('TRY', 'Turkish Lira'), ('TMT', 'Turkmenistan New Manat'), ('TVD', 'Tuvalu dollar'), ('AED', 'UAE Dirham'), ('XFU', 'UIC-Franc'), ('USD', 'US Dollar'), ('USN', 'US Dollar (Next day)'), ('UGX', 'Uganda Shilling'), ('CLF', 'Unidad de Fomento'), ('COU', 'Unidad de Valor Real'), ('UYI', 'Uruguay Peso en Unidades Indexadas (URUIURUI)'), ('UYU', 'Uruguayan peso'), ('UZS', 'Uzbekistan Sum'), ('VUV', 'Vatu'), ('CHE', 'WIR Euro'), ('CHW', 'WIR Franc'), ('KRW', 'Won'), ('YER', 'Yemeni Rial'), ('JPY', 'Yen'), ('CNY', 'Yuan Renminbi'), ('ZMK', 'Zambian Kwacha'), ('ZMW', 'Zambian Kwacha'), ('ZWD', 'Zimbabwe Dollar A/06'), ('ZWN', 'Zimbabwe dollar A/08'), ('ZWL', 'Zimbabwe dollar A/09'), ('PLN', 'Zloty')], default='XYZ', editable=False, max_length=3)), + ('total', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=12, verbose_name='total')), + ('captured_amount_currency', djmoney.models.fields.CurrencyField(choices=[('XUA', 'ADB Unit of Account'), ('AFN', 'Afghani'), ('DZD', 'Algerian Dinar'), ('ARS', 'Argentine Peso'), ('AMD', 'Armenian Dram'), ('AWG', 'Aruban Guilder'), ('AUD', 'Australian Dollar'), ('AZN', 'Azerbaijanian Manat'), ('BSD', 'Bahamian Dollar'), ('BHD', 'Bahraini Dinar'), ('THB', 'Baht'), ('PAB', 'Balboa'), ('BBD', 'Barbados Dollar'), ('BYN', 'Belarussian Ruble'), ('BYR', 'Belarussian Ruble'), ('BZD', 'Belize Dollar'), ('BMD', 'Bermudian Dollar (customarily known as Bermuda Dollar)'), ('BTN', 'Bhutanese ngultrum'), ('VEF', 'Bolivar Fuerte'), ('BOB', 'Boliviano'), ('XBA', 'Bond Markets Units European Composite Unit (EURCO)'), ('BRL', 'Brazilian Real'), ('BND', 'Brunei Dollar'), ('BGN', 'Bulgarian Lev'), ('BIF', 'Burundi Franc'), ('XOF', 'CFA Franc BCEAO'), ('XAF', 'CFA franc BEAC'), ('XPF', 'CFP Franc'), ('CAD', 'Canadian Dollar'), ('CVE', 'Cape Verde Escudo'), ('KYD', 'Cayman Islands Dollar'), ('CLP', 'Chilean peso'), ('XTS', 'Codes specifically reserved for testing purposes'), ('COP', 'Colombian peso'), ('KMF', 'Comoro Franc'), ('CDF', 'Congolese franc'), ('BAM', 'Convertible Marks'), ('NIO', 'Cordoba Oro'), ('CRC', 'Costa Rican Colon'), ('HRK', 'Croatian Kuna'), ('CUP', 'Cuban Peso'), ('CUC', 'Cuban convertible peso'), ('CZK', 'Czech Koruna'), ('GMD', 'Dalasi'), ('DKK', 'Danish Krone'), ('MKD', 'Denar'), ('DJF', 'Djibouti Franc'), ('STD', 'Dobra'), ('DOP', 'Dominican Peso'), ('VND', 'Dong'), ('XCD', 'East Caribbean Dollar'), ('EGP', 'Egyptian Pound'), ('SVC', 'El Salvador Colon'), ('ETB', 'Ethiopian Birr'), ('EUR', 'Euro'), ('XBB', 'European Monetary Unit (E.M.U.-6)'), ('XBD', 'European Unit of Account 17(E.U.A.-17)'), ('XBC', 'European Unit of Account 9(E.U.A.-9)'), ('FKP', 'Falkland Islands Pound'), ('FJD', 'Fiji Dollar'), ('HUF', 'Forint'), ('GHS', 'Ghana Cedi'), ('GIP', 'Gibraltar Pound'), ('XAU', 'Gold'), ('XFO', 'Gold-Franc'), ('PYG', 'Guarani'), ('GNF', 'Guinea Franc'), ('GYD', 'Guyana Dollar'), ('HTG', 'Haitian gourde'), ('HKD', 'Hong Kong Dollar'), ('UAH', 'Hryvnia'), ('ISK', 'Iceland Krona'), ('INR', 'Indian Rupee'), ('IRR', 'Iranian Rial'), ('IQD', 'Iraqi Dinar'), ('IMP', 'Isle of Man Pound'), ('JMD', 'Jamaican Dollar'), ('JOD', 'Jordanian Dinar'), ('KES', 'Kenyan Shilling'), ('PGK', 'Kina'), ('LAK', 'Kip'), ('KWD', 'Kuwaiti Dinar'), ('AOA', 'Kwanza'), ('MMK', 'Kyat'), ('GEL', 'Lari'), ('LVL', 'Latvian Lats'), ('LBP', 'Lebanese Pound'), ('ALL', 'Lek'), ('HNL', 'Lempira'), ('SLL', 'Leone'), ('LSL', 'Lesotho loti'), ('LRD', 'Liberian Dollar'), ('LYD', 'Libyan Dinar'), ('SZL', 'Lilangeni'), ('LTL', 'Lithuanian Litas'), ('MGA', 'Malagasy Ariary'), ('MWK', 'Malawian Kwacha'), ('MYR', 'Malaysian Ringgit'), ('TMM', 'Manat'), ('MUR', 'Mauritius Rupee'), ('MZN', 'Metical'), ('MXV', 'Mexican Unidad de Inversion (UDI)'), ('MXN', 'Mexican peso'), ('MDL', 'Moldovan Leu'), ('MAD', 'Moroccan Dirham'), ('BOV', 'Mvdol'), ('NGN', 'Naira'), ('ERN', 'Nakfa'), ('NAD', 'Namibian Dollar'), ('NPR', 'Nepalese Rupee'), ('ANG', 'Netherlands Antillian Guilder'), ('ILS', 'New Israeli Sheqel'), ('RON', 'New Leu'), ('TWD', 'New Taiwan Dollar'), ('NZD', 'New Zealand Dollar'), ('KPW', 'North Korean Won'), ('NOK', 'Norwegian Krone'), ('PEN', 'Nuevo Sol'), ('MRO', 'Ouguiya'), ('TOP', 'Paanga'), ('PKR', 'Pakistan Rupee'), ('XPD', 'Palladium'), ('MOP', 'Pataca'), ('PHP', 'Philippine Peso'), ('XPT', 'Platinum'), ('GBP', 'Pound Sterling'), ('BWP', 'Pula'), ('QAR', 'Qatari Rial'), ('GTQ', 'Quetzal'), ('ZAR', 'Rand'), ('OMR', 'Rial Omani'), ('KHR', 'Riel'), ('MVR', 'Rufiyaa'), ('IDR', 'Rupiah'), ('RUB', 'Russian Ruble'), ('RWF', 'Rwanda Franc'), ('XDR', 'SDR'), ('SHP', 'Saint Helena Pound'), ('SAR', 'Saudi Riyal'), ('RSD', 'Serbian Dinar'), ('SCR', 'Seychelles Rupee'), ('XAG', 'Silver'), ('SGD', 'Singapore Dollar'), ('SBD', 'Solomon Islands Dollar'), ('KGS', 'Som'), ('SOS', 'Somali Shilling'), ('TJS', 'Somoni'), ('SSP', 'South Sudanese Pound'), ('LKR', 'Sri Lanka Rupee'), ('XSU', 'Sucre'), ('SDG', 'Sudanese Pound'), ('SRD', 'Surinam Dollar'), ('SEK', 'Swedish Krona'), ('CHF', 'Swiss Franc'), ('SYP', 'Syrian Pound'), ('BDT', 'Taka'), ('WST', 'Tala'), ('TZS', 'Tanzanian Shilling'), ('KZT', 'Tenge'), ('XXX', 'The codes assigned for transactions where no currency is involved'), ('TTD', 'Trinidad and Tobago Dollar'), ('MNT', 'Tugrik'), ('TND', 'Tunisian Dinar'), ('TRY', 'Turkish Lira'), ('TMT', 'Turkmenistan New Manat'), ('TVD', 'Tuvalu dollar'), ('AED', 'UAE Dirham'), ('XFU', 'UIC-Franc'), ('USD', 'US Dollar'), ('USN', 'US Dollar (Next day)'), ('UGX', 'Uganda Shilling'), ('CLF', 'Unidad de Fomento'), ('COU', 'Unidad de Valor Real'), ('UYI', 'Uruguay Peso en Unidades Indexadas (URUIURUI)'), ('UYU', 'Uruguayan peso'), ('UZS', 'Uzbekistan Sum'), ('VUV', 'Vatu'), ('CHE', 'WIR Euro'), ('CHW', 'WIR Franc'), ('KRW', 'Won'), ('YER', 'Yemeni Rial'), ('JPY', 'Yen'), ('CNY', 'Yuan Renminbi'), ('ZMK', 'Zambian Kwacha'), ('ZMW', 'Zambian Kwacha'), ('ZWD', 'Zimbabwe Dollar A/06'), ('ZWN', 'Zimbabwe dollar A/08'), ('ZWL', 'Zimbabwe dollar A/09'), ('PLN', 'Zloty')], default='XYZ', editable=False, max_length=3)), + ('captured_amount', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=12, verbose_name='captured amount')), + ('cc_first_digits', models.CharField(blank=True, default='', max_length=6, verbose_name='cc first digits')), + ('cc_last_digits', models.CharField(blank=True, default='', max_length=4, verbose_name='cc last digits')), + ('cc_brand', models.CharField(blank=True, default='', max_length=40, verbose_name='cc brand')), + ('cc_exp_month', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(12)], verbose_name='cc exp month')), + ('cc_exp_year', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1000)], verbose_name='cc exp year')), + ('customer_email', models.EmailField(max_length=254, verbose_name='customer email')), + ('customer_ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='customer ip address')), + ('extra_data', models.TextField(blank=True, default='', verbose_name='extra data')), + ], + options={ + 'verbose_name': 'payment', + 'verbose_name_plural': 'payments', + 'ordering': ('pk',), + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('token', models.CharField(blank=True, default='', max_length=128, verbose_name='token')), + ('kind', models.CharField(choices=[('auth', 'Authorization'), ('refund', 'Refund'), ('capture', 'Capture'), ('void', 'Void')], max_length=10, verbose_name='kind')), + ('is_success', models.BooleanField(default=False, verbose_name='is success')), + ('amount_currency', djmoney.models.fields.CurrencyField(choices=[('XUA', 'ADB Unit of Account'), ('AFN', 'Afghani'), ('DZD', 'Algerian Dinar'), ('ARS', 'Argentine Peso'), ('AMD', 'Armenian Dram'), ('AWG', 'Aruban Guilder'), ('AUD', 'Australian Dollar'), ('AZN', 'Azerbaijanian Manat'), ('BSD', 'Bahamian Dollar'), ('BHD', 'Bahraini Dinar'), ('THB', 'Baht'), ('PAB', 'Balboa'), ('BBD', 'Barbados Dollar'), ('BYN', 'Belarussian Ruble'), ('BYR', 'Belarussian Ruble'), ('BZD', 'Belize Dollar'), ('BMD', 'Bermudian Dollar (customarily known as Bermuda Dollar)'), ('BTN', 'Bhutanese ngultrum'), ('VEF', 'Bolivar Fuerte'), ('BOB', 'Boliviano'), ('XBA', 'Bond Markets Units European Composite Unit (EURCO)'), ('BRL', 'Brazilian Real'), ('BND', 'Brunei Dollar'), ('BGN', 'Bulgarian Lev'), ('BIF', 'Burundi Franc'), ('XOF', 'CFA Franc BCEAO'), ('XAF', 'CFA franc BEAC'), ('XPF', 'CFP Franc'), ('CAD', 'Canadian Dollar'), ('CVE', 'Cape Verde Escudo'), ('KYD', 'Cayman Islands Dollar'), ('CLP', 'Chilean peso'), ('XTS', 'Codes specifically reserved for testing purposes'), ('COP', 'Colombian peso'), ('KMF', 'Comoro Franc'), ('CDF', 'Congolese franc'), ('BAM', 'Convertible Marks'), ('NIO', 'Cordoba Oro'), ('CRC', 'Costa Rican Colon'), ('HRK', 'Croatian Kuna'), ('CUP', 'Cuban Peso'), ('CUC', 'Cuban convertible peso'), ('CZK', 'Czech Koruna'), ('GMD', 'Dalasi'), ('DKK', 'Danish Krone'), ('MKD', 'Denar'), ('DJF', 'Djibouti Franc'), ('STD', 'Dobra'), ('DOP', 'Dominican Peso'), ('VND', 'Dong'), ('XCD', 'East Caribbean Dollar'), ('EGP', 'Egyptian Pound'), ('SVC', 'El Salvador Colon'), ('ETB', 'Ethiopian Birr'), ('EUR', 'Euro'), ('XBB', 'European Monetary Unit (E.M.U.-6)'), ('XBD', 'European Unit of Account 17(E.U.A.-17)'), ('XBC', 'European Unit of Account 9(E.U.A.-9)'), ('FKP', 'Falkland Islands Pound'), ('FJD', 'Fiji Dollar'), ('HUF', 'Forint'), ('GHS', 'Ghana Cedi'), ('GIP', 'Gibraltar Pound'), ('XAU', 'Gold'), ('XFO', 'Gold-Franc'), ('PYG', 'Guarani'), ('GNF', 'Guinea Franc'), ('GYD', 'Guyana Dollar'), ('HTG', 'Haitian gourde'), ('HKD', 'Hong Kong Dollar'), ('UAH', 'Hryvnia'), ('ISK', 'Iceland Krona'), ('INR', 'Indian Rupee'), ('IRR', 'Iranian Rial'), ('IQD', 'Iraqi Dinar'), ('IMP', 'Isle of Man Pound'), ('JMD', 'Jamaican Dollar'), ('JOD', 'Jordanian Dinar'), ('KES', 'Kenyan Shilling'), ('PGK', 'Kina'), ('LAK', 'Kip'), ('KWD', 'Kuwaiti Dinar'), ('AOA', 'Kwanza'), ('MMK', 'Kyat'), ('GEL', 'Lari'), ('LVL', 'Latvian Lats'), ('LBP', 'Lebanese Pound'), ('ALL', 'Lek'), ('HNL', 'Lempira'), ('SLL', 'Leone'), ('LSL', 'Lesotho loti'), ('LRD', 'Liberian Dollar'), ('LYD', 'Libyan Dinar'), ('SZL', 'Lilangeni'), ('LTL', 'Lithuanian Litas'), ('MGA', 'Malagasy Ariary'), ('MWK', 'Malawian Kwacha'), ('MYR', 'Malaysian Ringgit'), ('TMM', 'Manat'), ('MUR', 'Mauritius Rupee'), ('MZN', 'Metical'), ('MXV', 'Mexican Unidad de Inversion (UDI)'), ('MXN', 'Mexican peso'), ('MDL', 'Moldovan Leu'), ('MAD', 'Moroccan Dirham'), ('BOV', 'Mvdol'), ('NGN', 'Naira'), ('ERN', 'Nakfa'), ('NAD', 'Namibian Dollar'), ('NPR', 'Nepalese Rupee'), ('ANG', 'Netherlands Antillian Guilder'), ('ILS', 'New Israeli Sheqel'), ('RON', 'New Leu'), ('TWD', 'New Taiwan Dollar'), ('NZD', 'New Zealand Dollar'), ('KPW', 'North Korean Won'), ('NOK', 'Norwegian Krone'), ('PEN', 'Nuevo Sol'), ('MRO', 'Ouguiya'), ('TOP', 'Paanga'), ('PKR', 'Pakistan Rupee'), ('XPD', 'Palladium'), ('MOP', 'Pataca'), ('PHP', 'Philippine Peso'), ('XPT', 'Platinum'), ('GBP', 'Pound Sterling'), ('BWP', 'Pula'), ('QAR', 'Qatari Rial'), ('GTQ', 'Quetzal'), ('ZAR', 'Rand'), ('OMR', 'Rial Omani'), ('KHR', 'Riel'), ('MVR', 'Rufiyaa'), ('IDR', 'Rupiah'), ('RUB', 'Russian Ruble'), ('RWF', 'Rwanda Franc'), ('XDR', 'SDR'), ('SHP', 'Saint Helena Pound'), ('SAR', 'Saudi Riyal'), ('RSD', 'Serbian Dinar'), ('SCR', 'Seychelles Rupee'), ('XAG', 'Silver'), ('SGD', 'Singapore Dollar'), ('SBD', 'Solomon Islands Dollar'), ('KGS', 'Som'), ('SOS', 'Somali Shilling'), ('TJS', 'Somoni'), ('SSP', 'South Sudanese Pound'), ('LKR', 'Sri Lanka Rupee'), ('XSU', 'Sucre'), ('SDG', 'Sudanese Pound'), ('SRD', 'Surinam Dollar'), ('SEK', 'Swedish Krona'), ('CHF', 'Swiss Franc'), ('SYP', 'Syrian Pound'), ('BDT', 'Taka'), ('WST', 'Tala'), ('TZS', 'Tanzanian Shilling'), ('KZT', 'Tenge'), ('XXX', 'The codes assigned for transactions where no currency is involved'), ('TTD', 'Trinidad and Tobago Dollar'), ('MNT', 'Tugrik'), ('TND', 'Tunisian Dinar'), ('TRY', 'Turkish Lira'), ('TMT', 'Turkmenistan New Manat'), ('TVD', 'Tuvalu dollar'), ('AED', 'UAE Dirham'), ('XFU', 'UIC-Franc'), ('USD', 'US Dollar'), ('USN', 'US Dollar (Next day)'), ('UGX', 'Uganda Shilling'), ('CLF', 'Unidad de Fomento'), ('COU', 'Unidad de Valor Real'), ('UYI', 'Uruguay Peso en Unidades Indexadas (URUIURUI)'), ('UYU', 'Uruguayan peso'), ('UZS', 'Uzbekistan Sum'), ('VUV', 'Vatu'), ('CHE', 'WIR Euro'), ('CHW', 'WIR Franc'), ('KRW', 'Won'), ('YER', 'Yemeni Rial'), ('JPY', 'Yen'), ('CNY', 'Yuan Renminbi'), ('ZMK', 'Zambian Kwacha'), ('ZMW', 'Zambian Kwacha'), ('ZWD', 'Zimbabwe Dollar A/06'), ('ZWN', 'Zimbabwe dollar A/08'), ('ZWL', 'Zimbabwe dollar A/09'), ('PLN', 'Zloty')], default='XYZ', editable=False, max_length=3)), + ('amount', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=12, verbose_name='amount')), + ('error', models.CharField(blank=True, max_length=256, null=True, verbose_name='error')), + ('gateway_response', models.TextField(verbose_name='gateway response')), + ('payment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='payment.Payment', verbose_name='payment')), + ], + options={ + 'verbose_name': 'transaction', + 'verbose_name_plural': 'transactions', + 'ordering': ('pk',), + }, + ), + ] diff --git a/payment/migrations/0002_cascade_delete_transaction.py b/payment/migrations/0002_cascade_delete_transaction.py new file mode 100644 index 0000000..425e1c9 --- /dev/null +++ b/payment/migrations/0002_cascade_delete_transaction.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.3 on 2019-07-29 09:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='transaction', + name='payment', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='payment.Payment', verbose_name='payment'), + ), + ] diff --git a/payment/migrations/__init__.py b/payment/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payment/models.py b/payment/models.py new file mode 100644 index 0000000..661d9d6 --- /dev/null +++ b/payment/models.py @@ -0,0 +1,239 @@ +import json +from operator import attrgetter + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import CASCADE +from django.utils.translation import ugettext_lazy as _ +from djmoney.models.fields import MoneyField +from moneyed import Money +from typing import Optional, Dict + +from . import ( + ChargeStatus, + CustomPaymentChoices, + TransactionKind, + get_payment_gateway, +) + +######################################################################################################## +# Credit Cards + +''' +def compute_expiry_date(two_digit_year: int, month: int) -> date: + year = 2000 + two_digit_year + _, last_day_of_month = calendar.monthrange(year, month) + return date(year=year, month=month, day=last_day_of_month) + + +class CreditCardQuerySet(models.QuerySet): + def valid(self, as_of: date = None): + if as_of is None: + as_of = date.today() + return self.filter(expiry_date__gte=as_of) + + +class CreditCard(models.Model): + ACTIVE = 'ACTIVE' + INACTIVE = 'INACTIVE' + STATUS_CHOICES = ( + (ACTIVE, pgettext_lazy('credit card', 'Active')), + (INACTIVE, pgettext_lazy('credit card', 'Inactive')), + ) + + created = models.DateTimeField(auto_now_add=True, db_index=True) + modified = models.DateTimeField(auto_now=True) + type = models.CharField(db_index=True, max_length=3) + number = models.CharField(max_length=255) + expiry_month = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(12)]) + expiry_year = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(99)]) + expiry_date = models.DateField() # A field in the database so we can search for expired cards + + status = FSMField(max_length=20, choices=STATUS_CHOICES, default=ACTIVE, db_index=True) + + objects = CreditCardQuerySet.as_manager() + + @transition(field=status, source=ACTIVE, target=INACTIVE) + def deactivate(self): + pass + + @transition(field=status, source=INACTIVE, target=ACTIVE) + def reactivate(self): + pass + + def is_valid(self, as_of: date = None): + if as_of is None: + as_of = datetime.now().date() + return self.expiry_date >= as_of + + def save(self, *args, **kwargs): + if self.expiry_year is not None and self.expiry_month is not None: + self.expiry_date = compute_expiry_date(two_digit_year=self.expiry_year, month=self.expiry_month) + super().save(*args, **kwargs) +''' + + +######################################################################################################## +# Payments + + +class Payment(models.Model): + """A model that represents a single payment. + + This might be a transactable payment information such as credit card + details, gift card information or a customer's authorization to charge + their PayPal account. + + All payment process related pieces of information are stored + at the gateway level, we are operating on the reusable token + which is a unique identifier of the customer for given gateway. + + Several payment methods can be used within a single order. Each payment + method may consist of multiple transactions. + """ + + gateway = models.CharField(_('gateway'), max_length=255) + is_active = models.BooleanField(_('is_active'), default=True) + created = models.DateTimeField(_('created'), auto_now_add=True) + modified = models.DateTimeField(_('modified'), auto_now=True) + charge_status = models.CharField(_('charge status'), max_length=20, choices=ChargeStatus.CHOICES, + default=ChargeStatus.NOT_CHARGED) + token = models.CharField(_('token'), max_length=128, blank=True, default="") + total = MoneyField(_('total'), max_digits=12, decimal_places=2) + captured_amount = MoneyField(_('captured amount'), max_digits=12, decimal_places=2) + + cc_first_digits = models.CharField(_('cc first digits'), max_length=6, blank=True, default="") + cc_last_digits = models.CharField(_('cc last digits'), max_length=4, blank=True, default="") + cc_brand = models.CharField(_('cc brand'), max_length=40, blank=True, default="") + cc_exp_month = models.PositiveIntegerField(_('cc exp month'), + validators=[MinValueValidator(1), MaxValueValidator(12)], null=True, + blank=True) + cc_exp_year = models.PositiveIntegerField(_('cc exp year'), validators=[MinValueValidator(1000)], null=True, + blank=True) + + customer_email = models.EmailField(_('customer email'), ) + + customer_ip_address = models.GenericIPAddressField(_('customer ip address'), blank=True, null=True) + extra_data = models.TextField(_('extra data'), blank=True, default="") + + class Meta: + verbose_name = _('payment') + verbose_name_plural = _('payments') + ordering = ("pk",) + + def __str__(self): + return _('Payment {} ({})').format(self.id, self.get_charge_status_display()) + + def __repr__(self): + return "Payment(gateway=%s, is_active=%s, created=%s, charge_status=%s)" % \ + (self.gateway, self.is_active, self.created, self.charge_status) + + def clean(self): + if self.captured_amount is None: + self.captured_amount = Money(0, self.total.currency) + + def get_last_transaction(self): + return max(self.transactions.all(), default=None, key=attrgetter("pk")) + + def get_authorized_amount(self): + money = Money(0, self.total.currency) + + # Query all the transactions which should be prefetched + # to optimize db queries + transactions = self.transactions.all() + + # There is no authorized amount anymore when capture is succeeded + # since capture can only be made once, even it is a partial capture + if any([txn.kind == TransactionKind.CAPTURE and txn.is_success for txn in transactions]): + return money + + # Filter the succeeded auth transactions + authorized_txns = [txn for txn in transactions if txn.kind == TransactionKind.AUTH and txn.is_success] + + for txn in authorized_txns: + money += txn.amount + + # If multiple partial capture is supported later though it's unlikely, + # the authorized amount should exclude the already captured amount here + return money + + def get_charge_amount(self): + """Retrieve the maximum capture possible.""" + return self.total - self.captured_amount + + @property + def is_authorized(self): + return any([txn.kind == TransactionKind.AUTH and txn.is_success for txn in self.transactions.all()]) + + @property + def not_charged(self): + return self.charge_status == ChargeStatus.NOT_CHARGED + + def can_authorize(self): + return self.is_active and self.not_charged + + def can_capture(self): + if not (self.is_active and self.not_charged): + return False + + _, gateway_config = get_payment_gateway(self.gateway) + if gateway_config.auto_capture: + return self.is_authorized + + return True + + def can_void(self): + return self.is_active and self.not_charged and self.is_authorized + + def can_refund(self): + can_refund_charge_status = ( + ChargeStatus.PARTIALLY_CHARGED, + ChargeStatus.FULLY_CHARGED, + ChargeStatus.PARTIALLY_REFUNDED, + ) + return ( + self.is_active + and self.charge_status in can_refund_charge_status + and self.gateway != CustomPaymentChoices.MANUAL + ) + + @property + def metadata(self) -> Dict[str, str]: + if self.extra_data == '': + return {} + else: + return json.loads(self.extra_data) + + @metadata.setter + def metadata(self, d: Optional[Dict[str, str]]): + if d == {}: + self.extra_data = '' + else: # Could so some assertions on the types of keys and values + self.extra_data = json.dumps(d) + + +class Transaction(models.Model): + """Represents a single payment operation. + + Transaction is an attempt to transfer money between your store + and your customers, with a chosen payment method. + """ + + created = models.DateTimeField(_('created'), auto_now_add=True, editable=False) + payment = models.ForeignKey(Payment, related_name="transactions", on_delete=CASCADE, + verbose_name=_('payment')) + token = models.CharField(_('token'), max_length=128, blank=True, default="") + kind = models.CharField(_('kind'), max_length=10, choices=TransactionKind.CHOICES) + is_success = models.BooleanField(_('is success'), default=False) + amount = MoneyField(_('amount'), max_digits=12, decimal_places=2) + error = models.CharField(_('error'), max_length=256, blank=True, null=True) + gateway_response = models.TextField(_('gateway response'), ) # JSON or XML + + class Meta: + verbose_name = _('transaction') + verbose_name_plural = _('transactions') + ordering = ("pk",) + + def __repr__(self): + return "Transaction(type=%s, is_success=%s, created=%s)" % \ + (self.kind, self.is_success, self.created) diff --git a/payment/utils.py b/payment/utils.py new file mode 100644 index 0000000..6e0e224 --- /dev/null +++ b/payment/utils.py @@ -0,0 +1,420 @@ +import json +import logging +from functools import wraps + +from django.core.serializers.json import DjangoJSONEncoder +from django.db import transaction +from moneyed import Money +from typing import Optional + +from . import ( + ChargeStatus, + GatewayError, + OperationType, + PaymentError, + TransactionKind, + get_payment_gateway, +) +from .interface import GatewayResponse, PaymentData, AddressData +from .models import Payment, Transaction + +logger = logging.getLogger(__name__) + +GENERIC_TRANSACTION_ERROR = "Transaction was unsuccessful" +REQUIRED_GATEWAY_KEYS = { + "transaction_id", + "is_success", + "kind", + "error", + "amount", + "currency", +} + +ALLOWED_GATEWAY_KINDS = {choices[0] for choices in TransactionKind.CHOICES} + + +def get_gateway_operation_func(gateway, operation_type): + """Return gateway method based on the operation type to be performed.""" + if operation_type == OperationType.PROCESS_PAYMENT: + return gateway.process_payment + if operation_type == OperationType.AUTH: + return gateway.authorize + if operation_type == OperationType.CAPTURE: + return gateway.capture + if operation_type == OperationType.VOID: + return gateway.void + if operation_type == OperationType.REFUND: + return gateway.refund + + +def create_payment_information( + payment: Payment, + payment_token: Optional[str] = None, + amount: Money = None, + billing_address: AddressData = None, + shipping_address: AddressData = None, +) -> PaymentData: + """Extracts order information along with payment details. + + Returns information required to process payment and additional + billing/shipping addresses for optional fraud-prevention mechanisms. + """ + + # PATCH: order_id = payment.order.pk if payment.order else None + order_id = None + + if amount is None: + amount = payment.total + + return PaymentData( # type:ignore + token=payment_token, # The contract is not clear, is this optional or not? + amount=amount.amount, + currency=amount.currency.code, + billing=billing_address, + shipping=shipping_address, + order_id=order_id, + customer_ip_address=payment.customer_ip_address, + customer_email=payment.customer_email, + metadata=payment.metadata, + ) + + +def require_active_payment(view): + """Require an active payment instance. + + Decorate a view to check if payment is authorized, so any actions + can be performed on it. + """ + + @wraps(view) + def func(payment: Payment, *args, **kwargs): + if not payment.is_active: + raise PaymentError("This payment is no longer active.") + return view(payment, *args, **kwargs) + + return func + + +''' +def create_payment( + gateway: str, + total: Decimal, + currency: str, + email: str, + billing_address: Address, + customer_ip_address: str = "", + payment_token: str = "", + extra_data: Dict = None, + checkout: Checkout = None, + order: Order = None, +) -> Payment: + """Create a payment instance. + + This method is responsible for creating payment instances that works for + both Django views and GraphQL mutations. + """ + defaults = { + "billing_email": email, + "billing_first_name": billing_address.first_name, + "billing_last_name": billing_address.last_name, + "billing_company_name": billing_address.company_name, + "billing_address_1": billing_address.street_address_1, + "billing_address_2": billing_address.street_address_2, + "billing_city": billing_address.city, + "billing_postal_code": billing_address.postal_code, + "billing_country_code": billing_address.country.code, + "billing_country_area": billing_address.country_area, + "currency": currency, + "gateway": gateway, + "total": total, + } + + if extra_data is None: + extra_data = {} + + data = { + "is_active": True, + "customer_ip_address": customer_ip_address, + "extra_data": extra_data, + "token": payment_token, + } + + if order is not None: + data["order"] = order + if checkout is not None: + data["checkout"] = checkout + + payment, _ = Payment.objects.get_or_create(defaults=defaults, **data) + return payment +''' + + +def create_transaction( + payment: Payment, + kind: str, + payment_information: PaymentData, + gateway_response: GatewayResponse = None, + error_msg=None, +) -> Transaction: + """Create a transaction based on transaction kind and gateway response.""" + + # Default values for token, amount, currency are only used in cases where + # response from gateway was invalid or an exception occured + if not gateway_response: + gateway_response = GatewayResponse( + kind=kind, + transaction_id=payment_information.token, + is_success=False, + amount=payment_information.amount, + currency=payment_information.currency, + error=error_msg, + raw_response={}, + ) + + return Transaction.objects.create( + payment=payment, + kind=gateway_response.kind, + token=gateway_response.transaction_id, + is_success=gateway_response.is_success, + amount=Money(gateway_response.amount, gateway_response.currency), + error=gateway_response.error, + gateway_response=gateway_response.raw_response or {}, + ) + + +def gateway_get_client_token(gateway_name: str): + """Gets client token, that will be used as a customer's identificator for + client-side tokenization of the chosen payment method. + """ + gateway, gateway_config = get_payment_gateway(gateway_name) + return gateway.get_client_token(config=gateway_config) + + +def clean_capture(payment: Payment, amount: Money): + """Check if payment can be captured.""" + if amount.amount <= 0: + raise PaymentError("Amount should be a positive number.") + if not payment.can_capture(): + raise PaymentError("This payment cannot be captured.") + if amount > payment.total or amount > (payment.total - payment.captured_amount): + raise PaymentError("Unable to charge more than un-captured amount.") + + +def clean_authorize(payment: Payment): + """Check if payment can be authorized.""" + if not payment.can_authorize(): + raise PaymentError("Charged transactions cannot be authorized again.") + + +def call_gateway(operation_type, payment, payment_token, **extra_params): + """Helper that calls the passed gateway function and handles exceptions. + + Additionally does validation of the returned gateway response. + """ + gateway, gateway_config = get_payment_gateway(payment.gateway) + gateway_response = None + error_msg = None + + payment_information = create_payment_information( + payment, payment_token, **extra_params + ) + + try: + func = get_gateway_operation_func(gateway, operation_type) + except AttributeError: + error_msg = "Gateway doesn't implement {} operation".format(operation_type.name) + logger.exception(error_msg) + raise PaymentError(error_msg) + + # The transaction kind is provided as a default value + # for creating transactions when gateway has invalid response + # The PROCESS_PAYMENT operation has CAPTURE as default transaction kind + # For other operations, the transaction kind is same wtih operation type + default_transaction_kind = TransactionKind.CAPTURE + if operation_type != OperationType.PROCESS_PAYMENT: + default_transaction_kind = getattr( + TransactionKind, OperationType(operation_type).name + ) + + # Validate the default transaction kind + if default_transaction_kind not in dict(TransactionKind.CHOICES): + error_msg = "The default transaction kind is invalid" + logger.exception(error_msg) + raise PaymentError(error_msg) + + try: + gateway_response = func( + payment_information=payment_information, config=gateway_config + ) + validate_gateway_response(gateway_response) + except GatewayError: + error_msg = "Gateway response validation failed" + logger.exception(error_msg) + gateway_response = None # Set response empty as the validation failed + except Exception as e: + error_msg = 'Gateway encountered an error {}'.format(e) + logger.exception(error_msg) + finally: + payment_transaction = create_transaction( + payment=payment, + kind=default_transaction_kind, + payment_information=payment_information, + error_msg=error_msg, + gateway_response=gateway_response, + ) + + if not payment_transaction.is_success: + # Attempt to get errors from response, if none raise a generic one + raise PaymentError(payment_transaction.error or GENERIC_TRANSACTION_ERROR) + + return payment_transaction + + +def validate_gateway_response(response: GatewayResponse): + """Validates response to be a correct format for Us to process.""" + + if not isinstance(response, GatewayResponse): + raise GatewayError("Gateway needs to return a GatewayResponse obj") + + if response.kind not in ALLOWED_GATEWAY_KINDS: + raise GatewayError("Gateway response kind must be one of {}".format(sorted(ALLOWED_GATEWAY_KINDS))) + + try: + json.dumps(response.raw_response, cls=DjangoJSONEncoder) + except (TypeError, ValueError): + raise GatewayError("Gateway response needs to be json serializable") + + +@transaction.atomic +def _gateway_postprocess(transaction, payment): + transaction_kind = transaction.kind + + if transaction_kind == TransactionKind.CAPTURE: + if payment.captured_amount is not None: + payment.captured_amount += transaction.amount + else: + payment.captured_amount = transaction.amount + + if payment.get_charge_amount().amount <= 0: + payment.charge_status = ChargeStatus.FULLY_CHARGED + else: + payment.charge_status = ChargeStatus.PARTIALLY_CHARGED + + payment.save() + + elif transaction_kind == TransactionKind.VOID: + payment.is_active = False + payment.save() + + elif transaction_kind == TransactionKind.REFUND: + payment.captured_amount -= transaction.amount + payment.charge_status = ChargeStatus.PARTIALLY_REFUNDED + if payment.captured_amount.amount <= 0: + payment.charge_status = ChargeStatus.FULLY_REFUNDED + payment.is_active = False + payment.save() + + +@require_active_payment +def gateway_process_payment(payment: Payment, payment_token: str, **extras) -> Transaction: + """Performs whole payment process on a gateway.""" + transaction = call_gateway( + operation_type=OperationType.PROCESS_PAYMENT, + payment=payment, + payment_token=payment_token, + **extras, + ) + + _gateway_postprocess(transaction, payment) + return transaction + + +@require_active_payment +def gateway_authorize(payment: Payment, payment_token: str) -> Transaction: + """Authorizes the payment and creates relevant transaction. + + Args: + - payment_token: One-time-use reference to payment information. + """ + clean_authorize(payment) + return call_gateway(operation_type=OperationType.AUTH, payment=payment, payment_token=payment_token) + + +@require_active_payment +def gateway_capture(payment: Payment, amount: Money = None) -> Transaction: + """Captures the money that was reserved during the authorization stage.""" + if amount is None: + amount = payment.get_charge_amount() + clean_capture(payment, amount) + + auth_transaction = payment.transactions.filter( + kind=TransactionKind.AUTH, is_success=True + ).first() + if auth_transaction is None: + raise PaymentError("Cannot capture unauthorized transaction") + payment_token = auth_transaction.token + + transaction = call_gateway( + operation_type=OperationType.CAPTURE, + payment=payment, + payment_token=payment_token, + amount=amount, + ) + + _gateway_postprocess(transaction, payment) + return transaction + + +@require_active_payment +def gateway_void(payment) -> Transaction: + if not payment.can_void(): + raise PaymentError("Only pre-authorized transactions can be voided.") + + auth_transaction = payment.transactions.filter( + kind=TransactionKind.AUTH, is_success=True + ).first() + if auth_transaction is None: + raise PaymentError("Cannot void unauthorized transaction") + payment_token = auth_transaction.token + + transaction = call_gateway( + operation_type=OperationType.VOID, payment=payment, payment_token=payment_token + ) + + _gateway_postprocess(transaction, payment) + return transaction + + +@require_active_payment +def gateway_refund(payment, amount: Money = None) -> Transaction: + """Refunds the charged funds back to the customer. + Refunds can be total or partial. + """ + if amount is None: + # If no amount is specified, refund the maximum possible + amount = payment.captured_amount + + if not payment.can_refund(): + raise PaymentError("This payment cannot be refunded.") + + if amount.amount <= 0: + raise PaymentError("Amount should be a positive number.") + if amount > payment.captured_amount: + raise PaymentError("Cannot refund more than captured") + + transaction = payment.transactions.filter( + kind=TransactionKind.CAPTURE, is_success=True + ).first() + if transaction is None: + raise PaymentError("Cannot refund uncaptured transaction") + payment_token = transaction.token + + transaction = call_gateway( + operation_type=OperationType.REFUND, + payment=payment, + payment_token=payment_token, + amount=amount, + ) + + _gateway_postprocess(transaction, payment) + return transaction diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..be7361a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,24 @@ +[flake8] +exclude = .git, .tox, .direnv, */migrations/* +max-line-length = 119 + +[tool:pytest] +DJANGO_SETTINGS_MODULE = tests.settings +markers = + integration + +[mypy] +ignore_missing_imports = True + +[mypy-payment.migrations.*] +ignore_errors = True + +[coverage:run] +branch = 1 +omit = + */migrations/* + +[coverage:report] +omit = + */management/* + */admin.py \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..174b450 --- /dev/null +++ b/setup.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +from setuptools import setup + +setup( + name='django-payment', + version='0.5', + description='', + long_description='', + author='Nicholas Wolff', + author_email='nwolff@gmail.com', + url='https://github.com/skioo/django-payment', + download_url='https://pypi.python.org/pypi/django-payment', + packages=[ + 'payment', + 'payment.gateways', + 'payment.gateways.dummy', + 'payment.gateways.stripe', + 'payment.migrations', + ], + package_data={ + 'payment': ['locale/*/LC_MESSAGES/*.*', ]}, + install_requires=[ + 'Django>=2.2,<2.3', + 'django-money', + 'django-fsm', + 'structlog', + 'typing', + 'stripe', + 'django-countries', + 'dataclasses', + ], + license='MIT', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Web Environment', + 'Framework :: Django :: 2.1', + 'Framework :: Django :: 2.2', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..461063a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +import pytest +from moneyed import Money + +from payment import TransactionKind, ChargeStatus +from payment.models import Payment + + +@pytest.fixture +def payment_dummy(db, settings): + return Payment.objects.create( + gateway=settings.DUMMY, + total=Money(80, 'USD'), + captured_amount=Money(0, 'USD'), + customer_email='test@example.com', + ) + + +@pytest.fixture +def payment_txn_preauth(payment_dummy): + payment = payment_dummy + payment.save() + + payment.transactions.create( + amount=payment.total, + kind=TransactionKind.AUTH, + gateway_response={}, + is_success=True, + ) + return payment + + +@pytest.fixture +def payment_txn_captured(payment_dummy): + payment = payment_dummy + payment.charge_status = ChargeStatus.FULLY_CHARGED + payment.captured_amount = payment.total + payment.save() + + payment.transactions.create( + amount=payment.total, + kind=TransactionKind.CAPTURE, + gateway_response={}, + is_success=True, + ) + return payment + + +@pytest.fixture +def payment_txn_refunded(payment_dummy): + payment = payment_dummy + payment.charge_status = ChargeStatus.FULLY_REFUNDED + payment.is_active = False + payment.save() + + payment.transactions.create( + amount=payment.total, + kind=TransactionKind.REFUND, + gateway_response={}, + is_success=True, + ) + return payment + + +@pytest.fixture +def payment_not_authorized(payment_dummy): + payment_dummy.is_active = False + payment_dummy.save() + return payment_dummy diff --git a/tests/gateways/__init__.py b/tests/gateways/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gateways/test_dummy.py b/tests/gateways/test_dummy.py new file mode 100644 index 0000000..dd0d512 --- /dev/null +++ b/tests/gateways/test_dummy.py @@ -0,0 +1,245 @@ +import pytest +from moneyed import Money + +from payment import ( + ChargeStatus, + PaymentError, + TransactionKind, + get_payment_gateway, +) +from payment.utils import ( + create_payment_information, + gateway_authorize, + gateway_capture, + gateway_process_payment, + gateway_refund, + gateway_void, +) + + +def test_authorize_success(payment_dummy): + txn = gateway_authorize(payment=payment_dummy, payment_token="Fake") + assert txn.is_success + assert txn.kind == TransactionKind.AUTH + assert txn.payment == payment_dummy + payment_dummy.refresh_from_db() + assert payment_dummy.is_active + + +@pytest.mark.parametrize( + "is_active, charge_status", + [ + (False, ChargeStatus.NOT_CHARGED), + (False, ChargeStatus.PARTIALLY_CHARGED), + (False, ChargeStatus.FULLY_CHARGED), + (False, ChargeStatus.PARTIALLY_REFUNDED), + (False, ChargeStatus.FULLY_REFUNDED), + (True, ChargeStatus.PARTIALLY_CHARGED), + (True, ChargeStatus.FULLY_CHARGED), + (True, ChargeStatus.PARTIALLY_REFUNDED), + (True, ChargeStatus.FULLY_REFUNDED), + ], +) +def test_authorize_failed(is_active, charge_status, payment_dummy): + payment = payment_dummy + payment.is_active = is_active + payment.charge_status = charge_status + payment.save() + with pytest.raises(PaymentError): + txn = gateway_authorize(payment=payment, payment_token="Fake") + assert txn is None + + +def test_authorize_gateway_error(payment_dummy, monkeypatch): + monkeypatch.setattr("payment.gateways.dummy.dummy_success", lambda: False) + with pytest.raises(PaymentError): + txn = gateway_authorize(payment=payment_dummy, payment_token="Fake") + assert txn.kind == TransactionKind.AUTH + assert not txn.is_success + assert txn.payment == payment_dummy + + +def test_void_success(payment_txn_preauth): + assert payment_txn_preauth.is_active + assert payment_txn_preauth.charge_status == ChargeStatus.NOT_CHARGED + txn = gateway_void(payment=payment_txn_preauth) + assert txn.is_success + assert txn.kind == TransactionKind.VOID + assert txn.payment == payment_txn_preauth + payment_txn_preauth.refresh_from_db() + assert not payment_txn_preauth.is_active + assert payment_txn_preauth.charge_status == ChargeStatus.NOT_CHARGED + + +@pytest.mark.parametrize( + "is_active, charge_status", + [ + (False, ChargeStatus.NOT_CHARGED), + (False, ChargeStatus.PARTIALLY_CHARGED), + (False, ChargeStatus.FULLY_CHARGED), + (False, ChargeStatus.PARTIALLY_REFUNDED), + (False, ChargeStatus.FULLY_REFUNDED), + (True, ChargeStatus.PARTIALLY_CHARGED), + (True, ChargeStatus.FULLY_CHARGED), + (True, ChargeStatus.PARTIALLY_REFUNDED), + (True, ChargeStatus.FULLY_REFUNDED), + ], +) +def test_void_failed(is_active, charge_status, payment_dummy): + payment = payment_dummy + payment.is_active = is_active + payment.charge_status = charge_status + payment.save() + with pytest.raises(PaymentError): + txn = gateway_void(payment=payment) + assert txn is None + + +def test_void_gateway_error(payment_txn_preauth, monkeypatch): + monkeypatch.setattr("payment.gateways.dummy.dummy_success", lambda: False) + with pytest.raises(PaymentError): + txn = gateway_void(payment=payment_txn_preauth) + assert txn.kind == TransactionKind.VOID + assert not txn.is_success + assert txn.payment == payment_txn_preauth + + +@pytest.mark.parametrize( + "amount, charge_status", + [(Money(80, 'USD'), ChargeStatus.FULLY_CHARGED), (Money(70, 'USD'), ChargeStatus.PARTIALLY_CHARGED)], +) +def test_capture_success(amount, charge_status, payment_txn_preauth): + txn = gateway_capture(payment=payment_txn_preauth, amount=amount) + assert txn.is_success + assert txn.payment == payment_txn_preauth + payment_txn_preauth.refresh_from_db() + assert payment_txn_preauth.charge_status == charge_status + assert payment_txn_preauth.is_active + + +@pytest.mark.parametrize( + "amount, captured_amount, charge_status, is_active", + [ + (Money(80, 'USD'), Money(0, 'USD'), ChargeStatus.NOT_CHARGED, False), + (Money(120, 'USD'), Money(0, 'USD'), ChargeStatus.NOT_CHARGED, True), + (Money(80, 'USD'), Money(20, 'USD'), ChargeStatus.PARTIALLY_CHARGED, True), + (Money(80, 'USD'), Money(80, 'USD'), ChargeStatus.FULLY_CHARGED, True), + (Money(80, 'USD'), Money(0, 'USD'), ChargeStatus.FULLY_REFUNDED, True), + ], +) +def test_capture_failed( + amount, captured_amount, charge_status, is_active, payment_dummy +): + payment = payment_dummy + payment.is_active = is_active + payment.captured_amount = captured_amount + payment.charge_status = charge_status + payment.save() + with pytest.raises(PaymentError): + txn = gateway_capture(payment=payment, amount=amount) + assert txn is None + + +def test_capture_gateway_error(payment_txn_preauth, monkeypatch): + monkeypatch.setattr("payment.gateways.dummy.dummy_success", lambda: False) + with pytest.raises(PaymentError): + txn = gateway_capture(payment=payment_txn_preauth, amount=Money(80, 'USD')) + assert txn.kind == TransactionKind.CAPTURE + assert not txn.is_success + assert txn.payment == payment_txn_preauth + + +@pytest.mark.parametrize( + ( + "initial_captured_amount, refund_amount, final_captured_amount, " + "final_charge_status, active_after" + ), + [ + (Money(80, 'USD'), Money(80, 'USD'), Money(0, 'USD'), ChargeStatus.FULLY_REFUNDED, False), + (Money(80, 'USD'), Money(10, 'USD'), Money(70, 'USD'), ChargeStatus.PARTIALLY_REFUNDED, True), + ], +) +def test_refund_success( + initial_captured_amount, + refund_amount, + final_captured_amount, + final_charge_status, + active_after, + payment_txn_captured, +): + payment = payment_txn_captured + payment.charge_status = ChargeStatus.FULLY_CHARGED + payment.captured_amount = initial_captured_amount + payment.save() + txn = gateway_refund(payment=payment, amount=refund_amount) + assert txn.kind == TransactionKind.REFUND + assert txn.is_success + assert txn.payment == payment + assert payment.charge_status == final_charge_status + assert payment.captured_amount == final_captured_amount + assert payment.is_active == active_after + + +@pytest.mark.parametrize( + "initial_captured_amount, refund_amount, initial_charge_status", + [ + (Money(0, 'USD'), Money(10, 'USD'), ChargeStatus.NOT_CHARGED), + (Money(10, 'USD'), Money(20, 'USD'), ChargeStatus.PARTIALLY_CHARGED), + (Money(10, 'USD'), Money(20, 'USD'), ChargeStatus.FULLY_CHARGED), + (Money(10, 'USD'), Money(20, 'USD'), ChargeStatus.PARTIALLY_REFUNDED), + (Money(80, 'USD'), Money(0, 'USD'), ChargeStatus.FULLY_REFUNDED), + ], +) +def test_refund_failed( + initial_captured_amount, refund_amount, initial_charge_status, payment_dummy +): + payment = payment_dummy + payment.charge_status = initial_charge_status + payment.captured_amount = initial_captured_amount + payment.save() + with pytest.raises(PaymentError): + txn = gateway_refund(payment=payment, amount=refund_amount) + assert txn is None + + +def test_refund_gateway_error(payment_txn_captured, monkeypatch): + monkeypatch.setattr("payment.gateways.dummy.dummy_success", lambda: False) + payment = payment_txn_captured + payment.charge_status = ChargeStatus.FULLY_CHARGED + payment.captured_amount = Money(80, 'USD') + payment.save() + with pytest.raises(PaymentError): + gateway_refund(payment=payment, amount=Money(80, 'USD')) + + payment.refresh_from_db() + txn = payment.transactions.last() + assert txn.kind == TransactionKind.REFUND + assert not txn.is_success + assert txn.payment == payment + assert payment.charge_status == ChargeStatus.FULLY_CHARGED + assert payment.captured_amount == Money(80, 'USD') + + +@pytest.mark.parametrize( + "kind, charge_status", + ( + (TransactionKind.AUTH, ChargeStatus.NOT_CHARGED), + (TransactionKind.CAPTURE, ChargeStatus.FULLY_CHARGED), + (TransactionKind.REFUND, ChargeStatus.FULLY_REFUNDED), + ), +) +def test_dummy_payment_form(kind, charge_status, payment_dummy): + payment = payment_dummy + data = {"charge_status": charge_status} + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + payment_info = create_payment_information(payment) + + form = payment_gateway.create_form( + data=data, + payment_information=payment_info, + connection_params=gateway_config.connection_params, + ) + assert form.is_valid() + gateway_process_payment(payment=payment, payment_token=form.get_payment_token()) + payment.refresh_from_db() + assert payment.transactions.last().kind == kind diff --git a/tests/gateways/test_stripe.py b/tests/gateways/test_stripe.py new file mode 100644 index 0000000..fa32e2c --- /dev/null +++ b/tests/gateways/test_stripe.py @@ -0,0 +1,593 @@ +from decimal import Decimal + +import pytest +import stripe +from math import isclose +from moneyed import Money +from unittest.mock import Mock, patch + +from payment import ChargeStatus +from payment.gateways.stripe import ( + TransactionKind, + _create_response, + _get_client, + _get_error_response_from_exc, + _get_stripe_charge_payload, + authorize, + capture, + create_form, + get_amount_for_stripe, + get_amount_from_stripe, + get_client_token, + get_currency_for_stripe, + get_currency_from_stripe, + refund, + void, +) +from payment.gateways.stripe.forms import ( + StripeCheckoutWidget, + StripePaymentModalForm, +) +from payment.gateways.stripe.utils import ( + get_payment_billing_fullname, +) +from payment.interface import GatewayConfig +from payment.utils import create_payment_information + +TRANSACTION_AMOUNT = Decimal(42.42) +TRANSACTION_REFUND_AMOUNT = Decimal(24.24) +TRANSACTION_CURRENCY = "USD" +TRANSACTION_TOKEN = "fake-stripe-id" +FAKE_TOKEN = "fake-token" +ERROR_MESSAGE = "error-message" + + +@pytest.fixture() +def gateway_config(): + return GatewayConfig( + auto_capture=False, + template_path="template.html", + connection_params={ + "public_key": "public", + "secret_key": "secret", + "store_name": "Saleor", + "store_image": "image.gif", + "prefill": True, + "remember_me": True, + "locale": "auto", + "enable_billing_address": False, + "enable_shipping_address": False, + }, + ) + + +@pytest.fixture() +def client_token(): + return FAKE_TOKEN + + +@pytest.fixture() +def stripe_payment(payment_dummy): + payment_dummy.total = TRANSACTION_AMOUNT + payment_dummy.currency = TRANSACTION_CURRENCY + return payment_dummy + + +@pytest.fixture() +def stripe_authorized_payment(stripe_payment): + stripe_payment.charge_status = ChargeStatus.NOT_CHARGED + stripe_payment.save(update_fields=["charge_status"]) + + return stripe_payment + + +@pytest.fixture() +def stripe_captured_payment(stripe_payment): + stripe_payment.captured_amount = stripe_payment.total + stripe_payment.charge_status = ChargeStatus.FULLY_CHARGED + stripe_payment.save(update_fields=["captured_amount", "charge_status"]) + stripe_payment.transactions.create( + amount=stripe_payment.total, + kind=TransactionKind.CAPTURE, + gateway_response={}, + is_success=True, + ) + return stripe_payment + + +@pytest.fixture() +def stripe_charge_success_response(): + return { + "id": TRANSACTION_TOKEN, + "amount": get_amount_for_stripe(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY), + "amount_refunded": 0, + "currency": get_currency_for_stripe(TRANSACTION_CURRENCY), + "status": "succeeded", + } + + +@pytest.fixture() +def stripe_partial_charge_success_response(stripe_charge_success_response): + response = stripe_charge_success_response.copy() + response["amount_refunded"] = get_amount_for_stripe( + TRANSACTION_REFUND_AMOUNT, TRANSACTION_CURRENCY + ) + return response + + +@pytest.fixture() +def stripe_refund_success_response(stripe_charge_success_response): + response = stripe_charge_success_response.copy() + response.pop("amount_refunded") + response["amount"] = get_amount_for_stripe( + TRANSACTION_REFUND_AMOUNT, TRANSACTION_CURRENCY + ) + return response + + +def test_get_amount_for_stripe(): + assert get_amount_for_stripe(Decimal(1), "USD") == 100 + assert get_amount_for_stripe(Decimal(1), "usd") == 100 + + assert get_amount_for_stripe(Decimal(0.01), "USD") == 1 + assert get_amount_for_stripe(Decimal(24.24), "USD") == 2424 + assert get_amount_for_stripe(Decimal(42.42), "USD") == 4242 + + assert get_amount_for_stripe(Decimal(1), "JPY") == 1 + assert get_amount_for_stripe(Decimal(1), "jpy") == 1 + + +def test_get_amount_from_stripe(): + assert get_amount_from_stripe(100, "USD") == Decimal(1) + assert get_amount_from_stripe(100, "usd") == Decimal(1) + + assert isclose(get_amount_from_stripe(1, "USD"), Decimal(0.01)) + assert isclose(get_amount_from_stripe(2424, "USD"), Decimal(24.24)) + assert isclose(get_amount_from_stripe(4242, "USD"), Decimal(42.42)) + + assert get_amount_from_stripe(1, "JPY") == Decimal(1) + assert get_amount_from_stripe(1, "jpy") == Decimal(1) + + +def test_get_currency_for_stripe(): + assert get_currency_for_stripe("USD") == "usd" + assert get_currency_for_stripe("usd") == "usd" + assert get_currency_for_stripe("uSd") == "usd" + + +def test_get_currency_from_stripe(): + assert get_currency_from_stripe("USD") == "USD" + assert get_currency_from_stripe("usd") == "USD" + assert get_currency_from_stripe("uSd") == "USD" + + +def test_widget_with_additional_attr(stripe_payment, gateway_config): + payment_info = create_payment_information(stripe_payment) + + widget = StripeCheckoutWidget( + payment_info, + gateway_config.connection_params, + attrs={"data-custom": "custom-data"}, + ) + assert 'data-custom="custom-data"' in widget.render() + + +def test_widget_with_prefill_option(stripe_payment, gateway_config): + payment_info = create_payment_information(stripe_payment) + connection_params = gateway_config.connection_params + connection_params["prefill"] = True + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-email="test@example.com"' in widget.render() + + connection_params["prefill"] = False + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-email="test@example.com"' not in widget.render() + + +def test_widget_with_remember_me_option(stripe_payment, gateway_config): + payment_info = create_payment_information(stripe_payment) + connection_params = gateway_config.connection_params + + connection_params["remember_me"] = True + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-allow-remember-me="true"' in widget.render() + + connection_params["remember_me"] = False + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-allow-remember-me="false"' in widget.render() + + +def test_widget_with_enable_billing_address_option(stripe_payment, gateway_config): + payment_info = create_payment_information(stripe_payment, FAKE_TOKEN) + connection_params = gateway_config.connection_params + + connection_params["enable_billing_address"] = True + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-billing-address="true"' in widget.render() + assert 'data-zip-code="true"' in widget.render() + + connection_params["enable_billing_address"] = False + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-billing-address="false"' in widget.render() + assert 'data-zip-code="false"' in widget.render() + + +def test_widget_with_enable_shipping_address_option(stripe_payment, gateway_config): + payment_info = create_payment_information(stripe_payment, FAKE_TOKEN) + connection_params = gateway_config.connection_params + + connection_params["enable_shipping_address"] = True + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-shipping-address="true"' in widget.render() + + connection_params["enable_shipping_address"] = False + widget = StripeCheckoutWidget(payment_info, connection_params) + assert 'data-shipping-address="false"' in widget.render() + + +def test_stripe_payment_form(stripe_payment, gateway_config): + payment_info = create_payment_information(stripe_payment, FAKE_TOKEN) + form = create_form( + None, + payment_information=payment_info, + connection_params=gateway_config.connection_params, + ) + assert isinstance(form, StripePaymentModalForm) + assert not form.is_valid() + + form = create_form( + data={"stripeToken": FAKE_TOKEN}, + payment_information=payment_info, + connection_params=gateway_config.connection_params, + ) + assert isinstance(form, StripePaymentModalForm) + assert form.is_valid() + + +def test_get_client(gateway_config): + assert _get_client(**gateway_config.connection_params).api_key == "secret" + + +def test_get_client_token(): + assert get_client_token() is None + + +def test_get_error_response_from_exc(): + stripe_error = stripe.error.StripeError(json_body={"message": ERROR_MESSAGE}) + invalid_request_error = stripe.error.InvalidRequestError( + message=ERROR_MESSAGE, param=None + ) + + assert _get_error_response_from_exc(stripe_error) == {"message": ERROR_MESSAGE} + assert _get_error_response_from_exc(invalid_request_error) == {} + + +def test_get_stripe_charge_payload_without_shipping(stripe_payment): + payment_info = create_payment_information(stripe_payment, FAKE_TOKEN) + billing_name = get_payment_billing_fullname(payment_info) + expected_payload = { + "capture": True, + "amount": get_amount_for_stripe(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY), + "currency": get_currency_for_stripe(TRANSACTION_CURRENCY), + "source": FAKE_TOKEN, + "description": billing_name, + "metadata": {}, + } + + charge_payload = _get_stripe_charge_payload(payment_info, True) + + assert charge_payload == expected_payload + + +def test_create_transaction_with_charge_success_response( + stripe_payment, stripe_charge_success_response +): + payment_info = create_payment_information(stripe_payment) + + response = _create_response( + payment_information=payment_info, + kind="ANYKIND", + response=stripe_charge_success_response, + error=None, + ) + + assert response.transaction_id == TRANSACTION_TOKEN + assert response.is_success is True + assert isclose(response.amount, TRANSACTION_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + + +def test_create_transaction_with_partial_charge_success_response( + stripe_payment, stripe_partial_charge_success_response +): + payment_info = create_payment_information(stripe_payment) + + response = _create_response( + payment_information=payment_info, + kind="ANYKIND", + response=stripe_partial_charge_success_response, + error=None, + ) + + assert response.transaction_id == TRANSACTION_TOKEN + assert response.is_success is True + assert isclose(response.amount, TRANSACTION_AMOUNT - TRANSACTION_REFUND_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + + +def test_create_transaction_with_refund_success_response( + stripe_payment, stripe_refund_success_response +): + payment_info = create_payment_information(stripe_payment) + + response = _create_response( + payment_information=payment_info, + kind="ANYKIND", + response=stripe_refund_success_response, + error=None, + ) + + assert response.transaction_id == TRANSACTION_TOKEN + assert response.is_success is True + assert isclose(response.amount, TRANSACTION_REFUND_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + + +def test_create_response_with_error_response(stripe_payment): + payment = stripe_payment + payment_info = create_payment_information(payment, FAKE_TOKEN) + stripe_error_response = {} + + response = _create_response( + payment_information=payment_info, + kind="ANYKIND", + response=stripe_error_response, + error=None, + ) + + assert response.transaction_id == FAKE_TOKEN + assert response.is_success is False + assert response.amount == payment.total.amount + assert response.currency == payment.total.currency.code + + +@pytest.mark.integration +@patch("stripe.Charge.create") +def test_authorize( + mock_charge_create, stripe_payment, gateway_config, stripe_charge_success_response +): + payment = stripe_payment + payment_info = create_payment_information(payment, FAKE_TOKEN) + response = stripe_charge_success_response + mock_charge_create.return_value = response + + response = authorize(payment_info, gateway_config) + + assert not response.error + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.AUTH + assert response.is_success + assert isclose(response.amount, TRANSACTION_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == stripe_charge_success_response + + +@pytest.mark.integration +@patch("stripe.Charge.create") +def test_authorize_error_response(mock_charge_create, stripe_payment, gateway_config): + payment = stripe_payment + payment_info = create_payment_information(payment, FAKE_TOKEN) + stripe_error = stripe.error.InvalidRequestError(message=ERROR_MESSAGE, param=None) + mock_charge_create.side_effect = stripe_error + + response = authorize(payment_info, gateway_config) + + assert response.error == ERROR_MESSAGE + assert response.transaction_id == FAKE_TOKEN + assert response.kind == TransactionKind.AUTH + assert not response.is_success + assert response.amount == payment.total.amount + assert response.currency == payment.total.currency.code + assert response.raw_response == _get_error_response_from_exc(stripe_error) + + +@pytest.mark.integration +@patch("stripe.Charge.retrieve") +def test_capture( + mock_charge_retrieve, + stripe_authorized_payment, + gateway_config, + stripe_charge_success_response, +): + payment = stripe_authorized_payment + payment_info = create_payment_information(payment, amount=Money(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY)) + response = stripe_charge_success_response + mock_charge_retrieve.return_value = Mock(capture=Mock(return_value=response)) + + response = capture(payment_info, gateway_config) + + assert not response.error + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.CAPTURE + assert response.is_success + assert isclose(response.amount, TRANSACTION_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == stripe_charge_success_response + + +@pytest.mark.integration +@patch("stripe.Charge.retrieve") +def test_partial_captureummy( + mock_charge_retrieve, + stripe_authorized_payment, + gateway_config, + stripe_partial_charge_success_response, +): + payment = stripe_authorized_payment + payment_info = create_payment_information(payment, amount=Money(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY)) + response = stripe_partial_charge_success_response + mock_charge_retrieve.return_value = Mock(capture=Mock(return_value=response)) + + response = capture(payment_info, gateway_config) + + assert not response.error + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.CAPTURE + assert response.is_success + assert isclose(response.amount, TRANSACTION_AMOUNT - TRANSACTION_REFUND_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == stripe_partial_charge_success_response + + +@pytest.mark.integration +@patch("stripe.Charge.retrieve") +def test_capture_error_response( + mock_charge_retrieve, stripe_authorized_payment, gateway_config +): + payment = stripe_authorized_payment + payment_info = create_payment_information( + payment, TRANSACTION_TOKEN, amount=Money(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY) + ) + stripe_error = stripe.error.InvalidRequestError(message=ERROR_MESSAGE, param=None) + mock_charge_retrieve.side_effect = stripe_error + + response = capture(payment_info, gateway_config) + + assert response.error == ERROR_MESSAGE + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.CAPTURE + assert not response.is_success + assert response.amount == payment.total.amount + assert response.currency == payment.total.currency.code + assert response.raw_response == _get_error_response_from_exc(stripe_error) + + +@pytest.mark.integration +@patch("stripe.Refund.create") +@patch("stripe.Charge.retrieve") +def test_refund_charged( + mock_charge_retrieve, + mock_refund_create, + stripe_captured_payment, + gateway_config, + stripe_refund_success_response, +): + payment = stripe_captured_payment + payment_info = create_payment_information( + payment, TRANSACTION_TOKEN, amount=Money(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY) + ) + response = stripe_refund_success_response + mock_charge_retrieve.return_value = Mock(id="") + mock_refund_create.return_value = response + + response = refund(payment_info, gateway_config) + + assert not response.error + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.REFUND + assert response.is_success + assert isclose(response.amount, TRANSACTION_REFUND_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == stripe_refund_success_response + + +@pytest.mark.integration +@patch("stripe.Refund.create") +@patch("stripe.Charge.retrieve") +def test_refund_captured( + mock_charge_retrieve, + mock_refund_create, + stripe_captured_payment, + gateway_config, + stripe_refund_success_response, +): + payment = stripe_captured_payment + payment_info = create_payment_information(payment, amount=Money(TRANSACTION_AMOUNT, 'USD')) + response = stripe_refund_success_response + mock_charge_retrieve.return_value = Mock(id="") + mock_refund_create.return_value = response + + response = refund(payment_info, gateway_config) + + assert not response.error + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.REFUND + assert response.is_success + assert isclose(response.amount, TRANSACTION_REFUND_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == stripe_refund_success_response + + +@pytest.mark.integration +@patch("stripe.Refund.create") +@patch("stripe.Charge.retrieve") +def test_refund_error_response( + mock_charge_retrieve, mock_refund_create, stripe_captured_payment, gateway_config +): + payment = stripe_captured_payment + payment_info = create_payment_information( + payment, TRANSACTION_TOKEN, amount=Money(TRANSACTION_AMOUNT, TRANSACTION_CURRENCY) + ) + mock_charge_retrieve.return_value = Mock(id="") + stripe_error = stripe.error.InvalidRequestError(message=ERROR_MESSAGE, param=None) + mock_refund_create.side_effect = stripe_error + + response = refund(payment_info, gateway_config) + + assert response.error == ERROR_MESSAGE + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.REFUND + assert not response.is_success + assert response.amount == payment.total.amount + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == _get_error_response_from_exc(stripe_error) + + +@pytest.mark.integration +@patch("stripe.Refund.create") +@patch("stripe.Charge.retrieve") +def test_void( + mock_charge_retrieve, + mock_refund_create, + stripe_authorized_payment, + gateway_config, + stripe_refund_success_response, +): + payment = stripe_authorized_payment + payment_info = create_payment_information(payment, TRANSACTION_TOKEN) + response = stripe_refund_success_response + mock_charge_retrieve.return_value = Mock(id="") + mock_refund_create.return_value = response + + response = void(payment_info, gateway_config) + + assert not response.error + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.VOID + assert response.is_success + assert isclose(response.amount, TRANSACTION_REFUND_AMOUNT) + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == stripe_refund_success_response + + +@pytest.mark.integration +@patch("stripe.Refund.create") +@patch("stripe.Charge.retrieve") +def test_void_error_response( + mock_charge_retrieve, mock_refund_create, stripe_authorized_payment, gateway_config +): + payment = stripe_authorized_payment + payment_info = create_payment_information(payment, TRANSACTION_TOKEN) + mock_charge_retrieve.return_value = Mock(id="") + stripe_error = stripe.error.InvalidRequestError(message=ERROR_MESSAGE, param=None) + mock_refund_create.side_effect = stripe_error + + response = void(payment_info, gateway_config) + + assert response.error == ERROR_MESSAGE + assert response.transaction_id == TRANSACTION_TOKEN + assert response.kind == TransactionKind.VOID + assert not response.is_success + assert response.amount == payment.total.amount + assert response.currency == TRANSACTION_CURRENCY + assert response.raw_response == {} diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..b91e108 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,103 @@ +# flake8: noqa + +import os + +from django.utils.translation import pgettext_lazy + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test.db' + }, +} + +SECRET_KEY = 'not_so_secret' + +USE_TZ = True + +# Use a fast hasher to speed up tests. +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.messages', + 'django.contrib.contenttypes', + 'django_fsm', + 'djmoney', + 'tests', + 'payment.apps.PaymentConfig', +] + +MIDDLEWARE = [ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +] + +STATIC_URL = '/static/' + +ROOT_URLCONF = 'tests.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.request', + 'django.template.context_processors.static', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +DUMMY = "dummy" +STRIPE = "stripe" + +CHECKOUT_PAYMENT_GATEWAYS = { + DUMMY: pgettext_lazy("Payment method name", "Dummy gateway"), + STRIPE: pgettext_lazy("Payment method name", "Stripe"), +} + +PAYMENT_GATEWAYS = { + DUMMY: { + "module": "payment.gateways.dummy", + "config": { + "auto_capture": True, + "connection_params": {}, + "template_path": "payment/dummy.html", + }, + }, + STRIPE: { + "module": "payment.gateways.stripe", + "config": { + "auto_capture": True, + "template_path": "payment/stripe.html", + "connection_params": { + "public_key": os.environ.get("STRIPE_PUBLIC_KEY"), + "secret_key": os.environ.get("STRIPE_SECRET_KEY"), + "store_name": os.environ.get("STRIPE_STORE_NAME", "skioo shop"), + "store_image": os.environ.get("STRIPE_STORE_IMAGE", None), + "prefill": os.environ.get("STRIPE_PREFILL", True), + "remember_me": os.environ.get("STRIPE_REMEMBER_ME", False), + "locale": os.environ.get("STRIPE_LOCALE", "auto"), + "enable_billing_address": os.environ.get( + "STRIPE_ENABLE_BILLING_ADDRESS", False + ), + "enable_shipping_address": os.environ.get( + "STRIPE_ENABLE_SHIPPING_ADDRESS", False + ), + }, + }, + }, +} diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..ede2ec9 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import url +from django.contrib import admin + +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..bd0aaba --- /dev/null +++ b/tox.ini @@ -0,0 +1,26 @@ +[tox] +envlist = + {py36,py37}-{django21,django22}-{test-with-coverage} + py36-django22-{checkmigrations,flake,mypy} + +[testenv] +basepython = + py36: python3.6 + py37: python3.7 +commands = + test-with-coverage: py.test tests --cov=payment + checkmigrations: ./manage.py makemigrations --check --dry-run + flake: flake8 + mypy: mypy . +deps = + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<2.3 + django-money + django-fsm + structlog + typing + pytest-django + pytest-cov + flake8 + mypy +