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

  • 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 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-0.1.8.tar.gz (36.4 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-0.1.8-py3-none-any.whl (48.3 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for payment_infra-0.1.8.tar.gz
Algorithm Hash digest
SHA256 671f82be740f9714f8abd6f91eb293853ae2026a364704a03a1a13c3b42a4642
MD5 ed7629e0f7009e5d34ab12d9d53a1cd3
BLAKE2b-256 59f4712e42b49cc5a69773eac7809fc1577aa41cfb3050c33133442e40e08be5

See more details on using hashes here.

Provenance

The following attestation bundles were made for payment_infra-0.1.8.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-0.1.8-py3-none-any.whl.

File metadata

  • Download URL: payment_infra-0.1.8-py3-none-any.whl
  • Upload date:
  • Size: 48.3 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-0.1.8-py3-none-any.whl
Algorithm Hash digest
SHA256 faba4aa6dd9cf72a82100c045e266118555b237dcbe350b6fd548e4b580df712
MD5 0cb29f1704e580636910e50440a42cdb
BLAKE2b-256 11833305b5fa9d6724151e33069c82122d9225e659b2e1f334504354338af62b

See more details on using hashes here.

Provenance

The following attestation bundles were made for payment_infra-0.1.8-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