Metadata-Version: 2.4
Name: django-credo-sdk
Version: 0.1.12
Summary: Unofficial Django SDK for Credo payment integration
Author-email: Samox <hello@credocentral.com>
License: MIT License
        
        Copyright (c) 2025 Samox
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/Onuigb0/django-sdk-for-credo
Project-URL: Bug Tracker, https://github.com/Onuigb0/django-sdk-for-credo/issues
Project-URL: Documentation, https://github.com/Onuigb0/django-sdk-for-credo#readme
Project-URL: Source Code, https://github.com/Onuigb0/django-sdk-for-credo
Keywords: django,credo,payments,sdk,payment-gateway,nigeria,fintech
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django>=5.0
Requires-Dist: requests>=2.31.0
Provides-Extra: drf
Requires-Dist: djangorestframework>=3.14.0; extra == "drf"
Dynamic: license-file

# Django Credo SDK

Unofficial Django SDK for integrating Credo Payment Gateway into Django applications.

The SDK gives you a small, server-side integration surface for:

- initializing Credo payment sessions
- generating readable unique payment references
- verifying transactions before fulfillment
- handling Credo browser callbacks and server webhooks on one endpoint
- verifying webhook signatures
- processing payment events idempotently
- using optional Django model mixins for common payment workflows
- testing your configuration from a built-in debug page

## Installation

### Traditional Django (no DRF)

```bash
pip install django-credo-sdk
```

### Django REST Framework projects

```bash
pip install "django-credo-sdk[drf]"
```

## Requirements

- Python 3.10+
- Django 5.0+
- requests 2.31+
- Django REST Framework 3.14+ *(optional — only needed for DRF views and endpoints)*

The package does not cap Django below future major versions, so supported environments can resolve newer Django releases such as Django 6 when your Python version supports them.

## Django Setup

### Traditional Django (no DRF)

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "credo_pay",   # required — enables template and static file discovery
]
```

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

urlpatterns = [
    path("api/credo/", include("credo_pay.urls")),
]
```

The `callback-webhook/` endpoint is always available. The debug interface is also available. Payment API endpoints (`initialize/`, `verify/`, `routed/initialize/`) are only registered when DRF is installed.

### DRF projects

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "credo_pay",
]
```

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

urlpatterns = [
    path("api/credo/", include("credo_pay.urls")),
]
```

Configure Credo from Django settings or environment variables:

```python
# settings.py
CREDO_PUBLIC_KEY = "your_public_key"
CREDO_SECRET_KEY = "your_secret_key"
CREDO_ENVIRONMENT = "sandbox"  # "sandbox" or "production"
CREDO_DEFAULT_CALLBACK_URL = "https://yoursite.com/api/credo/callback-webhook/"

# Optional, but recommended for webhooks
CREDO_WEBHOOK_SECRET_TOKEN = "token-you-set-in-credo-dashboard"

# Optional defaults
CREDO_DEFAULT_CURRENCY = "NGN"
CREDO_DEFAULT_CHANNELS = ["card", "bank"]
CREDO_DEFAULT_BEARER = 0  # 0 = customer pays fee, 1 = merchant pays fee
CREDO_LOGO_URL = ""       # Credo expects a 60x60 logo
CREDO_REQUEST_TIMEOUT = 15
```

If you use `python-decouple`, the same settings can be loaded from `.env`.

```env
CREDO_PUBLIC_KEY=your_public_key
CREDO_SECRET_KEY=your_secret_key
CREDO_ENVIRONMENT=sandbox
CREDO_DEFAULT_CALLBACK_URL=https://yoursite.com/api/credo/callback-webhook/
CREDO_WEBHOOK_SECRET_TOKEN=your_webhook_token
CREDO_REQUEST_TIMEOUT=15
```

`CREDO_DEFAULT_CALLBACK_URL` should point to the SDK's unified callback/webhook endpoint. The same URL handles:

- `GET` browser redirects from Credo after payment attempts
- `POST` server-to-server webhook notifications from Credo

