Reusable Django payment infrastructure with idempotency and webhook support
Project description
payment_infra
Reusable Django payment infrastructure for production payment orchestration with provider abstractions, idempotency, secure webhooks, subscriptions, and virtual/dedicated accounts.
Features
- Provider abstraction (
paystack,stripe,monnify) through shared interfaces. - Idempotent payment initialization that binds keys to amount, currency, email, callback URL, plan, and principal.
- Provider-neutral status/result objects for payments, subscriptions, and virtual accounts.
- Secure webhook processing: raw-body HMAC validation, event normalization, duplicate-event protection, payload redaction, and amount/currency checks before payment state changes.
- Subscription support: create, fetch/verify, cancel, status mapping, webhook normalization.
- Virtual account support: create, fetch, deactivate where supported, payment event normalization, reconciliation references.
- Optional Django persistence/admin models for transactions, webhook events, subscriptions, virtual accounts, and virtual account transactions.
Architecture discovered / package layout
payment_infra/
├── api/ # DRF serializers, views, and URL routes
├── application/
│ ├── interfaces/ # Provider/repository contracts
│ ├── services/ # PaymentService, WebhookService, SubscriptionService, VirtualAccountService
│ └── webhooks/ # Provider event mappers
├── domain/entities/ # Dataclasses, provider-neutral result/status types, Django models
├── infrastructure/
│ ├── idempotency/ # Durable/in-memory idempotency + Redis/local lock fallback
│ ├── providers/ # Paystack/Stripe/Monnify adapters
│ └── repositories/ # Django data access adapters
└── migrations/ # Django migrations for optional persistence
The public payment API remains centered on get_payment_service().process_payment(...) and verify_payment(...). New capabilities follow the same pattern via get_subscription_service(...) and get_virtual_account_service(...) while Paystack-specific HTTP details remain inside the Paystack adapter.
Installation
pip install payment_infra
For local development:
git clone https://github.com/0FFSIDE1/payment_infra.git
cd payment_infra
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
Django setup
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"rest_framework",
"payment_infra",
]
from django.urls import include, path
urlpatterns = [
path("payments/", include("payment_infra.api.urls")),
]
Run migrations:
python manage.py migrate
Required configuration
Set DJANGO_PAYMENTS_PROVIDER to one of paystack, stripe, or monnify.
Paystack
PAYSTACK_SECRET_KEYrequired for provider calls and webhook HMAC validation.PAYSTACK_PUBLIC_KEYfor frontend checkout use.PAYSTACK_BASE_URLoptional; defaults tohttps://api.paystack.coand must remain HTTPS and on the allowed-host list.PAYSTACK_CALLBACK_URLfor redirects.
Stripe
STRIPE_SECRET_KEYSTRIPE_PUBLIC_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_BASE_URLoptional; defaults tohttps://api.stripe.com/v1.
Monnify
MONNIFY_API_KEYMONNIFY_SECRET_KEYMONNIFY_CONTRACT_CODEMONNIFY_PUBLIC_KEYoptionalMONNIFY_BASE_URLoptional
Common hardening settings
PAYMENT_ALLOWED_PROVIDER_HOSTS: restrict custom provider base URLs.PAYMENT_ALLOWED_CALLBACK_HOSTS: restrict user-supplied callback URLs.PAYMENT_ALLOWED_CURRENCIES: allowed API currencies.PAYMENT_MIN_AMOUNT: minimum charge amount.REDIS_URL: lock backend for durable idempotency.
Initialize a payment
from decimal import Decimal
from payment_infra.infrastructure.providers.registry import get_payment_service
service = get_payment_service("paystack")
result = service.process_payment(
email="customer@example.com",
amount=Decimal("1000.00"),
currency="NGN",
idempotency_key="checkout-ord-1001-v1",
principal="user-42",
metadata={"callback_url": "https://example.com/payments/callback"},
)
HTTP endpoint when mounted at /payments/:
curl -X POST https://merchant.example.com/payments/charge/ \
-H 'Content-Type: application/json' \
-d '{
"email": "customer@example.com",
"amount": "1000.00",
"currency": "NGN",
"idempotency_key": "checkout-ord-1001-v1",
"callback_url": "https://example.com/payments/callback"
}'
Verify a payment
verification = service.verify_payment("checkout-ord-1001-v1", principal="user-42")
Verification checks provider amount/currency against the local transaction before success is accepted.
Webhook verification and processing
Use raw request bytes exactly as received from the provider. Do not JSON-decode and re-encode before signature verification.
from payment_infra.application.services.webhook_service import WebhookService
from payment_infra.infrastructure.providers.registry import get_mapper, get_provider
provider = get_provider("paystack")
mapper = get_mapper("paystack")
service = WebhookService(provider, mapper)
result = service.handle(raw_body=request.body, signature=request.headers["x-paystack-signature"])
Security behavior:
- HMAC signatures are validated with constant-time comparison.
- Invalid signatures and malformed JSON are logged with SHA-256 body hashes/previews, not secrets.
- Duplicate events are ignored idempotently via provider event IDs.
- Payment success webhooks must match the local reference, amount, and currency.
- Payload metadata is treated as untrusted. For high-value fulfillment, verify directly with the provider before crediting value.
Idempotency guidance
- Generate one idempotency key per logical checkout/subscription/virtual-account creation request.
- Reusing a key with different amount, currency, plan, callback URL, customer, or principal raises a conflict.
- Store order IDs in your application and use stable, non-guessable idempotency keys such as
checkout-<uuid>-v1. - Do not expose provider secret keys, authorization codes, or full raw webhook payloads in logs.
Subscriptions
from payment_infra.infrastructure.providers.registry import get_subscription_service
subscriptions = get_subscription_service("paystack")
created = subscriptions.create_subscription(
customer="CUS_xxxxx", # provider customer code or supported customer identifier
plan_code="PLN_basic_monthly",
authorization="AUTH_xxxxx", # provider authorization where required
idempotency_key="sub-user-42-basic-v1",
)
fetched = subscriptions.fetch_subscription(created["subscription_code"])
cancelled = subscriptions.cancel_subscription(created["subscription_code"], email_token=created.get("email_token"))
Provider statuses are normalized to: active, pending, cancelled, disabled, expired, non_renewing, or unknown.
Virtual / dedicated accounts
from payment_infra.infrastructure.providers.registry import get_virtual_account_service
virtual_accounts = get_virtual_account_service("paystack")
account = virtual_accounts.create_virtual_account(
customer={"email": "customer@example.com", "customer_code": "CUS_xxxxx"},
account_reference="va-user-42-ngn",
preferred_bank="wema-bank",
idempotency_key="va-user-42-ngn-v1",
)
same_account = virtual_accounts.fetch_virtual_account("va-user-42-ngn")
Virtual-account payment webhooks normalize to virtual_account.payment and are recorded by provider reference for reconciliation. Process fulfillment only after reconciling the reference, expected account, amount, and provider verification result.
Production security checklist
- Use HTTPS for callback and webhook URLs.
- Keep provider secret keys in a secret manager or environment variables.
- Restrict callback hosts and provider base URLs.
- Verify raw webhook signatures before parsing.
- Treat webhook payload amounts/statuses as untrusted until matched against local records and/or provider verification.
- Use durable idempotency storage and a Redis lock in multi-worker deployments.
- Make provider references and idempotency keys non-guessable.
- Redact emails, authorization codes, and raw provider internals from logs/admin views.
- Monitor duplicate/invalid webhook logs for replay or forgery attempts.
Testing
Run non-integration tests without real provider calls:
pytest -m "not integration"
Run all tests, including opt-in integration tests, only when explicitly configured:
RUN_PAYMENT_INTEGRATION=1 pytest
Backwards compatibility / migration notes
- Existing
PaymentService.process_payment(...),verify_payment(...),get_payment_service(...), and webhook URL behavior are preserved. initialize_payment(...)is an alias forprocess_payment(...).- Existing provider adapters continue to implement
charge,verify, andverify_signature; optional subscription and virtual-account methods are additive. - New migration
0005adds callback/provider verification fields, webhook event IDs, and optional subscription/virtual-account persistence models. - Webhook duplicates now return duplicate status internally without reapplying side effects.
Changelog-style summary
- Added secure exception hierarchy and provider-neutral result/status types.
- Added Paystack subscription and dedicated account operations.
- Added subscription and virtual account services.
- Hardened payment metadata, amount/currency validation, provider error wrapping, and verification mismatch detection.
- Hardened webhook signature, parsing, redaction, idempotency, subscription normalization, and virtual-account payment handling.
- Added Django models/admin/migration for subscriptions and virtual accounts.
- Added comprehensive unit tests with mocked providers only.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
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 payment_infra-0.1.7.tar.gz.
File metadata
- Download URL: payment_infra-0.1.7.tar.gz
- Upload date:
- Size: 33.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6f2d583549669cd38fd581673e519a36c4af0367ee64bd3d6c0b443fa652e004
|
|
| MD5 |
19b5da9aed9cf433d63154b3d5cdf5d7
|
|
| BLAKE2b-256 |
4559af9ef332cefd9ea6c10618381ae6f16c77cf080436e9f089c217fb0617de
|
Provenance
The following attestation bundles were made for payment_infra-0.1.7.tar.gz:
Publisher:
cd.yml on 0FFSIDE1/payment_infra
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
payment_infra-0.1.7.tar.gz -
Subject digest:
6f2d583549669cd38fd581673e519a36c4af0367ee64bd3d6c0b443fa652e004 - Sigstore transparency entry: 1553905471
- Sigstore integration time:
-
Permalink:
0FFSIDE1/payment_infra@d945d2bde975244e1ad05abe3849ee89445f533f -
Branch / Tag:
refs/tags/v0.1.7 - Owner: https://github.com/0FFSIDE1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@d945d2bde975244e1ad05abe3849ee89445f533f -
Trigger Event:
push
-
Statement type:
File details
Details for the file payment_infra-0.1.7-py3-none-any.whl.
File metadata
- Download URL: payment_infra-0.1.7-py3-none-any.whl
- Upload date:
- Size: 44.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
724c005f9eef21587b522a0a069e043b79862c1987d6c0d470e2657c3d1718a4
|
|
| MD5 |
c50da1f30791b2bbd6d73fcdb5c280f4
|
|
| BLAKE2b-256 |
3c387cb76b338f6db5c647f99422bbab410f43eca8298801f6575ec88b68f310
|
Provenance
The following attestation bundles were made for payment_infra-0.1.7-py3-none-any.whl:
Publisher:
cd.yml on 0FFSIDE1/payment_infra
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
payment_infra-0.1.7-py3-none-any.whl -
Subject digest:
724c005f9eef21587b522a0a069e043b79862c1987d6c0d470e2657c3d1718a4 - Sigstore transparency entry: 1553905489
- Sigstore integration time:
-
Permalink:
0FFSIDE1/payment_infra@d945d2bde975244e1ad05abe3849ee89445f533f -
Branch / Tag:
refs/tags/v0.1.7 - Owner: https://github.com/0FFSIDE1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@d945d2bde975244e1ad05abe3849ee89445f533f -
Trigger Event:
push
-
Statement type: