Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker/compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ services:
POSTGRES_USER: acoruss
POSTGRES_PASSWORD: acoruss
ports:
- "5433:5432"
- "5483:5432"
volumes:
- acoruss_postgres_data:/var/lib/postgresql/data
healthcheck:
Expand Down
66 changes: 65 additions & 1 deletion docs/PRODUCT_PAYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ This document explains how an external service (e.g. **xperience-nairobi**) inte
9. [Currencies](#currencies)
10. [Rate Limits](#rate-limits)
11. [Error Handling](#error-handling)
12. [Example: xperience-nairobi Integration](#example-xperience-nairobi-integration)
12. [Revenue Sharing (Subaccounts)](#revenue-sharing-subaccounts)
13. [Example: xperience-nairobi Integration](#example-xperience-nairobi-integration)

---

Expand Down Expand Up @@ -668,3 +669,66 @@ async def check_payment(reference: str) -> dict:
- Callback URL (e.g. `https://xperience-nairobi.com/orders/{id}/paid/`)
- Required currencies (e.g. `KES` only)
- Server IPs if you want IP allowlisting

---

## Revenue Sharing (Subaccounts)

Acoruss supports automatic revenue splitting via Paystack subaccounts. When a service product is configured with revenue sharing, payments are automatically split between the Acoruss platform and the partner.

### How It Works

```
Customer pays KES 1,000
├── Acoruss (platform fee): KES 200 (20%)
├── Partner (subaccount): KES 800 (80%)
└── Paystack fees: deducted from bearer (configurable)
```

### Configuration

Revenue sharing is configured per service product in the Acoruss dashboard (`/dashboard/services/create/`). The admin provides:

| Field | Description |
|-------|-------------|
| **Partner Business Name** | Business name registered with the bank |
| **Settlement Bank** | Bank code from Paystack's bank list (auto-populated by country) |
| **Account Number** | Partner's bank account number |
| **Platform Fee (%)** | Percentage Acoruss keeps per transaction |
| **Flat Fee** | Optional fixed amount (in smallest currency unit) Acoruss keeps. Overrides percentage. |
| **Fee Bearer** | Who absorbs Paystack processing fees: `account` (Acoruss) or `subaccount` (partner) |

### Subaccount Lifecycle

1. **Creation**: When a product is saved with revenue sharing enabled, bank details are stored. A Paystack subaccount is created (can be retried from the detail page if it fails).
2. **Activation**: Once the `subaccount_code` is stored, all future payments for that service automatically include the split.
3. **Payments**: The `subaccount`, `bearer`, and optional `transaction_charge` fields are passed to Paystack's `/transaction/initialize` endpoint.

### Model Fields on `ServiceProduct`

| Field | Type | Description |
|-------|------|-------------|
| `subaccount_code` | CharField | Paystack subaccount code (e.g., `ACCT_xxx`) |
| `subaccount_paystack_id` | CharField | Paystack internal ID |
| `subaccount_business_name` | CharField | Partner business name |
| `settlement_bank` | CharField | Bank code |
| `settlement_bank_name` | CharField | Human-readable bank name |
| `account_number` | CharField | Partner bank account number |
| `percentage_charge` | DecimalField | % Acoruss keeps |
| `transaction_charge` | IntegerField | Flat fee in kobo/cents (optional) |
| `charge_bearer` | CharField | `account` or `subaccount` |

### API Behavior

External services do **not** need to change their integration. The split is entirely transparent — when a service with a configured subaccount initiates a payment via the API, the split parameters are automatically included in the Paystack request.

### Dashboard Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/dashboard/services/banks/?country=kenya` | GET | Fetch Paystack bank list (cached 24h) |
| `/dashboard/services/<slug>/create-subaccount/` | POST | Create/retry Paystack subaccount |

### Test vs Live

Subaccounts respect the service's `is_test` flag. Test services create subaccounts using Paystack test credentials; live services use live credentials. Always test revenue splits in test mode first.
15 changes: 15 additions & 0 deletions src/apps/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
path("sitemap.xml", views.SitemapXmlView.as_view(), name="sitemap_xml"),
path("favicon.ico", views.FaviconView.as_view(), name="favicon"),
path(".well-known/security.txt", views.SecurityTxtView.as_view(), name="security_txt"),
path(
".well-known/apple-developer-merchantid-domain-association",
views.ApplePayDomainVerificationView.as_view(),
name="apple_pay_verification",
),
# Health check for Docker / load balancer
path("healthz/", views.HealthCheckView.as_view(), name="health_check"),
# Public pages
Expand Down Expand Up @@ -50,6 +55,11 @@
),
path("dashboard/services/", payment_views.ServiceListView.as_view(), name="dashboard_services"),
path("dashboard/services/create/", payment_views.ServiceCreateView.as_view(), name="dashboard_service_create"),
path(
"dashboard/services/banks/",
payment_views.BankListView.as_view(),
name="dashboard_banks",
),
path("dashboard/services/<slug:slug>/", payment_views.ServiceDetailView.as_view(), name="dashboard_service_detail"),
path(
"dashboard/services/<slug:slug>/toggle/",
Expand All @@ -66,6 +76,11 @@
payment_views.ServiceUpdateView.as_view(),
name="dashboard_service_update",
),
path(
"dashboard/services/<slug:slug>/create-subaccount/",
payment_views.ServiceCreateSubaccountView.as_view(),
name="dashboard_service_create_subaccount",
),
path("dashboard/analytics/", views.AnalyticsView.as_view(), name="dashboard_analytics"),
path(
"dashboard/login/",
Expand Down
21 changes: 21 additions & 0 deletions src/apps/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ def get(self, request: HttpRequest) -> HttpResponse:
return HttpResponse(self.SECURITY_TXT, content_type="text/plain")


class ApplePayDomainVerificationView(View):
"""Serve Apple Pay domain verification file for Paystack."""

def get(self, request: HttpRequest) -> HttpResponse:
from pathlib import Path

file_path = (
Path(__file__).resolve().parent.parent.parent
/ "static"
/ ".well-known"
/ "apple-developer-merchantid-domain-association"
)
try:
content = file_path.read_text()
except FileNotFoundError as err:
from django.http import Http404

raise Http404 from err
return HttpResponse(content, content_type="text/plain")


class IndexView(TemplateView):
"""Public homepage."""

Expand Down
9 changes: 9 additions & 0 deletions src/apps/payments/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ async def post(self, request: HttpRequest) -> JsonResponse:
# Tell Paystack to redirect back to Acoruss (not the external service)
paystack_callback = f"{settings.SITE_URL}/payments/verify/"

# Revenue sharing: include subaccount split params if configured
split_kwargs: dict = {}
if request.service.has_subaccount:
split_kwargs["subaccount"] = request.service.subaccount_code
split_kwargs["bearer"] = request.service.charge_bearer
if request.service.transaction_charge is not None:
split_kwargs["transaction_charge"] = request.service.transaction_charge

result = await services.initialise_transaction(
email=email,
amount_kobo=payment.amount_in_kobo,
Expand All @@ -138,6 +146,7 @@ async def post(self, request: HttpRequest) -> JsonResponse:
"exchange_rate": str(exchange_rate),
"settlement_amount_kes": str(settlement_amount),
},
**split_kwargs,
)

if result.get("status") and result.get("data", {}).get("authorization_url"):
Expand Down
58 changes: 58 additions & 0 deletions src/apps/payments/migrations/0005_add_subaccount_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Generated by Django 5.2.14 on 2026-05-26 20:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('payments', '0004_serviceproduct_is_test'),
]

operations = [
migrations.AddField(
model_name='serviceproduct',
name='account_number',
field=models.CharField(blank=True, max_length=30, verbose_name='bank account number'),
),
migrations.AddField(
model_name='serviceproduct',
name='charge_bearer',
field=models.CharField(choices=[('account', 'Main account'), ('subaccount', 'Subaccount')], default='account', help_text='Who bears Paystack processing fees.', max_length=20, verbose_name='Paystack fee bearer'),
),
migrations.AddField(
model_name='serviceproduct',
name='percentage_charge',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Percentage Acoruss (main account) keeps from each payment.', max_digits=5, null=True, verbose_name='platform percentage'),
),
migrations.AddField(
model_name='serviceproduct',
name='settlement_bank',
field=models.CharField(blank=True, help_text='Paystack bank code for settlement.', max_length=20, verbose_name='settlement bank code'),
),
migrations.AddField(
model_name='serviceproduct',
name='settlement_bank_name',
field=models.CharField(blank=True, help_text='Human-readable bank name.', max_length=255, verbose_name='settlement bank name'),
),
migrations.AddField(
model_name='serviceproduct',
name='subaccount_business_name',
field=models.CharField(blank=True, help_text='Partner business name for the subaccount.', max_length=255, verbose_name='subaccount business name'),
),
migrations.AddField(
model_name='serviceproduct',
name='subaccount_code',
field=models.CharField(blank=True, help_text='Paystack subaccount code (e.g. ACCT_xxx). Auto-generated.', max_length=100, verbose_name='Paystack subaccount code'),
),
migrations.AddField(
model_name='serviceproduct',
name='subaccount_paystack_id',
field=models.CharField(blank=True, max_length=50, verbose_name='Paystack subaccount ID'),
),
migrations.AddField(
model_name='serviceproduct',
name='transaction_charge',
field=models.IntegerField(blank=True, help_text='Flat fee Acoruss keeps per transaction (in smallest currency unit).', null=True, verbose_name='flat transaction charge (kobo)'),
),
]
62 changes: 62 additions & 0 deletions src/apps/payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,63 @@ class ServiceProduct(models.Model):
help_text="List of IPs allowed to call the API. Empty = all.",
)

# Revenue Sharing / Subaccount
subaccount_code = models.CharField(
"Paystack subaccount code",
max_length=100,
blank=True,
help_text="Paystack subaccount code (e.g. ACCT_xxx). Auto-generated.",
)
subaccount_paystack_id = models.CharField(
"Paystack subaccount ID",
max_length=50,
blank=True,
)
subaccount_business_name = models.CharField(
"subaccount business name",
max_length=255,
blank=True,
help_text="Partner business name for the subaccount.",
)
settlement_bank = models.CharField(
"settlement bank code",
max_length=20,
blank=True,
help_text="Paystack bank code for settlement.",
)
settlement_bank_name = models.CharField(
"settlement bank name",
max_length=255,
blank=True,
help_text="Human-readable bank name.",
)
account_number = models.CharField(
"bank account number",
max_length=30,
blank=True,
)
percentage_charge = models.DecimalField(
"platform percentage",
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Percentage Acoruss (main account) keeps from each payment.",
)
transaction_charge = models.IntegerField(
"flat transaction charge (kobo)",
null=True,
blank=True,
help_text="Flat fee Acoruss keeps per transaction (in smallest currency unit).",
)
charge_bearer = models.CharField(
"Paystack fee bearer",
max_length=20,
default="account",
choices=[("account", "Main account"), ("subaccount", "Subaccount")],
help_text="Who bears Paystack processing fees.",
)

# Metadata
metadata = models.JSONField("metadata", default=dict, blank=True)

Expand All @@ -99,6 +156,11 @@ def save(self, *args, **kwargs):
self.slug = slugify(self.name)
super().save(*args, **kwargs)

@property
def has_subaccount(self) -> bool:
"""Whether this service has an active Paystack subaccount for revenue sharing."""
return bool(self.subaccount_code)

def regenerate_credentials(self) -> tuple[str, str]:
"""Regenerate API key and secret. Returns (new_key, new_secret)."""
self.api_key = generate_api_key()
Expand Down
Loading
Loading