Skip to main content

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_KEY required for provider calls and webhook HMAC validation.
  • PAYSTACK_PUBLIC_KEY for frontend checkout use.
  • PAYSTACK_BASE_URL optional; defaults to https://api.paystack.co and must remain HTTPS and on the allowed-host list.
  • PAYSTACK_CALLBACK_URL for redirects.

Stripe

  • STRIPE_SECRET_KEY
  • STRIPE_PUBLIC_KEY
  • STRIPE_WEBHOOK_SECRET
  • STRIPE_BASE_URL optional; defaults to https://api.stripe.com/v1.

Monnify

  • MONNIFY_API_KEY
  • MONNIFY_SECRET_KEY
  • MONNIFY_CONTRACT_CODE
  • MONNIFY_PUBLIC_KEY optional
  • MONNIFY_BASE_URL optional

Common hardening settings

Setting Default Purpose
DJANGO_PAYMENTS_PROVIDER paystack Active provider used by get_payment_service() and the API when a provider-specific webhook URL is not used.
PAYMENT_REQUIRE_AUTHENTICATION False When True, charge/verify/customer API views require an authenticated Django user. Webhooks always allow provider calls and verify signatures instead.
PAYMENT_REQUIRE_HTTPS_CALLBACK_URL True Requires user-supplied callback URLs to use HTTPS. Set to False only for local testing to allow http://localhost, http://127.0.0.1, or http://[::1] callbacks.
PAYMENT_ALLOWED_CALLBACK_HOSTS Hosts inferred from configured provider callback URLs Optional allow-list for user-supplied callback hosts. Local loopback HTTP callbacks bypass this only when PAYMENT_REQUIRE_HTTPS_CALLBACK_URL=False.
PAYMENT_ALLOWED_PROVIDER_HOSTS Provider defaults Restricts custom provider API base URLs to approved hosts.
PAYMENT_ALLOWED_CURRENCIES NGN, USD, GHS, ZAR Allowed API currencies.
PAYMENT_MIN_AMOUNT 1.00 Minimum amount accepted by the API serializer.
REDIS_URL unset/application-specific Lock backend for durable idempotency in multi-worker deployments.

Local callback URLs for development

Production callback URLs should use HTTPS. For local browser checkout tests, disable the HTTPS callback requirement and point your provider callback URL at localhost:

PAYMENT_REQUIRE_HTTPS_CALLBACK_URL = False
PAYSTACK_CALLBACK_URL = "http://localhost:8000/paystack/callback/"
# Optional when you also want to accept browser-supplied non-local callback hosts.
PAYMENT_ALLOWED_CALLBACK_HOSTS = ["localhost", "127.0.0.1"]

With that setting disabled, http://localhost, http://127.0.0.1, and http://[::1] callback URLs are accepted for testing. Private LAN/link-local/reserved hosts such as http://192.168.1.10/... remain blocked, and the default True value rejects non-HTTPS callback URLs.


HTTP API flows

Mount payment_infra.api.urls wherever you want the API to live. If mounted at /payments/, the available routes are:

Method Route Flow Notes
POST /payments/charge/ Initialize a payment Validates amount/currency/callback URL, creates or reuses an idempotent local transaction, then calls the active provider.
GET /payments/verify/<reference>/ Verify a payment Calls the provider verification endpoint and only marks success after amount/currency match the local transaction.
POST /payments/webhooks/ Active-provider webhook Uses DJANGO_PAYMENTS_PROVIDER to select the provider.
POST /payments/webhooks/<provider_name>/ Provider-specific webhook provider_name can be paystack, stripe, or monnify.
POST /payments/customers/ Create a provider customer Supports idempotent customer creation where an idempotency_key is supplied.
GET /payments/customers/<identifier>/ Fetch a provider customer Persists the normalized customer record locally.
POST /payments/customers/<customer_code>/verify/ Start/submit customer verification For providers that support customer identification/KYC verification.

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")

HTTP endpoint when mounted at /payments/:

curl https://merchant.example.com/payments/verify/checkout-ord-1001-v1/

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.

Customer flow

Create a customer when you want a stable provider customer code for subscriptions, dedicated accounts, or later checkout metadata:

from payment_infra.infrastructure.providers.registry import get_customer_service

customers = get_customer_service("paystack")
created = customers.create_customer(
    email="customer@example.com",
    first_name="Ada",
    last_name="Lovelace",
    phone="+2348000000000",
    idempotency_key="customer-user-42-v1",
    metadata={"source": "signup"},
)