In production, callback and logo URLs must use HTTPS. Local `http://localhost` callback URLs are only for sandbox browser-redirect testing.

## Terminal ID

Credo Terminal IDs identify merchant accounts on Credo's platform. The current Credo initialize, verify, callback, and webhook endpoints used by this SDK do not accept a `terminalId` field, so `CREDO_TERMINAL_ID` is not required by this package.

Keep your Terminal ID with your Credo credentials, but do not configure it for this SDK unless Credo adds an endpoint requirement later.

## Environment URLs

The SDK chooses Credo's API URL from `CREDO_ENVIRONMENT`.

| Environment | API URL |
| --- | --- |
| `sandbox` | `https://api.credodemo.com` |
| `production` | `https://api.credocentral.com` |

## Amounts

Credo expects payment initialization amounts in the lowest currency denomination. For NGN, that is kobo.

The SDK public APIs accept the currency's main unit instead:

```python
client.initialize_payment(
    amount=15000,  # NGN 15,000
    email="customer@example.com",
)
```

The SDK sends `1500000` to Credo for NGN. Credo's callback, verify, and webhook amount fields (`transAmount`, `debitedAmount`, etc.) are handled as the currency's main unit. The `*_major` properties expose those values as two-decimal `Decimal` objects:

- `trans_amount_major` — transaction amount in Naira
- `debited_amount_major` — debited amount in Naira
- `trans_fee_amount_major` — fee amount in Naira
- `settlement_amount_major` — settlement amount in Naira

`assert_amount()` and `amount_matches()` compare your expected main-unit amount against `transAmount` by default. They accept `float` or `Decimal` — pass `Decimal` when your model stores `DecimalField` amounts for lossless comparison. Fees such as `transFeeAmount` are not used for merchant-side amount validation.

## Initialize a Payment

```python
from credo_pay import CredoClient

client = CredoClient()

response = client.initialize_payment(
    amount=15000,  # NGN 15,000; SDK sends kobo to Credo
    email="customer@example.com",
    callback_url="https://yoursite.com/api/credo/callback-webhook/",
    first_name="John",
    last_name="Doe",
    phone_number="2348032132100",
    reference_prefix="ORDER",
)

redirect_url = response.authorization_url
trans_ref = response.trans_ref
business_reference = response.reference
```

Redirect the customer to `response.authorization_url`.

## Payment References

Credo references must be unique and alphanumeric. If you want references that carry product or order context, pass `reference_prefix`:

```python
response = client.initialize_payment(
    amount=15000,
    email="customer@example.com",
    reference_prefix="codex",
)
```

You can also generate a reference yourself:

```python
from credo_pay import generate_payment_reference

reference = generate_payment_reference(prefix="codex")

response = client.initialize_payment(
    amount=15000,
    email="customer@example.com",
    reference=reference,
)
```

The prefix is sanitized to alphanumeric characters because Credo references do not support spaces, hyphens, or symbols. Store both your business reference and Credo's `trans_ref` for reconciliation.

## Verify Before Fulfillment

Credo's browser callback is a redirect with query parameters. Treat it as a signal to verify, not as proof of payment.

```python
verification = client.verify_payment("jIMj005d3Y100M0A00MB")

if verification.is_successful:
    verification.assert_amount(15000, currency="NGN")
    fulfill_order()
elif verification.is_failed:
    mark_order_failed()
elif verification.is_review:
    flag_for_manual_review()
else:
    keep_order_pending()
```

`assert_amount()` receives the expected amount in the currency's main unit, not kobo. It accepts `float` or `Decimal`.

## Unified Callback/Webhook View

The SDK's built-in URL is:

```text
/api/credo/callback-webhook/
```

It is intentionally named `callback-webhook` because it serves two Credo flows:

| Method | Source | Purpose |
| --- | --- | --- |
| `GET` | Customer browser redirect | Parse callback query params and verify transaction |
| `POST` | Credo server webhook | Verify signature, parse event, run handlers |

