Metadata-Version: 2.4
Name: payment_infra
Version: 1.0.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

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

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

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

HTTP endpoint when mounted at `/payments/`:

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

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

---

## Customer flow

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

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

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

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

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

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