fetched = customers.fetch_customer(created["customer_code"])
verified = customers.verify_customer(
    created["customer_code"],
    verification_type="bank_account",
    value="0000000000",
    country="NG",
)

HTTP examples when mounted at /payments/:

curl -X POST https://merchant.example.com/payments/customers/ \
  -H 'Content-Type: application/json' \
  -d '{"email":"customer@example.com","first_name":"Ada","idempotency_key":"customer-user-42-v1"}'

curl https://merchant.example.com/payments/customers/CUS_xxxxx/

curl -X POST https://merchant.example.com/payments/customers/CUS_xxxxx/verify/ \
  -H 'Content-Type: application/json' \
  -d '{"verification_type":"bank_account","value":"0000000000","country":"NG"}'

Customer statuses normalize to active, inactive, pending, verified, unverified, or unknown, and successful operations upsert the optional local Customer model.


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. Subscription create requests are idempotent when an idempotency key is supplied, and normalized subscription records are persisted where the optional Django models are installed.


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. Virtual account creation is idempotent when an idempotency key is supplied.


Provider-specific notes

  • Paystack: supports payments, raw-body webhook signature validation with PAYSTACK_SECRET_KEY, customers, subscriptions, and dedicated/virtual accounts where enabled on your Paystack account. Checkout initialization receives callback_url, optional plan_code, and sanitized metadata.
  • Stripe: supports payment initialization/verification through the provider abstraction and Stripe webhook signatures with STRIPE_WEBHOOK_SECRET. Pass a callback URL as the Stripe return URL where applicable.
  • Monnify: supports payment initialization/verification, Monnify webhook signature validation, and virtual-account style flows through the normalized service contracts where supported by the adapter. Configure MONNIFY_CONTRACT_CODE for Monnify payment initialization.

Data and idempotency model

The package ships optional Django models and migrations for:

  • Payment: local transaction state, amount/currency, idempotency key, callback URL, and provider verification fields.
  • PaymentWebhookLog: duplicate protection, signature validity, normalized event details, and sanitized payload diagnostics.
  • IdempotencyKey: durable idempotency records that bind a key to a request hash and principal.
  • Customer: normalized customer profile and verification status.
  • Subscription: normalized subscription status and provider identifiers.
  • VirtualAccount and VirtualAccountTransaction: dedicated account details and reconciled incoming transfers.

Use stable, non-guessable idempotency keys for every create-style operation. Reusing a key with a different request body or principal raises a conflict instead of silently creating a second provider resource.


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 for process_payment(...).
  • Existing provider adapters continue to implement charge, verify, and verify_signature; optional subscription and virtual-account methods are additive.
  • New migration 0005 adds 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

payment_infra-1.0.7.tar.gz (40.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

payment_infra-1.0.7-py3-none-any.whl (50.6 kB view details)

Uploaded Python 3

File details

Details for the file payment_infra-1.0.7.tar.gz.

File metadata

  • Download URL: payment_infra-1.0.7.tar.gz
  • Upload date:
  • Size: 40.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for payment_infra-1.0.7.tar.gz
Algorithm Hash digest
SHA256 9bbc2b5cef0a3b4209c7f5156e64366b0bafb6b4ce636bc14a68b9ae2126c61f
MD5 7f895cc1cb20f8d2bf3d67f98d010dd4
BLAKE2b-256 d4eaf6417ba80ae9cc65abfc77c8bf9e0337eaafadd99cd6d728462e3a064e9b

See more details on using hashes here.

Provenance

The following attestation bundles were made for payment_infra-1.0.7.tar.gz:

Publisher: cd.yml on 0FFSIDE1/payment_infra

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file payment_infra-1.0.7-py3-none-any.whl.

File metadata

  • Download URL: payment_infra-1.0.7-py3-none-any.whl
  • Upload date:
  • Size: 50.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for payment_infra-1.0.7-py3-none-any.whl
Algorithm Hash digest
SHA256 3800215b0ac3ba3b9ac51b5bb1bf3c72610bb5267f37eb2319d2130085315f60
MD5 9d78c5ac07a40c71962dbbae303789bb
BLAKE2b-256 bbcc69cb380407e08a47dd604bd8c0a907721730cd0b29c060c0b6304ad6f20a

See more details on using hashes here.

Provenance

The following attestation bundles were made for payment_infra-1.0.7-py3-none-any.whl:

Publisher: cd.yml on 0FFSIDE1/payment_infra

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page