### Traditional Django (no DRF)

Subclass `DjangoCredoWebhookView` from `credo_pay.views.django`:

```python
from django.shortcuts import redirect
from credo_pay.views.django import DjangoCredoWebhookView


class MyCredoView(DjangoCredoWebhookView):

    def handle_callback(self, callback_data, verify_response):
        if verify_response and verify_response.is_successful:
            Order.objects.filter(reference=callback_data.reference).update(status="paid")
            return redirect("/payment/success/")
        return redirect("/payment/failed/")

    def handle_webhook(self, event):
        if event.is_successful:
            Order.objects.filter(credo_trans_ref=event.trans_ref).update(status="paid")
        elif event.is_failed:
            Order.objects.filter(credo_trans_ref=event.trans_ref).update(status="failed")
        elif event.is_transfer_reversed:
            Order.objects.filter(credo_trans_ref=event.trans_ref).update(status="reversed")
        elif event.is_settlement:
            Transaction.objects.filter(credo_trans_ref=event.trans_ref).update(settled=True)
```

```python
# urls.py
from django.urls import path
from .views import MyCredoView

urlpatterns = [
    path("api/credo/callback-webhook/", MyCredoView.as_view(), name="credo-callback-webhook"),
]
```

### DRF projects

Subclass `CredoWebhookView` from `credo_pay.views.drf`:

```python
from django.shortcuts import redirect
from credo_pay.views.drf import CredoWebhookView


class MyCredoView(CredoWebhookView):

    def handle_callback(self, callback_data, verify_response):
        if verify_response and verify_response.is_successful:
            Order.objects.filter(reference=callback_data.reference).update(status="paid")
            return redirect("/payment/success/")
        return redirect("/payment/failed/")

    def handle_webhook(self, event):
        if event.is_successful:
            Order.objects.filter(credo_trans_ref=event.trans_ref).update(status="paid")
        elif event.is_failed:
            Order.objects.filter(credo_trans_ref=event.trans_ref).update(status="failed")
        elif event.is_transfer_reversed:
            Order.objects.filter(credo_trans_ref=event.trans_ref).update(status="reversed")
        elif event.is_settlement:
            Transaction.objects.filter(credo_trans_ref=event.trans_ref).update(settled=True)
```

`handle_callback()` should return an `HttpResponse` (or redirect) to override the default response, or `None` to fall through to the SDK's built-in JSON response.

`verify_response` is `None` when `verify_callback_transaction=False` or when `trans_ref` was absent from Credo's redirect. Always check before using it.

## Webhook Event Properties

`WebhookEvent` and `VerifyResponse` share a consistent set of properties:

```python
event.is_successful        # True for status 0, 4, 5
event.is_failed            # True for status 3, 7, 8, 9
event.is_pending           # True for status 13, 14, 15, 6
event.is_review            # True for status 6 (flagged for human review)
event.transaction_status   # int status code
event.payment_channel      # normalised channel string ("MasterCard", "Card", etc.)
event.raw_payload          # full raw API/webhook payload dict (for auditing)
event.trans_amount_major        # Decimal amount in Naira
event.debited_amount_major      # Decimal debited amount in Naira
event.trans_fee_amount_major    # Decimal fee in Naira
event.settlement_amount_major   # Decimal settlement amount in Naira
```

`WebhookEvent`-specific properties:

```python
event.is_settlement        # True when event_type == "transaction.settlement.success"
event.is_transfer_reversed # True when event_type == "transaction.transaction.transfer.reverse"
event.metadata             # dict from the webhook payload
```

## Webhook Signatures

Credo signs webhook requests with a SHA512 hash:

```text
SHA512(webhook_token + business_code)
```

Set `CREDO_WEBHOOK_SECRET_TOKEN` to the token configured in Credo's dashboard. Credo sends `data.businessCode` in each webhook payload, and the SDK uses that payload value for signature verification.

## Idempotent Payment Processing

