├── .github └── workflows │ └── lint_and_test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── paystack ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── paystack_urls.py ├── serializers │ ├── __init__.py │ ├── customer.py │ └── transaction.py ├── services │ ├── __init__.py │ ├── base_api_service.py │ ├── customer_service.py │ ├── transaction_service.py │ └── webhook_service.py ├── urls.py ├── utils.py └── views │ ├── __init__.py │ ├── customer.py │ ├── transaction.py │ └── webhook.py ├── pyproject.toml ├── requirements-dev.txt ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── mixins ├── __init__.py ├── request_mixin.py └── urls_mixin.py ├── mock_data.py ├── settings.py ├── test_services ├── __init__.py ├── test_customer_service.py └── test_transaction_service.py └── test_views ├── __init__.py ├── test_customer.py └── test_transactions.py /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | 3 | on: 4 | push: 5 | branches: [ main, production ] 6 | pull_request: 7 | branches: [ main, production ] 8 | schedule: 9 | - cron: '30 8,20 * * *' 10 | jobs: 11 | lint-and-test: 12 | runs-on: ubuntu-latest 13 | services: 14 | postgres: 15 | image: postgres:latest 16 | env: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: paystack 20 | ports: 21 | - 5432:5432 22 | # needed because the postgres container does not provide a healthcheck 23 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 24 | strategy: 25 | max-parallel: 4 26 | matrix: 27 | python-version: [3.7, 3.8, 3.9] 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: psycopg2 prerequisites 35 | run: | 36 | sudo apt-get update 37 | sudo apt-get install libpq-dev 38 | - name: Install Dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -r requirements-dev.txt 42 | - name: Lint 43 | run: | 44 | black . 45 | isort . 46 | flake8 . 47 | - name: Run Tests 48 | env: 49 | PAYSTACK_PUBLIC_KEY: ${{ secrets.PAYSTACK_PUBLIC_KEY }} 50 | PAYSTACK_PRIVATE_KEY: ${{ secrets.PAYSTACK_PRIVATE_KEY }} 51 | run: | 52 | python -m pytest tests 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Django # 2 | *.log 3 | *.pot 4 | *.pyc 5 | __pycache__ 6 | db.sqlite3 7 | media 8 | 9 | # Environments 10 | .env 11 | .venv 12 | env/ 13 | venv/ 14 | ENV/ 15 | env.bak/ 16 | venv.bak/ 17 | 18 | # Backup files # 19 | *.bak 20 | 21 | # Pytest 22 | .pytest_cache 23 | 24 | dist 25 | django_rest_paystack.egg-info -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: stable 4 | hooks: 5 | - id: black 6 | language_version: python3.8 7 | - repo: https://github.com/pycqa/isort 8 | rev: 5.10.1 9 | hooks: 10 | - id: isort 11 | name: isort (python) 12 | - repo: https://gitlab.com/pycqa/flake8 13 | rev: 3.7.9 14 | hooks: 15 | - id: flake8 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-2030 Nyior Clement 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | django-rest-paystack: a minimal SDK for integrating Paystack into your django-rest API backend. 3 |

