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
5 changes: 5 additions & 0 deletions src/apps/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
name="dashboard_banks",
),
path("dashboard/services/<slug:slug>/", payment_views.ServiceDetailView.as_view(), name="dashboard_service_detail"),
path(
"dashboard/services/<slug:slug>/edit/",
payment_views.ServiceEditView.as_view(),
name="dashboard_service_edit",
),
path(
"dashboard/services/<slug:slug>/toggle/",
payment_views.ServiceToggleActiveView.as_view(),
Expand Down
1 change: 1 addition & 0 deletions src/apps/payments/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async def post(self, request: HttpRequest) -> JsonResponse:
"exchange_rate": str(exchange_rate),
"settlement_amount_kes": str(settlement_amount),
},
channels=services.DEFAULT_PAYMENT_CHANNELS,
**split_kwargs,
)

Expand Down
9 changes: 9 additions & 0 deletions src/apps/payments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

PAYSTACK_API_URL = "https://api.paystack.co"

# Default payment channels including Apple Pay
DEFAULT_PAYMENT_CHANNELS = ["card", "bank", "ussd", "mobile_money", "apple_pay"]


def get_paystack_secret_key(*, is_test: bool = False) -> str:
"""Return the Paystack secret key from settings.
Expand Down Expand Up @@ -83,6 +86,7 @@ async def initialise_transaction(
subaccount: str = "",
transaction_charge: int | None = None,
bearer: str = "",
channels: list[str] | None = None,
) -> dict:
"""
Initialise a Paystack transaction.
Expand All @@ -97,6 +101,7 @@ async def initialise_transaction(
subaccount: Paystack subaccount code for split payments.
transaction_charge: Flat fee (kobo) the main account keeps.
bearer: Who bears Paystack fees ("account" or "subaccount").
channels: Payment channels to enable (e.g. ["card", "apple_pay"]).

Returns:
Paystack API response data dict.
Expand All @@ -121,6 +126,10 @@ async def initialise_transaction(
if metadata:
payload["metadata"] = metadata

# Payment channels (include apple_pay explicitly if desired)
if channels:
payload["channels"] = channels

# Revenue sharing / split payment
if subaccount:
payload["subaccount"] = subaccount
Expand Down
168 changes: 145 additions & 23 deletions src/apps/payments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ async def post(self, request: HttpRequest) -> HttpResponse:
currency=currency,
callback_url=callback_url,
metadata={"payment_id": payment.pk, "description": description},
channels=services.DEFAULT_PAYMENT_CHANNELS,
)

if result.get("status") and result.get("data", {}).get("authorization_url"):
Expand Down Expand Up @@ -336,8 +337,10 @@ def form_valid(self, form):
class ServiceCreateSubaccountView(AdminRequiredMixin, View):
"""Create or retry Paystack subaccount creation for a service."""

async def post(self, request: HttpRequest, slug: str) -> HttpResponse:
service = await ServiceProduct.objects.aget(slug=slug)
def post(self, request: HttpRequest, slug: str) -> HttpResponse:
import json

service = ServiceProduct.objects.get(slug=slug)
if service.subaccount_code:
messages.info(request, "Subaccount already exists.")
return redirect("core:dashboard_service_detail", slug=slug)
Expand All @@ -346,23 +349,32 @@ async def post(self, request: HttpRequest, slug: str) -> HttpResponse:
messages.error(request, "Bank details are required to create a subaccount.")
return redirect("core:dashboard_service_detail", slug=slug)

result = await services.create_subaccount(
business_name=service.subaccount_business_name or service.name,
settlement_bank=service.settlement_bank,
account_number=service.account_number,
percentage_charge=float(service.percentage_charge or 0),
description=service.description,
primary_contact_email=service.contact_email,
payload: dict = {
"business_name": service.subaccount_business_name or service.name,
"settlement_bank": service.settlement_bank,
"account_number": service.account_number,
"percentage_charge": float(service.percentage_charge or 0),
}
if service.description:
payload["description"] = service.description
if service.contact_email:
payload["primary_contact_email"] = service.contact_email

data = json.dumps(payload).encode()
result = services._make_paystack_request(
endpoint="/subaccount",
method="POST",
data=data,
is_test=service.is_test,
)

if result.get("status") and result.get("data"):
data = result["data"]
service.subaccount_code = data.get("subaccount_code", "")
service.subaccount_paystack_id = str(data.get("id", ""))
resp_data = result["data"]
service.subaccount_code = resp_data.get("subaccount_code", "")
service.subaccount_paystack_id = str(resp_data.get("id", ""))
if not service.settlement_bank_name:
service.settlement_bank_name = data.get("settlement_bank", "")
await service.asave(
service.settlement_bank_name = resp_data.get("settlement_bank", "")
service.save(
update_fields=[
"subaccount_code",
"subaccount_paystack_id",
Expand Down Expand Up @@ -483,6 +495,103 @@ def post(self, request: HttpRequest, slug: str) -> HttpResponse:
return redirect("core:dashboard_service_detail", slug=slug)


class ServiceEditView(AdminRequiredMixin, DetailView):
"""Full edit page for a service product."""

model = ServiceProduct
template_name = "dashboard/services/edit.html"
context_object_name = "service"
slug_url_kwarg = "slug"

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["bank_country"] = "kenya"
return context

def post(self, request: HttpRequest, slug: str) -> HttpResponse:
import contextlib
import json

service = get_object_or_404(ServiceProduct, slug=slug)

# Basic fields
service.name = request.POST.get("name", service.name).strip()
service.description = request.POST.get("description", "").strip()
service.logo_url = request.POST.get("logo_url", "").strip()
service.is_test = request.POST.get("is_test") == "on"

# Integration fields
service.webhook_url = request.POST.get("webhook_url", "").strip()
service.default_callback_url = request.POST.get("default_callback_url", "").strip()
service.contact_email = request.POST.get("contact_email", "").strip()

# Security fields
allowed_currencies_raw = request.POST.get("allowed_currencies", "").strip()
if allowed_currencies_raw:
service.allowed_currencies = [c.strip().upper() for c in allowed_currencies_raw.split(",") if c.strip()]
else:
service.allowed_currencies = []

allowed_ips_raw = request.POST.get("allowed_ips", "").strip()
if allowed_ips_raw:
service.allowed_ips = [ip.strip() for ip in allowed_ips_raw.split(",") if ip.strip()]
else:
service.allowed_ips = []

# Revenue sharing fields
service.subaccount_business_name = request.POST.get("subaccount_business_name", "").strip()
service.settlement_bank = request.POST.get("settlement_bank", "").strip()
service.settlement_bank_name = request.POST.get("settlement_bank_name", "").strip()
service.account_number = request.POST.get("account_number", "").strip()
service.charge_bearer = request.POST.get("charge_bearer", "account").strip()

percentage_raw = request.POST.get("percentage_charge", "").strip()
if percentage_raw:
with contextlib.suppress(ValueError, TypeError):
service.percentage_charge = round(float(percentage_raw), 2)
else:
service.percentage_charge = None

transaction_charge_raw = request.POST.get("transaction_charge", "").strip()
if transaction_charge_raw:
with contextlib.suppress(ValueError, TypeError):
service.transaction_charge = int(transaction_charge_raw)
else:
service.transaction_charge = None

service.save()

# If subaccount exists and bank details changed, update on Paystack
if service.subaccount_code and service.settlement_bank:
payload = {
"business_name": service.subaccount_business_name or service.name,
"settlement_bank": service.settlement_bank,
"account_number": service.account_number,
"percentage_charge": float(service.percentage_charge or 0),
}
if service.contact_email:
payload["primary_contact_email"] = service.contact_email

data = json.dumps(payload).encode()
result = services._make_paystack_request(
endpoint=f"/subaccount/{service.subaccount_code}",
method="PUT",
data=data,
is_test=service.is_test,
)
if result.get("status"):
messages.success(request, "Service updated and Paystack subaccount synced.")
else:
messages.warning(
request,
f"Service saved locally, but Paystack update failed: {result.get('message', 'Unknown error')}",
)
else:
messages.success(request, "Service updated successfully.")

return redirect("core:dashboard_service_detail", slug=slug)


class ServiceUpdateView(AdminRequiredMixin, View):
"""Update service settings (webhook URL, callback URL, etc.)."""

Expand Down Expand Up @@ -545,9 +654,13 @@ def get_context_data(self, **kwargs):
class PaymentRefundView(AdminRequiredMixin, View):
"""Dashboard action to initiate a refund for a payment."""

async def post(self, request: HttpRequest, pk: int) -> HttpResponse:
def post(self, request: HttpRequest, pk: int) -> HttpResponse:
import json

from asgiref.sync import async_to_sync

try:
payment = await Payment.objects.select_related("service").aget(pk=pk)
payment = Payment.objects.select_related("service").get(pk=pk)
except Payment.DoesNotExist:
messages.error(request, "Payment not found.")
return redirect("core:dashboard_payments")
Expand All @@ -574,11 +687,20 @@ async def post(self, request: HttpRequest, pk: int) -> HttpResponse:
messages.error(request, "Invalid refund amount.")
return redirect("core:dashboard_payment_detail", pk=pk)

result = await services.create_refund(
transaction_reference=payment.reference,
amount_kobo=amount_kobo,
reason=reason,
merchant_note=f"Initiated by {request.user} from dashboard",
# Build refund payload and call Paystack directly
payload: dict = {"transaction": payment.reference}
if amount_kobo:
payload["amount"] = amount_kobo
if reason:
payload["customer_note"] = reason
payload["merchant_note"] = f"Initiated by {request.user} from dashboard"

data = json.dumps(payload).encode()
result = services._make_paystack_request(
endpoint="/refund",
method="POST",
data=data,
is_test=payment.service.is_test if payment.service else False,
)

if result.get("status"):
Expand All @@ -590,7 +712,7 @@ async def post(self, request: HttpRequest, pk: int) -> HttpResponse:
payment.refund_status = Payment.RefundStatus.FULL
else:
payment.refund_status = Payment.RefundStatus.PARTIAL
await payment.asave(
payment.save(
update_fields=[
"refunded_amount",
"refund_status",
Expand All @@ -602,7 +724,7 @@ async def post(self, request: HttpRequest, pk: int) -> HttpResponse:
if payment.service:
from .webhook_dispatcher import dispatch_webhook

await dispatch_webhook(
async_to_sync(dispatch_webhook)(
service=payment.service,
payment=payment,
event="payment.refunded",
Expand Down
21 changes: 17 additions & 4 deletions src/templates/dashboard/services/create.html
Original file line number Diff line number Diff line change
Expand Up @@ -384,16 +384,29 @@ <h2 class="font-bold text-sm mb-4 flex items-center gap-2">
}

function populateBanks(banks) {
var seen = {};
var sorted = banks
.filter(function(b) { return b.active; })
.filter(function(b) {
if (!b.active || seen[b.code]) return false;
seen[b.code] = true;
return true;
})
.sort(function(a, b) { return a.name.localeCompare(b.name); });

bankSelect.innerHTML = '<option value="">Select a bank...</option>';
for (var i = 0; i < sorted.length; i++) {
var bank = sorted[i];
var opt = document.createElement('option');
opt.value = sorted[i].code;
opt.textContent = sorted[i].name;
opt.dataset.bankName = sorted[i].name;
opt.value = bank.code;
var label = bank.name;
if (bank.type && bank.type !== 'nuban' && bank.type !== 'ghipss') {
var typeLabel = bank.type.replace(/_/g, ' ');
typeLabel = typeLabel.charAt(0).toUpperCase() + typeLabel.slice(1);
label += ' (' + typeLabel + ')';
}
opt.textContent = label;
opt.dataset.bankName = bank.name;
opt.dataset.bankType = bank.type || '';
bankSelect.appendChild(opt);
}

Expand Down
4 changes: 4 additions & 0 deletions src/templates/dashboard/services/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
{% endblock %}

{% block topbar_actions %}
<a href="{% url 'core:dashboard_service_edit' slug=service.slug %}" class="btn btn-ghost btn-sm sm:btn-md rounded-lg gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
Edit
</a>
<form method="post" action="{% url 'core:dashboard_service_toggle' slug=service.slug %}" class="inline" id="toggle-form">
{% csrf_token %}
{% if service.is_active %}
Expand Down
Loading
Loading