Callbacks and webhooks may arrive close together. Use `process_payment_event()` with a row lock so fulfillment happens exactly once.

```python
from credo_pay import model_payment_lock, process_payment_event

result = process_payment_event(
    reference=event.trans_ref,
    event=event,
    lock=model_payment_lock(Order, lookup_field="credo_trans_ref"),
    terminal_field="status",
    terminal_values=["paid", "failed"],
    on_success=lambda event, order: order.mark_paid(),
    on_failure=lambda event, order: order.mark_failed(),
    on_commit=lambda event, order, _: send_confirmation_email(order),
    on_failure_commit=lambda event, order, _: send_failure_notification(order),
)

if result.is_skipped:
    pass  # already processed — idempotency guard fired
```

- `lock` — locks the matching row with `SELECT FOR UPDATE` inside a transaction
- `terminal_field` / `terminal_values` — skips the handler if the row is already in a terminal state. **Requires `lock`** — raises `ValueError` immediately if `lock` is not also set
- `on_commit` — called `(event, locked_object, handler_result)` after commit when outcome is `"success"`; use for emails, notifications, WebSocket broadcasts that must not run on rollback
- `on_failure_commit` — same signature as `on_commit`, fires when outcome is `"failed"` (confirmed failure or force-failed from `on_success`)
- `result.is_skipped` — `True` when the idempotency guard fired

### Force failure from on_success (amount mismatch)

When Credo reports a transaction as successful but your own validation rejects it, raise `CredoPaymentError` or `CredoValidationError` inside `on_success`. Both are caught — the SDK calls `on_failure` instead, sets outcome to `"failed"`, and fires `on_failure_commit`.

The most common case is `assert_amount()`, which raises `CredoValidationError` internally:

```python
def handle_success(event, payment):
    event.assert_amount(payment.amount)   # raises CredoValidationError on mismatch
    payment.mark_paid()

process_payment_event(
    ...
    on_success=handle_success,
    on_failure=lambda event, payment: payment.mark_failed(),
    on_failure_commit=lambda event, payment, _: notify_team_of_mismatch(payment),
)
```

You can also raise `CredoPaymentError` directly for custom conditions:

```python
from credo_pay import CredoPaymentError

def handle_success(event, payment):
    if not payment.is_eligible():
        raise CredoPaymentError("Payment not eligible — treating as failure")
    payment.mark_paid()
```

### Stale related objects in on_commit

`locked_object` reflects the database state at the time of the lock query. If `on_success` updated related rows via `filter().update()`, those changes are **not** reflected on the in-memory Python object. Call `refresh_from_db()` inside `on_commit` before reading related objects:

```python
def send_confirmation(event, payment, _result):
    payment.applicant.refresh_from_db()   # filter().update() does not update in-memory objects
    send_email(payment.applicant.email, subject="Payment confirmed")
```

### Custom OR lookups

When a webhook carries `trans_ref` and your callback carries `business_ref`, pass a callable to `lookup_field`:

```python
from django.db.models import Q

lock = model_payment_lock(
    Payment,
    lookup_field=lambda model, ref: model.objects.filter(
        Q(credo_trans_ref=ref) | Q(business_reference=ref)
    ),
)
```

### Controlling the row lock scope

When `lookup_field` is a callable that calls `select_related()`, the default `SELECT FOR UPDATE` locks all joined tables. Pass `select_for_update_kwargs` to limit the lock to the payment row only:

```python
lock = model_payment_lock(
    Payment,
    lookup_field=lambda m, ref: m.objects.select_related("application").filter(pk=ref),
    select_for_update_kwargs={"of": ("self",)},
)
```

## Model Mixin

`CredoPaymentMixin` is optional. It reduces boilerplate for the common pattern of keeping payment state on a Django model.

The mixin handles only Credo API calls. You own your model's fields and decide what to persist in each lifecycle hook.

