Reusable Django payment app with a unified service interface across multiple payment providers
Project description
kunshort-django-payment
A reusable Django app for processing mobile money payments. It exposes a single PaymentService that your project calls directly from its own views. The package handles the provider integrations, the database audit trail, background status polling, and payment lifecycle signals — your project controls the URLs, authentication, and response shapes.
Table of Contents
- How it works
- Requirements
- Installation
- Configuration
- Database setup
- Providers overview
- Using PaymentService in your views
- Webhook endpoints (optional)
- Reacting to payment events (signals)
- Background polling — MTN MoMo (Celery)
- Models reference
- Fee calculation
- Adding a new provider
- Running the tests
How it works
This package sits between your project and the payment providers. Your project is responsible for:
- Your own views — you decide the URL structure, authentication, and response format
- Your own serializers — you decide what data to return to the client
- Reacting to events — connect to the signals this package fires to update orders, send receipts, etc.
This package is responsible for:
- Calling the correct provider API (MTN MoMo, PawaPay, Flutterwave, Orange Money)
- Creating and updating
PaymentTransactionrecords in your database - Enforcing valid status transitions (
PENDING → COMPLETED → REFUNDED, etc.) - Polling MTN MoMo in the background via Celery
- Providing ready-made webhook handlers for provider callbacks (optional)
Your view → PaymentService → Provider (MTN / PawaPay / Flutterwave)
↓
PaymentTransaction (DB)
↓
Signals fired → Your signal handlers (update order, send email, etc.)
Requirements
- Python 3.11+
- Django 5.0+
- Django REST Framework 3.14+
- drf-spectacular 0.27+
- Celery 5.3+
- django-redis 5.4+ (or any Django cache backend — used to cache MTN access tokens)
- Pillow 10.0+ (for the
PaymentType.logoimage field)
Installation
pip install kunshort-django-payment
Or with uv:
uv add kunshort-django-payment
Configuration
1. Add to INSTALLED_APPS
# settings.py
INSTALLED_APPS = [
...
"kunshort_payment",
]
2. Declare which providers you use
# Maps the key you pass to PaymentService(...) to the internal provider name.
# Only include providers you actually use.
PROVIDERS = {
"MTN_CAMEROON": "MTN_CAMEROON",
# "ORANGE_CAMEROON": "ORANGE_CAMEROON",
# "FLUTTERWAVE": "FLUTTERWAVE",
# "PAWAPAY": "PAWAPAY",
}
# The provider name stamped on transactions when no explicit provider is set.
PAYMENT_PROVIDER = "mtn_money"
3. Add provider credentials
Only include the blocks for providers you have enabled above.
# MTN MoMo — Collection (Request to Pay)
MTN_MOMO = {
"BASE_URL": "https://sandbox.momodeveloper.mtn.com",
"API_USER_ID": "<your-api-user-id>",
"API_KEY": "<your-api-key>",
"SUBSCRIPTION_KEY": "<your-collection-subscription-key>",
"TARGET_ENVIRONMENT": "sandbox", # "production" in live
"CALLBACK_URL": "", # Your webhook URL for MTN to call back
}
# MTN MoMo — Disbursement & Refund (only needed if you use transfer/refund)
MTN_DISBURSEMENT = {
"BASE_URL": "https://sandbox.momodeveloper.mtn.com",
"API_USER_ID": "<your-disbursement-api-user-id>",
"API_KEY": "<your-disbursement-api-key>",
"SUBSCRIPTION_KEY": "<your-disbursement-subscription-key>",
"TARGET_ENVIRONMENT": "sandbox",
"CALLBACK_URL": "",
"CHECK_BALANCE_BEFORE_TRANSFER": True, # Set False to skip the pre-transfer balance check
}
# PawaPay
PAWAPAY = {
"BASE_URL": "https://api.pawapay.io",
"BEARER_TOKEN": "<your-pawapay-token>",
}
# Flutterwave
FLUTTERWAVE_PAYMENT = {
"SECRET_KEY": "<your-flutterwave-secret-key>",
"FLW_SECRET_HASH": "<your-webhook-secret-hash>", # Used to verify incoming webhook signatures
}
Database setup
Run migrations to create the payment tables:
python manage.py migrate kunshort_payment
Providers overview
All providers implement the same MobileMoneyProvider interface so PaymentService works identically regardless of which one is behind it. The distinction is which operations each provider supports:
| Provider | Collection | Disbursement | Refund | Notes |
|---|---|---|---|---|
MTN_CAMEROON |
Yes | Yes | Yes | Direct MTN MoMo API; tokens auto-refreshed in cache |
ORANGE_CAMEROON |
Stub | — | — | Not yet implemented |
PAWAPAY |
Yes | — | Yes | Auto-detects MTN vs Orange by phone prefix |
FLUTTERWAVE |
Yes | — | Yes | Flutterwave mobile money (Francophone Africa) |
If you call a disbursement on a provider that does not support it (e.g. PawaPay), the service raises an Exception with a message explaining which provider to use instead.
Using PaymentService in your views
Import PaymentService and call it directly from your own views. You own the URL, the authentication, and the response — the package handles everything below that.
from kunshort_payment import PaymentService
Initiating a payment (collection)
# views.py — your own view, your own auth
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from kunshort_payment import PaymentService
from kunshort_payment.models import PaymentType
class InitiatePaymentView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
payment_type = PaymentType.objects.get(
payment_provider="mtn_cameroon",
is_active=True,
)
service = PaymentService("MTN_CAMEROON")
success, message, transaction = service.initiate_payment(
user_id=str(request.user.id),
amount=request.data["amount"],
amount_refundable=request.data["amount"],
payment_type=payment_type,
payment_detail={"phone_number": request.data["phone_number"]},
order_id=request.data["order_id"],
)
return Response({"transaction_id": str(transaction.transaction_id)})
initiate_payment returns (True, message, PaymentTransaction) on success, or raises Exception with the provider error on failure. The transaction is persisted immediately with status PENDING. The provider's webhook or the Celery polling task will update it to COMPLETED or FAILED.
Phone numbers must be passed without the country code — the service adds the 237 prefix before sending to the provider.
Initiating a disbursement (payout)
Send money out to a phone number — for payouts, commissions, etc. Only MTN_CAMEROON currently supports disbursements.
service = PaymentService("MTN_CAMEROON")
success, message, transaction = service.initiate_disbursement(
user_id=str(request.user.id),
phone_number=request.data["phone_number"], # without country code
amount=str(request.data["amount"]),
payment_type=payment_type,
order_id=request.data["order_id"],
)
Returns (True, "Disbursement Initiated", PaymentTransaction) on success, or raises Exception on failure.
If CHECK_BALANCE_BEFORE_TRANSFER is True in MTN_DISBURSEMENT settings, the provider checks the disbursement account balance before sending and raises an error if funds are insufficient.
Initiating a refund
Refund a previous collection back to the payer.
from kunshort_payment.models import PaymentTransaction
original = PaymentTransaction.objects.get(transaction_id=request.data["transaction_id"])
service = PaymentService("MTN_CAMEROON")
success, message, refund_transaction = service.initiate_refund(
user_id=str(request.user.id),
original_transaction=original,
amount=str(request.data["amount"]),
)
A new PaymentTransaction record with transaction_type=REFUND is created and linked to the same order_id as the original, giving you the full payment history per order in one place.
Verifying a transaction
Check the current status of any transaction directly with the provider.
success, data = service.verify_transaction(transaction.external_reference)
success, data = service.verify_disbursement(transaction.external_reference)
success, data = service.verify_refund(transaction.external_reference)
Each returns (True, <dict>) on a successful API call, or (False, error_message) on failure. The data dict contains the raw provider response.
Retrying a failed payment
Re-attempt a failed collection using the same details as the original transaction.
success, message, new_transaction = service.initiate_payment_retry(original_transaction)
A new PaymentTransaction row is created — the original is left unchanged.
Webhook endpoints (optional)
When a payment completes, providers call a webhook URL on your server to notify you. This package ships with ready-made webhook handlers for each provider. You can include them in your project's URL configuration:
# urls.py
from django.urls import path, include
urlpatterns = [
...
path("payments/", include("kunshort_payment.urls")),
]
This registers:
| Method | Path | Description |
|---|---|---|
| POST | payments/flutterwave/webhook/ |
Flutterwave payment callback |
| POST | payments/pawapay/webhook/ |
PawaPay payment callback |
| POST | payments/momo/collection/webhook/ |
MTN MoMo collection callback |
| POST | payments/momo/disbursement/webhook/ |
MTN MoMo disbursement callback |
These are optional. If you prefer to handle provider callbacks in your own views, or if your project uses a different URL structure, you can skip this include entirely and write your own webhook handlers using PaymentService and the transaction models directly.
MTN MoMo note: MTN is asynchronous — it does not always call the webhook reliably. The package includes a Celery polling task as a fallback (see below).
Reacting to payment events (signals)
Connect to these Django signals in your app to react to payment lifecycle events. This is how you bridge between the payment package and the rest of your application (updating orders, sending receipts, releasing inventory, etc.) without coupling your code to the package's internals.
# your_app/signals.py
from django.dispatch import receiver
from kunshort_payment import payment_succeeded, payment_failed, payment_refunded
@receiver(payment_succeeded)
def on_payment_success(sender, transaction, **kwargs):
Order.objects.filter(id=transaction.order_id).update(status="paid")
# send_receipt_email(transaction)
@receiver(payment_failed)
def on_payment_failed(sender, transaction, **kwargs):
# notify the user, release reserved stock, etc.
pass
@receiver(payment_refunded)
def on_refund(sender, transaction, provider_refund_id, **kwargs):
Order.objects.filter(id=transaction.order_id).update(status="refunded")
Make sure your signal handlers are imported when Django starts — register them in your app's AppConfig.ready():
# your_app/apps.py
class YourAppConfig(AppConfig):
def ready(self):
import your_app.signals # noqa: F401
All available signals:
| Signal | Fired when | Extra kwargs |
|---|---|---|
payment_initiated |
Transaction created and set to pending | transaction |
payment_succeeded |
Transaction marked completed | transaction |
payment_failed |
Transaction marked failed | transaction |
payment_refunded |
Refund successfully initiated | transaction, provider_refund_id |
payment_refund_failed |
Refund attempt failed | transaction |
Background polling — MTN MoMo (Celery)
MTN MoMo is asynchronous — it accepts a payment request with HTTP 202 and processes it in the background. The package uses Celery to poll for the final status. This only applies to MTN MoMo. PawaPay and Flutterwave notify your server via webhooks instead.
How it works
PaymentTransaction.pending()fires thepayment_initiatedsignal.- A signal receiver in
tasks.pyschedules thepoll_momo_transactionCelery task 15 seconds later — only for MTN transactions. - The task checks
transaction.transaction_typeto call the right MTN endpoint:COLLECTION→GET /collection/v2_0/payment/{ref}DISBURSEMENT→GET /disbursement/v1_0/transfer/{ref}REFUND→GET /disbursement/v1_0/refund/{ref}
- If still
PENDING, the task retries up to 6 times with exponential backoff (15 s → 30 s → 60 s → …). - If retries are exhausted the transaction stays
PENDING. A nightly Celery Beat task (check_pending_transactions) sweeps up any remaining stuck transactions.
Setup
Add the nightly sweep to your Celery Beat schedule:
# celery.py
from celery.schedules import crontab
app.conf.beat_schedule = {
"payment-pending-sweep": {
"task": "payment.check_pending_transactions",
"schedule": crontab(hour=0, minute=0),
},
}
Start your workers:
celery -A your_project worker -l info
celery -A your_project beat -l info
Models reference
PaymentType
Configured via the Django admin — represents a payment channel (e.g. "MTN MoMo Cameroon").
| Field | Description |
|---|---|
name |
Display name |
short_name |
Short identifier (max 15 chars) |
payment_class |
phone_number, credit_card, or master_card |
payment_provider |
mtn_cameroon or orange_cameroon |
is_active |
Whether this type is available to users |
deposit_fee_percentage |
Provider fee as a percentage |
deposit_fee_fixed |
Provider fixed fee |
platform_deposit_fee_percentage |
Your platform fee as a percentage |
platform_deposit_fee_fixed |
Your platform fixed fee |
PaymentTransaction
One row per money movement — collection, disbursement, or refund.
| Field | Description |
|---|---|
transaction_id |
UUID — your internal identifier |
external_reference |
Reference ID returned by the provider |
transaction_type |
collection, disbursement, or refund |
provider |
Which provider processed it |
amount |
Amount charged / disbursed / refunded |
amount_refundable |
Maximum refundable portion |
currency |
ISO currency code (default XAF) |
order_id |
Your application's order identifier |
user_id |
Your application's user identifier (stored as string — no FK assumption) |
payment_detail |
JSON blob — e.g. {"phone_number": "670000000"} |
PaymentStatus
Append-only status log — one row per transition. Valid transitions:
PENDING → COMPLETED | FAILED
COMPLETED → REFUNDED | REFUND_FAILED
FAILED → FAILED | COMPLETED
REFUND_FAILED → REFUND_FAILED | REFUNDED
The first status for any transaction must be PENDING. Invalid transitions raise ValidationError.
PaymentRefund
Linked one-to-one to the refund PaymentTransaction. Either provider_refund_id (automated) or manual_refund_id (manual override) must be set — exactly one, not both.
Fee calculation
PaymentType.calculate_deposit_amount(amount) returns the gross amount to charge the customer so that after all fees are deducted the net received equals amount.
payment_type = PaymentType.objects.get(...)
gross = payment_type.calculate_deposit_amount(5000)
# gross > 5000; the extra covers provider + platform fees
Formula:
gross = 100 × (net + fixed_fees) / (100 − percentage_fees)
Adding a new provider
- Create a class in
src/kunshort_payment/providers/that extendsMobileMoneyProviderand implementscollect,verify_transaction, andinitiate_refund. Overridetransfertoo if the provider supports disbursements. - Add a constant for it in
SupportedProvidersinproviders/__init__.py. - Register it in
PaymentProviderFactory.get_instance()inproviders/provider_factory.py. - Add its key to
PROVIDERSin your project's settings.
Running the tests
uv run pytest -v
Tests use an in-memory SQLite database and mock all HTTP calls — no external services or credentials needed.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kunshort_django_payment-0.1.3.tar.gz.
File metadata
- Download URL: kunshort_django_payment-0.1.3.tar.gz
- Upload date:
- Size: 72.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3b701f9d97011a3cfb86961328a16036803cc061029fdef32ee42df6279ad93e
|
|
| MD5 |
8cbd6656ff0918c2a61121bd7efa9422
|
|
| BLAKE2b-256 |
a521a2d6e1ff1c27e7bd48b2b7998cc91fff4ae30438ba5644b1f8e415e33db9
|
File details
Details for the file kunshort_django_payment-0.1.3-py3-none-any.whl.
File metadata
- Download URL: kunshort_django_payment-0.1.3-py3-none-any.whl
- Upload date:
- Size: 43.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a435c1e84b64f27591b4f06a132ba1e908227b19fc82f690b00371407ec34830
|
|
| MD5 |
14bc7791c9558ac4d5d743d6b8f5f8f5
|
|
| BLAKE2b-256 |
c8dda4413e3a176550fbd99d8ab654d54578d2ea1e8944c2736c565fe942a385
|