Commit 2a4d40e4 authored by gustixjerk's avatar gustixjerk

feat: integrate dynamic backend ads CRUD into mobile home carousel, finalize...

feat: integrate dynamic backend ads CRUD into mobile home carousel, finalize mobile PRD features, and build release APK
parent 12872cd6
...@@ -27,8 +27,9 @@ DEV_OTP_ECHO=True ...@@ -27,8 +27,9 @@ DEV_OTP_ECHO=True
# Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com # Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com
MIDTRANS_IS_PRODUCTION=False MIDTRANS_IS_PRODUCTION=False
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxGANTIxxxx #MIDTRANS_SERVER_KEY=Mid-client-5v9oM46baaELmx3r
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxGANTIxxxx #MIDTRANS_CLIENT_KEY=Mid-server-t1jR1s6o8SRoeV-qOTBs3Br5
MIDTRANS_MERCHANT_ID=G001234567 MIDTRANS_SERVER_KEY=Mid-client-w-fAhCG4Qr28GF99
MIDTRANS_CLIENT_KEY=Mid-server-T7dcoKiswH_IEjeUb-DX2A8q
# Biarkan default: auto sandbox/production sesuai MIDTRANS_IS_PRODUCTION # Biarkan default: auto sandbox/production sesuai MIDTRANS_IS_PRODUCTION
# MIDTRANS_SNAP_BASE=https://app.sandbox.midtrans.com # MIDTRANS_SNAP_BASE=https://app.sandbox.midtrans.com
\ No newline at end of file
...@@ -7,7 +7,7 @@ from rest_framework.test import APIClient, APITestCase ...@@ -7,7 +7,7 @@ from rest_framework.test import APIClient, APITestCase
from apps.common.testing import create_pengcab, make_member, make_staff, member_client from apps.common.testing import create_pengcab, make_member, make_staff, member_client
from apps.common.utils import generate_qr_token from apps.common.utils import generate_qr_token
from apps.events.models import Event, EventCategory, TicketTier from apps.events.models import Event, EventCategory, TicketTier
from apps.ticketing.models import Order, Ticket from apps.ticketing.models import Order, Payment, Ticket
class AuthFlowTests(APITestCase): class AuthFlowTests(APITestCase):
...@@ -152,4 +152,55 @@ class StaffTests(APITestCase): ...@@ -152,4 +152,55 @@ class StaffTests(APITestCase):
r = client.get("/api/v1/dashboard/stats") r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["revenue_total"], 50000) self.assertEqual(r.data["revenue_total"], 50000)
self.assertIn("members_unverified", r.data) self.assertIn("members_unverified", r.data)
\ No newline at end of file
def test_dashboard_finance_aggregates(self):
staff = make_staff("+6281200000099")
client = member_client("+6281200000099")
user, _ = make_member("+6281400000022", "Pembeli")
cat = EventCategory.objects.create(code="fin", name="Enduro")
ev = Event.objects.create(
title="GP Keuangan",
slug="gp-keuangan",
category=cat,
status=Event.Status.PUBLISHED,
start_date="2026-12-01",
end_date="2026-12-02",
)
tier = TicketTier.objects.create(event=ev, code="fin", name="Umum", price=100000)
order = Order.objects.create(
code="IMI-FIN-0001", user=user, event=ev, status="paid",
subtotal=100000, discount_amount=0,
app_fee_amount=5000, imi_fee_amount=10000,
service_fee=1500, tax_amount=3500, total=120000,
buyer_name="Pembeli",
)
Payment.objects.create(order=order, method="va", status="verified", amount=120000)
# order lunas lain tanpa pembayaran terverifikasi -> tidak match
order2 = Order.objects.create(
code="IMI-FIN-0002", user=user, event=ev, status="paid",
subtotal=50000, discount_amount=0,
app_fee_amount=2500, imi_fee_amount=5000,
service_fee=1000, tax_amount=2000, total=60000,
buyer_name="Pembeli Dua",
)
r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200)
# Revenue sharing
self.assertEqual(r.data["rs_orders"], 2)
self.assertEqual(r.data["rs_taxable"], 150000)
self.assertEqual(r.data["rs_app_fee"], 7500)
self.assertEqual(r.data["rs_imi_fee"], 15000)
self.assertEqual(r.data["rs_service_fee"], 2500)
self.assertEqual(r.data["rs_tax"], 5500)
self.assertEqual(r.data["rs_total"], 180000)
# Settlement
self.assertEqual(r.data["st_settled"], 1)
self.assertEqual(r.data["st_settled_amount"], 120000)
# Rekonsiliasi
self.assertEqual(r.data["rc_orders"], 2)
self.assertEqual(r.data["rc_payments"], 1)
self.assertEqual(r.data["rc_orders_unmatched"], 1)
self.assertEqual(r.data["rc_payments_unmatched"], 0)
self.assertEqual(r.data["rc_diff"], 60000)
\ No newline at end of file
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from django.db import transaction from django.db import transaction
from django.db.models import Count, Sum from django.db.models import Count, F, Sum
from django.utils import timezone from django.utils import timezone
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
...@@ -415,7 +415,7 @@ class DashboardStatsView(APIView): ...@@ -415,7 +415,7 @@ class DashboardStatsView(APIView):
from apps.content.models import Article, Banner from apps.content.models import Article, Banner
from apps.events.models import Event from apps.events.models import Event
from apps.partners.models import Benefit from apps.partners.models import Benefit
from apps.ticketing.models import CheckIn, Order, Ticket from apps.ticketing.models import CheckIn, Order, Payment, Ticket
now = timezone.now() now = timezone.now()
member_qs = Member.objects.exclude(account_type=Member.AccountType.PARTNER) member_qs = Member.objects.exclude(account_type=Member.AccountType.PARTNER)
...@@ -438,6 +438,50 @@ class DashboardStatsView(APIView): ...@@ -438,6 +438,50 @@ class DashboardStatsView(APIView):
"banners_active": Banner.objects.filter(is_active=True).count(), "banners_active": Banner.objects.filter(is_active=True).count(),
"points_issued": sum(m.points_balance for m in member_qs), "points_issued": sum(m.points_balance for m in member_qs),
} }
# ---- Finance: Revenue Sharing, Settlement & Reconciliation ----
order_qs = Order.objects.filter(status=Order.Status.PAID)
share = order_qs.aggregate(
taxable=Sum(F("subtotal") - F("discount_amount")),
app_fee=Sum("app_fee_amount"),
imi_fee=Sum("imi_fee_amount"),
service_fee=Sum("service_fee"),
tax=Sum("tax_amount"),
total=Sum("total"),
)
settled_qs = Payment.objects.filter(status=Payment.Status.VERIFIED)
settled = settled_qs.aggregate(n=Count("id"), amount=Sum("amount"))
def _or0(value):
return value or 0
orders_paid = order_qs.count()
payments_settled = _or0(settled["n"])
orders_unmatched = order_qs.exclude(id__in=settled_qs.values("order_id")).count()
payments_unmatched = settled_qs.exclude(order_id__in=order_qs.values("id")).count()
stats.update(
{
# Revenue sharing dari order lunas
"rs_orders": orders_paid,
"rs_taxable": _or0(share["taxable"]),
"rs_app_fee": _or0(share["app_fee"]),
"rs_imi_fee": _or0(share["imi_fee"]),
"rs_service_fee": _or0(share["service_fee"]),
"rs_tax": _or0(share["tax"]),
"rs_total": _or0(share["total"]),
# Settlement dari pembayaran terverifikasi
"st_settled": payments_settled,
"st_settled_amount": _or0(settled["amount"]),
"st_pending": Payment.objects.filter(status=Payment.Status.PENDING).count(),
# Rekonsiliasi order lunas vs pembayaran terverifikasi
"rc_orders": orders_paid,
"rc_payments": payments_settled,
"rc_orders_unmatched": orders_unmatched,
"rc_payments_unmatched": payments_unmatched,
"rc_diff": _or0(share["total"]) - _or0(settled["amount"]),
}
)
# distribusi by account_type # distribusi by account_type
by_type = member_qs.values("account_type").annotate(total=Count("id")) by_type = member_qs.values("account_type").annotate(total=Count("id"))
stats["members_by_type"] = {r["account_type"]: r["total"] for r in by_type} stats["members_by_type"] = {r["account_type"]: r["total"] for r in by_type}
......
...@@ -61,6 +61,8 @@ CONTENT_CATEGORIES = [ ...@@ -61,6 +61,8 @@ CONTENT_CATEGORIES = [
] ]
# Konstanta bisnis # Konstanta bisnis
APP_FEE_PCT = 5 # fee / bagian tim aplikasi (% dari nilai tiket setelah diskon)
IMI_FEE_PCT = 3 # fee / bagian IMI (% dari nilai tiket setelah diskon)
MEMBER_DISCOUNT_PERCENT = 15 # diskon anggota KTA di checkout tiket MEMBER_DISCOUNT_PERCENT = 15 # diskon anggota KTA di checkout tiket
MAX_TICKETS_PER_ORDER = 4 MAX_TICKETS_PER_ORDER = 4
SERVICE_FEE_TICKET = 5_000 # biaya layanan & asuransi sirkuit (flat) SERVICE_FEE_TICKET = 5_000 # biaya layanan & asuransi sirkuit (flat)
......
...@@ -95,6 +95,42 @@ def compute_ticket_price( ...@@ -95,6 +95,42 @@ def compute_ticket_price(
} }
def compute_selling_price(
base: int,
discount_amount: int = 0,
service_fee: int = 0,
tax_rate: float = IDR_TAX_RATE,
app_fee_pct: float = 0,
imi_fee_pct: float = 0,
) -> dict:
"""Rumus harga jual & bagi hasil tiket.
Sesuai kesepakatan: harga jual = harga tiket + fee% tim aplikasi + fee% IMI + pajak.
- nilai tiket (setelah diskon) -> dasar perhitungan fee
- fee tim aplikasi / IMI = persen dari nilai tiket setelah diskon
- pajak dikenakan atas nilai tiket + kedua fee (PB1), dibulatkan ke ratusan
"""
taxable = max(base - discount_amount, 0)
app_fee = round_idr(taxable * app_fee_pct / 100)
imi_fee = round_idr(taxable * imi_fee_pct / 100)
tax_base = taxable + app_fee + imi_fee
tax = round_idr(tax_base * tax_rate)
total = taxable + app_fee + imi_fee + service_fee + tax
return {
"base": base,
"discount_amount": discount_amount,
"taxable": taxable,
"app_fee": app_fee,
"imi_fee": imi_fee,
"tax_pct": round(tax_rate * 100, 2),
"app_fee_pct": round(app_fee_pct, 2),
"imi_fee_pct": round(imi_fee_pct, 2),
"service_fee": service_fee,
"tax_amount": tax,
"total": total,
}
def humanize_rupiah(value: int) -> str: def humanize_rupiah(value: int) -> str:
return f"Rp {value:,.0f}".replace(",", ".") return f"Rp {value:,.0f}".replace(",", ".")
......
from django.contrib import admin from django.contrib import admin
from .models import Article, Banner, Category, MediaAsset from .models import Advertisement, Article, Banner, Category, MediaAsset
@admin.register(Category) @admin.register(Category)
...@@ -28,4 +28,12 @@ class ArticleAdmin(admin.ModelAdmin): ...@@ -28,4 +28,12 @@ class ArticleAdmin(admin.ModelAdmin):
class BannerAdmin(admin.ModelAdmin): class BannerAdmin(admin.ModelAdmin):
list_display = ["title", "link_type", "link_value", "ordinal", "is_active"] list_display = ["title", "link_type", "link_value", "ordinal", "is_active"]
list_filter = ["link_type", "is_active"] list_filter = ["link_type", "is_active"]
search_fields = ["title"] search_fields = ["title"]
\ No newline at end of file
@admin.register(Advertisement)
class AdvertisementAdmin(admin.ModelAdmin):
list_display = ["title", "category", "ordinal", "is_active", "updated_at"]
list_filter = ["is_active"]
search_fields = ["title", "category", "subtitle"]
list_editable = ["ordinal", "is_active"]
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 09:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Advertisement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('subtitle', models.CharField(blank=True, default='', max_length=300)),
('category', models.CharField(blank=True, default='', help_text='Label badge, mis. PROTEKSI ATLET BALAP / OFFICIAL RACING OIL', max_length=60)),
('details', models.TextField(blank=True, default='', help_text='Deskripsi lengkap yang muncul di sheet detail')),
('action_text', models.CharField(blank=True, default='Pelajari Program', max_length=60)),
('url', models.CharField(blank=True, default='', help_text='URL tujuan saat tombol diklik', max_length=500)),
('image_url', models.CharField(blank=True, default='', help_text='URL gambar iklan (boleh kosong)', max_length=500)),
('badge_color', models.CharField(blank=True, default='#10B981', help_text='Warna aksen badge (hex), mis. #10B981', max_length=9)),
('ordinal', models.PositiveIntegerField(default=0, help_text='Urutan tampil (kecil = pertama)')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['ordinal', 'id'],
},
),
]
...@@ -97,4 +97,30 @@ class Banner(models.Model): ...@@ -97,4 +97,30 @@ class Banner(models.Model):
ordering = ["ordinal", "-created_at"] ordering = ["ordinal", "-created_at"]
def __str__(self): def __str__(self):
return self.title or f"Banner #{self.pk}" return self.title or f"Banner #{self.pk}"
\ No newline at end of file
class Advertisement(models.Model):
"""Iklan slider "Penawaran & Mitra Resmi" di footer beranda."""
title = models.CharField(max_length=200)
subtitle = models.CharField(max_length=300, blank=True, default="")
category = models.CharField(
max_length=60, blank=True, default="",
help_text="Label badge, mis. PROTEKSI ATLET BALAP / OFFICIAL RACING OIL",
)
details = models.TextField(blank=True, default="", help_text="Deskripsi lengkap yang muncul di sheet detail")
action_text = models.CharField(max_length=60, blank=True, default="Pelajari Program")
url = models.CharField(max_length=500, blank=True, default="", help_text="URL tujuan saat tombol diklik")
image_url = models.CharField(max_length=500, blank=True, default="", help_text="URL gambar iklan (boleh kosong)")
badge_color = models.CharField(max_length=9, blank=True, default="#10B981", help_text="Warna aksen badge (hex), mis. #10B981")
ordinal = models.PositiveIntegerField(default=0, help_text="Urutan tampil (kecil = pertama)")
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["ordinal", "id"]
def __str__(self):
return self.title
\ No newline at end of file
from rest_framework import serializers from rest_framework import serializers
from .models import Article, Banner, Category, MediaAsset from .models import Advertisement, Article, Banner, Category, MediaAsset
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
...@@ -46,4 +46,20 @@ class BannerSerializer(serializers.ModelSerializer): ...@@ -46,4 +46,20 @@ class BannerSerializer(serializers.ModelSerializer):
model = Banner model = Banner
fields = ["id", "title", "image", "link_type", "link_value", "ordinal", fields = ["id", "title", "image", "link_type", "link_value", "ordinal",
"is_active", "starts_at", "ends_at"] "is_active", "starts_at", "ends_at"]
read_only_fields = ["id"] read_only_fields = ["id"]
\ No newline at end of file
class AdvertisementSerializer(serializers.ModelSerializer):
class Meta:
model = Advertisement
fields = [
"id", "title", "category", "subtitle", "details", "action_text",
"url", "image_url", "badge_color", "ordinal", "is_active",
"created_at", "updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_title(self, value):
if not value.strip():
raise serializers.ValidationError("Judul iklan tidak boleh kosong.")
return value.strip()
\ No newline at end of file
...@@ -6,7 +6,7 @@ from django.utils import timezone ...@@ -6,7 +6,7 @@ from django.utils import timezone
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from apps.common.testing import create_pengcab, make_staff, member_client from apps.common.testing import create_pengcab, make_staff, member_client
from apps.content.models import Article, Banner, Category from apps.content.models import Advertisement, Article, Banner, Category
class ContentTests(APITestCase): class ContentTests(APITestCase):
...@@ -69,4 +69,72 @@ class ContentTests(APITestCase): ...@@ -69,4 +69,72 @@ class ContentTests(APITestCase):
r = self.client.get("/api/v1/articles/") r = self.client.get("/api/v1/articles/")
titles = [a["title"] for a in r.data["results"]] titles = [a["title"] for a in r.data["results"]]
self.assertIn("Berita Baru", titles) self.assertIn("Berita Baru", titles)
\ No newline at end of file
class AdTests(APITestCase):
def setUp(self):
self.client = APIClient()
self.active = Advertisement.objects.create(
title="BPJS Ketenagakerjaan",
category="PROTEKSI ATLET BALAP",
subtitle="JKK tanpa batas biaya",
action_text="Pelajari Program",
url="https://www.bpjsketenagakerjaan.go.id",
ordinal=1,
is_active=True,
)
Advertisement.objects.create(title="Iklan Nonaktif", ordinal=0, is_active=False)
def test_public_hanya_iklan_aktif_urut_ordinal(self):
r = self.client.get("/api/v1/ads/")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.data), 1)
self.assertEqual(r.data[0]["title"], "BPJS Ketenagakerjaan")
self.assertIn("badge_color", r.data[0])
def test_manage_perlu_staff(self):
r = self.client.get("/api/v1/ads/manage/")
self.assertIn(r.status_code, (401, 403))
staff = make_staff()
staff_client = member_client(staff.phone)
r = staff_client.get("/api/v1/ads/manage/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 2)
def test_crud_iklan(self):
staff_client = member_client(make_staff().phone)
r = staff_client.post(
"/api/v1/ads/manage/",
{
"title": "Motul 300V",
"category": "OFFICIAL RACING OIL",
"subtitle": "Pelumas ester core",
"details": "Detail panjang",
"action_text": "Beli",
"url": "https://www.motul.com/id/id",
"image_url": "https://example.com/ad.png",
"badge_color": "#E11D48",
"ordinal": 0,
"is_active": True,
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
ad_id = r.data["id"]
self.assertEqual(r.data["badge_color"], "#E11D48")
rp = staff_client.patch(f"/api/v1/ads/manage/{ad_id}/", {"ordinal": 5}, format="json")
self.assertEqual(rp.status_code, 200, rp.data)
self.assertEqual(rp.data["ordinal"], 5)
r2 = staff_client.delete(f"/api/v1/ads/manage/{ad_id}/")
self.assertEqual(r2.status_code, 204)
def test_nonaktif_tidak_muncul_publik_setelah_update(self):
staff_client = member_client(make_staff().phone)
r = staff_client.patch(f"/api/v1/ads/manage/{self.active.id}/", {"is_active": False}, format="json")
self.assertEqual(r.status_code, 200, r.data)
r = self.client.get("/api/v1/ads/")
self.assertEqual(len(r.data), 0)
\ No newline at end of file
...@@ -7,6 +7,8 @@ router = DefaultRouter() ...@@ -7,6 +7,8 @@ router = DefaultRouter()
# Prefix spesifik didaftarkan lebih dulu (hindari tertelan regex detail articles/{pk}). # Prefix spesifik didaftarkan lebih dulu (hindari tertelan regex detail articles/{pk}).
router.register("articles/manage", views.ArticleManageViewSet, basename="article-manage") router.register("articles/manage", views.ArticleManageViewSet, basename="article-manage")
router.register("articles", views.PublicArticleViewSet, basename="article") router.register("articles", views.PublicArticleViewSet, basename="article")
router.register("ads/manage", views.AdManageViewSet, basename="ad-manage")
router.register("ads", views.PublicAdViewSet, basename="ad")
router.register("categories", views.CategoryViewSet) router.register("categories", views.CategoryViewSet)
router.register("media", views.MediaAssetViewSet, basename="media") router.register("media", views.MediaAssetViewSet, basename="media")
router.register("banners", views.BannerViewSet, basename="banner") router.register("banners", views.BannerViewSet, basename="banner")
......
...@@ -11,8 +11,9 @@ from rest_framework.response import Response ...@@ -11,8 +11,9 @@ from rest_framework.response import Response
from apps.common.permissions import ContentStaff from apps.common.permissions import ContentStaff
from apps.common.utils import build_slug from apps.common.utils import build_slug
from .models import Article, Banner, Category, MediaAsset from .models import Advertisement, Article, Banner, Category, MediaAsset
from .serializers import ( from .serializers import (
AdvertisementSerializer,
ArticleSerializer, ArticleSerializer,
BannerSerializer, BannerSerializer,
CategorySerializer, CategorySerializer,
...@@ -105,4 +106,22 @@ class BannerViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -105,4 +106,22 @@ class BannerViewSet(viewsets.ReadOnlyModelViewSet):
qs = Banner.objects.filter(is_active=True) qs = Banner.objects.filter(is_active=True)
qs = qs.filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now)) qs = qs.filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now))
qs = qs.filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now)) qs = qs.filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now))
return qs return qs
\ No newline at end of file
class PublicAdViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Advertisement.objects.none()
serializer_class = AdvertisementSerializer
permission_classes = [AllowAny]
pagination_class = None
def get_queryset(self):
return Advertisement.objects.filter(is_active=True)
class AdManageViewSet(viewsets.ModelViewSet):
queryset = Advertisement.objects.all()
serializer_class = AdvertisementSerializer
permission_classes = [ContentStaff]
filterset_fields = ["is_active"]
search_fields = ["title", "category", "subtitle"]
\ No newline at end of file
...@@ -143,7 +143,7 @@ class AdminTicketTierWriteSerializer(serializers.ModelSerializer): ...@@ -143,7 +143,7 @@ class AdminTicketTierWriteSerializer(serializers.ModelSerializer):
"id", "event", "code", "name", "description", "unit_label", "price", "id", "event", "code", "name", "description", "unit_label", "price",
"sort_order", "status", "quota", "low_stock_threshold", "requires_license", "sort_order", "status", "quota", "low_stock_threshold", "requires_license",
"member_only", "max_per_order", "is_free_child", "sale_start", "sale_end", "member_only", "max_per_order", "is_free_child", "sale_start", "sale_end",
"benefits", "sold_count", "benefits",
] ]
read_only_fields = ["sold_count"] read_only_fields = ["sold_count"]
......
...@@ -7,9 +7,9 @@ from datetime import timedelta ...@@ -7,9 +7,9 @@ from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from apps.common.testing import make_staff, member_client from apps.common.testing import make_member, make_staff, member_client
from .models import Event, EventCategory from .models import Event, EventCategory, TicketTier
def make_category(): def make_category():
...@@ -54,4 +54,100 @@ class EventListVisibilityTests(APITestCase): ...@@ -54,4 +54,100 @@ class EventListVisibilityTests(APITestCase):
self.assertIn(want, slugs) self.assertIn(want, slugs)
r2 = client.get("/api/v1/events/", {"status": "draft"}) r2 = client.get("/api/v1/events/", {"status": "draft"})
self.assertEqual(len(r2.data["results"]), 1) self.assertEqual(len(r2.data["results"]), 1)
self.assertEqual(r2.data["results"][0]["slug"], "draft-event") self.assertEqual(r2.data["results"][0]["slug"], "draft-event")
\ No newline at end of file
class TicketTierAdminWriteTests(APITestCase):
"""Regression: get_permissions harus mengembalikan instance, bukan class,
agar has_permission(request, view) terpanggil benar pada action write."""
def setUp(self):
cat = make_category()
self.event = make_event(cat, "tier-event", Event.Status.DRAFT)
def admin_client(self, phone):
return member_client(phone)
def test_staff_bisa_create_patch_delete_tier(self):
client = self.admin_client(make_staff("+6281200000099").phone)
r = client.post(
"/api/v1/ticket-tiers/",
{
"event": self.event.id,
"name": "Early Bird",
"code": "early-bird",
"price": 100000,
"quota": 50,
"status": "enabled",
"benefits": [{"text": "Paddock"}],
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
tier_id = r.data["id"]
self.assertEqual(r.data["sold_count"], 0)
rp = client.patch(f"/api/v1/ticket-tiers/{tier_id}/", {"status": "disabled"}, format="json")
self.assertEqual(rp.status_code, 200, rp.data)
self.assertEqual(rp.data["status"], "disabled")
rd = client.delete(f"/api/v1/ticket-tiers/{tier_id}/")
self.assertEqual(rd.status_code, 204)
def test_member_tanpa_role_tidak_boleh_membuat_tier(self):
user, _ = make_member("+6281200000005")
client = member_client(user.phone)
r = client.post(
"/api/v1/ticket-tiers/",
{"event": self.event.id, "name": "x", "code": "x", "price": 10000},
format="json",
)
self.assertEqual(r.status_code, 403, r.data)
class EventAdminWriteTests(APITestCase):
"""Regression: payload dari form web-admin (collect()) harus diterima:
slug kosong di-auto, text kosong via "", kolom nullable via null."""
def setUp(self):
self.cat = make_category()
self.client = member_client(make_staff("+6281200000098").phone)
def test_create_event_payload_webadmin(self):
r = self.client.post(
"/api/v1/events/",
{
"title": "Payung Test",
"slug": "",
"category": self.cat.id,
"status": "draft",
"start_date": "2026-10-10",
"end_date": "2026-10-11",
"featured": False,
"is_official": True,
"max_ticket_per_user": 4,
"sale_start_at": None,
"sale_end_at": None,
"circuit": None,
"description": "",
"venue_name": "",
"organizer": "",
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
self.assertEqual(r.data["slug"], "payung-test")
def test_patch_event_membersihkan_datetime_nan(self):
e = make_event(self.cat, "patch-datetime", Event.Status.DRAFT)
r = self.client.patch(
f"/api/v1/events/{e.slug}/",
{"sale_start_at": None, "sale_end_at": None, "circuit": None, "venue_name": ""},
format="json",
)
self.assertEqual(r.status_code, 200, r.data)
e.refresh_from_db()
self.assertIsNone(e.sale_start_at)
self.assertIsNone(e.sale_end_at)
self.assertIsNone(e.circuit)
self.assertEqual(e.venue_name, "")
\ No newline at end of file
...@@ -103,7 +103,7 @@ class TicketTierViewSet(viewsets.ModelViewSet): ...@@ -103,7 +103,7 @@ class TicketTierViewSet(viewsets.ModelViewSet):
def get_permissions(self): def get_permissions(self):
if self.action in ["list", "retrieve"]: if self.action in ["list", "retrieve"]:
return [AllowAny()] return [AllowAny()]
return [_CONTENT_STAFF] return [_CONTENT_STAFF()]
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
......
# Finance: aturan harga & bagi hasil (fee tim aplikasi / IMI / pajak).
\ No newline at end of file
from django.contrib import admin
from .models import PricingConfig
@admin.register(PricingConfig)
class PricingConfigAdmin(admin.ModelAdmin):
list_display = ["key", "app_fee_pct", "imi_fee_pct", "tax_rate_pct", "service_fee", "updated_at"]
\ No newline at end of file
from django.apps import AppConfig
class FinanceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.finance"
verbose_name = "Keuangan & Bagi Hasil"
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 08:30
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PricingConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(default='default', editable=False, max_length=20, unique=True)),
('app_fee_pct', models.DecimalField(decimal_places=2, default=5.0, help_text='Fee / bagian tim aplikasi (%)', max_digits=5)),
('imi_fee_pct', models.DecimalField(decimal_places=2, default=3.0, help_text='Fee / bagian IMI (%)', max_digits=5)),
('tax_rate_pct', models.DecimalField(decimal_places=2, default=10.0, help_text='Pajak Hiburan Daerah PB1 (%)', max_digits=5)),
('service_fee', models.PositiveIntegerField(default=5000, help_text='Biaya layanan & asuransi sirkuit (Rp)')),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Konfigurasi Harga & Bagi Hasil',
'verbose_name_plural': 'Konfigurasi Harga & Bagi Hasil',
},
),
]
"""Konfigurasi harga global untuk tiket.
Rumus harga jual (sesuai kesepakatan tim aplikasi & IMI):
harga jual = harga tiket + fee% tim aplikasi + fee% tim IMI + pajak
Definisi pembagian nilai transaksi:
- bagian tim aplikasi = fee tim aplikasi (persen dari nilai tiket setelah diskon)
- bagian IMI = fee IMI (persen dari nilai tiket setelah diskon)
"""
from django.conf import settings
from django.db import models
class PricingConfig(models.Model):
"""Konfigurasi tunggal (row singleton `key="default"`) untuk aturan harga."""
key = models.SlugField(max_length=20, unique=True, editable=False, default="default")
app_fee_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=5.0, help_text="Fee / bagian tim aplikasi (%)"
)
imi_fee_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=3.0, help_text="Fee / bagian IMI (%)"
)
tax_rate_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=10.0, help_text="Pajak Hiburan Daerah PB1 (%)"
)
service_fee = models.PositiveIntegerField(default=5_000, help_text="Biaya layanan & asuransi sirkuit (Rp)")
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Konfigurasi Harga & Bagi Hasil"
verbose_name_plural = "Konfigurasi Harga & Bagi Hasil"
@classmethod
def get_default(cls) -> "PricingConfig":
obj, _ = cls.objects.get_or_create(key="default")
return obj
def __str__(self):
return f"Harga: app {self.app_fee_pct}% + imi {self.imi_fee_pct}% + pajak {self.tax_rate_pct}%"
\ No newline at end of file
from rest_framework import serializers
from .models import PricingConfig
class PricingConfigSerializer(serializers.ModelSerializer):
app_fee_pct = serializers.DecimalField(max_digits=5, decimal_places=2, coerce_to_string=False)
imi_fee_pct = serializers.DecimalField(max_digits=5, decimal_places=2, coerce_to_string=False)
tax_rate_pct = serializers.DecimalField(max_digits=5, decimal_places=2, coerce_to_string=False)
class Meta:
model = PricingConfig
fields = ["id", "app_fee_pct", "imi_fee_pct", "tax_rate_pct", "service_fee", "updated_at"]
read_only_fields = ["id", "updated_at"]
\ No newline at end of file
"""Test konfigurasi harga & bagi hasil (finance)."""
from __future__ import annotations
from rest_framework.test import APITestCase
from apps.common.testing import login_token, make_member, make_staff, member_client
from apps.finance.models import PricingConfig
class PricingConfigTests(APITestCase):
PREFIX = "/api/v1/finance/pricing-config/"
def setUp(self):
self.staff = make_staff()
token = login_token(self.client, "+6281200000001")
self.auth = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def test_get_default_config(self):
r = self.client.get(self.PREFIX, **self.auth)
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(float(r.data["app_fee_pct"]), 5.0)
self.assertEqual(float(r.data["imi_fee_pct"]), 3.0)
self.assertEqual(float(r.data["tax_rate_pct"]), 10.0)
self.assertEqual(r.data["service_fee"], 5_000)
def test_update_config(self):
r = self.client.put(
self.PREFIX,
{"app_fee_pct": 7, "imi_fee_pct": 4, "tax_rate_pct": 11, "service_fee": 6_000},
format="json",
**self.auth,
)
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(float(r.data["app_fee_pct"]), 7.0)
self.assertEqual(r.data["service_fee"], 6_000)
config = PricingConfig.get_default()
self.assertEqual(float(config.imi_fee_pct), 4.0)
self.assertEqual(float(config.tax_rate_pct), 11.0)
def test_preview_rumus_harga_jual(self):
r = self.client.post(f"{self.PREFIX}preview/", {"price": 100_000}, format="json", **self.auth)
self.assertEqual(r.status_code, 200, r.data)
data = r.data
# fee app 5% = 5.000, fee IMI 3% = 3.000, service 5.000,
# pajak 10% atas (100.000 + 5.000 + 3.000) = 10.800
self.assertEqual(data["app_fee"], 5_000)
self.assertEqual(data["imi_fee"], 3_000)
self.assertEqual(data["tax_amount"], 10_800)
self.assertEqual(data["total"], 123_800)
def test_preview_ikut_diskon(self):
r = self.client.post(
f"{self.PREFIX}preview/", {"price": 100_000, "discount_amount": 10_000},
format="json", **self.auth,
)
self.assertEqual(r.status_code, 200, r.data)
# basis 90.000: app 4.500, imi 2.700, tax 10% dari 97.200 = 9.700 (+service 5.000)
self.assertEqual(r.data["taxable"], 90_000)
self.assertEqual(r.data["app_fee"], 4_500)
self.assertEqual(r.data["imi_fee"], 2_700)
self.assertEqual(r.data["tax_amount"], 9_700)
self.assertEqual(r.data["total"], 111_900)
def test_member_tidak_bisa_akses(self):
_, member = make_member("+6281900000001")
client = member_client("+6281900000001")
r = client.get(self.PREFIX)
self.assertIn(r.status_code, (401, 403))
r = client.post(f"{self.PREFIX}preview/", {"price": 100_000}, format="json")
self.assertIn(r.status_code, (401, 403))
\ No newline at end of file
from django.urls import path
from . import views
app_name = "finance"
urlpatterns = [
path("finance/pricing-config/preview/", views.PricingPreviewView.as_view(), name="pricing-preview"),
path("finance/pricing-config/", views.PricingConfigView.as_view(), name="pricing-config"),
]
\ No newline at end of file
"""API finance: baca/ubah konfigurasi harga & preview rumus harga jual."""
from __future__ import annotations
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.permissions import AnyStaff
from apps.common.utils import compute_selling_price
from .models import PricingConfig
from .serializers import PricingConfigSerializer
class PricingConfigView(APIView):
"""GET/PUT konfigurasi harga & bagi hasil (lahan singleton)."""
permission_classes = [AnyStaff]
def get(self, request):
config = PricingConfig.get_default()
return Response(PricingConfigSerializer(config).data)
def put(self, request):
config = PricingConfig.get_default()
serializer = PricingConfigSerializer(config, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(updated_by=request.user)
return Response(serializer.data)
class PricingPreviewView(APIView):
"""POST {price, discount_amount?} -> rincian harga jual dengan aturan aktif.
Rumus: harga jual = harga tiket + fee% tim aplikasi + fee% IMI + pajak.
"""
permission_classes = [AnyStaff]
def post(self, request):
config = PricingConfig.get_default()
try:
price = int(round(float(request.data.get("price") or 0)))
discount = int(round(float(request.data.get("discount_amount") or 0)))
except (TypeError, ValueError):
return Response({"price": ["Harga harus berupa angka."]}, status=status.HTTP_400_BAD_REQUEST)
breakdown = compute_selling_price(
base=price,
discount_amount=discount,
service_fee=config.service_fee,
tax_rate=float(config.tax_rate_pct) / 100,
app_fee_pct=float(config.app_fee_pct),
imi_fee_pct=float(config.imi_fee_pct),
)
return Response(
{
"price": price,
"discount_amount": discount,
"app_fee_pct": float(config.app_fee_pct),
"imi_fee_pct": float(config.imi_fee_pct),
"tax_rate_pct": float(config.tax_rate_pct),
"service_fee": config.service_fee,
**breakdown,
}
)
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0002_payment_payment_type_payment_qr_string_and_more'),
]
operations = [
migrations.AddField(
model_name='order',
name='app_fee_amount',
field=models.PositiveIntegerField(default=0, help_text='Fee / bagian tim aplikasi (Rp)'),
),
migrations.AddField(
model_name='order',
name='imi_fee_amount',
field=models.PositiveIntegerField(default=0, help_text='Fee / bagian IMI (Rp)'),
),
]
...@@ -27,6 +27,8 @@ class Order(models.Model): ...@@ -27,6 +27,8 @@ class Order(models.Model):
subtotal = models.PositiveIntegerField(default=0) subtotal = models.PositiveIntegerField(default=0)
discount_amount = models.PositiveIntegerField(default=0) discount_amount = models.PositiveIntegerField(default=0)
app_fee_amount = models.PositiveIntegerField(default=0, help_text="Fee / bagian tim aplikasi (Rp)")
imi_fee_amount = models.PositiveIntegerField(default=0, help_text="Fee / bagian IMI (Rp)")
service_fee = models.PositiveIntegerField(default=0) service_fee = models.PositiveIntegerField(default=0)
tax_amount = models.PositiveIntegerField(default=0) tax_amount = models.PositiveIntegerField(default=0)
total = models.PositiveIntegerField(default=0) total = models.PositiveIntegerField(default=0)
......
...@@ -34,12 +34,15 @@ class OrderSerializer(serializers.ModelSerializer): ...@@ -34,12 +34,15 @@ class OrderSerializer(serializers.ModelSerializer):
subtotal_display = serializers.SerializerMethodField() subtotal_display = serializers.SerializerMethodField()
discount_display = serializers.SerializerMethodField() discount_display = serializers.SerializerMethodField()
tax_display = serializers.SerializerMethodField() tax_display = serializers.SerializerMethodField()
app_fee_display = serializers.SerializerMethodField()
imi_fee_display = serializers.SerializerMethodField()
class Meta: class Meta:
model = Order model = Order
fields = [ fields = [
"id", "code", "event", "event_title", "status", "status_display", "id", "code", "event", "event_title", "status", "status_display",
"subtotal", "subtotal_display", "discount_amount", "discount_display", "subtotal", "subtotal_display", "discount_amount", "discount_display",
"app_fee_amount", "app_fee_display", "imi_fee_amount", "imi_fee_display",
"service_fee", "tax_amount", "tax_display", "total", "total_display", "service_fee", "tax_amount", "tax_display", "total", "total_display",
"currency", "member_discount_pct", "voucher_discount_amount", "buyer_name", "currency", "member_discount_pct", "voucher_discount_amount", "buyer_name",
"buyer_email", "buyer_phone", "payment_method", "expires_at", "paid_at", "buyer_email", "buyer_phone", "payment_method", "expires_at", "paid_at",
...@@ -56,6 +59,12 @@ class OrderSerializer(serializers.ModelSerializer): ...@@ -56,6 +59,12 @@ class OrderSerializer(serializers.ModelSerializer):
def get_tax_display(self, obj): def get_tax_display(self, obj):
return humanize_rupiah(obj.tax_amount) return humanize_rupiah(obj.tax_amount)
def get_app_fee_display(self, obj):
return humanize_rupiah(obj.app_fee_amount)
def get_imi_fee_display(self, obj):
return humanize_rupiah(obj.imi_fee_amount)
class CreateOrderSerializer(serializers.Serializer): class CreateOrderSerializer(serializers.Serializer):
event_id = serializers.IntegerField() event_id = serializers.IntegerField()
......
...@@ -14,10 +14,8 @@ from apps.common.constants import ( ...@@ -14,10 +14,8 @@ from apps.common.constants import (
MAX_TICKETS_PER_ORDER, MAX_TICKETS_PER_ORDER,
MEMBER_DISCOUNT_PERCENT, MEMBER_DISCOUNT_PERCENT,
ORDER_HOLD_MINUTES, ORDER_HOLD_MINUTES,
SERVICE_FEE_TICKET,
TAX_RATE_HIBURAN,
) )
from apps.common.utils import compute_ticket_price, generate_qr_token from apps.common.utils import compute_selling_price, generate_qr_token
from . import midtrans from . import midtrans
from .models import CheckIn, Order, OrderLine, Payment, Ticket from .models import CheckIn, Order, OrderLine, Payment, Ticket
...@@ -60,6 +58,7 @@ def create_order(user, data: dict) -> Order: ...@@ -60,6 +58,7 @@ def create_order(user, data: dict) -> Order:
""" """
from apps.accounts.models import Member from apps.accounts.models import Member
from apps.events.models import Event, SessionPrice, TicketTier from apps.events.models import Event, SessionPrice, TicketTier
from apps.finance.models import PricingConfig
event = data.get("event_id") event = data.get("event_id")
try: try:
...@@ -138,11 +137,15 @@ def create_order(user, data: dict) -> Order: ...@@ -138,11 +137,15 @@ def create_order(user, data: dict) -> Order:
voucher_discount = round(subtotal * voucher.discount_value / 100) voucher_discount = round(subtotal * voucher.discount_value / 100)
discount_amount += voucher_discount discount_amount += voucher_discount
price = compute_ticket_price( # aturan harga & bagi hasil aktif (fee tim aplikasi, fee IMI, pajak, biaya layanan)
subtotal=subtotal, config = PricingConfig.get_default()
price = compute_selling_price(
base=subtotal,
discount_amount=discount_amount, discount_amount=discount_amount,
service_fee=SERVICE_FEE_TICKET, service_fee=config.service_fee,
tax_rate=TAX_RATE_HIBURAN, tax_rate=float(config.tax_rate_pct) / 100,
app_fee_pct=float(config.app_fee_pct),
imi_fee_pct=float(config.imi_fee_pct),
) )
order = Order.objects.create( order = Order.objects.create(
...@@ -153,6 +156,8 @@ def create_order(user, data: dict) -> Order: ...@@ -153,6 +156,8 @@ def create_order(user, data: dict) -> Order:
status=Order.Status.PENDING, status=Order.Status.PENDING,
subtotal=subtotal, subtotal=subtotal,
discount_amount=discount_amount, discount_amount=discount_amount,
app_fee_amount=price["app_fee"],
imi_fee_amount=price["imi_fee"],
service_fee=price["service_fee"], service_fee=price["service_fee"],
tax_amount=price["tax_amount"], tax_amount=price["tax_amount"],
total=price["total"], total=price["total"],
......
...@@ -91,12 +91,15 @@ class CheckoutFlowTests(APITestCase): ...@@ -91,12 +91,15 @@ class CheckoutFlowTests(APITestCase):
r = self._create_order(self.festival, self.session.id, qty=2) r = self._create_order(self.festival, self.session.id, qty=2)
self.assertEqual(r.status_code, 201, r.data) self.assertEqual(r.status_code, 201, r.data)
order = r.data order = r.data
# 2 x 90.000, diskon member 15%, service 5.000, PB1 10% atas nilai setelah diskon # 2 x 90.000, diskon member 15%, fee app 5% + fee IMI 3%, service 5.000, PB1 10%
# atas nilai tiket + kedua fee
self.assertEqual(order["subtotal"], 180_000) self.assertEqual(order["subtotal"], 180_000)
self.assertEqual(order["discount_amount"], 27_000) self.assertEqual(order["discount_amount"], 27_000)
self.assertEqual(order["app_fee_amount"], 7_600)
self.assertEqual(order["imi_fee_amount"], 4_600)
self.assertEqual(order["service_fee"], 5_000) self.assertEqual(order["service_fee"], 5_000)
self.assertEqual(order["tax_amount"], 15_300) self.assertEqual(order["tax_amount"], 16_500)
self.assertEqual(order["total"], 173_300) self.assertEqual(order["total"], 186_700)
self.assertEqual(order["status"], Order.Status.PENDING) self.assertEqual(order["status"], Order.Status.PENDING)
self.festival.refresh_from_db() self.festival.refresh_from_db()
...@@ -105,7 +108,7 @@ class CheckoutFlowTests(APITestCase): ...@@ -105,7 +108,7 @@ class CheckoutFlowTests(APITestCase):
with mock.patch.object(midtrans_client, "create_transaction", return_value=SNAP_CREATE): with mock.patch.object(midtrans_client, "create_transaction", return_value=SNAP_CREATE):
r = self.client.post(f"/api/v1/orders/{order['id']}/payment/", {}, format="json") r = self.client.post(f"/api/v1/orders/{order['id']}/payment/", {}, format="json")
self.assertEqual(r.status_code, 201, r.data) self.assertEqual(r.status_code, 201, r.data)
self.assertEqual(r.data["amount"], 173_300) self.assertEqual(r.data["amount"], 186_700)
self.assertEqual(r.data["method"], Payment.Method.SNAP) self.assertEqual(r.data["method"], Payment.Method.SNAP)
self.assertEqual(r.data["snap_token"], "snap-token-abc") self.assertEqual(r.data["snap_token"], "snap-token-abc")
self.assertIn("app.sandbox.midtrans.com", r.data["snap_redirect_url"]) self.assertIn("app.sandbox.midtrans.com", r.data["snap_redirect_url"])
......
...@@ -65,6 +65,7 @@ INSTALLED_APPS = [ ...@@ -65,6 +65,7 @@ INSTALLED_APPS = [
"apps.partners", "apps.partners",
"apps.content", "apps.content",
"apps.notifications", "apps.notifications",
"apps.finance",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
...@@ -176,6 +177,7 @@ SPECTACULAR_SETTINGS = { ...@@ -176,6 +177,7 @@ SPECTACULAR_SETTINGS = {
{"name": "partners", "description": "Partner, benefit/katalog voucher, poin"}, {"name": "partners", "description": "Partner, benefit/katalog voucher, poin"},
{"name": "content", "description": "News/artikel, banner, media"}, {"name": "content", "description": "News/artikel, banner, media"},
{"name": "notifications", "description": "Notifikasi pengguna"}, {"name": "notifications", "description": "Notifikasi pengguna"},
{"name": "finance", "description": "Aturan harga, bagi hasil, laporan keuangan"},
{"name": "dashboard", "description": "Statistik & reporting untuk admin"}, {"name": "dashboard", "description": "Statistik & reporting untuk admin"},
], ],
"ENUM_NAME_OVERRIDES": { "ENUM_NAME_OVERRIDES": {
......
...@@ -13,6 +13,7 @@ api_v1_patterns = [ ...@@ -13,6 +13,7 @@ api_v1_patterns = [
path("", include("apps.partners.urls")), path("", include("apps.partners.urls")),
path("", include("apps.content.urls")), path("", include("apps.content.urls")),
path("", include("apps.notifications.urls")), path("", include("apps.notifications.urls")),
path("", include("apps.finance.urls")),
] ]
urlpatterns = [ urlpatterns = [
......
This diff is collapsed.
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Operations to perform:
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/django/db/models/fields/__init__.py:1535: RuntimeWarning: DateTimeField Event.sale_end_at received a naive datetime (2026-10-13 00:00:00) while time zone support is active.
warnings.warn(
Pengcab: 27 (baru 27)
Admin dibuat: +6281200000001 / Admin123!
Demo member: +6281200000002 / Member123! (GOLD, 1.450 poin)
Seed selesai.
"""Independent PRD probes. Run from backend with audit directory on PYTHONPATH."""
from rest_framework.test import APITestCase, APIClient
from apps.common.testing import make_staff, make_user
from apps.accounts.models import AuditLog
from apps.ticketing.tests import _event_fixtures, SNAP_CREATE, _settled_status, _signed_notification
from apps.common.testing import make_member, member_client
from apps.ticketing.midtrans import client as gateway
from apps.ticketing.models import Order, Ticket
from unittest.mock import patch
class FinancePRDProbes(APITestCase):
def setUp(self):
self.staff = make_staff()
self.client.force_authenticate(self.staff)
self.url = '/api/v1/finance/pricing-config/'
self.cfg = dict(app_fee_pct=5, imi_fee_pct=3, tax_rate_pct=10, service_fee=5000)
def test_negative_share_rejected(self):
r = self.client.put(self.url, {**self.cfg, 'app_fee_pct': -5}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_invalid_allocation_rejected(self):
r = self.client.put(self.url, {**self.cfg, 'app_fee_pct': 60, 'imi_fee_pct': 45}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_content_staff_cannot_configure_finance(self):
self.client.force_authenticate(make_user('+6281555500001', role='content'))
r = self.client.put(self.url, self.cfg, format='json')
self.assertEqual(r.status_code, 403, r.data)
def test_reporting_staff_cannot_configure_finance(self):
self.client.force_authenticate(make_user('+6281555500002', role='reporting'))
r = self.client.put(self.url, self.cfg, format='json')
self.assertEqual(r.status_code, 403, r.data)
def test_negative_price_rejected(self):
r = self.client.post(self.url + 'preview/', {'price': -100000}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_price_update_has_audit(self):
before = AuditLog.objects.count()
r = self.client.put(self.url, {**self.cfg, 'app_fee_pct': 7}, format='json')
self.assertEqual(r.status_code, 200)
self.assertGreater(AuditLog.objects.count(), before)
def test_required_finance_routes_exist(self):
for path in ('revenue-sharing/models/', 'transactions/', 'settlements/', 'reconciliations/', 'reports/revenue/', 'reports/settlement/', 'reports/reconciliation/'):
with self.subTest(path=path):
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
class TicketIntegrityProbes(APITestCase):
def setUp(self):
self.staff = make_staff()
self.user, self.member = make_member('+6281555566666')
self.event, self.tier, _, self.session = _event_fixtures()
self.client = member_client(self.user.phone)
def order(self, lines=None):
r = self.client.post('/api/v1/orders/', {'event_id': self.event.id, 'terms_accepted': True, 'lines': lines or [{'tier_id': self.tier.id, 'quantity': 1}]}, format='json')
self.assertEqual(r.status_code, 201, r.data)
return Order.objects.get(pk=r.data['id'])
def pay(self, order):
with patch.object(gateway, 'create_transaction', return_value=SNAP_CREATE):
self.client.post(f'/api/v1/orders/{order.pk}/payment/', {}, format='json')
self.client.post('/api/v1/payments/midtrans/notification', _signed_notification(order, _settled_status()), format='json')
def test_refunded_ticket_cannot_check_in(self):
order = self.order()
self.pay(order)
self.client.post('/api/v1/payments/midtrans/notification', _signed_notification(order, {'transaction_status': 'refund', 'status_code': '200'}), format='json')
ticket = Ticket.objects.get(order=order)
self.client.force_authenticate(self.staff)
r = self.client.post('/api/v1/scan/check-in', {'scan_code': ticket.qr_token}, format='json')
self.assertEqual(r.data['status'], 'rejected', r.data)
def test_duplicate_lines_do_not_oversell(self):
self.tier.quota = 3
self.tier.save()
r = self.client.post('/api/v1/orders/', {'event_id': self.event.id, 'terms_accepted': True, 'lines': [{'tier_id': self.tier.id, 'quantity': 2}, {'tier_id': self.tier.id, 'quantity': 2}]}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_paid_order_late_expire_does_not_change_payment(self):
order = self.order()
self.pay(order)
self.client.post('/api/v1/payments/midtrans/notification', _signed_notification(order, {'transaction_status': 'expire', 'status_code': '407'}), format='json')
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(order.payment.status, 'verified')
def test_signed_wrong_amount_not_accepted(self):
import hashlib
from django.conf import settings
order = self.order()
with patch.object(gateway, 'create_transaction', return_value=SNAP_CREATE):
self.client.post(f'/api/v1/orders/{order.pk}/payment/', {}, format='json')
payload = _signed_notification(order, _settled_status())
payload['gross_amount'] = '1'
payload['signature_key'] = hashlib.sha512(f"{order.code}{payload['status_code']}1{settings.MIDTRANS_SERVER_KEY}".encode()).hexdigest()
self.client.post('/api/v1/payments/midtrans/notification', payload, format='json')
order.refresh_from_db()
self.assertNotEqual(order.status, 'paid')
def test_draft_event_not_publicly_retrievable(self):
self.event.status = 'draft'
self.event.save()
r = APIClient().get(f'/api/v1/events/{self.event.slug}/')
self.assertEqual(r.status_code, 404)
def test_event_staff_can_create_event(self):
self.client.force_authenticate(make_user('+6281555500003', role='event'))
r = self.client.post('/api/v1/events/', {'title': 'Role Event Audit', 'slug': 'role-event-audit', 'category': self.event.category_id, 'start_date': str(self.event.start_date), 'end_date': str(self.event.end_date), 'status': 'draft'}, format='json')
self.assertEqual(r.status_code, 201, r.data)
def test_order_requires_terms(self):
r = self.client.post('/api/v1/orders/', {'event_id': self.event.id, 'terms_accepted': False, 'lines': [{'tier_id': self.tier.id, 'quantity': 1}]}, format='json')
self.assertEqual(r.status_code, 400, r.data)
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Found 47 test(s).
Operations to perform:
Synchronize unmigrated apps: common, corsheaders, django_filters, drf_spectacular, messages, rest_framework, staticfiles
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Synchronizing apps without migrations:
Creating tables...
Running deferred SQL...
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
System check identified no issues (0 silenced).
test_login_salah_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_verify_otp_login_me (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_match_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_terms (apps.accounts.tests.AuthFlowTests) ... ok
test_kartu_pengcab_terexpose (apps.accounts.tests.ProfileTests) ... ok
test_patch_profile (apps.accounts.tests.ProfileTests) ... ok
test_dashboard_finance_aggregates (apps.accounts.tests.StaffTests) ... ok
test_dashboard_stats_hanya_staff (apps.accounts.tests.StaffTests) ... ok
test_revenue_total_dihitung_per_order_paid (apps.accounts.tests.StaffTests) ... ok
test_crud_iklan (apps.content.tests.AdTests) ... ok
test_manage_perlu_staff (apps.content.tests.AdTests) ... ok
test_nonaktif_tidak_muncul_publik_setelah_update (apps.content.tests.AdTests) ... ok
test_public_hanya_iklan_aktif_urut_ordinal (apps.content.tests.AdTests) ... ok
test_articles_manage_perlu_staff (apps.content.tests.ContentTests) ... ok
test_artikel_publik_dan_baca_meningkat (apps.content.tests.ContentTests) ... ok
test_artikel_publish_otomatis_set_published_at (apps.content.tests.ContentTests) ... ok
test_banner_dan_kategori_publik (apps.content.tests.ContentTests) ... ok
test_create_event_payload_webadmin (apps.events.tests.EventAdminWriteTests) ... ok
test_patch_event_membersihkan_datetime_nan (apps.events.tests.EventAdminWriteTests) ... ok
test_admin_list_melihat_semua_status_dan_fungsi_filter (apps.events.tests.EventListVisibilityTests) ... ok
test_public_list_hanya_status_publik (apps.events.tests.EventListVisibilityTests) ... ok
test_member_tanpa_role_tidak_boleh_membuat_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_staff_bisa_create_patch_delete_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_get_default_config (apps.finance.tests.PricingConfigTests) ... ok
test_member_tidak_bisa_akses (apps.finance.tests.PricingConfigTests) ... ok
test_preview_ikut_diskon (apps.finance.tests.PricingConfigTests) ... ok
test_preview_rumus_harga_jual (apps.finance.tests.PricingConfigTests) ... ok
test_update_config (apps.finance.tests.PricingConfigTests) ... ok
test_inbox_milik_pengguna_sendiri (apps.notifications.tests.NotificationTests) ... ok
test_inbox_unread_dan_mark_read (apps.notifications.tests.NotificationTests) ... ok
test_preferensi_get_patch (apps.notifications.tests.NotificationTests) ... ok
test_klaim_dan_redeem_voucher (apps.partners.tests.WalletRedemptionTests) ... ok
test_klaim_tanpa_cukup_poin (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_duplikat_lalu_kode_tak_dikenal (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_wajib_staff (apps.partners.tests.WalletRedemptionTests) ... ok
test_wallet_balance_default (apps.partners.tests.WalletRedemptionTests) ... ok
test_scan_duplikat_dan_tak_dikenal (apps.ticketing.tests.CheckInTests) ... ok
test_scan_memakai_hash_code (apps.ticketing.tests.CheckInTests) ... ok
test_scan_tanpa_auth_ditolak (apps.ticketing.tests.CheckInTests) ... ok
test_full_checkout_flow (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_pembatalan_order_melepas_kuota (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_requires_license_ditolak_untuk_member_nonverified (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_confirm_tetap_pending_menghasilkan_400 (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_expire_melepas_kuota (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_idempoten (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_settlement_terbitkan_tiket (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_signature_keliru_ditolak (apps.ticketing.tests.MidtransNotificationTests) ... ok
----------------------------------------------------------------------
Ran 47 tests in 17.581s
OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Found 46 test(s).
Operations to perform:
Synchronize unmigrated apps: common, corsheaders, django_filters, drf_spectacular, messages, rest_framework, staticfiles
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Synchronizing apps without migrations:
Creating tables...
Running deferred SQL...
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
System check identified no issues (0 silenced).
test_login_salah_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_verify_otp_login_me (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_match_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_terms (apps.accounts.tests.AuthFlowTests) ... ok
test_kartu_pengcab_terexpose (apps.accounts.tests.ProfileTests) ... ok
test_patch_profile (apps.accounts.tests.ProfileTests) ... ok
test_dashboard_stats_hanya_staff (apps.accounts.tests.StaffTests) ... ok
test_revenue_total_dihitung_per_order_paid (apps.accounts.tests.StaffTests) ... ok
test_crud_iklan (apps.content.tests.AdTests) ... ok
test_manage_perlu_staff (apps.content.tests.AdTests) ... ok
test_nonaktif_tidak_muncul_publik_setelah_update (apps.content.tests.AdTests) ... ok
test_public_hanya_iklan_aktif_urut_ordinal (apps.content.tests.AdTests) ... ok
test_articles_manage_perlu_staff (apps.content.tests.ContentTests) ... ok
test_artikel_publik_dan_baca_meningkat (apps.content.tests.ContentTests) ... ok
test_artikel_publish_otomatis_set_published_at (apps.content.tests.ContentTests) ... ok
test_banner_dan_kategori_publik (apps.content.tests.ContentTests) ... ok
test_create_event_payload_webadmin (apps.events.tests.EventAdminWriteTests) ... ok
test_patch_event_membersihkan_datetime_nan (apps.events.tests.EventAdminWriteTests) ... ok
test_admin_list_melihat_semua_status_dan_fungsi_filter (apps.events.tests.EventListVisibilityTests) ... ok
test_public_list_hanya_status_publik (apps.events.tests.EventListVisibilityTests) ... ok
test_member_tanpa_role_tidak_boleh_membuat_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_staff_bisa_create_patch_delete_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_get_default_config (apps.finance.tests.PricingConfigTests) ... ok
test_member_tidak_bisa_akses (apps.finance.tests.PricingConfigTests) ... ok
test_preview_ikut_diskon (apps.finance.tests.PricingConfigTests) ... ok
test_preview_rumus_harga_jual (apps.finance.tests.PricingConfigTests) ... ok
test_update_config (apps.finance.tests.PricingConfigTests) ... ok
test_inbox_milik_pengguna_sendiri (apps.notifications.tests.NotificationTests) ... ok
test_inbox_unread_dan_mark_read (apps.notifications.tests.NotificationTests) ... ok
test_preferensi_get_patch (apps.notifications.tests.NotificationTests) ... ok
test_klaim_dan_redeem_voucher (apps.partners.tests.WalletRedemptionTests) ... ok
test_klaim_tanpa_cukup_poin (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_duplikat_lalu_kode_tak_dikenal (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_wajib_staff (apps.partners.tests.WalletRedemptionTests) ... ok
test_wallet_balance_default (apps.partners.tests.WalletRedemptionTests) ... ok
test_scan_duplikat_dan_tak_dikenal (apps.ticketing.tests.CheckInTests) ... ok
test_scan_memakai_hash_code (apps.ticketing.tests.CheckInTests) ... ok
test_scan_tanpa_auth_ditolak (apps.ticketing.tests.CheckInTests) ... ok
test_full_checkout_flow (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_pembatalan_order_melepas_kuota (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_requires_license_ditolak_untuk_member_nonverified (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_confirm_tetap_pending_menghasilkan_400 (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_expire_melepas_kuota (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_idempoten (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_settlement_terbitkan_tiket (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_signature_keliru_ditolak (apps.ticketing.tests.MidtransNotificationTests) ... ok
----------------------------------------------------------------------
Ran 46 tests in 17.077s
OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
"""Local isolated audit server; payment gateway responses are explicitly simulated."""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'backend'))
os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings'
os.environ['DATABASE_URL'] = 'sqlite:////private/tmp/imi-audit-20261007.sqlite3'
import django
django.setup()
from apps.ticketing.midtrans import client
client.create_transaction = lambda order: {'token': 'AUDIT-SIMULATED', 'redirect_url': 'http://127.0.0.1:5300/#audit-payment', 'transaction_id': 'audit-' + order.code}
client.check_transaction_status = lambda order: {'transaction_status': 'settlement', 'fraud_status': 'accept', 'status_code': '200', 'payment_type': 'bank_transfer', 'transaction_id': 'audit-' + order.code}
from django.core.management import call_command
call_command('runserver', '127.0.0.1:8100', use_reloader=False)
Analyzing mobile...
No issues found! (ran in 1.2s)
00:00 +0: loading /Users/trendy/Documents/IMIapps/mobile/test/widget_test.dart 00:01 +0: loading /Users/trendy/Documents/IMIapps/mobile/test/widget_test.dart 00:01 +0: App smoke test 00:02 +0: App smoke test 00:02 +1: App smoke test 00:02 +1: All tests passed!
Compiling lib/main.dart for the Web...
Font asset "CupertinoIcons.ttf" was tree-shaken, reducing it from 257628 to 1172 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 16784 bytes (99.0% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Compiling lib/main.dart for the Web... 17.0s
✓ Built build/web
This diff is collapsed.
This diff is collapsed.
...@@ -37,6 +37,17 @@ class ApiService { ...@@ -37,6 +37,17 @@ class ApiService {
return headers; return headers;
} }
static dynamic _decodeJsonSafely(http.Response res) {
if (res.statusCode >= 500) {
throw 'Server backend mengalami gangguan (HTTP ${res.statusCode} ${res.reasonPhrase ?? "Internal Error"}). Mohon periksa status server.';
}
final body = utf8.decode(res.bodyBytes).trim();
if (body.startsWith('<')) {
throw 'Server mengembalikan halaman HTML (HTTP ${res.statusCode}), bukan format API JSON.';
}
return jsonDecode(body);
}
// Auth Endpoints (no trailing slash) // Auth Endpoints (no trailing slash)
static Future<ApiResponse<Map<String, dynamic>>> login({ static Future<ApiResponse<Map<String, dynamic>>> login({
required String identifier, required String identifier,
...@@ -53,7 +64,7 @@ class ApiService { ...@@ -53,7 +64,7 @@ class ApiService {
}), }),
); );
final data = jsonDecode(utf8.decode(res.bodyBytes)); final data = _decodeJsonSafely(res);
if (res.statusCode == 200) { if (res.statusCode == 200) {
if (data['tokens'] != null) { if (data['tokens'] != null) {
await StorageService.saveTokens( await StorageService.saveTokens(
...@@ -496,6 +507,21 @@ class ApiService { ...@@ -496,6 +507,21 @@ class ApiService {
} }
} }
static Future<ApiResponse<List<dynamic>>> getAds() async {
try {
final url = Uri.parse('$baseUrl/ads/');
final res = await http.get(url, headers: _headers(withAuth: false));
if (res.statusCode == 200) {
final data = jsonDecode(utf8.decode(res.bodyBytes));
final list = data is List ? data : (data is Map && data['results'] != null ? data['results'] : []);
return ApiResponse(success: true, data: list, statusCode: res.statusCode);
}
return ApiResponse(success: false, statusCode: res.statusCode);
} catch (e) {
return ApiResponse(success: false, statusCode: 0);
}
}
// Notifications (trailing slash for collection, specific paths for actions) // Notifications (trailing slash for collection, specific paths for actions)
static Future<ApiResponse<List<dynamic>>> getNotifications() async { static Future<ApiResponse<List<dynamic>>> getNotifications() async {
try { try {
......
...@@ -62,3 +62,47 @@ class BannerModel { ...@@ -62,3 +62,47 @@ class BannerModel {
} }
} }
class AdModel {
final int id;
final String title;
final String category;
final String subtitle;
final String details;
final String actionText;
final String url;
final String imageUrl;
final String badgeColor;
final int ordinal;
final bool isActive;
const AdModel({
required this.id,
required this.title,
this.category = '',
this.subtitle = '',
this.details = '',
this.actionText = '',
this.url = '',
this.imageUrl = '',
this.badgeColor = '#10B981',
this.ordinal = 0,
this.isActive = true,
});
factory AdModel.fromJson(Map<String, dynamic> json) {
return AdModel(
id: json['id'] ?? 0,
title: json['title'] ?? '',
category: json['category'] ?? '',
subtitle: json['subtitle'] ?? '',
details: json['details'] ?? '',
actionText: json['action_text'] ?? '',
url: json['url'] ?? '',
imageUrl: json['image_url'] ?? '',
badgeColor: json['badge_color'] ?? '#10B981',
ordinal: json['ordinal'] ?? 0,
isActive: json['is_active'] ?? true,
);
}
}
...@@ -20,6 +20,7 @@ class AppState extends ChangeNotifier { ...@@ -20,6 +20,7 @@ class AppState extends ChangeNotifier {
List<BenefitModel> _benefits = []; List<BenefitModel> _benefits = [];
List<ArticleModel> _articles = []; List<ArticleModel> _articles = [];
List<BannerModel> _banners = []; List<BannerModel> _banners = [];
List<AdModel> _ads = [];
List<NotificationModel> _notifications = []; List<NotificationModel> _notifications = [];
UserModel? get currentUser => _currentUser; UserModel? get currentUser => _currentUser;
...@@ -34,6 +35,7 @@ class AppState extends ChangeNotifier { ...@@ -34,6 +35,7 @@ class AppState extends ChangeNotifier {
List<BenefitModel> get benefits => _benefits; List<BenefitModel> get benefits => _benefits;
List<ArticleModel> get articles => _articles; List<ArticleModel> get articles => _articles;
List<BannerModel> get banners => _banners; List<BannerModel> get banners => _banners;
List<AdModel> get ads => _ads;
List<NotificationModel> get notifications => _notifications; List<NotificationModel> get notifications => _notifications;
MembershipCardModel? get primaryCard => _currentUser?.primaryCard; MembershipCardModel? get primaryCard => _currentUser?.primaryCard;
...@@ -141,6 +143,11 @@ class AppState extends ChangeNotifier { ...@@ -141,6 +143,11 @@ class AppState extends ChangeNotifier {
_banners = banRes.data!.map((b) => BannerModel.fromJson(b)).toList(); _banners = banRes.data!.map((b) => BannerModel.fromJson(b)).toList();
} }
final adRes = await ApiService.getAds();
if (adRes.success && adRes.data != null) {
_ads = adRes.data!.map((a) => AdModel.fromJson(a)).toList();
}
notifyListeners(); notifyListeners();
} }
......
...@@ -28,6 +28,7 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -28,6 +28,7 @@ class _HomeScreenState extends State<HomeScreen> {
List<EventModel> _events = []; List<EventModel> _events = [];
List<BenefitModel> _benefits = []; List<BenefitModel> _benefits = [];
List<ArticleModel> _articles = []; List<ArticleModel> _articles = [];
List<AdModel> _ads = [];
bool _isLoading = true; bool _isLoading = true;
// Bottom Ads Carousel // Bottom Ads Carousel
...@@ -53,8 +54,8 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -53,8 +54,8 @@ class _HomeScreenState extends State<HomeScreen> {
void _startAdAutoScroll() { void _startAdAutoScroll() {
_adTimer?.cancel(); _adTimer?.cancel();
_adTimer = Timer.periodic(const Duration(seconds: 4), (_) { _adTimer = Timer.periodic(const Duration(seconds: 4), (_) {
if (!_adPageController.hasClients) return; if (!_adPageController.hasClients || _ads.length < 2) return;
final nextIndex = (_currentAdIndex + 1) % 3; // 3 ads: asuransi, oli, merch final nextIndex = (_currentAdIndex + 1) % _ads.length;
_adPageController.animateToPage( _adPageController.animateToPage(
nextIndex, nextIndex,
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 600),
...@@ -90,6 +91,12 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -90,6 +91,12 @@ class _HomeScreenState extends State<HomeScreen> {
_articles = artRes.data!.map((a) => ArticleModel.fromJson(a)).toList(); _articles = artRes.data!.map((a) => ArticleModel.fromJson(a)).toList();
} }
// Load Footer Ads
final adRes = await ApiService.getAds();
if (adRes.success && adRes.data != null) {
_ads = adRes.data!.map((a) => AdModel.fromJson(a)).toList();
}
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
...@@ -821,43 +828,97 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -821,43 +828,97 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
Color _badgeColor(String hex) {
final h = hex.replaceAll('#', '');
if (h.length == 6) {
return Color(int.parse('FF$h', radix: 16));
}
return const Color(0xFF10B981);
}
Widget _buildAdImageWidget(String imageSrc, Color badgeColor) {
if (imageSrc.isEmpty) {
return Center(child: Icon(Icons.shopping_bag, size: 36, color: badgeColor));
}
String? localFallback;
if (imageSrc.contains('ad_bpjs_banner')) {
localFallback = 'assets/images/ad_bpjs_banner.jpg';
} else if (imageSrc.contains('ad_motul_300v')) {
localFallback = 'assets/images/ad_motul_300v.png';
} else if (imageSrc.contains('ad_merch_jacket')) {
localFallback = 'assets/images/ad_merch_jacket.webp';
}
if (imageSrc.startsWith('http://') || imageSrc.startsWith('https://')) {
return Image.network(
imageSrc,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) {
if (localFallback != null) {
return Image.asset(localFallback, fit: BoxFit.contain);
}
return Center(child: Icon(Icons.shopping_bag, size: 36, color: badgeColor));
},
);
}
return Image.asset(
imageSrc,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.shopping_bag, size: 36, color: badgeColor),
),
);
}
Widget _buildBottomAdsCarouselSection(BuildContext context) { Widget _buildBottomAdsCarouselSection(BuildContext context) {
// 3 Real Ads: Asuransi (BPJS Ketenagakerjaan JKK Motorsport), Oli (Motul 300V Factory Line), Merch (Juaraga Official IMI) List<Map<String, dynamic>> ads;
final ads = [ if (_ads.isNotEmpty) {
{ ads = _ads.map((a) => <String, dynamic>{
'category': 'PROTEKSI ATLET BALAP', 'category': a.category,
'badgeColor': const Color(0xFF10B981), // Emerald 'badgeColor': _badgeColor(a.badgeColor),
'title': 'BPJS Ketenagakerjaan Atlet & Pembalap', 'title': a.title,
'subtitle': 'Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.', 'subtitle': a.subtitle,
'image': 'assets/images/ad_bpjs_banner.jpg', 'actionText': a.actionText,
'fit': BoxFit.cover, 'url': a.url,
'actionText': 'Pelajari Program', 'details': a.details,
'url': 'https://www.bpjsketenagakerjaan.go.id', 'image': a.imageUrl,
'details': 'Program perlindungan resmi bagi pembalap dan atlet motorsport IMI sesuai UU Keolahragaan No. 11/2022. Mencakup perawatan medis tanpa batas biaya sampai sembuh untuk insiden di sirkuit maupun latihan resmi.', }).toList();
}, } else {
{ ads = [
'category': 'OFFICIAL RACING OIL', {
'badgeColor': const Color(0xFFE11D48), // Motul Crimson 'category': 'PROTEKSI ATLET BALAP',
'title': 'Motul 300V Factory Line Road Racing', 'badgeColor': const Color(0xFF10B981),
'subtitle': 'Pelumas Ester Core® 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.', 'title': 'BPJS Ketenagakerjaan Atlet & Pembalap',
'image': 'assets/images/ad_motul_300v.png', 'subtitle': 'Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.',
'fit': BoxFit.contain, 'image': 'assets/images/ad_bpjs_banner.jpg',
'actionText': 'Beli di Official Store', 'actionText': 'Pelajari Program',
'url': 'https://www.motul.com/id/id', 'url': 'https://www.bpjsketenagakerjaan.go.id',
'details': 'Oli resmi balap Kejurnas IMI Jabar didistribusikan oleh PT Motul Indonesia Energy (MIE). Teknologi ESTER Core® memberikan daya tahan gesekan ekstrem pada transmisi dan kopling basah (JASO MA2).', 'details': 'Program perlindungan resmi bagi pembalap dan atlet motorsport IMI sesuai UU Keolahragaan No. 11/2022. Mencakup perawatan medis tanpa batas biaya sampai sembuh untuk insiden di sirkuit maupun latihan resmi.',
}, },
{ {
'category': 'OFFICIAL MERCHANDISE', 'category': 'OFFICIAL RACING OIL',
'badgeColor': AppColors.circuitGold, // Gold 'badgeColor': const Color(0xFFE11D48),
'title': 'Juaraga Official Merchandise IMI', 'title': 'Motul 300V Factory Line Road Racing',
'subtitle': 'Koleksi jaket windbreaker, paddock jersey, dan apparel resmi berlisensi Ikatan Motor Indonesia.', 'subtitle': 'Pelumas Ester Core® 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.',
'image': 'assets/images/ad_merch_jacket.webp', 'image': 'assets/images/ad_motul_300v.png',
'fit': BoxFit.contain, 'actionText': 'Beli di Official Store',
'actionText': 'Lihat Katalog Juaraga', 'url': 'https://www.motul.com/id/id',
'url': 'https://juaraga.id', 'details': 'Oli resmi balap Kejurnas IMI Jabar didistribusikan oleh PT Motul Indonesia Energy (MIE). Teknologi ESTER Core® memberikan daya tahan gesekan ekstrem pada transmisi dan kopling basah (JASO MA2).',
'details': 'Merchandise resmi IMI diproduksi eksklusif oleh Juaraga dengan material premium standar motorsport internasional. Dapatkan apparel resmi dan dukung ekosistem balap nasional.', },
}, {
]; 'category': 'OFFICIAL MERCHANDISE',
'badgeColor': AppColors.circuitGold,
'title': 'Juaraga Official Merchandise IMI',
'subtitle': 'Koleksi jaket windbreaker, paddock jersey, dan apparel resmi berlisensi Ikatan Motor Indonesia.',
'image': 'assets/images/ad_merch_jacket.webp',
'actionText': 'Lihat Katalog Juaraga',
'url': 'https://juaraga.id',
'details': 'Merchandise resmi IMI diproduksi eksklusif oleh Juaraga dengan material premium standar motorsport internasional. Dapatkan apparel resmi dan dukung ekosistem balap nasional.',
},
];
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
...@@ -1028,12 +1089,9 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -1028,12 +1089,9 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Image.asset( child: _buildAdImageWidget(
ad['image'] as String, ad['image'] as String? ?? '',
fit: ad['fit'] as BoxFit, badgeColor,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.shopping_bag, size: 36, color: badgeColor),
),
), ),
), ),
), ),
...@@ -1118,8 +1176,10 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -1118,8 +1176,10 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () async { onPressed: () async {
final url = (ad['url'] as String).trim();
if (url.isEmpty) return;
Navigator.pop(ctx); Navigator.pop(ctx);
final uri = Uri.parse(ad['url'] as String); final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(uri, mode: LaunchMode.externalApplication);
} }
......
This diff is collapsed.
...@@ -4,8 +4,10 @@ import * as dashboard from "./pages/dashboard.js"; ...@@ -4,8 +4,10 @@ import * as dashboard from "./pages/dashboard.js";
import * as members from "./pages/members.js"; import * as members from "./pages/members.js";
import * as events from "./pages/events.js"; import * as events from "./pages/events.js";
import * as content from "./pages/content.js"; import * as content from "./pages/content.js";
import * as ads from "./pages/ads.js";
import * as partners from "./pages/partners.js"; import * as partners from "./pages/partners.js";
import * as checkins from "./pages/checkins.js"; import * as checkins from "./pages/checkins.js";
import * as finance from "./pages/finance.js";
import * as roles from "./pages/roles.js"; import * as roles from "./pages/roles.js";
const ROLE_LABEL = Object.fromEntries(ROLES.map((r) => [r.value, r.label])); const ROLE_LABEL = Object.fromEntries(ROLES.map((r) => [r.value, r.label]));
...@@ -15,8 +17,10 @@ const PAGES = [ ...@@ -15,8 +17,10 @@ const PAGES = [
{ key: "members", title: "Member Management", icon: "◉", page: members }, { key: "members", title: "Member Management", icon: "◉", page: members },
{ key: "events", title: "Event Management", icon: "❖", page: events }, { key: "events", title: "Event Management", icon: "❖", page: events },
{ key: "content", title: "Content Management", icon: "▤", page: content }, { key: "content", title: "Content Management", icon: "▤", page: content },
{ key: "ads", title: "Slider Iklan", icon: "▰", page: ads },
{ key: "partners", title: "Partner & Benefit", icon: "◈", page: partners }, { key: "partners", title: "Partner & Benefit", icon: "◈", page: partners },
{ key: "checkins", title: "Check-in & Tiket", icon: "✓", page: checkins }, { key: "checkins", title: "Check-in & Tiket", icon: "✓", page: checkins },
{ key: "finance", title: "Harga & Bagi Hasil", icon: "¥", page: finance },
{ key: "roles", title: "User & Role", icon: "⚿", page: roles }, { key: "roles", title: "User & Role", icon: "⚿", page: roles },
]; ];
......
import { api } from "../api.js";
import { h, badge, emptyRow, field, makePager, toast, openModal, select, confirmDialog, errorCard, withBusy, clearFieldErrors, addFieldError, applyApiErrors } from "../ui.js";
const PAGE_SIZE = 20;
export function render(mount) {
mount.innerHTML = "";
const state = { page: 1, search: "", active: "" };
const searchInput = h("input", {
type: "search", placeholder: "Cari judul / kategori…", class: "grow",
oninput: (e) => debounce(() => { state.search = e.target.value.trim(); state.page = 1; load(); }),
});
const activeSel = select([
{ value: "", label: "Semua status" },
{ value: "true", label: "Aktif" },
{ value: "false", label: "Nonaktif" },
], state.active, {
onchange: (e) => { state.active = e.target.value; state.page = 1; load(); },
});
const newBtn = h("button", { class: "btn btn-primary", onclick: () => openAdForm(null, () => load()) }, "+ Tambah Iklan");
mount.append(h("div", { class: "toolbar" }, searchInput, activeSel, h("div", { class: "spacer" }), newBtn));
const tableWrap = h("div");
mount.append(tableWrap);
async function load() {
tableWrap.innerHTML = "";
tableWrap.append(h("div", { class: "card card-pad" }, "Memuat…"));
try {
const data = await api.get("/ads/manage/", {
page: state.page, search: state.search, is_active: state.active,
});
renderTable(tableWrap, data, state, load);
} catch (err) {
tableWrap.innerHTML = "";
tableWrap.append(errorCard(err.message, load));
}
}
load();
}
function colorPreview(hex) {
const box = h("span", {
style: `display:inline-block;width:12px;height:12px;border-radius:3px;background:${hex || "#ccc"};vertical-align:-1px;margin-right:6px;border:1px solid var(--line,#e3e8ee)`,
});
return box;
}
function renderTable(wrap, data, state, reload) {
wrap.innerHTML = "";
const card = h("div", { class: "card" });
const rows = data.results.map((ad) =>
h("tr", {},
h("td", {},
h("div", { style: "display:flex;align-items:center;gap:10px" },
ad.image_url
? h("img", { src: ad.image_url, style: "width:44px;height:32px;object-fit:cover;border-radius:6px;background:#f1f4f8" })
: h("span", { style: "width:44px;height:32px;border-radius:6px;background:#f1f4f8;display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:#94a3b8" }, "—"),
h("div", {},
h("strong", {}, ad.title),
h("div", { class: "muted", style: "font-size:12px" }, ad.category || "-"),
),
),
),
h("td", {}, colorPreview(ad.badge_color), ad.badge_color),
h("td", {}, ad.action_text || "-"),
h("td", { class: "num" }, ad.ordinal),
h("td", {}, badge(ad.is_active ? "enabled" : "disabled", ad.is_active ? "Aktif" : "Nonaktif")),
h("td", {}, h("div", { class: "row-actions" },
h("button", { class: "btn btn-sm", onclick: () => openAdForm(ad, reload) }, "Edit"),
h("button", { class: "btn btn-sm btn-danger", onclick: () => remove(ad, reload) }, "Hapus"),
)),
),
);
card.append(
h("div", { class: "table-wrap" },
h("table", { class: "tbl" },
h("thead", {}, h("tr", {}, ["Iklan", "Warna", "Tombol", "Urutan", "Status", ""].map((t) => h("th", {}, t)))),
h("tbody", {}, rows.length ? rows : emptyRow("Tidak ada iklan.")),
),
),
makePager({ page: state.page, totalPages: Math.max(1, Math.ceil(data.count / PAGE_SIZE)), count: data.count, onChange: (p) => { state.page = p; reload(); } }),
);
wrap.append(card);
}
async function remove(ad, reload) {
if (!(await confirmDialog(`Hapus iklan "${ad.title}"?`))) return;
try {
await api.del(`/ads/manage/${ad.id}/`);
toast("Iklan dihapus.", "ok");
reload();
} catch (err) {
toast(err.message, "err");
}
}
function openAdForm(ad, reload) {
const V = (name) => (ad ? (ad[name] ?? "") : "");
const preview = h("img", {
style: "max-height:90px;border-radius:8px;background:#f1f4f8;display:none",
onerror: () => { preview.style.display = "none"; },
});
const imageUrlInput = h("input", {
type: "url", name: "image_url", value: V("image_url"),
placeholder: "https://… (boleh kosong)",
oninput: () => {
const v = imageUrlInput.value.trim();
if (v) { preview.src = v; preview.style.display = ""; }
else preview.style.display = "none";
},
});
const form = h("form", { novalidate: "" },
field("Judul Iklan *", h("input", { type: "text", required: true, name: "title", value: V("title") })),
h("div", { class: "form-grid" },
field("Kategori / Badge", h("input", { type: "text", name: "category", value: V("category"), placeholder: "mis. PROTEKSI ATLET BALAP" })),
field("Teks Tombol", h("input", { type: "text", name: "action_text", value: V("action_text") })),
),
field("Subjudul", h("input", { type: "text", name: "subtitle", value: V("subtitle") })),
field("Detail (sheet saat diklik)", h("textarea", { name: "details", rows: 3 }, V("details"))),
field("URL Tujuan", h("input", { type: "url", name: "url", value: V("url"), placeholder: "https://…" })),
field("Gambar Iklan", imageUrlInput),
preview,
h("div", { class: "form-grid" },
field("Warna Aksen", h("input", { type: "color", name: "badge_color", value: V("badge_color") || "#10B981", style: "height:38px;width:64px;padding:2px" })),
field("Urutan", h("input", { type: "number", min: "0", name: "ordinal", value: V("ordinal") })),
),
h("div", { style: "margin-top:10px" },
h("label", { class: "check" }, h("input", { type: "checkbox", name: "is_active", checked: ad ? !!ad.is_active : true }), "Aktif (ditampilkan di slider)"),
),
);
if (imageUrlInput.value) {
preview.src = imageUrlInput.value;
preview.style.display = "";
}
const saveBtn = h("button", { class: "btn btn-primary", type: "button", onclick: submit }, ad ? "Simpan Perubahan" : "Tambah Iklan");
const modal = openModal({
title: ad ? "Edit Iklan" : "Tambah Iklan",
wide: true,
body: form,
foot: h("div", { class: "modal-foot" },
h("button", { class: "btn btn-ghost", type: "button", onclick: () => modal.close() }, "Batal"),
saveBtn,
),
});
async function submit() {
clearFieldErrors(form);
if (!form.reportValidity()) {
const bad = form.querySelector("[aria-invalid], :invalid");
if (bad) bad.focus();
return;
}
const vals = collect(form);
if (!vals.title) { addFieldError(form, "title", "Judul iklan wajib diisi."); form.elements.title.focus(); return; }
await withBusy(saveBtn, async () => {
try {
if (ad) {
await api.patch(`/ads/manage/${ad.id}/`, vals);
toast("Perubahan disimpan.", "ok");
} else {
await api.post("/ads/manage/", vals);
toast("Iklan ditambahkan.", "ok");
}
modal.close();
reload();
} catch (err) {
applyApiErrors(form, err);
}
});
}
}
function collect(form) {
const out = {};
for (const el of form.querySelectorAll("input, select, textarea")) {
if (!el.name) continue;
if (el.type === "checkbox") { out[el.name] = el.checked; continue; }
const v = el.value.trim();
if (el.type === "number") out[el.name] = v === "" ? 0 : Number(v);
else out[el.name] = v;
}
return out;
}
let _debounce;
function debounce(fn, ms = 400) {
clearTimeout(_debounce);
_debounce = setTimeout(fn, ms);
}
\ No newline at end of file
...@@ -70,7 +70,14 @@ function summary(stats, definitions) { ...@@ -70,7 +70,14 @@ function summary(stats, definitions) {
), ),
), ),
); );
const destinations = [["#/members", "Kelola anggota"], ["#/events", "Kelola event"], ["#/content", "Kelola konten"]]; const destinations = [
["#/members", "Kelola anggota"],
["#/events", "Kelola event"],
["#/content", "Kelola konten"],
["#/finance", "Atur bagi hasil & harga"],
["#/finance", "Atur settlement & payout"],
["#/finance", "Detail laporan keuangan"],
];
const sections = h("div", { class: "dashboard-sections" }, const sections = h("div", { class: "dashboard-sections" },
definitions.map(({ title, items }, index) => h("section", { class: "dashboard-panel" }, definitions.map(({ title, items }, index) => h("section", { class: "dashboard-panel" },
h("h3", {}, title), h("h3", {}, title),
......
...@@ -34,6 +34,38 @@ const SECTIONS = [ ...@@ -34,6 +34,38 @@ const SECTIONS = [
["Banner aktif", "banners_active"], ["Banner aktif", "banners_active"],
], ],
}, },
{
title: "Revenue Sharing",
items: [
["Order lunas", "rs_orders"],
["Nilai tiket (setelah diskon)", "rs_taxable", money],
["Bagian Tim Aplikasi", "rs_app_fee", money],
["Bagian IMI", "rs_imi_fee", money],
["Biaya layanan", "rs_service_fee", money],
["Pajak PB1", "rs_tax", money],
["Total diterima", "rs_total", money],
],
},
{
title: "Settlement",
items: [
["Pembayaran terverifikasi", "st_settled"],
["Nominal settlement", "st_settled_amount", money],
["Menunggu pembayaran", "st_pending"],
["Bagian IMI (siap payout)", "rs_imi_fee", money],
["Bagian Tim Aplikasi (siap payout)", "rs_app_fee", money],
],
},
{
title: "Rekonsiliasi",
items: [
["Order lunas", "rc_orders"],
["Pembayaran terverifikasi", "rc_payments"],
["Order lunas tanpa verifikasi", "rc_orders_unmatched"],
["Pembayaran tanpa order lunas", "rc_payments_unmatched"],
["Selisih nominal", "rc_diff", money],
],
},
]; ];
export function render(mount) { export function render(mount) {
......
This diff is collapsed.
import { api } from "../api.js";
import {
h, field, toast, money, errorCard, withBusy, clearFieldErrors, addFieldError, applyApiErrors,
} from "../ui.js";
const pct = (v) => `${String(Number(v)).replace(".", ",")}%`;
export function render(mount) {
mount.innerHTML = "";
mount.append(h("div", { class: "card card-pad" }, "Memuat…"));
load(mount);
}
async function load(mount) {
let cfg;
try {
cfg = await api.get("/finance/pricing-config/");
} catch (err) {
mount.innerHTML = "";
mount.append(errorCard(err.message, () => load(mount)));
return;
}
mount.innerHTML = "";
mount.append(cardForm(cfg, () => load(mount)));
mount.append(cardCalculator(cfg));
}
function cardForm(cfg, reload) {
const form = h("form", { novalidate: "" },
h("div", { class: "section-title" }, "Aturan Harga & Bagi Hasil"),
h("p", { class: "muted", style: "font-size:12px;margin:0 0 12px" },
"Rumus harga jual = harga tiket + fee % tim aplikasi + fee % tim IMI + pajak (PB1). Fee dihitung dari nilai tiket setelah diskon."),
h("div", { class: "form-grid" },
field("Fee / Bagian Tim Aplikasi (%)", h("input", { type: "number", step: "0.01", min: "0", name: "app_fee_pct", value: cfg.app_fee_pct })),
field("Fee / Bagian IMI (%)", h("input", { type: "number", step: "0.01", min: "0", name: "imi_fee_pct", value: cfg.imi_fee_pct })),
field("Pajak Hiburan PB1 (%)", h("input", { type: "number", step: "0.01", min: "0", name: "tax_rate_pct", value: cfg.tax_rate_pct })),
field("Biaya Layanan (Rp)", h("input", { type: "number", min: "0", name: "service_fee", value: cfg.service_fee })),
),
h("div", { style: "margin-top:12px" },
h("button", { class: "btn btn-primary", type: "button", onclick: () => save() }, "Simpan Aturan"),
),
);
async function save() {
clearFieldErrors(form);
const vals = {};
for (const el of form.elements) {
if (!el.name) continue;
const v = el.value.trim();
if (v === "" || isNaN(Number(v))) {
addFieldError(form, el.name, "Isi dengan angka.");
el.focus();
return;
}
vals[el.name] = Number(v);
}
await withBusy(form.querySelector('[type="button"]'), async () => {
try {
await api.put("/finance/pricing-config/", vals);
toast("Aturan harga disimpan.", "ok");
reload();
} catch (err) {
applyApiErrors(form, err);
}
});
}
return h("div", { class: "card card-pad" }, form);
}
function cardCalculator(cfg) {
const price = h("input", { type: "number", min: "0", value: 100000 });
const discount = h("input", { type: "number", min: "0", value: 0 });
const box = h("tbody", {});
const summary = h("div", { class: "muted", style: "font-size:12px;margin-top:8px" }, "Masukkan harga tiket untuk melihat rincian harga jual.");
function run() {
const p = Math.max(0, Number(price.value) || 0);
if (p <= 0) return;
api.post("/finance/pricing-config/preview/", {
price: p,
discount_amount: Math.max(0, Number(discount.value) || 0),
}).then((d) => {
box.innerHTML = "";
box.append(
row("Harga Tiket", money(d.price)),
d.discount_amount ? row("Diskon", `-${money(d.discount_amount)}`) : null,
row("Nilai Tiket (setelah diskon)", money(d.taxable)),
row(`Fee / Bagian Tim Aplikasi (${pct(d.app_fee_pct)})`, money(d.app_fee)),
row(`Fee / Bagian IMI (${pct(d.imi_fee_pct)})`, money(d.imi_fee)),
row("Biaya Layanan", money(d.service_fee)),
row(`Pajak PB1 (${pct(d.tax_rate_pct)})`, money(d.tax_amount)),
h("tr", {}, h("td", { class: "k", style: "font-weight:700" }, "TOTAL HARGA JUAL"), h("td", { class: "v", style: "font-weight:700" }, money(d.total))),
);
summary.textContent =
`Bagi hasil dari nilai tiket: Tim Aplikasi ${money(d.app_fee)} (${pct(d.app_fee_pct)}), IMI ${money(d.imi_fee)} (${pct(d.imi_fee_pct)}).`;
}).catch((err) => { summary.textContent = err.message; });
}
let timer;
const debounceRun = () => { clearTimeout(timer); timer = setTimeout(run, 250); };
price.addEventListener("input", debounceRun);
discount.addEventListener("input", debounceRun);
run();
return h("div", { class: "card card-pad" },
h("div", { class: "section-title" }, "Kalkulator Harga Jual"),
h("p", { class: "muted", style: "font-size:12px;margin:0 0 12px" },
`Aturan aktif: aplikasi ${pct(cfg.app_fee_pct)}, IMI ${pct(cfg.imi_fee_pct)}, pajak ${pct(cfg.tax_rate_pct)}, biaya layanan ${money(cfg.service_fee)}.`),
h("div", { class: "form-grid" },
field("Harga Tiket (Rp)", price),
field("Diskon (Rp)", discount),
),
h("table", { class: "tbl" }, box),
summary,
);
}
function row(k, v) {
return h("tr", {}, h("td", { class: "k" }, k), h("td", { class: "v", style: "text-align:right" }, v));
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment