diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index c5d4fea..4ef64b4 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -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: diff --git a/docs/PRODUCT_PAYMENTS.md b/docs/PRODUCT_PAYMENTS.md index 8c45051..38bb709 100644 --- a/docs/PRODUCT_PAYMENTS.md +++ b/docs/PRODUCT_PAYMENTS.md @@ -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) --- @@ -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//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. diff --git a/src/apps/core/urls.py b/src/apps/core/urls.py index 5fae503..bd23ebe 100644 --- a/src/apps/core/urls.py +++ b/src/apps/core/urls.py @@ -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 @@ -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//", payment_views.ServiceDetailView.as_view(), name="dashboard_service_detail"), path( "dashboard/services//toggle/", @@ -66,6 +76,11 @@ payment_views.ServiceUpdateView.as_view(), name="dashboard_service_update", ), + path( + "dashboard/services//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/", diff --git a/src/apps/core/views.py b/src/apps/core/views.py index f351f96..107cbd8 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -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.""" diff --git a/src/apps/payments/api_views.py b/src/apps/payments/api_views.py index 63f717b..a2895ee 100644 --- a/src/apps/payments/api_views.py +++ b/src/apps/payments/api_views.py @@ -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, @@ -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"): diff --git a/src/apps/payments/migrations/0005_add_subaccount_fields.py b/src/apps/payments/migrations/0005_add_subaccount_fields.py new file mode 100644 index 0000000..f347800 --- /dev/null +++ b/src/apps/payments/migrations/0005_add_subaccount_fields.py @@ -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)'), + ), + ] diff --git a/src/apps/payments/models.py b/src/apps/payments/models.py index 0d26233..b451d65 100644 --- a/src/apps/payments/models.py +++ b/src/apps/payments/models.py @@ -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) @@ -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() diff --git a/src/apps/payments/services.py b/src/apps/payments/services.py index 8663088..2385934 100644 --- a/src/apps/payments/services.py +++ b/src/apps/payments/services.py @@ -80,6 +80,9 @@ async def initialise_transaction( callback_url: str = "", metadata: dict | None = None, is_test: bool = False, + subaccount: str = "", + transaction_charge: int | None = None, + bearer: str = "", ) -> dict: """ Initialise a Paystack transaction. @@ -91,6 +94,9 @@ async def initialise_transaction( currency: Currency code (KES, USD, NGN). callback_url: URL to redirect after payment. metadata: Additional metadata for the 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"). Returns: Paystack API response data dict. @@ -115,6 +121,14 @@ async def initialise_transaction( if metadata: payload["metadata"] = metadata + # Revenue sharing / split payment + if subaccount: + payload["subaccount"] = subaccount + if bearer: + payload["bearer"] = bearer + if transaction_charge is not None: + payload["transaction_charge"] = transaction_charge + data = json.dumps(payload).encode() loop = asyncio.get_event_loop() @@ -267,3 +281,138 @@ def validate_webhook_signature(payload: bytes, signature: str) -> bool: if hmac.compare_digest(expected, signature): return True return False + + +# ───────────────────────────── Subaccount Management ───────────────────────── + + +async def list_banks(country: str = "kenya", *, is_test: bool = False) -> list[dict]: + """ + Fetch list of banks from Paystack for a given country. + + Args: + country: Country name (kenya, nigeria, ghana, south africa). + is_test: Use test or live credentials. + + Returns: + List of bank dicts with 'name', 'code', 'active' etc. + + """ + import asyncio + + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, + lambda: _make_paystack_request( + endpoint=f"/bank?country={country}&perPage=100", + is_test=is_test, + ), + ) + if result.get("status") and result.get("data"): + return result["data"] + return [] + except Exception: + logger.exception("Failed to fetch banks for country: %s", country) + return [] + + +async def create_subaccount( + *, + business_name: str, + settlement_bank: str, + account_number: str, + percentage_charge: float, + description: str = "", + primary_contact_email: str = "", + primary_contact_phone: str = "", + is_test: bool = False, +) -> dict: + """ + Create a Paystack subaccount for revenue sharing. + + Args: + business_name: Partner business name. + settlement_bank: Bank code from Paystack's bank list. + account_number: Partner's bank account number. + percentage_charge: Percentage the main account (Acoruss) keeps. + description: Optional description. + primary_contact_email: Contact email for the subaccount. + primary_contact_phone: Contact phone for the subaccount. + is_test: Use test or live credentials. + + Returns: + Paystack API response dict. On success, data contains subaccount_code. + + """ + import asyncio + import json + + payload: dict = { + "business_name": business_name, + "settlement_bank": settlement_bank, + "account_number": account_number, + "percentage_charge": percentage_charge, + } + if description: + payload["description"] = description + if primary_contact_email: + payload["primary_contact_email"] = primary_contact_email + if primary_contact_phone: + payload["primary_contact_phone"] = primary_contact_phone + + data = json.dumps(payload).encode() + + loop = asyncio.get_event_loop() + try: + return await loop.run_in_executor( + None, + lambda: _make_paystack_request( + endpoint="/subaccount", + method="POST", + data=data, + is_test=is_test, + ), + ) + except Exception: + logger.exception("Failed to create Paystack subaccount") + return {"status": False, "message": "Subaccount creation failed"} + + +async def update_subaccount( + *, + id_or_code: str, + is_test: bool = False, + **kwargs, +) -> dict: + """ + Update a Paystack subaccount. + + Args: + id_or_code: Subaccount ID or code. + is_test: Use test or live credentials. + **kwargs: Fields to update (business_name, percentage_charge, etc.) + + Returns: + Paystack API response dict. + + """ + import asyncio + import json + + data = json.dumps(kwargs).encode() + + loop = asyncio.get_event_loop() + try: + return await loop.run_in_executor( + None, + lambda: _make_paystack_request( + endpoint=f"/subaccount/{id_or_code}", + method="PUT", + data=data, + is_test=is_test, + ), + ) + except Exception: + logger.exception("Failed to update Paystack subaccount: %s", id_or_code) + return {"status": False, "message": "Subaccount update failed"} diff --git a/src/apps/payments/views.py b/src/apps/payments/views.py index 49bd8e4..d653df6 100644 --- a/src/apps/payments/views.py +++ b/src/apps/payments/views.py @@ -293,15 +293,116 @@ def get_context_data(self, **kwargs): return context def form_valid(self, form): - """Save the service and show the generated credentials.""" + """Save the service, optionally create a Paystack subaccount.""" + import contextlib + self.object = form.save() - messages.success( - self.request, - f"Service '{self.object.name}' created! API credentials generated.", - ) + + # Check if revenue sharing fields were provided + enable_revenue_sharing = self.request.POST.get("enable_revenue_sharing") + if enable_revenue_sharing: + self.object.subaccount_business_name = self.request.POST.get("subaccount_business_name", "").strip() + self.object.settlement_bank = self.request.POST.get("settlement_bank", "").strip() + self.object.settlement_bank_name = self.request.POST.get("settlement_bank_name", "").strip() + self.object.account_number = self.request.POST.get("account_number", "").strip() + self.object.charge_bearer = self.request.POST.get("charge_bearer", "account").strip() + + percentage_raw = self.request.POST.get("percentage_charge", "").strip() + if percentage_raw: + with contextlib.suppress(ValueError, TypeError): + self.object.percentage_charge = round(float(percentage_raw), 2) + + transaction_charge_raw = self.request.POST.get("transaction_charge", "").strip() + if transaction_charge_raw: + with contextlib.suppress(ValueError, TypeError): + self.object.transaction_charge = int(transaction_charge_raw) + + self.object.save() + + # Schedule subaccount creation (handled async in detail view or via management) + messages.success( + self.request, + f"Service '{self.object.name}' created! Revenue sharing configured — " + f"subaccount will be created on Paystack.", + ) + else: + messages.success( + self.request, + f"Service '{self.object.name}' created! API credentials generated.", + ) return redirect("core:dashboard_service_detail", slug=self.object.slug) +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) + if service.subaccount_code: + messages.info(request, "Subaccount already exists.") + return redirect("core:dashboard_service_detail", slug=slug) + + if not service.settlement_bank or not service.account_number: + 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, + 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", "")) + if not service.settlement_bank_name: + service.settlement_bank_name = data.get("settlement_bank", "") + await service.asave( + update_fields=[ + "subaccount_code", + "subaccount_paystack_id", + "settlement_bank_name", + "updated_at", + ] + ) + messages.success( + request, + f"Subaccount created on Paystack: {service.subaccount_code}", + ) + else: + error_msg = result.get("message", "Unknown error") + messages.error(request, f"Failed to create subaccount: {error_msg}") + + return redirect("core:dashboard_service_detail", slug=slug) + + +class BankListView(AdminRequiredMixin, View): + """API endpoint to fetch Paystack bank list for a country (used by dashboard forms).""" + + async def get(self, request: HttpRequest) -> JsonResponse: + from django.core.cache import cache + + country = request.GET.get("country", "kenya").lower() + is_test = request.GET.get("is_test", "false").lower() == "true" + + cache_key = f"paystack_banks_{country}_{is_test}" + cached = cache.get(cache_key) + if cached: + return JsonResponse({"status": True, "data": cached}) + + banks = await services.list_banks(country=country, is_test=is_test) + if banks: + # Cache for 24 hours + cache.set(cache_key, banks, 86400) + + return JsonResponse({"status": True, "data": banks}) + + class ServiceDetailView(AdminRequiredMixin, DetailView): """Dashboard detail view for a service product.""" diff --git a/src/static/.well-known/apple-developer-merchantid-domain-association b/src/static/.well-known/apple-developer-merchantid-domain-association new file mode 100644 index 0000000..af1d75c --- /dev/null +++ b/src/static/.well-known/apple-developer-merchantid-domain-association @@ -0,0 +1 @@ +7B227073704964223A2234424538444645374337303544443538353133393637344446363439463242374446383942343435393143433236323435423834384542323538364530383742222C2276657273696F6E223A312C22637265617465644F6E223A313631393639363438313430372C227369676E6174757265223A223330383030363039326138363438383666373064303130373032613038303330383030323031303133313066333030643036303936303836343830313635303330343032303130353030333038303036303932613836343838366637306430313037303130303030613038303330383230336533333038323033383861303033303230313032303230383463333034313439353139643534333633303061303630383261383634386365336430343033303233303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330316531373064333133393330333533313338333033313333333233353337356131373064333233343330333533313336333033313333333233353337356133303566333132353330323330363033353530343033306331633635363336333264373336643730326436323732366636623635373232643733363936373665356635353433333432643530353234663434333131343330313230363033353530343062306330623639346635333230353337393733373436353664373333313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030346332313537376564656264366337623232313866363864643730393061313231386463376230626436663263323833643834363039356439346166346135343131623833343230656438313166333430376538333333316631633534633366376562333232306436626164356434656666343932383938393365376330663133613338323032313133303832303230643330306330363033353531643133303130316666303430323330303033303166303630333535316432333034313833303136383031343233663234396334346639336534656632376536633466363238366333666132626266643265346233303435303630383262303630313035303530373031303130343339333033373330333530363038326230363031303530353037333030313836323936383734373437303361326632663666363337333730326536313730373036633635326536333666366432663666363337333730333033343264363137303730366336353631363936333631333333303332333038323031316430363033353531643230303438323031313433303832303131303330383230313063303630393261383634383836663736333634303530313330383166653330383163333036303832623036303130353035303730323032333038316236306338316233353236353663363936313665363336353230366636653230373436383639373332303633363537323734363936363639363336313734363532303632373932303631366537393230373036313732373437393230363137333733373536643635373332303631363336333635373037343631366536333635323036663636323037343638363532303734363836353665323036313730373036633639363336313632366336353230373337343631366536343631373236343230373436353732366437333230363136653634323036333666366536343639373436393666366537333230366636363230373537333635326332303633363537323734363936363639363336313734363532303730366636633639363337393230363136653634323036333635373237343639363636393633363137343639366636653230373037323631363337343639363336353230373337343631373436353664363536653734373332653330333630363038326230363031303530353037303230313136326136383734373437303361326632663737373737373265363137303730366336353265363336663664326636333635373237343639363636393633363137343635363137353734363836663732363937343739326633303334303630333535316431663034326433303262333032396130323761303235383632333638373437343730336132663266363337323663326536313730373036633635326536333666366432663631373037303663363536313639363336313333326536333732366333303164303630333535316430653034313630343134393435376462366664353734383138363839383937363266376535373835303765373962353832343330306530363033353531643066303130316666303430343033303230373830333030663036303932613836343838366637363336343036316430343032303530303330306130363038326138363438636533643034303330323033343930303330343630323231303062653039353731666537316531653733356235356535616661636234633732666562343435663330313835323232633732353130303262363165626436663535303232313030643138623335306135646436646436656231373436303335623131656232636538376366613365366166366362643833383038393064633832636464616136333330383230326565333038323032373561303033303230313032303230383439366432666266336139386461393733303061303630383261383634386365336430343033303233303637333131623330313930363033353530343033306331323431373037303663363532303532366636663734323034333431323032643230343733333331323633303234303630333535303430623063316434313730373036633635323034333635373237343639363636393633363137343639366636653230343137353734363836663732363937343739333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353333303165313730643331333433303335333033363332333333343336333333303561313730643332333933303335333033363332333333343336333333303561333037613331326533303263303630333535303430333063323534313730373036633635323034313730373036633639363336313734363936663665323034393665373436353637373236313734363936663665323034333431323032643230343733333331323633303234303630333535303430623063316434313730373036633635323034333635373237343639363636393633363137343639366636653230343137353734363836663732363937343739333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353333303539333031333036303732613836343863653364303230313036303832613836343863653364303330313037303334323030303466303137313138343139643736343835643531613565323538313037373665383830613265666465376261653464653038646663346239336531333335366435363635623335616532326430393737363064323234653762626130386664373631376365383863623736626236363730626563386538323938346666353434356133383166373330383166343330343630363038326230363031303530353037303130313034336133303338333033363036303832623036303130353035303733303031383632613638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635373236663666373436333631363733333330316430363033353531643065303431363034313432336632343963343466393365346566323765366334663632383663336661326262666432653462333030663036303335353164313330313031666630343035333030333031303166663330316630363033353531643233303431383330313638303134626262306465613135383333383839616134386139396465626562646562616664616362323461623330333730363033353531643166303433303330326533303263613032616130323838363236363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353732366636663734363336313637333332653633373236633330306530363033353531643066303130316666303430343033303230313036333031303036306132613836343838366637363336343036303230653034303230353030333030613036303832613836343863653364303430333032303336373030333036343032333033616366373238333531313639396231383666623335633335366361363262666634313765646439306637353464613238656265663139633831356534326237383966383938663739623539396639386435343130643866396465396332666530323330333232646435343432316230613330353737366335646633333833623930363766643137376332633231366439363466633637323639383231323666353466383761376431623939636239623039383932313631303639393066303939323164303030303331383230313863333038323031383830323031303133303831383633303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333032303834633330343134393531396435343336333030643036303936303836343830313635303330343032303130353030613038313935333031383036303932613836343838366637306430313039303333313062303630393261383634383836663730643031303730313330316330363039326138363438383666373064303130393035333130663137306433323331333033343332333933313331333433313332333135613330326130363039326138363438383666373064303130393334333131643330316233303064303630393630383634383031363530333034303230313035303061313061303630383261383634386365336430343033303233303266303630393261383634383836663730643031303930343331323230343230663662366364646432343338653966326237306563336133343733666537666232333338616536303736646566303038373464323664366638663935333531323330306130363038326138363438636533643034303330323034343733303435303232303736663339633739306566396161313866326562333261396333323261343961373433383337356263636537323332326136363935313038376138346536653330323231303039316439323932633564306163396363333339343861343838306533333039303463366365333762366265663839353733623930343934363734363262343162303030303030303030303030227D \ No newline at end of file diff --git a/src/static/css/main.css b/src/static/css/main.css index 6ba15b3..03c4a98 100644 --- a/src/static/css/main.css +++ b/src/static/css/main.css @@ -2256,6 +2256,23 @@ html { grid-column-start: 1; grid-row-start: 1; } +.\!input { + flex-shrink: 1 !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + height: 3rem !important; + padding-left: 1rem !important; + padding-right: 1rem !important; + font-size: 1rem !important; + line-height: 2 !important; + line-height: 1.5rem !important; + border-radius: var(--rounded-btn, 0.5rem) !important; + border-width: 1px !important; + border-color: transparent !important; + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))) !important; +} .input { flex-shrink: 1; -webkit-appearance: none; @@ -2273,6 +2290,11 @@ html { --tw-bg-opacity: 1; background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); } +.\!input[type="number"]::-webkit-inner-spin-button { + margin-top: -1rem !important; + margin-bottom: -1rem !important; + margin-inline-end: -1rem !important; +} .input[type="number"]::-webkit-inner-spin-button, .input-md[type="number"]::-webkit-inner-spin-button { margin-top: -1rem; @@ -3386,21 +3408,42 @@ details.collapse summary::-webkit-details-marker { --tw-text-opacity: 1; color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); } +.\!input input { + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity))) !important; + background-color: transparent !important; +} .input input { --tw-bg-opacity: 1; background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity))); background-color: transparent; } +.\!input input:focus { + outline: 2px solid transparent !important; + outline-offset: 2px !important; +} .input input:focus { outline: 2px solid transparent; outline-offset: 2px; } +.\!input[list]::-webkit-calendar-picker-indicator { + line-height: 1em !important; +} .input[list]::-webkit-calendar-picker-indicator { line-height: 1em; } .input-bordered { border-color: var(--fallback-bc,oklch(var(--bc)/0.2)); } +.\!input:focus, + .\!input:focus-within { + box-shadow: none !important; + border-color: var(--fallback-bc,oklch(var(--bc)/0.2)) !important; + outline-style: solid !important; + outline-width: 2px !important; + outline-offset: 2px !important; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)) !important; +} .input:focus, .input:focus-within { box-shadow: none; @@ -3410,6 +3453,35 @@ details.collapse summary::-webkit-details-marker { outline-offset: 2px; outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)); } +.\!input:focus, + .\!input:focus-within { + box-shadow: none !important; + border-color: var(--fallback-bc,oklch(var(--bc)/0.2)) !important; + outline-style: solid !important; + outline-width: 2px !important; + outline-offset: 2px !important; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)) !important; +} +.input-error { + --tw-border-opacity: 1; + border-color: var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity))); +} +.input-error:focus, + .input-error:focus-within { + --tw-border-opacity: 1; + border-color: var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity))); + outline-color: var(--fallback-er,oklch(var(--er)/1)); +} +.\!input:has(> input[disabled]), + .\!input:disabled, + .\!input[disabled] { + cursor: not-allowed !important; + --tw-border-opacity: 1 !important; + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))) !important; + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))) !important; + color: var(--fallback-bc,oklch(var(--bc)/0.4)) !important; +} .input:has(> input[disabled]), .input-disabled, .input:disabled, @@ -3421,6 +3493,26 @@ details.collapse summary::-webkit-details-marker { background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))); color: var(--fallback-bc,oklch(var(--bc)/0.4)); } +.\!input:has(> input[disabled]), + .\!input:disabled, + .\!input[disabled] { + cursor: not-allowed !important; + --tw-border-opacity: 1 !important; + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))) !important; + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))) !important; + color: var(--fallback-bc,oklch(var(--bc)/0.4)) !important; +} +.\!input:has(> input[disabled])::-moz-placeholder, .\!input:disabled::-moz-placeholder, .\!input[disabled]::-moz-placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))) !important; + --tw-placeholder-opacity: 0.2 !important; +} +.\!input:has(> input[disabled])::placeholder, + .\!input:disabled::placeholder, + .\!input[disabled]::placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))) !important; + --tw-placeholder-opacity: 0.2 !important; +} .input:has(> input[disabled])::-moz-placeholder, .input-disabled::-moz-placeholder, .input:disabled::-moz-placeholder, .input[disabled]::-moz-placeholder { color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))); --tw-placeholder-opacity: 0.2; @@ -3432,9 +3524,25 @@ details.collapse summary::-webkit-details-marker { color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))); --tw-placeholder-opacity: 0.2; } +.\!input:has(> input[disabled])::-moz-placeholder, .\!input:disabled::-moz-placeholder, .\!input[disabled]::-moz-placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))) !important; + --tw-placeholder-opacity: 0.2 !important; +} +.\!input:has(> input[disabled])::placeholder, + .\!input:disabled::placeholder, + .\!input[disabled]::placeholder { + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity))) !important; + --tw-placeholder-opacity: 0.2 !important; +} +.\!input:has(> input[disabled]) > input[disabled] { + cursor: not-allowed !important; +} .input:has(> input[disabled]) > input[disabled] { cursor: not-allowed; } +.\!input::-webkit-date-and-time-value { + text-align: inherit !important; +} .input::-webkit-date-and-time-value { text-align: inherit; } @@ -3470,6 +3578,21 @@ details.collapse summary::-webkit-details-marker { outline: 2px solid currentColor; outline-offset: 2px; } +.loading { + pointer-events: none; + display: inline-block; + aspect-ratio: 1 / 1; + width: 1.5rem; + background-color: currentColor; + -webkit-mask-size: 100%; + mask-size: 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); +} :where(.menu li:empty) { --tw-bg-opacity: 1; background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); @@ -3557,6 +3680,21 @@ details.collapse summary::-webkit-details-marker { border-radius: 40px; margin-top: -25px; } +.mockup-browser .mockup-browser-toolbar .\!input { + position: relative !important; + margin-left: auto !important; + margin-right: auto !important; + display: block !important; + height: 1.75rem !important; + width: 24rem !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity))) !important; + padding-left: 2rem !important; + direction: ltr !important; +} .mockup-browser .mockup-browser-toolbar .input { position: relative; margin-left: auto; @@ -3572,6 +3710,20 @@ details.collapse summary::-webkit-details-marker { padding-left: 2rem; direction: ltr; } +.mockup-browser .mockup-browser-toolbar .\!input:before { + content: "" !important; + position: absolute !important; + left: 0.5rem !important; + top: 50% !important; + aspect-ratio: 1 / 1 !important; + height: 0.75rem !important; + --tw-translate-y: -50% !important; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important; + border-radius: 9999px !important; + border-width: 2px !important; + border-color: currentColor !important; + opacity: 0.6 !important; +} .mockup-browser .mockup-browser-toolbar .input:before { content: ""; position: absolute; @@ -3586,6 +3738,20 @@ details.collapse summary::-webkit-details-marker { border-color: currentColor; opacity: 0.6; } +.mockup-browser .mockup-browser-toolbar .\!input:after { + content: "" !important; + position: absolute !important; + left: 1.25rem !important; + top: 50% !important; + height: 0.5rem !important; + --tw-translate-y: 25% !important; + --tw-rotate: -45deg !important; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important; + border-radius: 9999px !important; + border-width: 1px !important; + border-color: currentColor !important; + opacity: 0.6 !important; +} .mockup-browser .mockup-browser-toolbar .input:after { content: ""; position: absolute; @@ -4047,6 +4213,15 @@ details.collapse summary::-webkit-details-marker { outline-offset: 2px; outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)); } +.textarea-error { + --tw-border-opacity: 1; + border-color: var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity))); +} +.textarea-error:focus { + --tw-border-opacity: 1; + border-color: var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity))); + outline-color: var(--fallback-er,oklch(var(--er)/1)); +} .textarea-disabled, .textarea:disabled, .textarea[disabled] { @@ -4144,6 +4319,18 @@ details.collapse summary::-webkit-details-marker { calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, 0 0 0 2px var(--tglbg) inset; } +.toggle-accent:focus-visible { + outline-color: var(--fallback-a,oklch(var(--a)/1)); +} +.toggle-accent:checked, + .toggle-accent[aria-checked="true"] { + border-color: var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity))); + --tw-border-opacity: 0.1; + --tw-bg-opacity: 1; + background-color: var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); +} .toggle:disabled { cursor: not-allowed; --tw-border-opacity: 1; @@ -4395,6 +4582,18 @@ html:has(.drawer-toggle:checked) { border-end-end-radius: inherit; border-start-end-radius: inherit; } +.select-sm { + height: 2rem; + min-height: 2rem; + padding-left: 0.75rem; + padding-right: 2rem; + font-size: 0.875rem; + line-height: 2rem; +} +[dir="rtl"] .select-sm { + padding-left: 2rem; + padding-right: 0.75rem; +} .steps-horizontal .step { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); @@ -4433,6 +4632,11 @@ html:has(.drawer-toggle:checked) { line-height: .75rem; --tab-padding: 0.5rem; } +[type="checkbox"].toggle-sm { + --handleoffset: 0.75rem; + height: 1.25rem; + width: 2rem; +} .avatar.online:before { content: ""; position: absolute; @@ -5224,6 +5428,9 @@ html:has(.drawer-toggle:checked) { .items-center { align-items: center; } +.justify-start { + justify-content: flex-start; +} .justify-end { justify-content: flex-end; } @@ -5376,6 +5583,9 @@ html:has(.drawer-toggle:checked) { border-top-left-radius: 1rem; border-top-right-radius: 1rem; } +.rounded-bl-lg { + border-bottom-left-radius: 0.5rem; +} .border { border-width: 1px; } @@ -5424,12 +5634,18 @@ html:has(.drawer-toggle:checked) { .border-error\/20 { border-color: var(--fallback-er,oklch(var(--er)/0.2)); } +.border-info\/20 { + border-color: var(--fallback-in,oklch(var(--in)/0.2)); +} .border-neutral-content\/10 { border-color: var(--fallback-nc,oklch(var(--nc)/0.1)); } .border-primary\/30 { border-color: var(--fallback-p,oklch(var(--p)/0.3)); } +.border-warning\/20 { + border-color: var(--fallback-wa,oklch(var(--wa)/0.2)); +} .border-white\/5 { border-color: rgb(255 255 255 / 0.05); } @@ -5534,6 +5750,9 @@ html:has(.drawer-toggle:checked) { .bg-info\/10 { background-color: var(--fallback-in,oklch(var(--in)/0.1)); } +.bg-info\/5 { + background-color: var(--fallback-in,oklch(var(--in)/0.05)); +} .bg-neutral-content { --tw-bg-opacity: 1; background-color: var(--fallback-nc,oklch(var(--nc)/var(--tw-bg-opacity, 1))); @@ -5589,6 +5808,9 @@ html:has(.drawer-toggle:checked) { .bg-warning\/20 { background-color: var(--fallback-wa,oklch(var(--wa)/0.2)); } +.bg-warning\/5 { + background-color: var(--fallback-wa,oklch(var(--wa)/0.05)); +} .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); } @@ -6208,19 +6430,15 @@ html:has(.drawer-toggle:checked) { animation: fade-in-up 0.6s ease-out forwards; } -@media (prefers-reduced-motion: no-preference) { - [data-animate] { - opacity: 0; - } +[data-animate] { + opacity: 0; } /* Tab active state */ /* Global styles */ -@media (prefers-reduced-motion: no-preference) { - html { - scroll-behavior: smooth; - } +html { + scroll-behavior: smooth; } /* Focus states for accessibility */ @@ -6237,15 +6455,6 @@ select:focus-visible { a, button { transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } - -/* Ensure minimum touch target size for tab buttons */ -.service-tab, -.project-tab { - min-height: 44px; - min-width: 44px; - padding-left: 1rem; - padding-right: 1rem; -} .hover\:badge-accent:hover { --tw-border-opacity: 1; border-color: var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity))); @@ -6290,6 +6499,9 @@ a, button { .hover\:border-accent\/30:hover { border-color: var(--fallback-a,oklch(var(--a)/0.3)); } +.hover\:border-accent\/40:hover { + border-color: var(--fallback-a,oklch(var(--a)/0.4)); +} .hover\:border-base-content\/30:hover { border-color: var(--fallback-bc,oklch(var(--bc)/0.3)); } diff --git a/src/templates/dashboard/base.html b/src/templates/dashboard/base.html index 9d5da84..ec5bbd3 100644 --- a/src/templates/dashboard/base.html +++ b/src/templates/dashboard/base.html @@ -60,18 +60,18 @@ - +
  • - Payments + Transactions
  • - +
  • @@ -79,7 +79,7 @@ - Products + Services
  • diff --git a/src/templates/dashboard/services/create.html b/src/templates/dashboard/services/create.html index 3c26b9e..f5d8aaf 100644 --- a/src/templates/dashboard/services/create.html +++ b/src/templates/dashboard/services/create.html @@ -5,7 +5,7 @@ {% block breadcrumbs %}
  • Dashboard
  • -
  • Products
  • +
  • Payments
  • Create
  • {% endblock %} @@ -13,12 +13,12 @@

    Create Product

    -
    + {% csrf_token %} {% if form.errors %} -
    - +
    - Cancel -
    @@ -159,23 +280,127 @@

    {% block extra_js %} {% endblock %} diff --git a/src/templates/dashboard/services/detail.html b/src/templates/dashboard/services/detail.html index f0414aa..0121f7d 100644 --- a/src/templates/dashboard/services/detail.html +++ b/src/templates/dashboard/services/detail.html @@ -5,22 +5,21 @@ {% block breadcrumbs %}
  • Dashboard
  • -
  • Products
  • +
  • Payments
  • {{ service.name }}
  • {% endblock %} {% block topbar_actions %} - + {% csrf_token %} {% if service.is_active %} - {% else %} - {% endif %} @@ -35,19 +34,19 @@ {% if service.logo_url %} {% else %} - + {% endif %}

    {{ service.name }} {% if service.is_active %} - + {% else %} - + {% endif %}

    -

    {{ service.slug }}

    +

    {{ service.slug }}

    @@ -55,36 +54,36 @@

    - Revenue + Revenue
    - +
    {{ total_revenue }}
    - Net + Net
    - +
    {{ net_revenue }}
    - Fees + Fees
    - +
    {{ total_fees }}
    - Refunded + Refunded
    - +
    {{ total_refunded }}
    @@ -98,13 +97,13 @@

    {% if revenue_by_currency %}

    - + Revenue by Currency

    - + @@ -130,34 +129,33 @@

    - + API Credentials

    - + {% csrf_token %} -
    - +
    {{ service.api_key }} -
    - +
    {{ service.api_secret }} -
    @@ -169,10 +167,10 @@

    - + Quick Integration Guide

    - +

    Initialize a payment via the API:

    @@ -197,14 +195,14 @@

    {% if recent_payments %}

    - + Recent Transactions

    Currency Revenue Fees
    - + @@ -229,7 +227,7 @@

    {{ p.get_status_display }} {% endif %} -

    + {% endfor %} @@ -263,13 +261,14 @@

    {% if recent_webhooks %}

    - + Webhook Delivery Logs

    -
    + +

    Reference Email Amount{{ p.created_at|date:"M j, H:i" }}{{ p.created_at|date:"M j, H:i" }}
    - + @@ -284,7 +283,7 @@

    - - + + {% endfor %}
    Event Status Attempt {% if log.success %} - + {{ log.response_status }} {% elif log.response_status %} @@ -294,13 +293,34 @@

    {% endif %}

    {{ log.attempt }}/3{{ log.duration_ms|default:"—" }}ms{{ log.created_at|date:"M j, H:i" }}{{ log.duration_ms|default:"—" }}ms{{ log.created_at|date:"M j, H:i" }}
    + +
    + {% for log in recent_webhooks %} +
    +
    + {{ log.event }} + {% if log.success %} + {{ log.response_status }} + {% elif log.response_status %} + {{ log.response_status }} + {% else %} + Pending + {% endif %} +
    +
    + Attempt {{ log.attempt }}/3 + {{ log.created_at|date:"M j, H:i" }} +
    +
    + {% endfor %} +
    {% endif %}

    @@ -310,73 +330,146 @@

    - + Overview

    - Total + Total {{ total_payments }}
    - Successful + Successful {{ successful_payments }}
    - Pending + Pending {{ pending_payments }}
    - Failed + Failed {{ failed_payments }}
    + +
    +

    + + Revenue Sharing +

    + {% if service.subaccount_code %} +
    +
    + Status + + + Active + +
    +
    + Subaccount + {{ service.subaccount_code }} +
    +
    + Partner + {{ service.subaccount_business_name|default:service.name }} +
    +
    + Bank + {{ service.settlement_bank_name|default:"—" }} +
    +
    + Account + {{ service.account_number }} +
    +
    + Platform Fee + {{ service.percentage_charge }}% +
    + {% if service.transaction_charge %} +
    + Flat Charge + {{ service.transaction_charge }} +
    + {% endif %} +
    + Fee Bearer + {{ service.get_charge_bearer_display }} +
    +
    + {% elif service.settlement_bank %} + +
    +
    +

    + + Bank details configured but Paystack subaccount not yet created. +

    +
    +
    +

    Partner: {{ service.subaccount_business_name|default:"—" }}

    +

    Bank: {{ service.settlement_bank_name|default:service.settlement_bank }}

    +

    Account: {{ service.account_number }}

    +
    +
    + {% csrf_token %} + +
    +
    + {% else %} +

    No revenue sharing configured. Set up during product creation or update bank details below.

    + {% endif %} +
    +

    - + Settings

    {% csrf_token %}
    - - Webhook URL + + class="input input-bordered input-sm sm:input-md w-full rounded-lg bg-base-100/50 text-xs" />
    - - Callback URL + + class="input input-bordered input-sm sm:input-md w-full rounded-lg bg-base-100/50 text-xs" />
    - - Contact Email + + class="input input-bordered input-sm sm:input-md w-full rounded-lg bg-base-100/50 text-xs" />
    - - Currencies + - + class="input input-bordered input-sm sm:input-md w-full rounded-lg bg-base-100/50 text-xs" /> +
    - - Allowed IPs + - + class="input input-bordered input-sm sm:input-md w-full rounded-lg bg-base-100/50 text-xs" /> +
    -
    @@ -384,7 +477,7 @@

    -
    +
    Created {{ service.created_at|date:"M j, Y" }} @@ -402,15 +495,72 @@

    {% block extra_js %} {% endblock %} diff --git a/src/templates/dashboard/services/list.html b/src/templates/dashboard/services/list.html index 5a9ea1c..fd1d7dc 100644 --- a/src/templates/dashboard/services/list.html +++ b/src/templates/dashboard/services/list.html @@ -1,11 +1,11 @@ {% extends "dashboard/base.html" %} -{% block title %}Products - Dashboard{% endblock %} +{% block title %}Payments - Dashboard{% endblock %} {% block nav_services %}active bg-white/10 text-neutral-content{% endblock %} {% block breadcrumbs %}
  • Dashboard
  • -
  • Products
  • +
  • Payments
  • {% endblock %} {% block topbar_actions %}