Metadata-Version: 2.4
Name: payment_infra
Version: 0.1.7
Summary: Reusable Django payment infrastructure with idempotency and webhook support
Author-email: "Offside Integrated Technology (Somtochukwu Emmanuel)" <offsideint@gmail.com>
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django<5.3,>=4.2
Requires-Dist: requests<3.0,>=2.32
Requires-Dist: redis<8.0,>=7.0.1
Requires-Dist: djangorestframework<3.17,>=3.15
Requires-Dist: python-dotenv<2.0,>=1.0
Requires-Dist: celery<6.0,>=5.3
Provides-Extra: dev
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
Requires-Dist: pytest-django<5.0,>=4.8; extra == "dev"
Requires-Dist: pytest-mock<4.0,>=3.12; extra == "dev"
Requires-Dist: coverage<8.0,>=7.4; extra == "dev"
Dynamic: license-file

# 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

```text
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

```bash
pip install payment_infra
```

For local development:

```bash
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

```python
INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "rest_framework",
    "payment_infra",
]
```

```python
from django.urls import include, path

urlpatterns = [
    path("payments/", include("payment_infra.api.urls")),
]
```

Run migrations:

```bash
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

```python
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/`:

```bash
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

```python
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.

```python
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

```python
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

```python
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:

```bash
pytest -m "not integration"
```

Run all tests, including opt-in integration tests, only when explicitly configured:

```bash
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`](LICENSE).