```python
from django.db import models
from credo_pay.mixins import CredoPaymentMixin


class Order(CredoPaymentMixin, models.Model):
    amount    = models.DecimalField(max_digits=12, decimal_places=2)
    email     = models.EmailField()
    credo_ref = models.CharField(max_length=64, blank=True)
    status    = models.CharField(max_length=20, default="pending")

    # Activate the idempotency guard in sync_from_event()
    terminal_status_field  = "status"
    terminal_status_values = ["paid", "failed"]

    # --- required ---

    def get_payment_amount(self):
        return self.amount

    def get_payment_email(self):
        return self.email

    # --- required for sync_payment_status() and sync_from_event() ---

    def get_credo_trans_ref(self):
        return self.credo_ref or None

    # --- hooks: override only what you need ---

    def on_payment_initiated(self, response):
        self.credo_ref = response.trans_ref or ""
        self.status = "awaiting_payment"
        self.save(update_fields=["credo_ref", "status"])

    def on_payment_confirmed(self, event):
        event.assert_amount(self.amount)   # CredoValidationError → failure path
        self.status = "paid"
        self.save(update_fields=["status"])

    def on_payment_failed(self, event):
        self.status = "failed"
        self.save(update_fields=["status"])
```

Starting a payment in a view:

```python
order = get_object_or_404(Order, pk=pk)
response = order.create_payment()
return redirect(response.authorization_url)
```

Handling a webhook (no extra API round-trip):

```python
def handle_webhook(self, event):
    order = Order.objects.get(credo_ref=event.trans_ref)
    result = order.sync_from_event(event)
    if result.is_skipped:
        logger.info("Duplicate event ignored for %s", event.trans_ref)
```

Handling a callback redirect:

```python
def handle_callback(self, callback_data, verify_response):
    if callback_data.trans_ref and verify_response:
        order = Order.objects.get(credo_ref=callback_data.trans_ref)
        order.sync_from_event(verify_response)
    return redirect("/payment/result/")
```

Two methods are required: `get_payment_amount()` and `get_payment_email()`. Override `get_credo_trans_ref()` so that `sync_payment_status()` can locate the right Credo transaction. Everything else returns `None` by default and can be overridden when needed:

- `get_payment_callback_url()` — per-object callback URL
- `get_payment_currency()` — currency code
- `get_payment_channels()` — payment channels
- `get_payment_first_name()`, `get_payment_last_name()`, `get_payment_phone_number()`
- `get_payment_service_code()`, `get_payment_bank_account()` — routed payments
- `get_payment_metadata()`, `get_payment_custom_fields()`
- `get_payment_bearer()` — who pays the transaction fee (0 = customer, 1 = merchant)

All keyword arguments accepted by `CredoClient.initialize_payment()` can also be passed directly to `create_payment()` as one-off overrides without subclassing:

```python
response = order.create_payment(callback_url="https://yoursite.com/api/credo/callback-webhook/")
```

### sync_from_event()

`sync_from_event(event)` routes a pre-parsed `VerifyResponse` or `WebhookEvent` to the appropriate hook under a database transaction. Use it when you already have the event — it avoids an extra Credo API round-trip.

It provides the same guarantees as `process_payment_event()`:

- Row-level `SELECT FOR UPDATE` lock via `get_payment_lock()`
- Terminal-state idempotency guard (`terminal_status_field` / `terminal_status_values`)
- Hooks run inside `transaction.atomic()`
- Post-commit hooks fire after commit and are discarded on rollback

Returns a `PaymentEventResult`:

```python
result = order.sync_from_event(event)
result.is_skipped     # True when the idempotency guard fired
result.is_successful  # True when outcome == "success"
result.is_failed      # True when outcome == "failed"
result.is_pending     # True when outcome == "pending"
```

`sync_payment_status()` calls Credo's verify API and then delegates to `sync_from_event()` internally. Use it only when you do not already have a verified event.

### Idempotency

Set `terminal_status_field` and `terminal_status_values` on the class to prevent double-processing when a callback and a webhook both arrive for the same transaction:

```python
class Order(CredoPaymentMixin, models.Model):
    terminal_status_field  = "status"
    terminal_status_values = ["paid", "failed"]
```

`sync_from_event()` acquires a row-level `SELECT FOR UPDATE` lock and checks the terminal state inside a single atomic transaction. If the row is already in a terminal state, it returns immediately with `result.is_skipped == True` without calling any hook.

`terminal_status_field` requires `get_payment_lock()` to return a non-`None` lock. The default implementation locks by primary key, which is sufficient for most cases.

### Post-commit hooks

Override `on_payment_confirmed_commit` or `on_payment_failed_commit` for notifications that must not fire if the database transaction rolls back:

```python
class Order(CredoPaymentMixin, models.Model):
    ...

    def on_payment_confirmed(self, event):
        self.status = "paid"
        self.save(update_fields=["status"])

    def on_payment_confirmed_commit(self, event):
        send_confirmation_email(self.email)

    def on_payment_failed_commit(self, event):
        notify_team(self)
```

Both hooks receive `event` (`VerifyResponse` or `WebhookEvent`). `self` inside commit hooks is the locked model row returned from `get_payment_lock()`.

If `on_payment_confirmed` updated related objects via `filter().update()`, those changes are not reflected on the in-memory object. Call `refresh_from_db()` before reading them:

```python
def on_payment_confirmed_commit(self, event):
    self.applicant.refresh_from_db()
    send_email(self.applicant.email)
```

### Custom lock strategy

`get_payment_lock()` returns a callable that locks the current row by primary key by default. Override it when you need to lock by a different field or eager-load a related object alongside the lock:

```python
def get_payment_lock(self):
    from credo_pay import model_payment_lock
    return model_payment_lock(
        self.__class__,
        lookup_field="credo_trans_ref",
    )
```

Return `None` to disable locking. Only safe when concurrent delivery of the same event is impossible.

### Routed payments with the mixin

Override `get_payment_service_code()` and `get_payment_bank_account()`:

```python
class VendorOrder(CredoPaymentMixin, models.Model):
    amount       = models.DecimalField(max_digits=12, decimal_places=2)
    email        = models.EmailField()
    service_code = models.CharField(max_length=80)
    bank_account = models.CharField(max_length=20)
    credo_ref    = models.CharField(max_length=64, blank=True)
    status       = models.CharField(max_length=20, default="pending")

    terminal_status_field  = "status"
    terminal_status_values = ["paid", "failed"]

    def get_payment_amount(self):        return self.amount
    def get_payment_email(self):         return self.email
    def get_credo_trans_ref(self):       return self.credo_ref or None
    def get_payment_service_code(self):  return self.service_code
    def get_payment_bank_account(self):  return self.bank_account

    def on_payment_initiated(self, response):
        self.credo_ref = response.trans_ref or ""
        self.status = "pending"
        self.save(update_fields=["credo_ref", "status"])

    def on_payment_confirmed(self, event):
        self.status = "paid"
        self.save(update_fields=["status"])
```

### Amount verification

Call `assert_amount()` on the event inside `on_payment_confirmed()` before fulfilling the order. It raises `CredoValidationError` on mismatch, which `sync_from_event()` and `process_payment_event()` both catch and redirect to the failure path:

```python
def on_payment_confirmed(self, event):
    event.assert_amount(self.amount, currency="NGN")
    self.status = "paid"
    self.save(update_fields=["status"])
```

### Type annotations

`PaymentEvent = Union[VerifyResponse, WebhookEvent]` is the canonical type for hook parameters. Import it from `credo_pay.idempotency` when annotating custom handlers:

```python
from credo_pay.idempotency import PaymentEvent

def my_handler(event: PaymentEvent) -> None:
    ...
```

## AbstractCredoPayment

`AbstractCredoPayment` is an abstract Django model with all standard payment fields pre-wired. Extend it when you want a complete payment model with zero boilerplate.

**Import path** — `AbstractCredoPayment` must be imported directly from `credo_pay.mixins`, not from `credo_pay`:

