diff --git a/src/apps/core/urls.py b/src/apps/core/urls.py index bd23ebe..3c246ee 100644 --- a/src/apps/core/urls.py +++ b/src/apps/core/urls.py @@ -61,6 +61,11 @@ name="dashboard_banks", ), path("dashboard/services//", payment_views.ServiceDetailView.as_view(), name="dashboard_service_detail"), + path( + "dashboard/services//edit/", + payment_views.ServiceEditView.as_view(), + name="dashboard_service_edit", + ), path( "dashboard/services//toggle/", payment_views.ServiceToggleActiveView.as_view(), diff --git a/src/apps/payments/api_views.py b/src/apps/payments/api_views.py index a2895ee..f1177cd 100644 --- a/src/apps/payments/api_views.py +++ b/src/apps/payments/api_views.py @@ -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, ) diff --git a/src/apps/payments/services.py b/src/apps/payments/services.py index 2385934..3ff6d3e 100644 --- a/src/apps/payments/services.py +++ b/src/apps/payments/services.py @@ -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. @@ -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. @@ -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. @@ -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 diff --git a/src/apps/payments/views.py b/src/apps/payments/views.py index 4526610..12be421 100644 --- a/src/apps/payments/views.py +++ b/src/apps/payments/views.py @@ -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"): @@ -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) @@ -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", @@ -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.).""" @@ -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") @@ -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"): @@ -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", @@ -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", diff --git a/src/templates/dashboard/services/create.html b/src/templates/dashboard/services/create.html index f5d8aaf..8650cdb 100644 --- a/src/templates/dashboard/services/create.html +++ b/src/templates/dashboard/services/create.html @@ -384,16 +384,29 @@

} 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 = ''; 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); } diff --git a/src/templates/dashboard/services/detail.html b/src/templates/dashboard/services/detail.html index 0121f7d..62761b4 100644 --- a/src/templates/dashboard/services/detail.html +++ b/src/templates/dashboard/services/detail.html @@ -10,6 +10,10 @@ {% endblock %} {% block topbar_actions %} + + + Edit +
{% csrf_token %} {% if service.is_active %} diff --git a/src/templates/dashboard/services/edit.html b/src/templates/dashboard/services/edit.html new file mode 100644 index 0000000..1cbcb5c --- /dev/null +++ b/src/templates/dashboard/services/edit.html @@ -0,0 +1,377 @@ +{% extends "dashboard/base.html" %} + +{% block title %}Edit {{ service.name }} - Dashboard{% endblock %} +{% block nav_services %}active bg-white/10 text-neutral-content{% endblock %} + +{% block breadcrumbs %} +
  • Dashboard
  • +
  • Payments
  • +
  • {{ service.name }}
  • +
  • Edit
  • +{% endblock %} + +{% block dashboard_content %} +
    +

    Edit {{ service.name }}

    + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + + + {% csrf_token %} + + +
    +

    + + Basic Info +

    +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    + + Integration +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    + + Revenue Sharing +

    + + {% if service.subaccount_code %} +
    +

    + + Paystack subaccount active: {{ service.subaccount_code }} +

    +
    + {% endif %} + +
    +
    + + + +
    + +
    +
    + + +
    +
    + +
    + + +
    + + +
    +
    + +
    + + +
    + +
    +
    + + + +
    +
    + + + +
    +
    + +
    + + +
    + + {% if service.subaccount_code %} +
    +

    + + Updating bank details here will also update the subaccount on Paystack. +

    +
    + {% endif %} +
    +
    + + +
    +

    + + Security & Limits +

    +
    +
    + + + +
    +
    + + + +
    +
    +
    + + +
    + Cancel + +
    + +
    +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/tests/test_payments_integration.py b/tests/test_payments_integration.py index 1c04371..a5ab508 100644 --- a/tests/test_payments_integration.py +++ b/tests/test_payments_integration.py @@ -929,12 +929,12 @@ def test_dashboard_sees_all_service_payments( assert "service-alpha@test.com" in content assert "service-beta@test.com" not in content - @patch("apps.payments.views.services.create_refund", new_callable=AsyncMock) + @patch("apps.payments.views.services._make_paystack_request") @patch("apps.payments.webhook_dispatcher.dispatch_webhook", new_callable=AsyncMock) def test_dashboard_refund_flow( self, mock_dispatch: AsyncMock, - mock_refund: AsyncMock, + mock_refund: MagicMock, admin_client: Client, service_a: ServiceProduct, ) -> None: