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
# Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com
MIDTRANS_IS_PRODUCTION=False
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxGANTIxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxGANTIxxxx
MIDTRANS_MERCHANT_ID=G001234567
#MIDTRANS_SERVER_KEY=Mid-client-5v9oM46baaELmx3r
#MIDTRANS_CLIENT_KEY=Mid-server-t1jR1s6o8SRoeV-qOTBs3Br5
MIDTRANS_SERVER_KEY=Mid-client-w-fAhCG4Qr28GF99
MIDTRANS_CLIENT_KEY=Mid-server-T7dcoKiswH_IEjeUb-DX2A8q
# Biarkan default: auto sandbox/production sesuai MIDTRANS_IS_PRODUCTION
# 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
from apps.common.testing import create_pengcab, make_member, make_staff, member_client
from apps.common.utils import generate_qr_token
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):
......@@ -152,4 +152,55 @@ class StaffTests(APITestCase):
r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["revenue_total"], 50000)
self.assertIn("members_unverified", r.data)
\ No newline at end of file
self.assertIn("members_unverified", r.data)
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 @@
from __future__ import annotations
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 rest_framework import status, viewsets
from rest_framework.decorators import action
......@@ -415,7 +415,7 @@ class DashboardStatsView(APIView):
from apps.content.models import Article, Banner
from apps.events.models import Event
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()
member_qs = Member.objects.exclude(account_type=Member.AccountType.PARTNER)
......@@ -438,6 +438,50 @@ class DashboardStatsView(APIView):
"banners_active": Banner.objects.filter(is_active=True).count(),
"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
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}
......
......@@ -61,6 +61,8 @@ CONTENT_CATEGORIES = [
]
# 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
MAX_TICKETS_PER_ORDER = 4
SERVICE_FEE_TICKET = 5_000 # biaya layanan & asuransi sirkuit (flat)
......
......@@ -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:
return f"Rp {value:,.0f}".replace(",", ".")
......
from django.contrib import admin
from .models import Article, Banner, Category, MediaAsset
from .models import Advertisement, Article, Banner, Category, MediaAsset
@admin.register(Category)
......@@ -28,4 +28,12 @@ class ArticleAdmin(admin.ModelAdmin):
class BannerAdmin(admin.ModelAdmin):
list_display = ["title", "link_type", "link_value", "ordinal", "is_active"]
list_filter = ["link_type", "is_active"]
search_fields = ["title"]
\ No newline at end of file
search_fields = ["title"]
@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):
ordering = ["ordinal", "-created_at"]
def __str__(self):
return self.title or f"Banner #{self.pk}"
\ No newline at end of file
return self.title or f"Banner #{self.pk}"
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 .models import Article, Banner, Category, MediaAsset
from .models import Advertisement, Article, Banner, Category, MediaAsset
class CategorySerializer(serializers.ModelSerializer):
......@@ -46,4 +46,20 @@ class BannerSerializer(serializers.ModelSerializer):
model = Banner
fields = ["id", "title", "image", "link_type", "link_value", "ordinal",
"is_active", "starts_at", "ends_at"]
read_only_fields = ["id"]
\ No newline at end of file
read_only_fields = ["id"]
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
from rest_framework.test import APIClient, APITestCase
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):
......@@ -69,4 +69,72 @@ class ContentTests(APITestCase):
r = self.client.get("/api/v1/articles/")
titles = [a["title"] for a in r.data["results"]]
self.assertIn("Berita Baru", titles)
\ No newline at end of file
self.assertIn("Berita Baru", titles)
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()
# Prefix spesifik didaftarkan lebih dulu (hindari tertelan regex detail articles/{pk}).
router.register("articles/manage", views.ArticleManageViewSet, basename="article-manage")
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("media", views.MediaAssetViewSet, basename="media")
router.register("banners", views.BannerViewSet, basename="banner")
......
......@@ -11,8 +11,9 @@ from rest_framework.response import Response
from apps.common.permissions import ContentStaff
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 (
AdvertisementSerializer,
ArticleSerializer,
BannerSerializer,
CategorySerializer,
......@@ -105,4 +106,22 @@ class BannerViewSet(viewsets.ReadOnlyModelViewSet):
qs = Banner.objects.filter(is_active=True)
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))
return qs
\ No newline at end of file
return qs
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):
"id", "event", "code", "name", "description", "unit_label", "price",
"sort_order", "status", "quota", "low_stock_threshold", "requires_license",
"member_only", "max_per_order", "is_free_child", "sale_start", "sale_end",
"benefits",
"sold_count", "benefits",
]
read_only_fields = ["sold_count"]
......
......@@ -7,9 +7,9 @@ from datetime import timedelta
from django.utils import timezone
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():
......@@ -54,4 +54,100 @@ class EventListVisibilityTests(APITestCase):
self.assertIn(want, slugs)
r2 = client.get("/api/v1/events/", {"status": "draft"})
self.assertEqual(len(r2.data["results"]), 1)
self.assertEqual(r2.data["results"][0]["slug"], "draft-event")
\ No newline at end of file
self.assertEqual(r2.data["results"][0]["slug"], "draft-event")
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):
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [_CONTENT_STAFF]
return [_CONTENT_STAFF()]
def get_queryset(self):
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):
subtotal = 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)
tax_amount = models.PositiveIntegerField(default=0)
total = models.PositiveIntegerField(default=0)
......
......@@ -34,12 +34,15 @@ class OrderSerializer(serializers.ModelSerializer):
subtotal_display = serializers.SerializerMethodField()
discount_display = serializers.SerializerMethodField()
tax_display = serializers.SerializerMethodField()
app_fee_display = serializers.SerializerMethodField()
imi_fee_display = serializers.SerializerMethodField()
class Meta:
model = Order
fields = [
"id", "code", "event", "event_title", "status", "status_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",
"currency", "member_discount_pct", "voucher_discount_amount", "buyer_name",
"buyer_email", "buyer_phone", "payment_method", "expires_at", "paid_at",
......@@ -56,6 +59,12 @@ class OrderSerializer(serializers.ModelSerializer):
def get_tax_display(self, obj):
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):
event_id = serializers.IntegerField()
......
......@@ -14,10 +14,8 @@ from apps.common.constants import (
MAX_TICKETS_PER_ORDER,
MEMBER_DISCOUNT_PERCENT,
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 .models import CheckIn, Order, OrderLine, Payment, Ticket
......@@ -60,6 +58,7 @@ def create_order(user, data: dict) -> Order:
"""
from apps.accounts.models import Member
from apps.events.models import Event, SessionPrice, TicketTier
from apps.finance.models import PricingConfig
event = data.get("event_id")
try:
......@@ -138,11 +137,15 @@ def create_order(user, data: dict) -> Order:
voucher_discount = round(subtotal * voucher.discount_value / 100)
discount_amount += voucher_discount
price = compute_ticket_price(
subtotal=subtotal,
# aturan harga & bagi hasil aktif (fee tim aplikasi, fee IMI, pajak, biaya layanan)
config = PricingConfig.get_default()
price = compute_selling_price(
base=subtotal,
discount_amount=discount_amount,
service_fee=SERVICE_FEE_TICKET,
tax_rate=TAX_RATE_HIBURAN,
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),
)
order = Order.objects.create(
......@@ -153,6 +156,8 @@ def create_order(user, data: dict) -> Order:
status=Order.Status.PENDING,
subtotal=subtotal,
discount_amount=discount_amount,
app_fee_amount=price["app_fee"],
imi_fee_amount=price["imi_fee"],
service_fee=price["service_fee"],
tax_amount=price["tax_amount"],
total=price["total"],
......
......@@ -91,12 +91,15 @@ class CheckoutFlowTests(APITestCase):
r = self._create_order(self.festival, self.session.id, qty=2)
self.assertEqual(r.status_code, 201, 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["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["tax_amount"], 15_300)
self.assertEqual(order["total"], 173_300)
self.assertEqual(order["tax_amount"], 16_500)
self.assertEqual(order["total"], 186_700)
self.assertEqual(order["status"], Order.Status.PENDING)
self.festival.refresh_from_db()
......@@ -105,7 +108,7 @@ class CheckoutFlowTests(APITestCase):
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")
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["snap_token"], "snap-token-abc")
self.assertIn("app.sandbox.midtrans.com", r.data["snap_redirect_url"])
......
......@@ -65,6 +65,7 @@ INSTALLED_APPS = [
"apps.partners",
"apps.content",
"apps.notifications",
"apps.finance",
]
MIDDLEWARE = [
......@@ -176,6 +177,7 @@ SPECTACULAR_SETTINGS = {
{"name": "partners", "description": "Partner, benefit/katalog voucher, poin"},
{"name": "content", "description": "News/artikel, banner, media"},
{"name": "notifications", "description": "Notifikasi pengguna"},
{"name": "finance", "description": "Aturan harga, bagi hasil, laporan keuangan"},
{"name": "dashboard", "description": "Statistik & reporting untuk admin"},
],
"ENUM_NAME_OVERRIDES": {
......
......@@ -13,6 +13,7 @@ api_v1_patterns = [
path("", include("apps.partners.urls")),
path("", include("apps.content.urls")),
path("", include("apps.notifications.urls")),
path("", include("apps.finance.urls")),
]
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 {
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)
static Future<ApiResponse<Map<String, dynamic>>> login({
required String identifier,
......@@ -53,7 +64,7 @@ class ApiService {
}),
);
final data = jsonDecode(utf8.decode(res.bodyBytes));
final data = _decodeJsonSafely(res);
if (res.statusCode == 200) {
if (data['tokens'] != null) {
await StorageService.saveTokens(
......@@ -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)
static Future<ApiResponse<List<dynamic>>> getNotifications() async {
try {
......
......@@ -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 {
List<BenefitModel> _benefits = [];
List<ArticleModel> _articles = [];
List<BannerModel> _banners = [];
List<AdModel> _ads = [];
List<NotificationModel> _notifications = [];
UserModel? get currentUser => _currentUser;
......@@ -34,6 +35,7 @@ class AppState extends ChangeNotifier {
List<BenefitModel> get benefits => _benefits;
List<ArticleModel> get articles => _articles;
List<BannerModel> get banners => _banners;
List<AdModel> get ads => _ads;
List<NotificationModel> get notifications => _notifications;
MembershipCardModel? get primaryCard => _currentUser?.primaryCard;
......@@ -141,6 +143,11 @@ class AppState extends ChangeNotifier {
_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();
}
......
......@@ -28,6 +28,7 @@ class _HomeScreenState extends State<HomeScreen> {
List<EventModel> _events = [];
List<BenefitModel> _benefits = [];
List<ArticleModel> _articles = [];
List<AdModel> _ads = [];
bool _isLoading = true;
// Bottom Ads Carousel
......@@ -53,8 +54,8 @@ class _HomeScreenState extends State<HomeScreen> {
void _startAdAutoScroll() {
_adTimer?.cancel();
_adTimer = Timer.periodic(const Duration(seconds: 4), (_) {
if (!_adPageController.hasClients) return;
final nextIndex = (_currentAdIndex + 1) % 3; // 3 ads: asuransi, oli, merch
if (!_adPageController.hasClients || _ads.length < 2) return;
final nextIndex = (_currentAdIndex + 1) % _ads.length;
_adPageController.animateToPage(
nextIndex,
duration: const Duration(milliseconds: 600),
......@@ -90,6 +91,12 @@ class _HomeScreenState extends State<HomeScreen> {
_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) {
setState(() => _isLoading = false);
}
......@@ -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) {
// 3 Real Ads: Asuransi (BPJS Ketenagakerjaan JKK Motorsport), Oli (Motul 300V Factory Line), Merch (Juaraga Official IMI)
final ads = [
{
'category': 'PROTEKSI ATLET BALAP',
'badgeColor': const Color(0xFF10B981), // Emerald
'title': 'BPJS Ketenagakerjaan Atlet & Pembalap',
'subtitle': 'Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.',
'image': 'assets/images/ad_bpjs_banner.jpg',
'fit': BoxFit.cover,
'actionText': 'Pelajari Program',
'url': 'https://www.bpjsketenagakerjaan.go.id',
'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 RACING OIL',
'badgeColor': const Color(0xFFE11D48), // Motul Crimson
'title': 'Motul 300V Factory Line Road Racing',
'subtitle': 'Pelumas Ester Core® 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.',
'image': 'assets/images/ad_motul_300v.png',
'fit': BoxFit.contain,
'actionText': 'Beli di Official Store',
'url': 'https://www.motul.com/id/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).',
},
{
'category': 'OFFICIAL MERCHANDISE',
'badgeColor': AppColors.circuitGold, // Gold
'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',
'fit': BoxFit.contain,
'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.',
},
];
List<Map<String, dynamic>> ads;
if (_ads.isNotEmpty) {
ads = _ads.map((a) => <String, dynamic>{
'category': a.category,
'badgeColor': _badgeColor(a.badgeColor),
'title': a.title,
'subtitle': a.subtitle,
'actionText': a.actionText,
'url': a.url,
'details': a.details,
'image': a.imageUrl,
}).toList();
} else {
ads = [
{
'category': 'PROTEKSI ATLET BALAP',
'badgeColor': const Color(0xFF10B981),
'title': 'BPJS Ketenagakerjaan Atlet & Pembalap',
'subtitle': 'Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.',
'image': 'assets/images/ad_bpjs_banner.jpg',
'actionText': 'Pelajari Program',
'url': 'https://www.bpjsketenagakerjaan.go.id',
'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 RACING OIL',
'badgeColor': const Color(0xFFE11D48),
'title': 'Motul 300V Factory Line Road Racing',
'subtitle': 'Pelumas Ester Core® 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.',
'image': 'assets/images/ad_motul_300v.png',
'actionText': 'Beli di Official Store',
'url': 'https://www.motul.com/id/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).',
},
{
'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(
crossAxisAlignment: CrossAxisAlignment.start,
......@@ -1028,12 +1089,9 @@ class _HomeScreenState extends State<HomeScreen> {
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
ad['image'] as String,
fit: ad['fit'] as BoxFit,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.shopping_bag, size: 36, color: badgeColor),
),
child: _buildAdImageWidget(
ad['image'] as String? ?? '',
badgeColor,
),
),
),
......@@ -1118,8 +1176,10 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () async {
final url = (ad['url'] as String).trim();
if (url.isEmpty) return;
Navigator.pop(ctx);
final uri = Uri.parse(ad['url'] as String);
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
......
This diff is collapsed.
......@@ -4,8 +4,10 @@ import * as dashboard from "./pages/dashboard.js";
import * as members from "./pages/members.js";
import * as events from "./pages/events.js";
import * as content from "./pages/content.js";
import * as ads from "./pages/ads.js";
import * as partners from "./pages/partners.js";
import * as checkins from "./pages/checkins.js";
import * as finance from "./pages/finance.js";
import * as roles from "./pages/roles.js";
const ROLE_LABEL = Object.fromEntries(ROLES.map((r) => [r.value, r.label]));
......@@ -15,8 +17,10 @@ const PAGES = [
{ key: "members", title: "Member Management", icon: "◉", page: members },
{ key: "events", title: "Event Management", icon: "❖", page: events },
{ 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: "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 },
];
......
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) {
),
),
);
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" },
definitions.map(({ title, items }, index) => h("section", { class: "dashboard-panel" },
h("h3", {}, title),
......
......@@ -34,6 +34,38 @@ const SECTIONS = [
["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) {
......
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