```python
from credo_pay.mixins import AbstractCredoPayment
```

This is intentional: importing it from the top-level package during Django's app loading phase causes an `AppRegistryNotReady` error.

### Minimal usage

```python
from credo_pay.mixins import AbstractCredoPayment


class Payment(AbstractCredoPayment):
    class Meta:
        app_label = "payments"
```

The full payment lifecycle works out of the box with zero method overrides:

```python
# Create and initialize
payment = Payment.objects.create(
    email="customer@example.com",
    amount=Decimal("15000"),
)
response = payment.create_payment()
return redirect(response.authorization_url)

# Handle a webhook — no extra Credo API call
def handle_webhook(self, event):
    payment = Payment.objects.get(credo_reference=event.trans_ref)
    payment.sync_from_event(event)
```

### Standard fields

| Field | Type | Description |
| --- | --- | --- |
| `email` | `EmailField` | Payer email (used by `create_payment()`) |
| `amount` | `DecimalField(12, 2)` | Amount in main units (Naira for NGN) |
| `currency` | `CharField` | ISO currency code (default: `"NGN"`) |
| `credo_reference` | `CharField` | Credo's `transRef` |
| `business_reference` | `CharField` | Your generated business reference |
| `status` | `CharField` | `"pending"` → `"awaiting_payment"` → `"paid"` / `"failed"` |
| `payment_method` | `CharField` | Card type or payment method from Credo |
| `confirmed_at` | `DateTimeField` | Timestamp when payment was confirmed |
| `raw_payload` | `JSONField` | Full raw API/webhook payload for auditing |

Idempotency is active by default — `terminal_status_field = "status"` and `terminal_status_values = ["paid", "failed"]` are pre-set.

### Extending with application logic

Override any hook and call `super()` to keep the standard field updates alongside your business logic:

```python
class Payment(AbstractCredoPayment):
    application = models.OneToOneField(Application, on_delete=models.CASCADE)

    class Meta:
        app_label = "payments"

    def on_payment_confirmed(self, event):
        super().on_payment_confirmed(event)   # persists status, confirmed_at, raw_payload
        self.application.mark_approved()
```

### Post-commit notifications

Override `on_payment_confirmed_commit` for emails and broadcasts that must not fire on rollback:

```python
class Payment(AbstractCredoPayment):
    class Meta:
        app_label = "payments"

    def on_payment_confirmed_commit(self, event):
        send_confirmation_email(self.email)

    def on_payment_failed_commit(self, event):
        notify_team(self)
```

### Helper methods

`AbstractCredoPayment` provides helper methods that persist the standard fields. The default hooks call these; you can call them from overridden hooks too:

- `mark_paid(event)` — sets `status="paid"`, `confirmed_at`, `payment_method`, `raw_payload`
- `mark_failed(event)` — sets `status="failed"`, `raw_payload`
- `mark_pending(event)` — sets `raw_payload`

## Routed Payments

Use routed payments when Credo has configured dynamic settlement service codes for your account.

```python
response = client.initialize_routed_payment(
    amount=15000,
    email="customer@example.com",
    service_code="YOUR_SERVICE_CODE",
    bank_account="0114877128",
    callback_url="https://yoursite.com/api/credo/callback-webhook/",
)
```

For model-based routed payments, use `CredoPaymentMixin` and override `get_payment_service_code()` and `get_payment_bank_account()`. See the Routed payments with the mixin example in the Model Mixin section above.

## REST Endpoints

When DRF is installed and `credo_pay.urls` is included under `/api/credo/`, the SDK provides:

| Endpoint | Method | Auth | Description |
| --- | --- | --- | --- |
| `/api/credo/initialize/` | `POST` | Open | Initialize a payment *(DRF only)* |
| `/api/credo/routed/initialize/` | `POST` | Open | Initialize a routed payment *(DRF only)* |
| `/api/credo/verify/<trans_ref>/` | `GET` | Open | Verify a transaction *(DRF only)* |
| `/api/credo/callback-webhook/` | `GET` | Open | Customer redirect callback |
| `/api/credo/callback-webhook/` | `POST` | Open | Credo server webhook |
| `/api/credo/debug/` | `GET` | Debug-gated | Development debug interface |

