Metadata-Version: 2.4
Name: django-bayarcash
Version: 1.0.0
Summary: Django integration for the Bayarcash payment gateway.
Project-URL: Homepage, https://bayarcash.com
Project-URL: Documentation, https://api.webimpian.support/bayarcash
Project-URL: Source, https://github.com/bayarcash/django
Author-email: Web Impian <infrastructure@webimpian.com>
License: MIT
License-File: LICENSE
Keywords: bayarcash,django,duitnow,fpx,gateway,malaysia,payment
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 3.2
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.8
Requires-Dist: bayarcash>=1.0.0
Requires-Dist: cryptography>=3.4
Requires-Dist: django>=3.2
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: pytest-django>=4.5; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# Bayarcash for Django

[![PyPI version](https://img.shields.io/pypi/v/django-bayarcash.svg?style=flat-square)](https://pypi.org/project/django-bayarcash/)
[![Python versions](https://img.shields.io/pypi/pyversions/django-bayarcash.svg?style=flat-square)](https://pypi.org/project/django-bayarcash/)
[![Django versions](https://img.shields.io/badge/Django-3.2%20%7C%204.x%20%7C%205.x-092E20.svg?style=flat-square)](https://pypi.org/project/django-bayarcash/)
[![License](https://img.shields.io/pypi/l/django-bayarcash.svg?style=flat-square)](LICENSE)

A Django integration for the [Bayarcash](https://bayar.cash) payment gateway. It
wraps the framework-agnostic [`bayarcash`](https://pypi.org/project/bayarcash/) SDK
and adds a Django-idiomatic developer experience: settings config, a payable model
mixin, optional database persistence, checksum-verified callback/return views,
scheduled reconciliation, and signals.

Targets **Bayarcash API v3**.

It fits two setups:

- **Single merchant** — one set of credentials in settings. Everything in [Usage](#usage) works out of the box.
- **Multi-tenant (SaaS)** — each tenant has its own Bayarcash account with credentials stored in your database. See [Multi-tenant](#multi-tenant-credentials-in-the-database).

Either way you choose whether to **store payment records** in your database with
[`STORE_RECORDS`](#store-records-store-data-or-pass-through).

## Requirements

- Python 3.8+
- Django 3.2, 4.x, or 5.x

## Installation

```bash
pip install django-bayarcash
```

Add the app to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    # ...
    "django.contrib.contenttypes",
    "django_bayarcash",
]
```

Run the migrations (skip if you want [stateless mode](#store_recordsfalse--stateless-pass-through)):

```bash
python manage.py migrate django_bayarcash
```

The per-tenant `bayarcash_accounts` table is a separate, opt-in migration
(`0002`). Single-merchant projects can stop at `0001`:

```bash
python manage.py migrate django_bayarcash 0001
```

## Configuration

Add a `BAYARCASH` dict to your settings:

```python
BAYARCASH = {
    "TOKEN": "your-personal-access-token",
    "SECRET_KEY": "your-api-secret-key",
    "SANDBOX": True,

    # Optional (defaults shown)
    "TIMEOUT": 30,
    "STORE_RECORDS": True,
    "CALLBACK": {"enabled": True, "path": "bayarcash/callback"},
    "RETURN": {"enabled": True, "path": "bayarcash/return", "redirect": None},
    "RECONCILE": {"enabled": True, "requery_after": 2, "cancel_after": 60},
    # "CREDENTIAL_RESOLVER": "django_bayarcash.credentials.DatabaseCredentialResolver",
    # "MULTI_TENANT": False,
    # "ENCRYPTION_KEY": None,   # falls back to Django SECRET_KEY
}
```

Include the webhook routes:

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

urlpatterns = [
    # ...
    path("", include("django_bayarcash.urls")),
]
```

In the Bayarcash portal, point your portal's URLs at these routes:

- **Callback URL** → `https://your-app.test/bayarcash/callback`
- **Return URL** → `https://your-app.test/bayarcash/return`

## Usage

### 1. Make a model payable

Add the `BayarcashPayableMixin` to any model (an `Order`, `User`, `Invoice`, ...):

```python
from django.db import models
from django_bayarcash.mixins import BayarcashPayableMixin


class Order(BayarcashPayableMixin):
    reference = models.CharField(max_length=64)
```

This adds `payments` and `mandates` relations plus the `charge()` and
`enroll_direct_debit()` helpers. (Prefer not to inherit? Use the standalone
`django_bayarcash.mixins.charge(owner, data, tenant=None)` helper.)

### 2. Create a payment

```python
from bayarcash import Bayarcash
from django.shortcuts import redirect

intent = order.charge({
    "portal_key": "your_portal_key",
    "payment_channel": Bayarcash.FPX,
    "order_number": order.reference,   # optional; auto-generated when omitted
    "amount": "10.00",
    "payer_name": order.customer_name,
    "payer_email": order.customer_email,
    "payer_telephone_number": order.customer_phone,
})

# Redirect the customer to the hosted checkout.
return redirect(intent.url)
```

The checksum is generated for you. When record storage is enabled, a pending
`BayarcashTransaction` is stored and linked to `order`, and a `payment_created`
signal fires.

### 3. Enrol a Direct Debit mandate

```python
from bayarcash import FpxDirectDebit

mandate = order.enroll_direct_debit({
    "portal_key": "your_portal_key",
    "amount": "10.00",
    "payer_name": "Ahmad bin Abdullah",
    "payer_id_type": FpxDirectDebit.NRIC,
    "payer_id": "900101011234",
    "payer_email": "ahmad@example.com",
    "payer_telephone_number": "0123456789",
    "application_reason": "Monthly subscription",
    "frequency_mode": FpxDirectDebit.MODE_MONTHLY,
})

return redirect(mandate.url)
```

### 4. Handle results

The package registers two views automatically — you do not write them:

| Route | Method | Purpose |
|---|---|---|
| `/bayarcash/callback` | `POST` | Server-to-server, authoritative. Checksum-verified (invalid → `403`). Updates the transaction and fires the status signal. |
| `/bayarcash/return` | `GET` | Browser redirect, best-effort. Verifies the checksum when present, never aborts, then redirects (or returns JSON). |

Set `RETURN["redirect"]` to a URL or URL name to control where the customer lands
after payment. When it is `None`, the return route responds with JSON.

### 5. Listen for signals

```python
from django.dispatch import receiver
from django_bayarcash.signals import payment_succeeded


@receiver(payment_succeeded)
def on_paid(sender, transaction, **kwargs):
    transaction.owner.mark_paid()
```

Available signals:

| Signal | Payload kwargs |
|---|---|
| `payment_created` | `transaction` |
| `payment_succeeded` | `transaction` |
| `payment_failed` | `transaction` |
| `payment_cancelled` | `transaction` |
| `mandate_authorized` | `mandate` |
| `mandate_approved` | `mandate` |
| `webhook_received` | `record_type`, `payload` |

### 6. Query stored records

```python
from django_bayarcash.models import BayarcashTransaction

order.payments.all()
BayarcashTransaction.objects.successful()
BayarcashTransaction.objects.pending()

transaction.status_label()   # "Successful", "Pending", ...
```

## Reconciliation

Callbacks and return redirects can be missed (downtime, network issues). The
package ships a `bayarcash_reconcile` command that re-queries pending payments and
auto-cancels stale ones:

```bash
python manage.py bayarcash_reconcile
```

Django has no built-in scheduler, so run it on a schedule. With **cron** (every
minute):

```
* * * * * cd /path/to/project && /path/to/venv/bin/python manage.py bayarcash_reconcile >> /dev/null 2>&1
```

Or with **Celery beat**:

```python
# celery.py
app.conf.beat_schedule = {
    "bayarcash-reconcile": {
        "task": "django_bayarcash.reconcile",  # a thin task that calls call_command
        "schedule": 60.0,
    },
}
```

```python
from celery import shared_task
from django.core.management import call_command


@shared_task(name="django_bayarcash.reconcile")
def reconcile():
    call_command("bayarcash_reconcile")
```

Reconciliation requires stored records.

## Store records (store data, or pass-through)

`STORE_RECORDS` decides whether the package keeps a local copy of every payment and
mandate in your database.

### `STORE_RECORDS = True` (default) — stateful

Transactions and mandates are recorded in the `bayarcash_transactions` and
`bayarcash_mandates` tables. This is what you get:

| Capability | What happens |
|---|---|
| **Pending row on `charge()`** | A `BayarcashTransaction` is created, linked to the payable model (`order.payments`), storing the `payment_intent_id` so the callback can complete the *same* row. |
| **Automatic webhook writes** | Callbacks update the record's `status`, set `paid_at` on success, and store the verified payload in `raw_callback`. |
| **Queryable history** | `BayarcashTransaction.objects.successful()`, `.pending()`, status labels, reporting — no extra API calls. |
| **Reconciliation** | `bayarcash_reconcile` can re-query and auto-cancel stale pending payments. |

### `STORE_RECORDS = False` — stateless (pass-through)

The package becomes a thin SDK wrapper. `charge()` / `enroll_direct_debit()` create
the intent and **return it without touching the database**, and the callback/return
views still **verify checksums and fire signals** — they just skip persistence. No
migrations are needed, and `bayarcash_reconcile` is disabled. Use this when you
already store payment state yourself and only want checksum-safe request/callback
handling.

## Multi-tenant (credentials in the database)

In a SaaS app each tenant has its own Bayarcash account. Store each tenant's
credentials in your own table and let the package resolve them per request. There
is **one shared webhook** for every tenant — no tenant id in the URL.

### 1. Store per-tenant credentials

The package ships an encrypted `bayarcash_accounts` table for this. Apply its
opt-in migration:

```bash
python manage.py migrate django_bayarcash
```

Then store each tenant's credentials — `token` and `secret_key` are encrypted at rest:

```python
from django_bayarcash.models import BayarcashAccount

BayarcashAccount.objects.create(
    tenant_id=tenant.id,
    token=token,
    secret_key=secret_key,
    sandbox=False,
)
```

### 2. Turn multi-tenant on

Point the package at its built-in resolver and enable multi-tenant:

```python
BAYARCASH = {
    # ...
    "CREDENTIAL_RESOLVER": "django_bayarcash.credentials.DatabaseCredentialResolver",
    "MULTI_TENANT": True,
}
```

**Credentials already elsewhere?** If your tenants' Bayarcash credentials live on
your own model/table, skip the migration and implement the resolver yourself —
return `token`, `secret_key`, and `sandbox` for a tenant:

```python
from django_bayarcash.credentials import CredentialResolver


class MyCredentialResolver(CredentialResolver):
    def resolve(self, tenant=None):
        account = MyAccount.objects.get(tenant_id=tenant)
        return {
            "token": account.token,
            "secret_key": account.secret_key,
            "sandbox": bool(account.sandbox),
        }
```

### 3. Create payments per tenant

Pass the tenant to `charge()` / `enroll_direct_debit()`. The package generates the
checksum and calls the gateway with **that tenant's** credentials, and stamps the
stored row with `tenant_id`:

```python
intent = order.charge(data, tenant=tenant_id)
```

With no `tenant` argument the default settings credentials are used — so single-
and multi-tenant code live side by side.

### 4. One shared webhook for every tenant

Point **every** tenant's portal Callback/Return URLs at the same package routes.
The package matches each callback to its local record, resolves **that tenant's**
secret, and verifies the checksum — rejecting with **`403`** (fail closed) when no
record matches. This lookup is why multi-tenant mode requires `STORE_RECORDS`.

## The client

For direct, lower-level access to the SDK:

```python
from django_bayarcash.manager import get_client

client = get_client()                 # default credentials, pinned to API v3
client = get_client(tenant="t1")      # a specific tenant's credentials

portals = client.get_portals()
intent = client.get_payment_intent("payment_intent_id")
```

## Error handling

SDK calls raise typed exceptions you can catch around `charge()` /
`enroll_direct_debit()`:

```python
from bayarcash.exceptions import BayarcashError, ValidationError

try:
    intent = order.charge({...})
except ValidationError as exc:
    errors = exc.errors  # 422
except BayarcashError as exc:
    ...
```

## Testing

```bash
pip install -e ".[dev]"
pytest
```

## License

The MIT License (MIT).