4 | 5 | ![Github-A Build Status](https://github.com//Nyior/django-rest-paystack/actions/workflows/lint_and_test.yml/badge.svg) 6 | [![PyPI version](https://badge.fury.io/py/django-rest-paystack.svg)](https://badge.fury.io/py/django-rest-paystack) 7 | 8 | 9 |

10 | Focus on your business logic. Leave all the mundane payment _serere_ to us. 11 | Our package will do all the heavy lifting for you :D 12 |

13 | 14 | ## Contents 15 | 16 | * [What is django-rest-paystack?](#what-is-django-rest-paystack) 17 | * [How do I use this package in my project?](#how-do-i-use-this-package-in-my-project) 18 | * [Paying for an order](#paying-for-an-order) 19 | * [How can I extend the webhook class?](#how-can-i-extend-the-webhook-class) 20 | * [Limitations](#todo) 21 | * [Contributing](#contributing) 22 | * [Acknowledgements](#acknowledgements) 23 | * [Wanna thank me? Just star this repo](#oh-okay-i-gerrit-thank-you-nyior) 24 | 25 | ## What is django-rest-paystack? 26 | 27 | ### Overview 28 | Creating those payment endpoints for every single e-commerce project we work on could become 29 | redundant and perharps somewhat boring overtime. While there are different approaches to integrating and processing payments with a gateway like Paystack(more on this later), in each approach, the flow doesn't really change. If it doesn't change then why repeat yourself? _you nor need stress lol_ 30 | 31 | DRY: Enter django-rest-paystack. 32 | when installed and configured, this package generates all the endpoints you'd need to start and 33 | complete a transaction. 34 | 35 | ### Endpoints 36 | * initialize a transaction: 37 | ```python 38 | POST /api/v1/paystack/transaction/initiate 39 | 40 | minimal_payload = { 41 | "amount": 0, 42 | "email": "string", 43 | "metadata": dict/json, --Optional 44 | } 45 | 46 | # By default, this package always sends the user_id metadata to paystack 47 | # There lots of other optional parameters you could add to the payload --refer to paystack docs 48 | ``` 49 | * verify a transaction: `GET /api/v1/paystack/transaction/verify/?transaction_ref="ref"` 50 | 51 | * Get user authorization code: `GET /api/v1/paystack/paystack-customer/{user__id}/` 52 | 53 | * charge an authorization: 54 | ```python 55 | POST /api/v1/paystack/transaction/charge-customer` 56 | minimal_payload = { 57 | "amount": 0, 58 | "email": "string", 59 | "authorization_code": "string", 60 | } 61 | ``` 62 | 63 | * handle webhooks: ` api/v1/paystack/webook-handler` 64 | 65 | * get all transactions: `/api/v1/paystack/transaction` 66 | 67 | * retrieve a single transaction: `/api/v1/paystack/transaction/{uuid}` 68 | * This package also logs some relevant data like the authorization_code in the db. 69 | 70 | If you're not very familiar with how some of those endpoints work, don't worry, I will get to that in a bit. 71 | 72 | 73 | ## How do I use this package in my project? 74 | 75 | ### Quick Setup 76 | 77 | Install package 78 | 79 | pip install django-rest-paystack 80 | 81 | Add `paystack` app to INSTALLED_APPS in your django `settings.py`: 82 | 83 | ```python 84 | INSTALLED_APPS = ( 85 | ..., 86 | 'rest_framework', 87 | 'rest_framework.authtoken', 88 | ..., 89 | 'paystack' 90 | ) 91 | ``` 92 | 93 | Load paystack credentials in your django `settings.py`: 94 | 95 | ```python 96 | # Ideally, these values should be stored as environment variables, and loaded like so: 97 | 98 | PAYSTACK_PUBLIC_KEY=os.environ.get('name-of-var') 99 | PAYSTACK_PRIVATE_KEY=os.environ.get('name-of-var') 100 | 101 | ``` 102 | 103 | Add URL patterns 104 | 105 | ```python 106 | urlpatterns = [ 107 | path('paystack/', include('paystack.urls')), 108 | ] 109 | ``` 110 | 111 | Specify DEFAULT_AUTHENTICATION_CLASSES to be applied to the Paystack views(OPTIONAL) 112 | in your `settings.py`like so: 113 | 114 | ```python 115 | # Note: Specifying this is optional, and when you don't, 116 | # This package defaults to the TokenAuthentication class 117 | 118 | REST_FRAMEWORK = { 119 | "DEFAULT_AUTHENTICATION_CLASSES": "rest_framework.schemas.coreapi.AutoSchema" 120 | } 121 | ``` 122 | 123 | Run migrations to create the `PaystackCustomer, TransactionLog` models that comes with this package 124 | 125 | ```python 126 | manage migrate 127 | 128 | # The created models are automically registered and made available to you in the admin view 129 | ``` 130 | 131 | ## Paying for an order 132 | While the checkout process could be handled in different ways with Paystack, the general flow is this: 133 | * Payment is initialized from the frontend. Initializing a payment entails collecting the user details(email, name), and total amount and sending it to Paystack. 134 | * A response is then returned to the frontend. The response usually contains useful data like the _access code_, and the _redirect url_. 135 | * The frontend then charges the user's card 136 | * Once the card is charged and the process completed, paystack then returns the _transaction_reference_(a unique identifier for each transaction) to the frontend 137 | * The frontend could then use the _transaction_reference_ to verify(get the status) of the transaction 138 | * In addition, Once the card is charged and the process completed, paystack then sends an event to a specified webhook url 139 | 140 | #### That's the general flow. Let's look at these specific ways... 141 | There are about four ways of handling checkouts with Paystack. This package has been designed to cater for the three most common approaches. 142 | Let's quickly go over the flow for each approach and how you could use this package to process an order in each scenario. 143 | 144 | ##### Paystack Popup: with Paystack inline Javascript 145 | Here you'd import Paystack's inline Javascript using the _script_ tag. This will inturn insert the Paystack's pay button somewhere on your page. on click of the pay button, the popup for collecting a customer's card details is loaded and shown to the user. (oversimplified sha). 146 | 147 | Follow the below steps to use this package to process an order in this scenario: 148 | * Do all the necessary frontend setup. The initialization of payment happens entirely on the frontend. 149 | * Once a card has been charged from the frontend. You could verify the transaction using the `GET /api/v1/paystack/transaction/verify/?transaction_ref="ref"` endpoint 150 | 151 | ##### Redirect: redirecting to a paystack page outside your website or mobile app 152 | No imports required here. A user is redirected to paystack where they make payment. 153 | 154 | Follow the below steps to use this package to process an order in this scenario: 155 | 156 | * Make a call to the ` POST /api/v1/paystack/transaction/initiate ` with the expected payload from the frontend to initialize a transaction 157 | * The endpoint then returns a response that contains the _redirect url_ and _access code_ to the frontend 158 | * The frontend then redirects the customer to the _redirect url_ returned in the reponse. The customer is charged there. 159 | * Make sure to add a CALL BACK URL on your paystack dashboard. Once the customer has been charged on the redirect page they'd be taken back to the CALL BACK URL you specify(usually a page on your site). When the users are taken back to the CALL BACK URL, the transaction reference for that transaction is appended to the URL. 160 | * Once a user is taken back to the CALL BACK URL on your site, You could then extract the _transaction reference_ appended to the URL and make a call to the `GET /api/v1/paystack/transaction/verify/?transaction_ref="ref"` endpoint to verify the transaction. 161 | 162 | ##### Paystack mobile SDKs 163 | No redirect here. It's the mobile version of the Paystack inline Javascript popup for web applications. 164 | 165 | Follow the below steps to use this package to process an order in this scenario: 166 | * Do all the necessary frontend setup. Essentially, you'd have to integrate some mobile SDK that allows users make payment within your mobile app without redirecting the user. 167 | * Make a call to the ` POST /api/v1/paystack/transaction/initiate ` with the expected payload from the frontend to initialize a transaction 168 | * The endpoint then returns a response that contains the _access code_ and _redirect url_ to the frontend 169 | * The frontend could then use the access code to charge a card within the app using the mobile SDK integrated. 170 | * Once a card has been charged from the frontend using the mobile SDK a response is returned containing the transaction reference. You could then verify the transaction using the `GET /api/v1/paystack/transaction/verify/?transaction_ref="ref"` endpoint 171 | 172 | 173 | In all scenarios, make sure to specify the `your-domain + api/v1/paystack/webook-handler` endpoint as your WEBHOOOK URL on your Paystack dashboard. It is important that you do this because, eventhough we have an endpoint where you could verify and get the status of a transaction, it is in the webhook that we are logging things like the transaction data as well as other things like the authorization_code that could be used to charge a customer that has already been charged in the past. See code snipet below: 174 | 175 | ```python 176 | class WebhookService(object): 177 | def __init__(self, request) -> None: 178 | self.request = request 179 | 180 | def webhook_handler(self): 181 | secret = getattr( 182 | settings, ' PAYSTACK_PRIVATE_KEY', None 183 | ) 184 | webhook_data = self.request.data 185 | hash = hmac.new(secret, webhook_data, digestmod=hashlib.sha512).hexdigest() 186 | 187 | if hash != self.request.headers["x-paystack-signature"]: 188 | raise ValidationError("MAC authentication failed") 189 | 190 | if webhook_data["event"] == "charge.success": 191 | paystack_service = TransactionService() 192 | paystack_service.log_transaction(webhook_data["data"]) 193 | 194 | customer_service = CustomerService() # logs customer data like the auth_code here 195 | customer_service.log_customer(webhook_data["data"]) 196 | 197 | return webhook_data 198 | ``` 199 | 200 | **NOTE:** Always offer value in the Webook. For exaxmple, if you want to create an instance of an 201 | order for users after they've paid, it is advisable that you do that in the webhook. Paystack recommends that. 202 | 203 | Keeping in mind that you might want to perform some custom actions in the webhook that we can't possibly 204 | predict, we made the webhook class extensible. 205 | 206 | ## How can I extend the webhook class? 207 | If you wish to extend the webhook class, then here is how to: 208 | 209 | ### The WebhookFacadeView 210 | 211 | ```python 212 | # First import the WebhookFacade 213 | from paystack.views import WebhookFacadeView 214 | 215 | 216 | # Then create your own view that extends the Facade 217 | class WebhookView(WebhookFacadeView): 218 | 219 | def post(self, request): 220 | webhook_data = super().post(request) 221 | 222 | # do whatever you want with the webhook data 223 | # Then return a response to Paystack 224 | 225 | ``` 226 | ## Oh okay, I gerrit. Thank you Nyior 227 | You're welcome. If you like this repo, click the :star: I'd appreciate that. 228 | 229 | 230 | ## TODO: 231 | * Add split payments feature 232 | * Enable transfers 233 | * Enable subscription based(recurring) payments 234 | * Make tests more encompassing 235 | 236 | 237 | ## Contributing 238 | * Create an Issue for the feature/bug you'd like to work on. Or just pick an existing issue. 239 | * Setup project locally and write your code. 240 | * We use isort, black, and flake8 as one of the measures taken to ensure high code quality(We still need more of these measures sha). We use a precommit hook to run isort, black and flake8 each time we make a commit. We advice that you do thesame. 241 | * Each time you're done making your changes, run the test suite locally, and ensure they're all passing. 242 | * Also write tests for your new changes. 243 | * If all test are passing, you can then open a PR targeted on main. 244 | **Note:** A workflow will be executed on your PR. So if you don't follow the instructions above. The build will fail and your PR won't be reviewed. 245 | 246 | ## Acknowledgements 247 | In building this, I found the following repositories really helpful 248 | * [laravel-paystack](https://github.com/unicodeveloper/laravel-paystack) 249 | * [popoola/pypaystack](https://github.com/edwardpopoola/pypaystack) 250 | * [gbozee/pypaystack](https://github.com/gbozee/pypaystack) 251 | 252 | ## License 253 | This project is released under the [MIT](https://choosealicense.com/licenses/mit/) License 254 | -------------------------------------------------------------------------------- /paystack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyior/django-rest-paystack/fd74dd26703fe4ce63664736c2063ace7020f71a/paystack/__init__.py -------------------------------------------------------------------------------- /paystack/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from paystack.models import PayStackCustomer, TransactionLog 4 | 5 | 6 | class PayStackCustomerAdmin(admin.ModelAdmin): 7 | model = PayStackCustomer 8 | list_display = ["user", "email", "authorization_code"] 9 | 10 | 11 | class TransactionLogAdmin(admin.ModelAdmin): 12 | model = TransactionLog 13 | list_display = ["uuid", "user", "charge_type", "amount"] 14 | 15 | 16 | admin.site.register(PayStackCustomer, PayStackCustomerAdmin) 17 | admin.site.register(TransactionLog, TransactionLogAdmin) 18 | -------------------------------------------------------------------------------- /paystack/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PaystackConfig(AppConfig): 5 | name = "paystack" 6 | -------------------------------------------------------------------------------- /paystack/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2021-12-12 11:26 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="TransactionLog", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), 32 | ( 33 | "charge_type", 34 | models.CharField( 35 | choices=[ 36 | ("GATEWAY_PURCHASE", "Gateway Purchase"), 37 | ("TRANSFER", "Transfer"), 38 | ], 39 | max_length=30, 40 | ), 41 | ), 42 | ("amount", models.FloatField(max_length=19)), 43 | ("currency", models.CharField(max_length=5)), 44 | ("txRef", models.CharField(blank=True, max_length=100, null=True)), 45 | ( 46 | "payment_date_time", 47 | models.DateTimeField(blank=True, max_length=100, null=True), 48 | ), 49 | ("status", models.CharField(blank=True, max_length=50, null=True)), 50 | ( 51 | "user", 52 | models.ForeignKey( 53 | null=True, 54 | on_delete=django.db.models.deletion.SET_NULL, 55 | to=settings.AUTH_USER_MODEL, 56 | ), 57 | ), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name="PayStackCustomer", 62 | fields=[ 63 | ( 64 | "id", 65 | models.AutoField( 66 | auto_created=True, 67 | primary_key=True, 68 | serialize=False, 69 | verbose_name="ID", 70 | ), 71 | ), 72 | ("email", models.CharField(blank=True, max_length=100, null=True)), 73 | ( 74 | "authorization_code", 75 | models.CharField(blank=True, max_length=100, null=True), 76 | ), 77 | ("card_type", models.CharField(blank=True, max_length=10, null=True)), 78 | ("last4", models.CharField(blank=True, max_length=4, null=True)), 79 | ("exp_month", models.CharField(blank=True, max_length=10, null=True)), 80 | ("exp_year", models.CharField(blank=True, max_length=10, null=True)), 81 | ("bin", models.CharField(blank=True, max_length=10, null=True)), 82 | ("bank", models.CharField(blank=True, max_length=100, null=True)), 83 | ( 84 | "account_name", 85 | models.CharField(blank=True, max_length=100, null=True), 86 | ), 87 | ( 88 | "user", 89 | models.ForeignKey( 90 | null=True, 91 | on_delete=django.db.models.deletion.SET_NULL, 92 | to=settings.AUTH_USER_MODEL, 93 | ), 94 | ), 95 | ], 96 | ), 97 | ] 98 | -------------------------------------------------------------------------------- /paystack/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyior/django-rest-paystack/fd74dd26703fe4ce63664736c2063ace7020f71a/paystack/migrations/__init__.py -------------------------------------------------------------------------------- /paystack/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.db import models 5 | 6 | User = get_user_model() 7 | 8 | 9 | class PayStackCustomer(models.Model): 10 | """ 11 | for charging a customer's card again using authorization code 12 | for transfers too 13 | """ 14 | 15 | user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) 16 | 17 | # authorization creds for recurring charges 18 | email = models.CharField(blank=True, null=True, max_length=100) 19 | authorization_code = models.CharField(blank=True, null=True, max_length=100) 20 | card_type = models.CharField(blank=True, null=True, max_length=10) 21 | last4 = models.CharField(blank=True, null=True, max_length=4) 22 | exp_month = models.CharField(blank=True, null=True, max_length=10) 23 | exp_year = models.CharField(blank=True, null=True, max_length=10) 24 | bin = models.CharField(blank=True, null=True, max_length=10) 25 | bank = models.CharField(blank=True, null=True, max_length=100) 26 | account_name = models.CharField(blank=True, null=True, max_length=100) 27 | 28 | 29 | class TransactionLog(models.Model): 30 | 31 | GATEWAY_PURCHASE = "GATEWAY_PURCHASE" 32 | TRANSFER = "TRANSFER" 33 | 34 | uuid = models.UUIDField(unique=True, default=uuid.uuid4) 35 | user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) 36 | charge_type = models.CharField( 37 | max_length=30, 38 | choices=( 39 | (GATEWAY_PURCHASE, "Gateway Purchase"), 40 | (TRANSFER, "Transfer"), 41 | ), 42 | ) 43 | amount = models.FloatField(max_length=19) 44 | currency = models.CharField(max_length=5) 45 | txRef = models.CharField(max_length=100, null=True, blank=True) 46 | 47 | payment_date_time = models.DateTimeField(max_length=100, null=True, blank=True) 48 | status = models.CharField(max_length=50, null=True, blank=True) 49 | -------------------------------------------------------------------------------- /paystack/paystack_urls.py: -------------------------------------------------------------------------------- 1 | PAYSTACK_BASE_URL = "https://api.paystack.co" 2 | 3 | TRANSACTION_URL = PAYSTACK_BASE_URL + "/transaction" 4 | PAYSTACK_INITIALIZE_TRANSACTION_URL = TRANSACTION_URL + "/initialize" 5 | PAYSTACK_VERIFY_TRANSACTION_URL = TRANSACTION_URL + "/verify/{0}" # insert tran. ref. 6 | 7 | PAYSTACK_CHARGE_AUTHORIZATION_URL = TRANSACTION_URL + "/charge_authorization" 8 | -------------------------------------------------------------------------------- /paystack/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .customer import * 2 | from .transaction import * 3 | -------------------------------------------------------------------------------- /paystack/serializers/customer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from paystack.models import PayStackCustomer 4 | 5 | 6 | class CustomerSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = PayStackCustomer 9 | fields = "__all__" 10 | -------------------------------------------------------------------------------- /paystack/serializers/transaction.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from paystack.models import TransactionLog 4 | 5 | 6 | class PaymentSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = TransactionLog 9 | fields = "__all__" 10 | -------------------------------------------------------------------------------- /paystack/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .customer_service import * 2 | from .transaction_service import * 3 | from .webhook_service import * 4 | -------------------------------------------------------------------------------- /paystack/services/base_api_service.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from rest_framework.exceptions import ValidationError 7 | 8 | User = get_user_model() 9 | 10 | 11 | class BaseAPIService(object): # Not to be instantiated directly 12 | def get_user(self, user_id): 13 | user = User.objects.get(id=user_id) 14 | return user 15 | 16 | def make_request(self, method, url, payload=None): 17 | 18 | try: 19 | PAYSTACK_PRIVATE_KEY = getattr(settings, "PAYSTACK_PRIVATE_KEY") 20 | except Exception as e: # If ser hasn't declared variable 21 | raise ValidationError(e) 22 | 23 | headers = { 24 | "Content-Type": "application/json", 25 | "Authorization": f"Bearer { PAYSTACK_PRIVATE_KEY }", 26 | } 27 | 28 | response = requests.request( 29 | method, url, data=json.dumps(payload), headers=headers 30 | ) 31 | 32 | if response.status_code != 200: 33 | if response.text: 34 | raise ValidationError(response.text) 35 | else: 36 | raise ValidationError( 37 | f"paystack failed with error code: {response.status_code}" 38 | ) 39 | 40 | data_json_str = json.dumps(json.loads(response.text)) 41 | # convert json str to json object 42 | result = json.loads(data_json_str) 43 | 44 | return result 45 | 46 | def validate_amount(self, amount): 47 | 48 | if isinstance(amount, int) or isinstance(amount, float): 49 | if amount < 0: 50 | raise ValidationError("Negative amount is not allowed") 51 | return amount * 100 # in kobo 52 | else: 53 | raise ValidationError("Amount must be a number") 54 | 55 | def validate_email(self, email): 56 | if not email: 57 | raise ValidationError("Customer Email is required") 58 | -------------------------------------------------------------------------------- /paystack/services/customer_service.py: -------------------------------------------------------------------------------- 1 | from paystack.models import PayStackCustomer 2 | 3 | from .base_api_service import BaseAPIService 4 | 5 | 6 | class CustomerService(BaseAPIService): 7 | def _create_customer_object(self, user, customer_data, authorization_data): 8 | defaults = { 9 | "user": user, 10 | "email": customer_data["email"], 11 | "authorization_code": authorization_data["authorization_code"], 12 | "card_type": authorization_data["card_type"], 13 | "last4": authorization_data["last4"], 14 | "exp_month": authorization_data["exp_month"], 15 | "exp_year": authorization_data["exp_year"], 16 | "bin": authorization_data["bin"], 17 | "bank": authorization_data["bank"], 18 | "account_name": authorization_data["account_name"], 19 | } 20 | 21 | PayStackCustomer.objects.update_or_create(**defaults, defaults=defaults) 22 | 23 | def log_customer(self, webhook_data) -> None: 24 | user_id = webhook_data["metadata"]["user_id"] 25 | user = self.get_user(user_id) 26 | 27 | customer_data = webhook_data["customer"] 28 | authorization_data = webhook_data["authorization"] 29 | 30 | self._create_customer_object(user, customer_data, authorization_data) 31 | -------------------------------------------------------------------------------- /paystack/services/transaction_service.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import ValidationError 2 | from rest_framework.response import Response 3 | 4 | from paystack.models import TransactionLog 5 | from paystack.paystack_urls import ( 6 | PAYSTACK_CHARGE_AUTHORIZATION_URL, 7 | PAYSTACK_INITIALIZE_TRANSACTION_URL, 8 | PAYSTACK_VERIFY_TRANSACTION_URL, 9 | ) 10 | 11 | from .base_api_service import BaseAPIService 12 | 13 | 14 | class TransactionService(BaseAPIService): 15 | def _create_transaction_object(self, transaction_data): 16 | user_id = transaction_data["metadata"]["user_id"] 17 | user = self.get_user(user_id) 18 | 19 | TransactionLog.objects.create( 20 | user=user, 21 | charge_type="GATEWAY PURCHASE", 22 | amount=transaction_data["amount"], 23 | currency=transaction_data["currency"], 24 | txRef=transaction_data["reference"], 25 | payment_date_time=transaction_data["paid_at"], 26 | status=transaction_data["status"], 27 | ) 28 | 29 | def log_transaction( 30 | self, transaction_data 31 | ): # transaction will be logged in the webhook 32 | self._create_transaction_object(transaction_data) 33 | 34 | def _validate_initiate_payload(self, payload: dict) -> None: 35 | """ 36 | check that payload has all the required params 37 | """ 38 | required = ["email", "amount"] 39 | 40 | for i in required: 41 | try: 42 | payload[i] 43 | except KeyError: 44 | raise ValidationError(f"{i} must be provided") 45 | 46 | self.validate_amount(payload["amount"]) 47 | 48 | def _validate_charge_payload(self, payload: dict) -> None: 49 | """ 50 | check that payload has all the required params 51 | """ 52 | required = ["email", "amount", "authorization_code"] 53 | 54 | for i in required: 55 | try: 56 | payload[i] 57 | except KeyError: 58 | raise ValidationError(f"{i} must be provided") 59 | 60 | self.validate_amount(payload["amount"]) 61 | 62 | def initialize_payment(self, payload: dict) -> Response: 63 | url = PAYSTACK_INITIALIZE_TRANSACTION_URL 64 | 65 | self._validate_initiate_payload(payload) 66 | return self.make_request("POST", url, payload) 67 | 68 | def verify_payment(self, transaction_ref: str) -> Response: 69 | url = PAYSTACK_VERIFY_TRANSACTION_URL.format(transaction_ref) 70 | 71 | return self.make_request("GET", url) 72 | 73 | def recurrent_charge(self, payload: dict) -> Response: 74 | self._validate_charge_payload(payload) 75 | 76 | url = PAYSTACK_CHARGE_AUTHORIZATION_URL 77 | 78 | return self.make_request("POST", url, payload) 79 | -------------------------------------------------------------------------------- /paystack/services/webhook_service.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | 4 | from django.conf import settings 5 | from rest_framework.exceptions import ValidationError 6 | 7 | from .customer_service import CustomerService 8 | from .transaction_service import TransactionService 9 | 10 | 11 | class WebhookService(object): 12 | def __init__(self, request) -> None: 13 | self.request = request 14 | 15 | def webhook_handler(self): 16 | try: 17 | secret = getattr(settings, "PAYSTACK_PRIVATE_KEY") 18 | except Exception as e: # If user hasn't declared variable 19 | raise ValidationError(e) 20 | 21 | webhook_data = self.request.data 22 | hash = hmac.new(secret, webhook_data, digestmod=hashlib.sha512).hexdigest() 23 | 24 | if hash != self.request.headers["x-paystack-signature"]: 25 | raise ValidationError("MAC authentication failed") 26 | 27 | if webhook_data["event"] == "charge.success": 28 | paystack_service = TransactionService() 29 | paystack_service.log_transaction(webhook_data["data"]) 30 | 31 | customer_service = CustomerService() 32 | customer_service.log_customer(webhook_data["data"]) 33 | 34 | return webhook_data 35 | -------------------------------------------------------------------------------- /paystack/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from rest_framework import routers 3 | 4 | from paystack.views import PaystackCustomerViewSet, TransactionViewSet, WebhookView 5 | 6 | router = routers.DefaultRouter() 7 | router.register("transaction", TransactionViewSet, basename="transaction") 8 | router.register("paystack-customer", PaystackCustomerViewSet, basename="customer") 9 | 10 | urlpatterns = [ 11 | path("", include(router.urls)), 12 | path("webook-handler", WebhookView.as_view(), name="webhook-handler"), 13 | ] 14 | -------------------------------------------------------------------------------- /paystack/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework import status 3 | from rest_framework.authentication import TokenAuthentication 4 | from rest_framework.response import Response 5 | 6 | 7 | def return_okay_response(data=None, status=status.HTTP_200_OK) -> Response: 8 | response = {"status": "success", "result": data} 9 | return Response(response, status=status) 10 | 11 | 12 | def return_bad_response(data, status=status.HTTP_400_BAD_REQUEST) -> Response: 13 | response = {"status": "Failure", "result": data} 14 | return Response(response, status=status) 15 | 16 | 17 | def get_authentication_class(): 18 | 19 | try: 20 | return getattr(settings, "REST_FRAMEWORK")["DEFAULT_AUTHENTICATION_CLASSES"] 21 | 22 | except Exception: 23 | return (TokenAuthentication,) 24 | -------------------------------------------------------------------------------- /paystack/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .customer import * 2 | from .transaction import * 3 | from .webhook import * 4 | -------------------------------------------------------------------------------- /paystack/views/customer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | 3 | from paystack.models import PayStackCustomer 4 | from paystack.serializers import CustomerSerializer 5 | from paystack.utils import get_authentication_class 6 | 7 | 8 | class PaystackCustomerViewSet(viewsets.ModelViewSet): 9 | queryset = PayStackCustomer.objects.all() 10 | serializer_class = CustomerSerializer 11 | http_method_names = ["get"] 12 | authentication_classes = get_authentication_class() 13 | lookup_field = "user__id" 14 | -------------------------------------------------------------------------------- /paystack/views/transaction.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from rest_framework.decorators import action 3 | 4 | from paystack.models import TransactionLog 5 | from paystack.serializers import PaymentSerializer 6 | from paystack.services import TransactionService 7 | from paystack.utils import get_authentication_class, return_okay_response 8 | 9 | 10 | class TransactionViewSet(viewsets.ModelViewSet): 11 | queryset = TransactionLog.objects.all() 12 | serializer_class = PaymentSerializer 13 | http_method_names = ["get", "post"] 14 | authentication_classes = get_authentication_class() 15 | lookup_field = "uuid" 16 | 17 | @action(detail=False, methods=["post"]) 18 | def initiate(self, request): 19 | """ 20 | This is used to charge customers that had already been charged in 21 | past. 22 | Expects the payload in the format below: 23 | 24 | { 25 | "email": "string", 26 | "amount": float/int, 27 | "metadata": dict/json, --Optional 28 | } 29 | """ 30 | payload = request.data 31 | 32 | if "metadata" in payload: 33 | payload["metadata"]["user_id"] = request.user.id 34 | else: 35 | payload["metadata"] = {"user_id": request.user.id} 36 | 37 | transaction_service = TransactionService() 38 | initiated_transaction = transaction_service.initialize_payment(payload) 39 | 40 | return return_okay_response(initiated_transaction) 41 | 42 | @action(detail=False, methods=["get"]) 43 | def verify(self, request): 44 | transaction_ref = request.query_params.get("transaction_ref") 45 | 46 | paystack_service = TransactionService() 47 | verified_transaction = paystack_service.verify_payment(transaction_ref) 48 | 49 | return return_okay_response(verified_transaction) 50 | 51 | @action(detail=False, methods=["post"], url_path="charge-customer") 52 | def charge_customer(self, request): 53 | """ 54 | This is used to charge customers that had already been charged in 55 | past. 56 | Expects the payload in the format below: 57 | 58 | { 59 | "email": "string", 60 | "amount": float/int, 61 | "authorization_code": "string", 62 | } 63 | 64 | """ 65 | payload = request.data 66 | 67 | transaction_service = TransactionService() 68 | charge = transaction_service.recurrent_charge(payload) 69 | 70 | return return_okay_response(charge) 71 | -------------------------------------------------------------------------------- /paystack/views/webhook.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import APIView 2 | 3 | from paystack.services import WebhookService 4 | from paystack.utils import return_okay_response 5 | 6 | 7 | class WebhookFacadeView(APIView): 8 | """ 9 | Exsits for extensibility reasons. Users might want to capture 10 | the data returned from Paystack and do some stuff with it. 11 | E.g retrieve the user tied to the 12 | payment(usually passed as a meta data in this package) 13 | and clear the user's cart or create an order for that user. 14 | """ 15 | 16 | authentication_classes = [] 17 | permission_classes = [] 18 | 19 | def post(self, request): 20 | webhook_service = WebhookService(request) 21 | return ( 22 | webhook_service.webhook_handler() 23 | ) # This returns raw JSON data from Paystack 24 | 25 | 26 | class WebhookView(WebhookFacadeView): 27 | def post(self, request): 28 | webhook_data = super().post(request) 29 | 30 | return_okay_response(webhook_data) # Return instance of JsonResponse 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | Django==2.2 2 | djangorestframework==3.12.4 3 | requests==2.26.0 4 | python-dotenv==0.19.2 5 | isort==5.10.1 6 | black==21.12b0 7 | flake8==4.0.1 8 | pre-commit==2.16.0 9 | pytest==6.2.5 10 | psycopg2-binary==2.8.6 11 | pytest-django==4.5.2 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-rest-paystack 3 | version = 2.2.2 4 | author = Nyior Clement 5 | author-email = cnyior27@gmail.com 6 | description = A minimal SDK for integrating Paystack into your django-rest API backend. 7 | description-file = README.md 8 | long-description = file:README.md 9 | long-description-content-type = text/markdown; charset=UTF-8 10 | url = https://github.com/Nyior/django-rest-paystack 11 | project-urls = 12 | Bug Tracker = https://github.com/Nyior/django-rest-paystack/issues 13 | Source Code = https://github.com/Nyior/django-rest-paystack 14 | keywords = 15 | django 16 | rest 17 | api 18 | paystack 19 | rest-framework 20 | payment 21 | initialize payment 22 | verify payment 23 | charge authorization 24 | handle webhook 25 | license = MIT 26 | license-file = LICENSE 27 | classifiers = 28 | Environment :: Web Environment 29 | Framework :: Django 30 | Framework :: Django :: 2.0 31 | Framework :: Django :: 3.0 32 | Intended Audience :: Developers 33 | License :: OSI Approved :: MIT License 34 | Operating System :: OS Independent 35 | Programming Language :: Python 36 | Programming Language :: Python :: 3 37 | Programming Language :: Python :: 3 :: Only 38 | Topic :: Internet 39 | Topic :: Internet :: WWW/HTTP 40 | 41 | [options] 42 | python_requires = >=3.7 43 | packages = find: 44 | include_package_data = true 45 | install_requires = 46 | Django>=2.2 47 | djangorestframework>=3.12.4 48 | requests>=2.26.0 49 | 50 | [options.packages.find] 51 | exclude = 52 | tests 53 | 54 | [tool:pytest] 55 | DJANGO_SETTINGS_MODULE = tests.settings 56 | django_find_project = false 57 | testpaths = 58 | tests 59 | 60 | 61 | [tool.black] 62 | line-length = 89 63 | # include = '\.pyi?$' 64 | exclude = migrations, dist, .env 65 | 66 | 67 | [isort] 68 | line_length = 89 69 | skip = migrations, .venv, dist 70 | known_third_party = django_dynamic_fixture 71 | known_first_party = paystack 72 | multi_line_output = 3 73 | include_trailing_comma = True 74 | 75 | [flake8] 76 | max-line-length = 89 77 | exclude = *migrations*, dist, .venv 78 | # ignore = E203, E266, E501, W503, F403, F401 79 | ignore = F403, F401 80 | max-complexity = 18 81 | select = B,C,E,F,W,T4,B9 82 | 83 | 84 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyior/django-rest-paystack/fd74dd26703fe4ce63664736c2063ace7020f71a/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | 4 | pytestmark = pytest.mark.django_db 5 | 6 | User = get_user_model() 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | @pytest.mark.django_db 11 | def user(): 12 | user, _ = User.objects.get_or_create(email="admin@gmail.com", password="Gr3@t!2021") 13 | 14 | return user 15 | 16 | 17 | @pytest.fixture 18 | def valid_transaction_payload(user): 19 | payload = { 20 | "email": user.email, 21 | "amount": 30, 22 | } 23 | 24 | return payload 25 | 26 | 27 | @pytest.fixture 28 | def invalid_transaction_payload(): 29 | payload = { 30 | "amount": None, 31 | } 32 | 33 | return payload 34 | 35 | 36 | @pytest.fixture 37 | def transaction_reference(): 38 | return "1i2r643qy9" # copied from paystack 39 | 40 | 41 | @pytest.fixture 42 | def valid_charge_payload(user): 43 | payload = { 44 | "email": user.email, 45 | "amount": 30100, 46 | "authorization_code": "AUTH_f9q3h9b0g8", 47 | } 48 | 49 | return payload 50 | 51 | 52 | @pytest.fixture 53 | def invalid_charge_payload(): 54 | payload = {"amount": None, "authorization_code": ""} 55 | 56 | return payload 57 | -------------------------------------------------------------------------------- /tests/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .request_mixin import * 2 | from .urls_mixin import * 3 | -------------------------------------------------------------------------------- /tests/mixins/request_mixin.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | from django.utils.encoding import force_str 6 | from rest_framework.authtoken.models import Token 7 | from rest_framework.test import APIClient 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | User = get_user_model() 12 | 13 | 14 | @pytest.mark.usefixtures("client") 15 | class RequestMixin(object): 16 | @pytest.mark.django_db 17 | def _client(self): 18 | user, _ = User.objects.get_or_create( 19 | email="admin@gmail.com", password="Gr3@t!2021" 20 | ) 21 | 22 | token, _ = Token.objects.get_or_create(user=user) 23 | 24 | client = APIClient() 25 | client.credentials(HTTP_AUTHORIZATION="Token " + token.key) 26 | 27 | return client 28 | 29 | def send_request(self, **kwargs): 30 | request_method = kwargs.get("request_method").lower() 31 | request_url = kwargs.get("request_url") 32 | 33 | client = self._client() 34 | if request_method == "post": 35 | response = client.post(request_url, data=kwargs["payload"], format="json") 36 | 37 | if request_method == "get": 38 | response = client.get(request_url) 39 | 40 | return response 41 | -------------------------------------------------------------------------------- /tests/mixins/urls_mixin.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | 4 | class URLsMixin(object): 5 | def initiate_transaction_url(self): 6 | return reverse("transaction-initiate") 7 | 8 | def verify_transaction_url(self, trans_ref): 9 | return reverse("transaction-verify") + f"?transaction_ref={trans_ref}" 10 | 11 | def charge_customer_url(self): 12 | return reverse("transaction-charge-customer") 13 | 14 | def transaction_url(self, transaction_id): 15 | return reverse("transaction-detail") 16 | 17 | def all_transactions_url(self): 18 | return reverse("transaction-list") 19 | 20 | def webhook_handler_url(self): 21 | return reverse("webhook-handler") 22 | 23 | def get_customer_url(self, user_id): 24 | return reverse("customer-detail") 25 | 26 | def all_customers_url(self): 27 | return reverse("customer-list") 28 | -------------------------------------------------------------------------------- /tests/mock_data.py: -------------------------------------------------------------------------------- 1 | webhook_data = { 2 | "id": 1499708386, 3 | "domain": "test", 4 | "status": "success", 5 | "reference": "5jtitec2tm", 6 | "amount": 5000000, 7 | "message": None, 8 | "gateway_response": "Successful", 9 | "paid_at": "2021-12-11T00:52:55.000Z", 10 | "created_at": "2021-12-11T00:52:04.000Z", 11 | "channel": "card", 12 | "currency": "NGN", 13 | "ip_address": "197.210.76.235", 14 | "metadata": {"user_id": "1"}, 15 | "log": { 16 | "start_time": 1639183967, 17 | "time_spent": 8, 18 | "attempts": 1, 19 | "errors": 0, 20 | "success": True, 21 | "mobile": False, 22 | "input": [], 23 | "history": [ 24 | {"type": "action", "message": "Attempted to pay with card", "time": 6}, 25 | {"type": "success", "message": "Successfully paid with card", "time": 8}, 26 | ], 27 | }, 28 | "fees": 85000, 29 | "fees_split": None, 30 | "authorization": { 31 | "authorization_code": "AUTH_f9q3h9b0g8", 32 | "bin": "408408", 33 | "last4": "4081", 34 | "exp_month": "12", 35 | "exp_year": "2030", 36 | "channel": "card", 37 | "card_type": "visa ", 38 | "bank": "TEST BANK", 39 | "country_code": "NG", 40 | "brand": "visa", 41 | "reusable": True, 42 | "signature": "SIG_yBjn5RzLN1p17nBYDgWi", 43 | "account_name": None, 44 | "receiver_bank_account_number": None, 45 | "receiver_bank": None, 46 | }, 47 | "customer": { 48 | "id": 64107141, 49 | "first_name": None, 50 | "last_name": None, 51 | "email": "admin@gmail.com", 52 | "customer_code": "CUS_lhx0fet131a070z", 53 | "phone": None, 54 | "metadata": None, 55 | "risk_action": "default", 56 | "international_format_phone": None, 57 | }, 58 | "plan": None, 59 | "split": {}, 60 | "order_id": None, 61 | "paidAt": "2021-12-11T00:52:55.000Z", 62 | "createdAt": "2021-12-11T00:52:04.000Z", 63 | "requested_amount": 5000000, 64 | "pos_transaction_data": None, 65 | "source": None, 66 | "fees_breakdown": None, 67 | "transaction_date": "2021-12-11T00:52:04.000Z", 68 | "plan_object": {}, 69 | "subaccount": {}, 70 | } 71 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_rest_paystack project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | from dotenv import load_dotenv 16 | 17 | load_dotenv() 18 | 19 | # Run tests: python -m pytest tests 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = Path(__file__).resolve().parent.parent 22 | 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = "django-insecure-lr6%h=1w*u_axr)+ec$++nn^=sc!vf7odcv8_mau&&=160)7wk" 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | # Third party packages 43 | "rest_framework", 44 | "rest_framework.authtoken", 45 | "paystack", 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | "django.middleware.security.SecurityMiddleware", 50 | "django.contrib.sessions.middleware.SessionMiddleware", 51 | "django.middleware.common.CommonMiddleware", 52 | "django.middleware.csrf.CsrfViewMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | "django.contrib.messages.middleware.MessageMiddleware", 55 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 56 | ] 57 | 58 | ROOT_URLCONF = "paystack.urls" 59 | 60 | TEMPLATES = [ 61 | { 62 | "BACKEND": "django.template.backends.django.DjangoTemplates", 63 | "DIRS": [], 64 | "APP_DIRS": True, 65 | "OPTIONS": { 66 | "context_processors": [ 67 | "django.template.context_processors.debug", 68 | "django.template.context_processors.request", 69 | "django.contrib.auth.context_processors.auth", 70 | "django.contrib.messages.context_processors.messages", 71 | ], 72 | "libraries": { 73 | "staticfiles": "django.templatetags.static", 74 | }, 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = "django_rest_paystack.wsgi.application" 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 84 | if os.environ.get("ENV") == "local": 85 | DATABASES = { 86 | "default": { 87 | "ENGINE": "django.db.backends.sqlite3", 88 | "NAME": str(os.path.join(BASE_DIR, "db.sqlite3")), 89 | } 90 | } 91 | elif os.environ.get("GITHUB_ACTIONS"): 92 | DATABASES = { 93 | "default": { 94 | "ENGINE": "django.db.backends.postgresql", 95 | "CONN_MAX_AGE": 120, 96 | "NAME": "paystack", 97 | "USER": "postgres", 98 | "PASSWORD": "postgres", 99 | "HOST": "localhost", 100 | "PORT": "5432", 101 | } 102 | } 103 | else: 104 | DATABASES = { 105 | "default": { 106 | "ENGINE": "django.db.backends.postgresql", 107 | "CONN_MAX_AGE": 120, 108 | "NAME": os.environ.get("POSTGRES_DB"), 109 | "USER": os.environ.get("POSTGRES_USER"), 110 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), 111 | "HOST": "localhost", 112 | "PORT": "5432", 113 | } 114 | } 115 | 116 | REST_FRAMEWORK = {"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema"} 117 | 118 | 119 | PAYSTACK_PUBLIC_KEY = os.environ.get("PAYSTACK_PUBLIC_KEY") 120 | PAYSTACK_PRIVATE_KEY = os.environ.get("PAYSTACK_PRIVATE_KEY") 121 | 122 | # Internationalization 123 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 124 | 125 | LANGUAGE_CODE = "en-us" 126 | 127 | TIME_ZONE = "UTC" 128 | 129 | USE_I18N = True 130 | 131 | USE_L10N = True 132 | 133 | USE_TZ = True 134 | 135 | 136 | # Static files (CSS, JavaScript, Images) 137 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 138 | 139 | STATIC_URL = "/static/" 140 | STATIC_ROOT = os.path.join(BASE_DIR, "static/") 141 | 142 | # Default primary key field type 143 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 144 | 145 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 146 | -------------------------------------------------------------------------------- /tests/test_services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyior/django-rest-paystack/fd74dd26703fe4ce63664736c2063ace7020f71a/tests/test_services/__init__.py -------------------------------------------------------------------------------- /tests/test_services/test_customer_service.py: -------------------------------------------------------------------------------- 1 | # import pytest 2 | 3 | # from paystack.models import PayStackCustomer 4 | # from paystack.services import CustomerService 5 | # from tests.mock_data import webhook_data 6 | 7 | # pytestmark = pytest.mark.django_db 8 | 9 | 10 | # class TestCustomerService(CustomerService): 11 | # @pytest.mark.django_db 12 | # def test_log_customer(self): 13 | # self.log_customer(webhook_data) 14 | 15 | # assert PayStackCustomer.objects.all().count() > 0 16 | -------------------------------------------------------------------------------- /tests/test_services/test_transaction_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.exceptions import ValidationError 3 | 4 | from paystack.models import TransactionLog 5 | from paystack.services import TransactionService 6 | from tests.mock_data import webhook_data 7 | 8 | pytestmark = pytest.mark.django_db 9 | 10 | 11 | class TestTransactionService(TransactionService): 12 | @pytest.mark.django_db 13 | def test_log_transaction(self): 14 | self.log_transaction(webhook_data) 15 | 16 | assert TransactionLog.objects.all().count() > 0 17 | 18 | def test_validate_initiate_transaction_payload(self, invalid_transaction_payload): 19 | with pytest.raises(ValidationError): 20 | self._validate_initiate_payload(invalid_transaction_payload) 21 | 22 | def test_validate_charge_payload(self, invalid_charge_payload): 23 | with pytest.raises(ValidationError): 24 | self._validate_charge_payload(invalid_charge_payload) 25 | -------------------------------------------------------------------------------- /tests/test_views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyior/django-rest-paystack/fd74dd26703fe4ce63664736c2063ace7020f71a/tests/test_views/__init__.py -------------------------------------------------------------------------------- /tests/test_views/test_customer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.mixins import RequestMixin, URLsMixin 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | class TestCustomerEndpoints(URLsMixin, RequestMixin): 9 | def test_list_customers(self): 10 | response = self.send_request( 11 | request_method="GET", request_url=self.all_customers_url() 12 | ) 13 | 14 | assert response.status_code == 200 15 | -------------------------------------------------------------------------------- /tests/test_views/test_transactions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.mixins import RequestMixin, URLsMixin 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | @pytest.mark.usefixtures( 9 | "valid_transaction_payload", 10 | "invalid_transaction_payload", 11 | "transaction_reference", 12 | "valid_charge_payload", 13 | "invalid_charge_payload", 14 | ) 15 | class TestTransactionEndpoints(URLsMixin, RequestMixin): 16 | def test_initiate_transaction(self, valid_transaction_payload): 17 | response = self.send_request( 18 | request_method="POST", 19 | request_url=self.initiate_transaction_url(), 20 | payload=valid_transaction_payload, 21 | ) 22 | 23 | assert response.status_code == 200 24 | 25 | def test_initiate_transaction_fails(self, invalid_transaction_payload): 26 | response = self.send_request( 27 | request_method="POST", 28 | request_url=self.initiate_transaction_url(), 29 | payload=invalid_transaction_payload, 30 | ) 31 | 32 | assert response.status_code != 200 33 | assert response.status_code == 400 34 | 35 | def test_verify_transaction(self, transaction_reference): 36 | response = self.send_request( 37 | request_method="GET", 38 | request_url=self.verify_transaction_url(transaction_reference), 39 | ) 40 | 41 | assert response.status_code == 200 42 | 43 | def test_verify_transaction_fails(self): 44 | response = self.send_request( 45 | request_method="GET", 46 | request_url=self.verify_transaction_url(""), 47 | ) 48 | 49 | assert response.status_code != 200 50 | assert response.status_code == 400 51 | 52 | def test_charge_customer(self, valid_charge_payload): 53 | response = self.send_request( 54 | request_method="POST", 55 | request_url=self.charge_customer_url(), 56 | payload=valid_charge_payload, 57 | ) 58 | 59 | assert response.status_code == 200 60 | 61 | def test_charge_customer_fails(self, invalid_charge_payload): 62 | response = self.send_request( 63 | request_method="POST", 64 | request_url=self.charge_customer_url(), 65 | payload=invalid_charge_payload, 66 | ) 67 | 68 | assert response.status_code != 200 69 | assert response.status_code == 400 70 | 71 | def test_list_transaction(self): 72 | response = self.send_request( 73 | request_method="GET", request_url=self.all_transactions_url() 74 | ) 75 | 76 | assert response.status_code == 200 77 | --------------------------------------------------------------------------------