DRF endpoints default to `AllowAny`. If your application requires users to be logged in before initiating a payment, subclass the view and set your own `permission_classes`:

```python
from rest_framework.permissions import IsAuthenticated
from credo_pay.views.drf import PaymentInitializeView

class AuthenticatedPaymentView(PaymentInitializeView):
    permission_classes = [IsAuthenticated]
```

Webhook and callback endpoints must remain open because Credo's servers cannot supply Django authentication credentials. Authenticity of POST webhooks is enforced through `X-Credo-Signature` verification instead.

## Debug Interface

Visit:

```text
http://localhost:8000/api/credo/debug/
```

The debug interface is available only when Django `DEBUG=True` or `CREDO_ENABLE_DEBUG_INTERFACE=True`. Do not enable it in production. It requires no DRF.

The debug interface uses the same configured SDK settings as normal payment calls. It does not invent callback URLs. If `CREDO_DEFAULT_CALLBACK_URL` is missing, the configuration check fails.

Debug payment initialization uses `CREDO_DEBUG_REQUEST_TIMEOUT` when set, defaulting to `8` seconds. Normal SDK calls use `CREDO_REQUEST_TIMEOUT`, defaulting to `15` seconds.

Use it to:

- confirm keys, environment, API URL, callback URL, and webhook token state
- initialize a sandbox payment with an optional callback URL override and reference prefix
- see the generated reference (with your prefix applied) next to the `trans_ref`
- verify a transaction by Credo `trans_ref`
- watch real callbacks and webhooks that reach the SDK endpoint
- copy integration examples

Local browser callbacks can work with `localhost` because the customer browser is redirected. Server-to-server webhooks from Credo need a public HTTPS URL, for example through a tunnel, and that public URL must be registered in Credo's dashboard.

The debug webhook inbox starts polling only after you initialize a debug payment and stops when a callback or webhook arrives.

## Status Codes

| Code | Meaning |
| --- | --- |
| `0` | Successful |
| `1` | Refunded |
| `2` | Refund queued |
| `3` | Failed |
| `4` | Queued for settlement |
| `5` | Settled |
| `6` | Review (flagged for manual review) |
| `7` | Declined |
| `8` | Failed (aged) |
| `9` | Cancelled by customer |
| `10` | Cancelled by merchant |
| `12` | Account generated, awaiting credit |
| `13` | Attempted |
| `14` | Initialized |
| `15` | Initializing |

## Production Checklist

- Set `CREDO_ENVIRONMENT=production`.
- Use live Credo public and secret keys.
- Set `CREDO_DEFAULT_CALLBACK_URL` to your HTTPS callback/webhook endpoint.
- Register the same HTTPS endpoint in Credo's webhook settings.
- Set a strong `CREDO_WEBHOOK_SECRET_TOKEN` in both Credo and Django.
- Always verify payment status before fulfillment.
- Make order fulfillment idempotent.
- Do not expose secret keys in frontend code.

## Repository Maintainer Notes

This repository contains the development Django project and the publishable package under `django-credo-sdk/`.

Use the maintainer guide for internal release workflow:

```text
SDK_MAINTAINER_GUIDE.md
```

Useful commands from the project root:

```bash
python manage.py build_credo_package
python manage.py release_credo_package --bump patch --skip-dist
python manage.py release_credo_package --bump patch
```

The release command creates local artifacts and commits only when requested. It does not push, and it does not publish to PyPI unless `--publish` is passed explicitly.

## Support

- Credo support: `hello@credocentral.com`
- Sandbox dashboard: `https://www.credodemo.com`
- Production dashboard: `https://www.credocentral.com`

## Disclaimer

This is an unofficial SDK and is not affiliated with or endorsed by Credo.
