Commit 5aa9fc30 authored by gustixjerk's avatar gustixjerk

feat(spklu,mobile,backend): implement SPKLU nearby feature with real OSM...

feat(spklu,mobile,backend): implement SPKLU nearby feature with real OSM dataset, rigorous E2E UI verification, and audit remediation
parent 04e8dd43
......@@ -37,4 +37,18 @@ MIDTRANS_IS_PRODUCTION=False
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
# MIDTRANS_SNAP_BASE=https://app.sandbox.midtrans.com
# SPKLU Terdekat — impor OSM (Overpass) + data manual. Google Places tidak dipakai.
# Impor dijalankan oleh command/penjadwal backend, bukan oleh request pengguna.
SPKLU_OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter
SPKLU_OSM_DEFAULT_BBOX=-7.90,105.00,-5.85,108.90
SPKLU_OSM_TIMEOUT_SECONDS=60
SPKLU_OSM_MAX_RETRIES=2
SPKLU_OSM_BACKOFF_SECONDS=2
SPKLU_OSM_MAX_ELEMENTS=5000
SPKLU_OSM_USER_AGENT=IMI-Jabar-SPKLU/1.0 (+https://imijabar.or.id; data import)
SPKLU_DATASET_STALE_DAYS=30
SPKLU_MIN_INTERVAL_SECONDS=2
SPKLU_THROTTLE_USER=30/min
SPKLU_THROTTLE_IP=120/min
\ No newline at end of file
......@@ -4,6 +4,8 @@ from __future__ import annotations
from rest_framework.test import APIClient, APITestCase
from django.utils import timezone
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
......@@ -224,3 +226,87 @@ class StaffTests(APITestCase):
self.assertEqual(r.data["rc_orders_unmatched"], 1)
self.assertEqual(r.data["rc_payments_unmatched"], 0)
self.assertEqual(r.data["rc_diff"], 60000)
def test_dashboard_partner_count_terpisah_dari_benefit(self):
"""Dashboard Partner (P2): partners_total menghitung Partner, bukan Benefit."""
from apps.partners.models import Benefit, BenefitCategory, Partner
staff = make_staff("+6281200000077")
client = member_client("+6281200000077")
p_active = Partner.objects.create(name="Mitra Aktif", slug="mitra-aktif", status=Partner.Status.ACTIVE)
Partner.objects.create(name="Mitra Draft", slug="mitra-draft", status=Partner.Status.DRAFT)
cat = BenefitCategory.objects.create(code="workshop", name="Workshop")
Benefit.objects.create(
partner=p_active, category=cat, title="Diskon", original_price=100, sale_price=50,
discount_type=Benefit.DiscountType.NOMINAL, discount_value=10,
status=Benefit.Status.PUBLISHED, is_active=True,
)
Benefit.objects.create(
partner=p_active, category=cat, title="Nonaktif", original_price=100, sale_price=50,
discount_type=Benefit.DiscountType.NOMINAL, discount_value=10,
status=Benefit.Status.PUBLISHED, is_active=False,
)
r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["partners_total"], 2, "jumlah Partner, bukan Benefit")
self.assertEqual(r.data["partners_active"], 1)
self.assertEqual(r.data["benefits_total"], 2)
self.assertEqual(r.data["benefits_active"], 1)
class DashboardActivityTests(APITestCase):
"""Feed aktivitas platform (P2 #5): hanya staff, gabungan lintas domain."""
def test_dashboard_activity_hanya_staff(self):
make_staff("+6281200000050")
r = member_client("+6281200000050").get("/api/v1/dashboard/activity")
self.assertEqual(r.status_code, 200)
self.assertIn("items", r.data)
self.assertIn("generated_at", r.data)
make_member("+6281400000051", "Biasa")
r2 = member_client("+6281400000051").get("/api/v1/dashboard/activity")
self.assertEqual(r2.status_code, 403)
anon = APIClient().get("/api/v1/dashboard/activity")
self.assertIn(anon.status_code, (401, 403))
def test_dashboard_activity_menggabungkan_sumber_dan_urut(self):
from apps.content.models import Article
make_staff("+6281200000052")
client = member_client("+6281200000052")
user, member = make_member("+6281400000053", "Peserta Aktivitas")
cat = EventCategory.objects.create(code="act", name="Road Race")
ev = Event.objects.create(
title="GP Aktivitas",
slug="gp-aktivitas",
category=cat,
status=Event.Status.PUBLISHED,
start_date="2026-12-01",
end_date="2026-12-02",
)
order = Order.objects.create(
code="IMI-ACT-0001", user=user, event=ev, status="paid",
subtotal=100000, discount_amount=0, total=100000, buyer_name="Peserta",
)
Article.objects.create(
title="Artikel Aktivitas", slug="artikel-aktivitas",
status=Article.Status.PUBLISHED, published_at=timezone.now(),
)
r = client.get("/api/v1/dashboard/activity")
self.assertEqual(r.status_code, 200)
types = {i["type"] for i in r.data["items"]}
self.assertIn("member", types)
self.assertIn("order", types)
self.assertIn("article", types)
# urut menurun berdasarkan waktu
times = [i["at"] for i in r.data["items"]]
self.assertEqual(times, sorted(times, reverse=True))
limited = client.get("/api/v1/dashboard/activity?limit=1")
self.assertEqual(limited.status_code, 200)
self.assertEqual(len(limited.data["items"]), 1)
self.assertEqual(limited.data["count"], 1)
......@@ -24,5 +24,6 @@ urlpatterns = [
path("me", views.MeView.as_view(), name="me"),
path("me/notification-prefs", views.NotificationPrefsView.as_view(), name="me-notif-prefs"),
path("dashboard/stats", views.DashboardStatsView.as_view(), name="dashboard-stats"),
path("dashboard/activity", views.DashboardActivityView.as_view(), name="dashboard-activity"),
path("", include(router.urls)),
]
\ No newline at end of file
......@@ -59,6 +59,7 @@ class ApiRootView(APIView):
"partners": "/api/v1/partners|partners/benefits|wallet|redeem/redeem|redemptions",
"content": "/api/v1/articles|articles/manage|categories|media|banners",
"notifications": "/api/v1/notifications|notification-preferences",
"spklu": "/api/v1/spklu/nearby",
"stats": "/api/v1/dashboard/stats (staff)",
},
}
......@@ -419,7 +420,7 @@ class DashboardStatsView(APIView):
def get(self, request):
from apps.content.models import Article, Banner
from apps.events.models import Event
from apps.partners.models import Benefit
from apps.partners.models import Benefit, Partner
from apps.ticketing.models import CheckIn, Order, Payment, Ticket
now = timezone.now()
......@@ -436,8 +437,10 @@ class DashboardStatsView(APIView):
"tickets_sold": Ticket.objects.exclude(status=Ticket.Status.CANCELLED).count(),
"tickets_checked_in": CheckIn.objects.filter(status=CheckIn.Status.SUCCESS).count(),
"revenue_total": Order.objects.filter(status=Order.Status.PAID).aggregate(t=Sum("total"))["t"] or 0,
"partners_total": Benefit.objects.filter(is_active=True).count(),
"partners_total": Partner.objects.count(),
"partners_active": Partner.objects.filter(status=Partner.Status.ACTIVE).count(),
"benefits_total": Benefit.objects.count(),
"benefits_active": Benefit.objects.filter(is_active=True).count(),
"articles_total": Article.objects.count(),
"articles_published": Article.objects.filter(status=Article.Status.PUBLISHED).count(),
"banners_active": Banner.objects.filter(is_active=True).count(),
......@@ -490,4 +493,115 @@ class DashboardStatsView(APIView):
# 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}
return Response(stats)
\ No newline at end of file
return Response(stats)
class DashboardActivityView(APIView):
"""Aktivitas platform terbaru (Web Admin) — feed lintas domain.
Menggabungkan peristiwa terkini dari domain member, ticketing,
kemitraan/redemption, dan konten menjadi satu feed kronologis.
"""
permission_classes = [AnyStaff]
def get(self, request):
from apps.content.models import Article
from apps.partners.models import Redemption
from apps.ticketing.models import CheckIn, Order
try:
limit = int(request.query_params.get("limit", 20))
except (TypeError, ValueError):
limit = 20
limit = max(1, min(limit, 50))
per_source = limit
items = []
def add(kind, label, detail, at, ref="", status=""):
if at is None:
return
items.append(
{
"type": kind,
"label": label,
"detail": detail,
"at": at,
"ref": ref,
"status": status,
}
)
for m in Member.objects.order_by("-created_at").select_related("pengcab")[:per_source]:
add(
"member",
"Pendaftaran member",
f"{m.full_name} ({m.member_number})" if m.member_number else m.full_name,
m.created_at,
ref=m.member_number or "",
status=m.status,
)
paid_orders = (
Order.objects.filter(status=Order.Status.PAID)
.select_related("event")
.order_by("-paid_at")[:per_source]
)
for o in paid_orders:
add(
"order",
"Pesanan dibayar",
f"{o.code} — {o.event.title if o.event_id else '-'}",
o.paid_at or o.created_at,
ref=o.code,
status=o.status,
)
checkins = (
CheckIn.objects.filter(status=CheckIn.Status.SUCCESS)
.select_related("ticket")
.order_by("-created_at")[:per_source]
)
for c in checkins:
add(
"checkin",
"Check-in tiket",
f"Gate {c.gate or '-'} — {c.ticket.code if c.ticket_id else '-'}",
c.created_at,
ref=c.ticket.code if c.ticket_id else "",
status=c.status,
)
redemptions = (
Redemption.objects.select_related("outlet", "member_voucher")
.order_by("-redeemed_at")[:per_source]
)
for r in redemptions:
outlet_name = r.outlet.name if r.outlet_id else "-"
add(
"redemption",
"Redemption voucher",
f"Outlet {outlet_name}",
r.redeemed_at,
ref=str(r.id),
status=r.status,
)
articles = (
Article.objects.filter(status=Article.Status.PUBLISHED)
.order_by("-published_at")[:per_source]
)
for a in articles:
add(
"article",
"Artikel dipublikasikan",
a.title,
a.published_at or a.created_at,
ref=a.slug,
status=a.status,
)
items.sort(key=lambda i: i["at"], reverse=True)
items = items[:limit]
return Response({"generated_at": timezone.now(), "count": len(items), "items": items})
\ No newline at end of file
"""Kontrak jendela waktu bisnis (N05).
Satu sumber kebenaran untuk batas tanggal event pada timezone bisnis
(settings.TIME_ZONE, default Asia/Jakarta). Semua perbandingan "hari ini"
untuk tiket/check-in memakai timezone lokal bisnis, bukan UTC.
Aturan domain:
- `start_date`/`end_date` adalah DateField (date-only).
- Event sah sampai AKHIR hari `end_date` (23:59:59.999999) pada timezone bisnis.
Date-only TIDAK kedaluwarsa pada awal hari terakhir.
- Payload menyediakan `valid_from`/`valid_until` (ISO 8601 dengan offset) dan
`is_open_now`/`phase` agar klien tidak perlu menghitung sendiri.
"""
from __future__ import annotations
from datetime import datetime, time
from django.conf import settings
from django.utils import timezone
try: # Python 3.9+
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover
ZoneInfo = None
def business_tzinfo():
name = getattr(settings, "TIME_ZONE", None) or "Asia/Jakarta"
if ZoneInfo is not None:
try:
return ZoneInfo(name)
except Exception:
pass
return timezone.get_current_timezone()
def business_now() -> datetime:
"""`now` dalam timezone bisnis (aware)."""
return timezone.now().astimezone(business_tzinfo())
def business_today():
"""Tanggal hari ini pada timezone bisnis (bukan UTC)."""
return business_now().date()
def _start_of_day(d, tz):
return datetime.combine(d, time.min, tzinfo=tz)
def _end_of_day(d, tz):
return datetime.combine(d, time.max, tzinfo=tz)
def event_valid_from(event) -> datetime:
return _start_of_day(event.start_date, business_tzinfo())
def event_valid_until(event) -> datetime:
"""Akhir hari terakhir event (inklusif) pada timezone bisnis."""
return _end_of_day(event.end_date, business_tzinfo())
def event_has_ended(event, now: datetime | None = None) -> bool:
now = now or business_now()
return now > event_valid_until(event)
def event_entry_window(event, now: datetime | None = None) -> dict:
"""Representasi tunggal jendela waktu event untuk klien.
`is_open_now` = event belum lewat; batas akhir inklusif pada hari terakhir.
"""
tz = business_tzinfo()
now = (now or business_now())
if timezone.is_naive(now):
now = timezone.make_aware(now, tz)
valid_from = event_valid_from(event)
valid_until = event_valid_until(event)
if now < valid_from:
phase = "upcoming"
elif now <= valid_until:
phase = "open"
else:
phase = "past"
return {
"timezone": getattr(settings, "TIME_ZONE", "Asia/Jakarta"),
"start_date": event.start_date.isoformat(),
"end_date": event.end_date.isoformat(),
"valid_from": valid_from.isoformat(),
"valid_until": valid_until.isoformat(),
"is_open_now": phase == "open",
"is_expired": phase == "past",
"phase": phase,
}
......@@ -18,9 +18,9 @@ class EventQueryset(models.QuerySet):
)
def upcoming(self):
from django.utils import timezone
from apps.common.timewindow import business_today
return self.filter(end_date__gte=timezone.now().date())
return self.filter(end_date__gte=business_today())
def by_category(self, code: str):
return self.filter(category__code=code) if code else self
......
......@@ -5,6 +5,7 @@ from __future__ import annotations
from rest_framework import serializers
from apps.common.utils import build_slug, humanize_rupiah
from apps.common.timewindow import event_entry_window
from .models import Circuit, Event, EventCategory, EventSession, SessionPrice, TicketTier, TierBenefit
......@@ -65,17 +66,21 @@ class EventListSerializer(serializers.ModelSerializer):
circuit = CircuitSerializer(read_only=True)
min_ticket_price = serializers.IntegerField(read_only=True)
min_ticket_price_display = serializers.SerializerMethodField()
entry_window = serializers.SerializerMethodField()
class Meta:
model = Event
fields = [
"id", "title", "slug", "category", "series", "series_order", "short_description",
"poster", "status", "start_date", "end_date", "circuit", "venue_name",
"poster", "status", "start_date", "end_date", "entry_window", "circuit", "venue_name",
"location_address", "featured", "is_official", "min_ticket_price",
"min_ticket_price_display",
]
read_only_fields = fields
def get_entry_window(self, obj):
return event_entry_window(obj)
def get_min_ticket_price_display(self, obj):
return humanize_rupiah(obj.min_ticket_price or 0)
......
......@@ -7,7 +7,8 @@ from datetime import timedelta
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
from apps.common.testing import make_member, make_staff, member_client
from apps.common.testing import make_member, make_staff, make_user, member_client
from apps.ticketing.models import Order, Ticket
from .models import Event, EventCategory, TicketTier
......@@ -150,4 +151,71 @@ class EventAdminWriteTests(APITestCase):
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
self.assertEqual(e.venue_name, "")
class EventParticipantsMonitoringTests(APITestCase):
"""#6: endpoint monitoring peserta per event (staff) + guard RBAC."""
def setUp(self):
self.cat = make_category()
self.event = make_event(self.cat, "monitor-event", Event.Status.PUBLISHED)
self.tier = TicketTier.objects.create(
event=self.event, code="fest", name="Festival", price=50000, quota=100,
)
self.member_user, _ = make_member("+6281500000011", "Peserta Satu")
self.order = Order.objects.create(
code="ORD-MON-1", user=self.member_user, event=self.event,
status=Order.Status.PAID, subtotal=100000, total=100000,
)
Ticket.objects.create(
code="TKT-MON-1", order=self.order, tier=self.tier, event=self.event,
holder_name="Peserta Satu", status=Ticket.Status.READY, qr_token="VQ-MON-1",
)
Ticket.objects.create(
code="TKT-MON-2", order=self.order, tier=self.tier, event=self.event,
holder_name="Peserta Dua", status=Ticket.Status.CHECKED_IN,
checked_in_at=timezone.now(), check_in_gate="GATE-A", qr_token="VQ-MON-2",
)
self.staff_client = member_client(make_user("+628177799005", "Event User", role="event").phone)
def test_staff_dapat_lihat_peserta_dan_ringkasan(self):
r = self.staff_client.get(f"/api/v1/events/{self.event.slug}/participants/")
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(r.data["summary"]["total"], 2)
self.assertEqual(r.data["summary"]["checked_in"], 1)
self.assertEqual(r.data["summary"]["ready"], 1)
names = [x["holder_name"] for x in r.data["results"]]
self.assertIn("Peserta Satu", names)
self.assertIn("Peserta Dua", names)
checked = next(x for x in r.data["results"] if x["holder_name"] == "Peserta Dua")
self.assertTrue(checked["checked_in"])
self.assertEqual(checked["gate"], "GATE-A")
self.assertEqual(checked["order_code"], "ORD-MON-1")
def test_filter_status_dan_pencarian(self):
r = self.staff_client.get(
f"/api/v1/events/{self.event.slug}/participants/", {"status": "checked_in"}
)
self.assertEqual(len(r.data["results"]), 1)
self.assertEqual(r.data["results"][0]["holder_name"], "Peserta Dua")
r2 = self.staff_client.get(
f"/api/v1/events/{self.event.slug}/participants/", {"search": "Satu"}
)
self.assertEqual(len(r2.data["results"]), 1)
self.assertEqual(r2.data["results"][0]["holder_name"], "Peserta Satu")
def test_member_dan_anon_ditolak(self):
r = member_client("+6281500000011").get(f"/api/v1/events/{self.event.slug}/participants/")
self.assertIn(r.status_code, (401, 403))
r2 = APIClient().get(f"/api/v1/events/{self.event.slug}/participants/")
self.assertIn(r2.status_code, (401, 403))
def test_role_content_ditolak(self):
content = make_user("+628177799001", "Content User", role="content")
r = member_client(content.phone).get(f"/api/v1/events/{self.event.slug}/participants/")
self.assertEqual(r.status_code, 403)
def test_admin_dapat_lihat(self):
admin = make_staff("+6281200000007")
r = member_client(admin.phone).get(f"/api/v1/events/{self.event.slug}/participants/")
self.assertEqual(r.status_code, 200, r.data)
......@@ -2,12 +2,15 @@
from __future__ import annotations
from django.db.models import Count, Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.common.permissions import IsAdminRole, role_allowed
from apps.common.permissions import EventStaff, IsAdminRole, role_allowed
from .models import Circuit, Event, EventCategory, TicketTier
from .serializers import (
......@@ -39,6 +42,8 @@ class EventViewSet(viewsets.ModelViewSet):
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
if self.action == "participants":
return [EventStaff()]
return [_CONTENT_STAFF()]
def get_serializer_class(self):
......@@ -46,6 +51,13 @@ class EventViewSet(viewsets.ModelViewSet):
return EventListSerializer
return AdminEventWriteSerializer
def filter_queryset(self, queryset):
# Untuk aksi participants, query param (status/search) adalah filter
# peserta, bukan filter Event — jangan diterapkan ke lookup Event.
if self.action == "participants":
return queryset
return super().filter_queryset(queryset)
_PUBLIC_STATUSES = [
"published", "registration_open", "early_bird", "low_stock", "sold_out", "ongoing",
]
......@@ -55,7 +67,7 @@ class EventViewSet(viewsets.ModelViewSet):
"tiers", "tiers__benefits", "sessions"
)
status = self.request.query_params.get("status")
if status:
if status and self.action != "participants":
qs = qs.filter(status=status)
if self.action in ("list", "retrieve"):
user = self.request.user
......@@ -77,6 +89,56 @@ class EventViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
@action(detail=True, methods=["get"], permission_classes=[EventStaff], url_path="participants")
def participants(self, request, slug=None):
"""Monitoring peserta per event (staff): daftar tiket + status check-in."""
from apps.ticketing.models import Ticket
event = self.get_object()
all_tickets = Ticket.objects.filter(event=event)
counts = dict(all_tickets.values_list("status").annotate(n=Count("id")))
summary = {
"total": all_tickets.count(),
"checked_in": counts.get(Ticket.Status.CHECKED_IN, 0) + counts.get(Ticket.Status.ATTENDED, 0),
"ready": counts.get(Ticket.Status.READY, 0) + counts.get(Ticket.Status.VALIDATED, 0),
"cancelled": counts.get(Ticket.Status.CANCELLED, 0),
"expired": counts.get(Ticket.Status.EXPIRED, 0),
}
qs = all_tickets.select_related("tier", "order", "order__user").order_by("created_at")
status = request.query_params.get("status")
if status:
qs = qs.filter(status=status)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(
Q(holder_name__icontains=search) | Q(code__icontains=search) | Q(holder_kta__icontains=search)
)
paginator = PageNumberPagination()
page = paginator.paginate_queryset(qs, request, view=self)
results = [
{
"code": t.code,
"holder_name": t.holder_name,
"holder_kta": t.holder_kta,
"tier": t.tier.name if t.tier_id else None,
"status": t.status,
"status_display": t.get_status_display(),
"checked_in": t.status in (Ticket.Status.CHECKED_IN, Ticket.Status.ATTENDED),
"checked_in_at": t.checked_in_at,
"gate": t.check_in_gate or t.gate,
"order_code": t.order.code if t.order_id else None,
"order_status": t.order.status if t.order_id else None,
"buyer": t.order.user.full_name if (t.order_id and t.order.user_id) else None,
}
for t in page
]
response = paginator.get_paginated_response(results)
response.data["event"] = {"slug": event.slug, "title": event.title}
response.data["summary"] = summary
return response
class EventCategoryViewSet(viewsets.ReadOnlyModelViewSet):
"""Kategori event (untuk form admin)."""
......
......@@ -818,3 +818,90 @@ class ProbeRouteParityTests(FinanceTestBase):
):
with self.subTest(path=path):
self.assertNotEqual(self.client.get("/api/v1/" + path).status_code, 404, path)
# ================================================================ cap / residual / over-basis
class RevenueAllocationCapResidualTests(FinanceTestBase):
"""P2 Rounding allocation reconcile: cap (min/max), residual remainder, dan tolak over-basis."""
def _activate(self, participants, allow_over_basis=False):
model = RevenueSharingModel.objects.create(
name="Cap/Residual " + str(len(participants)),
transaction_type=RevenueSharingModel.TransactionType.EVENT_TICKET,
calculation_basis=RevenueSharingModel.Basis.GROSS,
status=RevenueSharingModel.Status.DRAFT,
current_version=0,
)
version = RevenueSharingService.create_version(
model, participants=participants, reason="cap test", user=self.staff,
allow_over_basis=allow_over_basis,
)
RevenueSharingService.approve(model, version, self.staff, reason="a")
RevenueSharingService.activate(model, version, self.staff)
return model, version
def _order(self, total=100_000):
user, _ = make_member("+6281555500099")
return Order.objects.create(
code="IMI-ALLOC-%d" % Order.objects.count(),
user=user, event=self.event, status=Order.Status.PAID,
subtotal=total, discount_amount=0, service_fee=0, tax_amount=0, total=total,
)
def test_max_amount_caps_allocation(self):
_, version = self._activate([
{"party": self.imi.pk, "share_type": "fixed", "fixed_amount": 5000, "max_amount": 1000},
])
allocs = RevenueSharingService.allocate(
self._order(), version, self.staff, basis={"basis": 100_000, "net": 100_000}
)
self.assertEqual(allocs[0].amount, 1000, "max_amount membatasi alokasi fixed.")
def test_min_amount_floors_allocation(self):
_, version = self._activate([
{"party": self.imi.pk, "share_type": "fixed", "fixed_amount": 500, "min_amount": 2000},
])
allocs = RevenueSharingService.allocate(
self._order(), version, self.staff, basis={"basis": 100_000, "net": 100_000}
)
self.assertEqual(allocs[0].amount, 2000, "min_amount menaikkan alokasi ke batas bawah.")
def test_residual_receives_remainder(self):
_, version = self._activate([
{"party": self.imi.pk, "share_type": "percentage", "percentage": 10, "calculation_base": "gross"},
{"party": self.platform.pk, "share_type": "percentage", "percentage": 0, "is_residual": True},
])
allocs = RevenueSharingService.allocate(
self._order(), version, self.staff, basis={"basis": 100_000, "net": 100_000}
)
by_party = {a.party_id: a for a in allocs}
imi_alloc = by_party[self.imi.pk]
residual = by_party[self.platform.pk]
basis = imi_alloc.basis_amount
self.assertEqual(imi_alloc.amount, round(basis * 10 / 100))
self.assertTrue(residual.is_residual)
self.assertEqual(residual.amount, basis - imi_alloc.amount, "residual = basis - jumlah non-residual.")
self.assertEqual(imi_alloc.amount + residual.amount, basis, "Σ alokasi == basis (tanpa rounding hilang).")
def test_over_basis_fixed_rejected(self):
from rest_framework.exceptions import ValidationError
_, version = self._activate([
{"party": self.imi.pk, "share_type": "fixed", "fixed_amount": 60_000},
{"party": self.platform.pk, "share_type": "fixed", "fixed_amount": 60_000},
])
with self.assertRaises(ValidationError):
RevenueSharingService.allocate(
self._order(), version, self.staff, basis={"basis": 100_000, "net": 100_000}
)
self.assertEqual(RevenueAllocation.objects.count(), 0, "alokasi over-basis tidak ditulis (atomic).")
def test_over_basis_allowed_when_flagged(self):
_, version = self._activate([
{"party": self.imi.pk, "share_type": "fixed", "fixed_amount": 60_000},
{"party": self.platform.pk, "share_type": "fixed", "fixed_amount": 60_000},
], allow_over_basis=True)
allocs = RevenueSharingService.allocate(
self._order(), version, self.staff, basis={"basis": 100_000, "net": 100_000}
)
self.assertEqual(sum(a.amount for a in allocs), 120_000, "allow_over_basis mengizinkan total > basis.")
......@@ -92,6 +92,25 @@ class BenefitSerializer(serializers.ModelSerializer):
def get_price(self, obj):
return obj.price
def create(self, validated_data):
# Stok tersisa diturunkan dari stok total saat benefit baru dibuat,
# supaya benefit buatan operator (Web Admin) langsung bisa diklaim.
if not validated_data.get("is_unlimited", False):
validated_data.setdefault("stock_remaining", validated_data.get("stock_total", 0))
return super().create(validated_data)
def update(self, instance, validated_data):
old_total = instance.stock_total
new_total = validated_data.get("stock_total", old_total)
is_unlimited = validated_data.get("is_unlimited", instance.is_unlimited)
obj = super().update(instance, validated_data)
# Sesuaikan stok tersisa dengan selisih stok total (restock/pengurangan),
# tanpa menurunkan stok yang sudah terjual.
if not is_unlimited and new_total != old_total:
obj.stock_remaining = max(0, min(new_total, obj.stock_remaining + (new_total - old_total)))
obj.save(update_fields=["stock_remaining", "updated_at"])
return obj
class MemberVoucherSerializer(serializers.ModelSerializer):
benefit_title = serializers.CharField(source="benefit.title", read_only=True)
......
......@@ -2,10 +2,13 @@
from __future__ import annotations
from rest_framework.test import APITestCase
from datetime import timedelta
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
from apps.common.constants import POINTS_CLAIM_BENEFIT
from apps.common.testing import create_pengcab, make_member, make_staff, member_client
from apps.common.testing import create_pengcab, make_member, make_staff, make_user, member_client
from apps.partners.models import Benefit, BenefitCategory, MemberVoucher, Partner, Redemption
......@@ -73,6 +76,22 @@ class WalletRedemptionTests(APITestCase):
voucher = MemberVoucher.objects.get(pk=r.data["member_voucher"])
self.assertEqual(voucher.status, MemberVoucher.Status.USED)
def test_redeem_voucher_kedaluwarsa_ditolak(self):
r = self._claim(self.benefit.id)
self.assertEqual(r.status_code, 201, r.data)
voucher = MemberVoucher.objects.get(code=r.data["code"])
voucher.expires_at = timezone.now() - timedelta(days=1)
voucher.save(update_fields=["expires_at"])
r = self.staff_client.post(
"/api/v1/redeem/redeem/",
{"scan_code": r.data["qr_token"]},
format="json",
)
self.assertEqual(r.data["status"], Redemption.Status.REJECTED)
voucher.refresh_from_db()
self.assertEqual(voucher.status, MemberVoucher.Status.EXPIRED)
def test_redeem_duplikat_lalu_kode_tak_dikenal(self):
r = self._claim(self.benefit.id)
qr = r.data["qr_token"]
......@@ -102,6 +121,104 @@ class WalletRedemptionTests(APITestCase):
self.assertIn(r2.status_code, (401, 403))
class RedemptionRoleGuardTests(APITestCase):
"""#10: redemption terbatas untuk PartnershipStaff (partnership/admin/super_admin)."""
def setUp(self):
_partner_fixtures()
_, self.member = make_member("+6281500000099", "Member Voucher")
self.member_client = member_client("+6281500000099")
def _claim(self):
benefit = Benefit.objects.first()
r = self.member_client.post(
"/api/v1/wallet/claim/", {"benefit_id": benefit.id}, format="json"
)
return r.data["qr_token"]
def test_partnership_staff_dapat_redeem(self):
qr = self._claim()
make_user("+6281300000001", "Partner Staff", role="partnership")
r = member_client("+6281300000001").post(
"/api/v1/redeem/redeem/", {"scan_code": qr}, format="json"
)
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(r.data["status"], Redemption.Status.SUCCESS)
def test_role_lain_tidak_dapat_redeem(self):
qr = self._claim()
make_user("+6281300000002", "Event Staff", role="event")
r = member_client("+6281300000002").post(
"/api/v1/redeem/redeem/", {"scan_code": qr}, format="json"
)
self.assertEqual(r.status_code, 403)
def test_role_lain_tidak_dapat_lihat_riwayat(self):
make_user("+6281300000003", "Content Staff", role="content")
r = member_client("+6281300000003").get("/api/v1/redemptions/")
self.assertEqual(r.status_code, 403)
def test_partnership_staff_dapat_lihat_riwayat(self):
make_user("+6281300000004", "Partner Staff 2", role="partnership")
r = member_client("+6281300000004").get("/api/v1/redemptions/")
self.assertEqual(r.status_code, 200)
class BenefitStockInitTests(APITestCase):
"""Benefit buatan admin harus punya stok tersisa dari stok total (bisa diklaim)."""
def setUp(self):
self.client = APIClient()
self.staff = make_staff("+6281200000060")
self.client.force_authenticate(self.staff)
self.category = BenefitCategory.objects.create(code="init", name="Inisialisasi")
def _payload(self, partner):
return {
"title": "Benefit Stok",
"partner_id": partner.id,
"category_id": self.category.id,
"original_price": 100000,
"discount_type": "nominal",
"discount_value": 10000,
"usable_days": ["mon"],
"stock_total": 12,
"validity_days": 30,
"is_unlimited": False,
"is_transferable": True,
"requires_kta": True,
"member_only": True,
"is_active": True,
"redemption_type": "voucher",
"status": "published",
}
def test_create_menurunkan_stok_tersisa_dari_total(self):
partner = Partner.objects.create(name="Mitra Init", slug="mitra-init")
r = self.client.post("/api/v1/partners/benefits/admin/", self._payload(partner), format="json")
self.assertEqual(r.status_code, 201, r.data)
benefit = Benefit.objects.get(pk=r.data["id"])
self.assertEqual(benefit.stock_total, 12)
self.assertEqual(benefit.stock_remaining, 12)
def test_edit_menyesuaikan_stok_tersisa(self):
partner = Partner.objects.create(name="Mitra Init 2", slug="mitra-init-2")
r = self.client.post("/api/v1/partners/benefits/admin/", self._payload(partner), format="json")
bid = r.data["id"]
# jual 2 voucher
benefit = Benefit.objects.get(pk=bid)
benefit.stock_remaining = 10
benefit.units_sold = 2
benefit.save(update_fields=["stock_remaining", "units_sold"])
# restock total dari 12 -> 20: sisa harus bertambah 8 (10 -> 18)
payload = self._payload(partner)
payload["stock_total"] = 20
r2 = self.client.put(f"/api/v1/partners/benefits/admin/{bid}/", payload, format="json")
self.assertEqual(r2.status_code, 200, r2.data)
benefit.refresh_from_db()
self.assertEqual(benefit.stock_remaining, 18)
class OptionalBlankCreateTests(APITestCase):
"""A07: form partner/benefit mengirim "" untuk optional teks kosong (bukan null)."""
......
from django.contrib import admin
from .models import GooglePlaceCoordinate, SpkluImportRun, SpkluStation
@admin.register(SpkluStation)
class SpkluStationAdmin(admin.ModelAdmin):
list_display = [
"name",
"source",
"osm_type",
"osm_id",
"operator",
"access",
"is_active",
"manual_override",
"imported_at",
]
list_filter = ["source", "osm_type", "is_active", "manual_override", "navigation_policy"]
search_fields = [
"name",
"address",
"operator",
"source_reference",
"provider_place_id",
"osm_id",
]
readonly_fields = [
"public_id",
"created_at",
"updated_at",
"imported_at",
"last_seen_at",
"source_updated_at",
]
fieldsets = (
(
"Data stasiun",
{
"fields": (
"public_id",
"name",
"address",
"latitude",
"longitude",
"operator",
"connector_types",
"access",
"source_status",
)
},
),
(
"Provenance",
{
"fields": (
"source",
"source_reference",
"osm_type",
"osm_id",
"source_url",
"source_updated_at",
"imported_at",
"last_seen_at",
"attribution",
"license",
"license_name",
"license_url",
"provenance_note",
"navigation_policy",
"provider",
"provider_place_id",
)
},
),
(
"Koreksi manual",
{
"fields": ("manual_override", "manual_note", "verified_at", "is_active"),
"description": (
"Aktifkan manual_override agar impor OSM berikutnya tidak menimpa "
"field konten baris ini. Impor tetap memperbarui metadata provenance."
),
},
),
("Timestamps", {"fields": ("created_at", "updated_at")}),
)
@admin.register(SpkluImportRun)
class SpkluImportRunAdmin(admin.ModelAdmin):
list_display = [
"started_at",
"region",
"status",
"is_complete",
"elements_received",
"stations_created",
"stations_updated",
"stations_skipped",
"finished_at",
]
list_filter = ["status", "is_complete", "region"]
search_fields = ["bbox", "message"]
readonly_fields = [
"region",
"bbox",
"endpoint",
"status",
"started_at",
"finished_at",
"is_complete",
"elements_received",
"stations_created",
"stations_updated",
"stations_preserved",
"stations_skipped",
"stations_failed",
"data_timestamp",
"message",
"created_at",
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.register(GooglePlaceCoordinate)
class GooglePlaceCoordinateAdmin(admin.ModelAdmin):
"""Cache koordinat Google legacy: hanya baca, tidak dipakai pencarian."""
list_display = ["place_id", "provider", "fetched_at", "expires_at"]
list_filter = ["provider"]
search_fields = ["place_id"]
readonly_fields = ["provider", "place_id", "latitude", "longitude", "fetched_at", "expires_at"]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
from django.apps import AppConfig
class SpkluConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.spklu"
verbose_name = "SPKLU Terdekat"
"""Impor stasiun pengisian kendaraan listrik dari OpenStreetMap."""
from __future__ import annotations
import json
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from apps.spklu.models import SpkluImportRun
from apps.spklu.osm import InvalidBoundingBox, OsmFetchResult, import_osm
class Command(BaseCommand):
help = (
"Impor stasiun OSM (amenity=charging_station) ke dataset SPKLU lokal. "
"Tidak menghapus atau menonaktifkan stasiun yang tidak muncul pada respons."
)
def add_arguments(self, parser):
parser.add_argument(
"--input-json",
help="Snapshot JSON Overpass asli untuk impor reproducible tanpa fetch ulang.",
)
parser.add_argument(
"--bbox",
help="Batas wilayah south,west,north,east. Default: konfigurasi SPKLU_OSM_DEFAULT_BBOX.",
)
parser.add_argument(
"--endpoint",
help="URL Overpass API. Default: konfigurasi SPKLU_OSM_OVERPASS_URL.",
)
parser.add_argument(
"--timeout",
type=int,
help="Timeout query Overpass dalam detik.",
)
parser.add_argument(
"--retries",
type=int,
help="Jumlah percobaan ulang saat timeout/koneksi/429/5xx.",
)
parser.add_argument(
"--backoff",
type=float,
help="Jeda awal backoff dalam detik (eksponensial).",
)
parser.add_argument(
"--max-elements",
type=int,
help="Batas elemen yang diproses; respons lebih besar ditandai parsial.",
)
parser.add_argument(
"--user-agent",
help="User-Agent yang jelas untuk Overpass.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Ambil dan hitung saja; tidak menulis stasiun atau import run.",
)
def handle(self, *args, **options):
fetcher = None
if options.get("input_json"):
try:
payload = json.loads(Path(options["input_json"]).read_text(encoding="utf-8"))
if not isinstance(payload, dict) or not isinstance(payload.get("elements"), list):
raise ValueError("JSON harus memiliki daftar elements")
except (OSError, ValueError) as exc:
raise CommandError(f"Snapshot Overpass tidak valid: {exc}") from exc
def fetcher(bbox, **kwargs):
elements = payload["elements"]
remark = str(payload.get("remark") or "")
limit = kwargs.get("max_elements")
if limit is None:
limit = settings.SPKLU_OSM_MAX_ELEMENTS
if limit < 1:
raise ValueError("max-elements harus positif")
if len(elements) > limit:
elements = elements[:limit]
remark = (remark + "; client_max_elements").strip("; ")
return OsmFetchResult(elements=elements, is_complete=not remark, remark=remark)
try:
summary = import_osm(
bbox=options.get("bbox"),
endpoint=options.get("endpoint"),
timeout=options.get("timeout"),
retries=options.get("retries"),
backoff=options.get("backoff"),
max_elements=options.get("max_elements"),
user_agent=options.get("user_agent"),
dry_run=options["dry_run"],
fetcher=fetcher,
)
except InvalidBoundingBox as exc:
raise CommandError(f"Bounding box tidak valid: {exc}") from exc
prefix = "[dry-run] " if options["dry_run"] else ""
self.stdout.write(
f"{prefix}status={summary['status']} bbox={summary['bbox']} "
f"elements={summary['elements_received']} created={summary['stations_created']} "
f"updated={summary['stations_updated']} preserved={summary['stations_preserved']} "
f"skipped={summary['stations_skipped']} failed={summary['stations_failed']} "
f"complete={summary['is_complete']}"
)
if summary["data_timestamp"]:
self.stdout.write(f"{prefix}data_timestamp={summary['data_timestamp']}")
if summary["message"]:
self.stdout.write(f"{prefix}message={summary['message']}")
if summary["status"] == SpkluImportRun.Status.FAILED:
raise CommandError(
"Impor OSM gagal; dataset lama tidak diubah. Lihat SpkluImportRun untuk detail."
)
if summary["status"] == SpkluImportRun.Status.PARTIAL:
self.stdout.write(
self.style.WARNING(
"Respons OSM parsial; stasiun yang tidak muncul tidak dinonaktifkan."
)
)
"""Bersihkan cache koordinat Google yang kedaluwarsa."""
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.spklu.models import GooglePlaceCoordinate
class Command(BaseCommand):
help = "Hapus cache koordinat Google Places yang sudah kedaluwarsa (maks 30 hari)."
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Hitung saja, jangan hapus.",
)
parser.add_argument(
"--all",
action="store_true",
help="Hapus semua baris cache, bukan hanya yang kedaluwarsa.",
)
def handle(self, *args, **options):
queryset = GooglePlaceCoordinate.objects.all()
if not options["all"]:
queryset = queryset.filter(expires_at__lte=timezone.now())
count = queryset.count()
if options["dry_run"]:
self.stdout.write(f"[dry-run] {count} baris cache akan dihapus.")
return
queryset.delete()
scope = "semua" if options["all"] else "kedaluwarsa"
self.stdout.write(self.style.SUCCESS(f"{count} baris cache {scope} dihapus."))
# Generated by Django 4.2.16 on 2026-10-21 11:16
from django.db import migrations, models
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='GooglePlaceCoordinate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(default='google_places', max_length=40)),
('place_id', models.CharField(max_length=300)),
('latitude', models.DecimalField(decimal_places=6, max_digits=9)),
('longitude', models.DecimalField(decimal_places=6, max_digits=9)),
('fetched_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField(db_index=True)),
],
options={
'ordering': ['expires_at'],
},
),
migrations.CreateModel(
name='SpkluStation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=200)),
('address', models.CharField(blank=True, default='', max_length=300)),
('latitude', models.DecimalField(decimal_places=6, max_digits=9)),
('longitude', models.DecimalField(decimal_places=6, max_digits=9)),
('operator', models.CharField(blank=True, default='', max_length=150)),
('connector_types', models.JSONField(blank=True, default=list, help_text='Daftar tipe konektor hanya bila sumbernya sah, contoh ["CCS2"].')),
('source', models.CharField(default='independent', help_text='Identitas dataset mandiri. Hasil Google tidak boleh disimpan di sini.', max_length=40)),
('source_reference', models.CharField(blank=True, default='', help_text='ID unik stasiun di dalam dataset sumber.', max_length=200)),
('provider', models.CharField(blank=True, default='', max_length=120)),
('provider_place_id', models.CharField(blank=True, default='', max_length=300)),
('source_status', models.CharField(blank=True, default='', help_text='Status dari sumber data, bukan status realtime/occupancy.', max_length=80)),
('license_name', models.CharField(blank=True, default='', max_length=200)),
('license_url', models.URLField(blank=True, default='', max_length=500)),
('provenance_note', models.TextField(blank=True, default='')),
('navigation_policy', models.CharField(choices=[('google_only', 'Google Maps saja'), ('google_apple', 'Google Maps dan Apple Maps')], default='google_only', help_text='Google/Apple hanya bila lisensi sumber memang mengizinkan.', max_length=20)),
('is_active', models.BooleanField(default=True)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name', 'public_id'],
},
),
migrations.AddConstraint(
model_name='spklustation',
constraint=models.CheckConstraint(check=models.Q(('latitude__gte', -90), ('latitude__lte', 90)), name='spklu_station_latitude_range'),
),
migrations.AddConstraint(
model_name='spklustation',
constraint=models.CheckConstraint(check=models.Q(('longitude__gte', -180), ('longitude__lte', 180)), name='spklu_station_longitude_range'),
),
migrations.AddConstraint(
model_name='spklustation',
constraint=models.UniqueConstraint(condition=models.Q(('provider_place_id', ''), _negated=True), fields=('provider', 'provider_place_id'), name='uniq_spklu_station_provider_place'),
),
migrations.AddConstraint(
model_name='spklustation',
constraint=models.UniqueConstraint(condition=models.Q(('source_reference', ''), _negated=True), fields=('source', 'source_reference'), name='uniq_spklu_station_source_ref'),
),
migrations.AddConstraint(
model_name='googleplacecoordinate',
constraint=models.UniqueConstraint(fields=('provider', 'place_id'), name='uniq_spklu_google_place'),
),
]
# Generated by Django 4.2.16 on 2026-10-23 08:32
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('spklu', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SpkluImportRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('region', models.CharField(default='jawa_barat', max_length=120)),
('bbox', models.CharField(blank=True, default='', max_length=120)),
('endpoint', models.URLField(blank=True, default='', max_length=500)),
('status', models.CharField(choices=[('running', 'Berjalan'), ('success', 'Sukses'), ('partial', 'Parsial'), ('failed', 'Gagal')], db_index=True, default='running', max_length=20)),
('started_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('is_complete', models.BooleanField(blank=True, null=True)),
('elements_received', models.PositiveIntegerField(default=0)),
('stations_created', models.PositiveIntegerField(default=0)),
('stations_updated', models.PositiveIntegerField(default=0)),
('stations_preserved', models.PositiveIntegerField(default=0)),
('stations_skipped', models.PositiveIntegerField(default=0)),
('stations_failed', models.PositiveIntegerField(default=0)),
('data_timestamp', models.DateTimeField(blank=True, null=True)),
('message', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-started_at'],
},
),
migrations.AddField(
model_name='spklustation',
name='access',
field=models.CharField(blank=True, default='', help_text='Nilai access OSM bila tersedia (mis. yes, customers, private).', max_length=40),
),
migrations.AddField(
model_name='spklustation',
name='attribution',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='spklustation',
name='imported_at',
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name='spklustation',
name='last_seen_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='spklustation',
name='license',
field=models.CharField(blank=True, default='', max_length=100),
),
migrations.AddField(
model_name='spklustation',
name='manual_note',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='spklustation',
name='manual_override',
field=models.BooleanField(default=False, help_text='Jangan timpa field konten saat impor OSM berikutnya.'),
),
migrations.AddField(
model_name='spklustation',
name='osm_id',
field=models.BigIntegerField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name='spklustation',
name='osm_type',
field=models.CharField(blank=True, choices=[('node', 'Node'), ('way', 'Way'), ('relation', 'Relation')], db_index=True, default='', max_length=20),
),
migrations.AddField(
model_name='spklustation',
name='source_updated_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='spklustation',
name='source_url',
field=models.URLField(blank=True, default='', max_length=500),
),
migrations.AlterField(
model_name='spklustation',
name='connector_types',
field=models.JSONField(blank=True, default=list, help_text='Daftar tipe konektor hanya bila sumbernya menyediakannya.'),
),
migrations.AlterField(
model_name='spklustation',
name='name',
field=models.CharField(blank=True, default='', max_length=200),
),
migrations.AlterField(
model_name='spklustation',
name='navigation_policy',
field=models.CharField(choices=[('google_only', 'Google Maps saja'), ('google_apple', 'Google Maps dan Apple Maps')], default='google_apple', help_text='OSM dan data manual mendukung Google Maps serta Apple Maps.', max_length=20),
),
migrations.AlterField(
model_name='spklustation',
name='source',
field=models.CharField(choices=[('osm', 'OpenStreetMap'), ('manual', 'Dataset manual terverifikasi'), ('independent', 'Dataset mandiri (legacy)')], db_index=True, default='manual', help_text='Hasil Google tidak boleh disimpan di sini.', max_length=40),
),
migrations.AddConstraint(
model_name='spklustation',
constraint=models.UniqueConstraint(condition=models.Q(('osm_id__isnull', False)), fields=('osm_type', 'osm_id'), name='uniq_spklu_station_osm'),
),
migrations.AddIndex(
model_name='spkluimportrun',
index=models.Index(fields=['status', 'finished_at'], name='spklu_spklu_status_e12f4a_idx'),
),
]
"""Model SPKLU Terdekat.
Sumber utama adalah OpenStreetMap (OSM) dan dataset manual terverifikasi.
``GooglePlaceCoordinate`` dipertahankan hanya sebagai tabel cache legacy untuk
pembersihan; tidak dipakai oleh pencarian dan tidak boleh dijadikan sumber OSM
atau manual baru.
"""
from __future__ import annotations
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils import timezone
class SpkluStation(models.Model):
"""Lokasi pengisian kendaraan listrik dari OSM atau dataset manual."""
class Source(models.TextChoices):
OSM = "osm", "OpenStreetMap"
MANUAL = "manual", "Dataset manual terverifikasi"
INDEPENDENT = "independent", "Dataset mandiri (legacy)"
class OsmType(models.TextChoices):
NODE = "node", "Node"
WAY = "way", "Way"
RELATION = "relation", "Relation"
class NavigationPolicy(models.TextChoices):
GOOGLE_ONLY = "google_only", "Google Maps saja"
GOOGLE_APPLE = "google_apple", "Google Maps dan Apple Maps"
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
name = models.CharField(max_length=200, blank=True, default="")
address = models.CharField(max_length=300, blank=True, default="")
latitude = models.DecimalField(max_digits=9, decimal_places=6)
longitude = models.DecimalField(max_digits=9, decimal_places=6)
operator = models.CharField(max_length=150, blank=True, default="")
connector_types = models.JSONField(
default=list,
blank=True,
help_text='Daftar tipe konektor hanya bila sumbernya menyediakannya.',
)
access = models.CharField(
max_length=40,
blank=True,
default="",
help_text="Nilai access OSM bila tersedia (mis. yes, customers, private).",
)
source = models.CharField(
max_length=40,
choices=Source.choices,
default=Source.MANUAL,
db_index=True,
help_text="Hasil Google tidak boleh disimpan di sini.",
)
source_reference = models.CharField(
max_length=200,
blank=True,
default="",
help_text="ID unik stasiun di dalam dataset sumber.",
)
provider = models.CharField(max_length=120, blank=True, default="")
provider_place_id = models.CharField(max_length=300, blank=True, default="")
source_status = models.CharField(
max_length=80,
blank=True,
default="",
help_text="Status dari sumber data, bukan status realtime/occupancy.",
)
osm_type = models.CharField(
max_length=20,
choices=OsmType.choices,
blank=True,
default="",
db_index=True,
)
osm_id = models.BigIntegerField(null=True, blank=True, db_index=True)
source_url = models.URLField(max_length=500, blank=True, default="")
source_updated_at = models.DateTimeField(null=True, blank=True)
imported_at = models.DateTimeField(null=True, blank=True, db_index=True)
last_seen_at = models.DateTimeField(null=True, blank=True)
attribution = models.CharField(max_length=255, blank=True, default="")
license = models.CharField(max_length=100, blank=True, default="")
license_name = models.CharField(max_length=200, blank=True, default="")
license_url = models.URLField(max_length=500, blank=True, default="")
provenance_note = models.TextField(blank=True, default="")
navigation_policy = models.CharField(
max_length=20,
choices=NavigationPolicy.choices,
default=NavigationPolicy.GOOGLE_APPLE,
help_text="OSM dan data manual mendukung Google Maps serta Apple Maps.",
)
manual_override = models.BooleanField(
default=False,
help_text="Jangan timpa field konten saat impor OSM berikutnya.",
)
manual_note = models.TextField(blank=True, default="")
is_active = models.BooleanField(default=True)
verified_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "public_id"]
constraints = [
models.CheckConstraint(
check=Q(latitude__gte=-90, latitude__lte=90),
name="spklu_station_latitude_range",
),
models.CheckConstraint(
check=Q(longitude__gte=-180, longitude__lte=180),
name="spklu_station_longitude_range",
),
models.UniqueConstraint(
fields=["provider", "provider_place_id"],
condition=~Q(provider_place_id=""),
name="uniq_spklu_station_provider_place",
),
models.UniqueConstraint(
fields=["source", "source_reference"],
condition=~Q(source_reference=""),
name="uniq_spklu_station_source_ref",
),
models.UniqueConstraint(
fields=["osm_type", "osm_id"],
condition=Q(osm_id__isnull=False),
name="uniq_spklu_station_osm",
),
]
def clean(self):
super().clean()
if self.source == "google_places":
raise ValidationError(
{"source": "Hasil Google tidak boleh disimpan sebagai stasiun SPKLU."}
)
if self.source == self.Source.OSM:
if not self.osm_type or self.osm_id is None:
raise ValidationError(
{"osm_id": "Stasiun OSM wajib memiliki osm_type dan osm_id."}
)
@property
def allows_apple_maps(self) -> bool:
if self.source in {self.Source.OSM, self.Source.MANUAL}:
return True
return self.navigation_policy == self.NavigationPolicy.GOOGLE_APPLE
@property
def display_name(self) -> str:
return self.name or "SPKLU"
def __str__(self):
return self.name or f"OSM {self.osm_type}/{self.osm_id}"
class SpkluImportRun(models.Model):
"""Catatan satu eksekusi impor OSM (status, cakupan, dan jumlah baris)."""
class Status(models.TextChoices):
RUNNING = "running", "Berjalan"
SUCCESS = "success", "Sukses"
PARTIAL = "partial", "Parsial"
FAILED = "failed", "Gagal"
region = models.CharField(max_length=120, default="jawa_barat")
bbox = models.CharField(max_length=120, blank=True, default="")
endpoint = models.URLField(max_length=500, blank=True, default="")
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.RUNNING, db_index=True
)
started_at = models.DateTimeField(default=timezone.now, db_index=True)
finished_at = models.DateTimeField(null=True, blank=True)
is_complete = models.BooleanField(null=True, blank=True)
elements_received = models.PositiveIntegerField(default=0)
stations_created = models.PositiveIntegerField(default=0)
stations_updated = models.PositiveIntegerField(default=0)
stations_preserved = models.PositiveIntegerField(default=0)
stations_skipped = models.PositiveIntegerField(default=0)
stations_failed = models.PositiveIntegerField(default=0)
data_timestamp = models.DateTimeField(null=True, blank=True)
message = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-started_at"]
indexes = [models.Index(fields=["status", "finished_at"])]
def __str__(self):
return f"{self.region} {self.status} {self.started_at.isoformat()}"
class GooglePlaceCoordinate(models.Model):
"""Cache koordinat Google legacy (maksimal 30 hari).
Tidak dipakai oleh pencarian SPKLU saat ini. Dipertahankan agar data cache
lama dapat dibersihkan tanpa mengubah atau memberi label ulang sebagai OSM.
"""
provider = models.CharField(max_length=40, default="google_places")
place_id = models.CharField(max_length=300)
latitude = models.DecimalField(max_digits=9, decimal_places=6)
longitude = models.DecimalField(max_digits=9, decimal_places=6)
fetched_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField(db_index=True)
class Meta:
ordering = ["expires_at"]
constraints = [
models.UniqueConstraint(
fields=["provider", "place_id"],
name="uniq_spklu_google_place",
),
]
def __str__(self):
return f"{self.provider}:{self.place_id}"
This diff is collapsed.
"""Validasi request SPKLU Terdekat."""
from __future__ import annotations
import math
from rest_framework import serializers
DEFAULT_RADIUS_M = 10_000
MAX_RADIUS_M = 50_000
DEFAULT_LIMIT = 10
MAX_LIMIT = 20
class StrictFloatField(serializers.FloatField):
"""Tolak bool, NaN, dan infinity; nilai 0 tetap valid."""
def to_internal_value(self, data):
if isinstance(data, bool):
self.fail("invalid")
value = super().to_internal_value(data)
if not math.isfinite(value):
self.fail("invalid")
return value
class StrictIntegerField(serializers.IntegerField):
"""Tolak bool; hanya integer bulat."""
def to_internal_value(self, data):
if isinstance(data, bool):
self.fail("invalid")
if isinstance(data, float) and not data.is_integer():
self.fail("invalid")
return super().to_internal_value(data)
class NearbyRequestSerializer(serializers.Serializer):
latitude = StrictFloatField(min_value=-90, max_value=90)
longitude = StrictFloatField(min_value=-180, max_value=180)
radius_m = StrictIntegerField(
required=False, default=DEFAULT_RADIUS_M, min_value=1, max_value=MAX_RADIUS_M
)
limit = StrictIntegerField(required=False, default=DEFAULT_LIMIT, min_value=1, max_value=MAX_LIMIT)
This diff is collapsed.
This diff is collapsed.
from django.urls import path
from .views import SpkluNearbyView
urlpatterns = [
path("spklu/nearby/", SpkluNearbyView.as_view(), name="spklu-nearby"),
]
"""Endpoint SPKLU Terdekat."""
from __future__ import annotations
import logging
from django.conf import settings
from django.core.cache import cache
from drf_spectacular.utils import OpenApiTypes, extend_schema
from rest_framework.exceptions import Throttled
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import SimpleRateThrottle, UserRateThrottle
from rest_framework.views import APIView
from . import services
from .serializers import NearbyRequestSerializer
logger = logging.getLogger(__name__)
class SpkluThrottled(Throttled):
default_detail = "Terlalu banyak permintaan. Coba lagi nanti."
default_code = "rate_limited"
class SpkluUserThrottle(UserRateThrottle):
scope = "spklu_user"
def throttle_failure(self):
raise SpkluThrottled(wait=self.wait())
class SpkluIPThrottle(SimpleRateThrottle):
scope = "spklu_ip"
def get_cache_key(self, request, view):
return self.cache_format % {"scope": self.scope, "ident": self.get_ident(request)}
def throttle_failure(self):
raise SpkluThrottled(wait=self.wait())
def _min_interval_seconds() -> int:
return max(0, int(getattr(settings, "SPKLU_MIN_INTERVAL_SECONDS", 2)))
class SpkluNearbyView(APIView):
"""POST /api/v1/spklu/nearby/ — daftar SPKLU terdekat untuk member login."""
permission_classes = [IsAuthenticated]
throttle_classes = [SpkluUserThrottle, SpkluIPThrottle]
serializer_class = NearbyRequestSerializer
@extend_schema(
tags=["spklu"],
request=NearbyRequestSerializer,
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT, 503: OpenApiTypes.OBJECT},
)
def post(self, request):
serializer = NearbyRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
interval = _min_interval_seconds()
if interval:
lock_key = f"spklu:nearby:user:{request.user.pk}"
if not cache.add(lock_key, 1, timeout=interval):
return Response(
{"detail": "Permintaan terlalu sering. Tunggu sebentar.", "code": "request_too_frequent"},
status=429,
headers={"Retry-After": str(interval)},
)
try:
payload = services.search_nearby(
data["latitude"], data["longitude"], data["radius_m"], data["limit"]
)
except services.SpkluConfigurationError as exc:
return Response(
{
"detail": (
"Dataset SPKLU lokal belum tersedia. Jalankan impor OSM "
"atau tambahkan data manual."
),
"code": "spklu_not_configured",
"reason": exc.reason,
},
status=503,
)
return Response(payload)
def handle_exception(self, exc):
response = super().handle_exception(exc)
if isinstance(exc, Throttled) and response is not None:
response.data = {
"detail": str(exc.detail),
"code": getattr(exc, "default_code", "throttled"),
}
return response
......@@ -5,6 +5,7 @@ from __future__ import annotations
from rest_framework import serializers
from apps.common.utils import humanize_rupiah
from apps.common.timewindow import event_entry_window
from .models import CheckIn, Order, OrderLine, Payment, Ticket
......@@ -163,13 +164,16 @@ class TicketSerializer(serializers.ModelSerializer):
event_title = serializers.CharField(source="event.title", read_only=True)
event_dates = serializers.SerializerMethodField()
venue = serializers.SerializerMethodField()
entry_window = serializers.SerializerMethodField()
entry_eligible = serializers.SerializerMethodField()
order_code = serializers.CharField(source="order.code", read_only=True)
class Meta:
model = Ticket
fields = [
"id", "code", "order_code", "tier", "tier_code", "tier_name", "event",
"event_title", "event_dates", "venue", "holder_name", "holder_kta",
"event_title", "event_dates", "entry_window", "entry_eligible", "venue",
"holder_name", "holder_kta",
"allocation_type", "allocation_value", "gate", "status", "status_display",
"qr_token", "hash_code", "checked_in_at", "check_in_gate", "created_at",
]
......@@ -182,6 +186,14 @@ class TicketSerializer(serializers.ModelSerializer):
"label": f"{e.start_date.strftime('%d')} - {e.end_date.strftime('%d %b %Y')}" if e.start_date != e.end_date else e.start_date.strftime("%d %b %Y"),
}
def get_entry_window(self, obj):
return event_entry_window(obj.event)
def get_entry_eligible(self, obj):
window = event_entry_window(obj.event)
usable = obj.status in (Ticket.Status.READY, Ticket.Status.VALIDATED)
return bool(usable and window["is_open_now"])
def get_venue(self, obj):
e = obj.event
return {
......
"""Kontrak jendela tanggal event & timezone bisnis (N05).
Membuktikan:
- date-only end_date inklusif sampai akhir hari terakhir pada timezone bisnis;
- hari sebelum/sedang/sesudah;
- scope=active/history memakai tanggal lokal bisnis, bukan UTC;
- payload tiket & event menyertakan entry_window (valid_from/valid_until/is_open_now).
"""
from datetime import datetime, timedelta
from unittest import mock
from zoneinfo import ZoneInfo
from django.test import TestCase
from django.utils import timezone
from apps.common.testing import make_member, member_client
from apps.common.timewindow import (
event_entry_window,
event_valid_until,
)
from apps.events.models import Circuit, Event, EventCategory, TicketTier
from apps.ticketing.models import Order, Ticket
JKT = ZoneInfo("Asia/Jakarta")
def _event(**overrides):
category, _ = EventCategory.objects.get_or_create(code="road", defaults={"name": "Road Race"})
circuit, _ = Circuit.objects.get_or_create(slug="sentul", defaults={"name": "Sirkuit Sentul", "city": "Kab. Bogor"})
data = {
"title": "Date Contract Race",
"slug": "date-contract-race-" + str(overrides.get("start_date", "x")),
"category": category,
"circuit": circuit,
"status": Event.Status.REGISTRATION_OPEN,
"start_date": timezone.now().date(),
"end_date": timezone.now().date(),
}
data.update(overrides)
return Event.objects.create(**data)
class EntryWindowHelperTests(TestCase):
def test_last_day_is_inclusive_end_of_day_business_tz(self):
d = timezone.now().date()
ev = _event(start_date=d, end_date=d)
win = event_entry_window(ev, now=datetime(d.year, d.month, d.day, 10, 0, tzinfo=JKT))
self.assertTrue(win["is_open_now"])
self.assertEqual(win["phase"], "open")
self.assertEqual(win["timezone"], "Asia/Jakarta")
self.assertEqual(win["valid_until"], f"{d.isoformat()}T23:59:59.999999+07:00")
def test_day_before_is_upcoming(self):
today = timezone.now().date()
ev = _event(start_date=today + timedelta(days=1), end_date=today + timedelta(days=2))
win = event_entry_window(ev, now=datetime(today.year, today.month, today.day, 12, 0, tzinfo=JKT))
self.assertEqual(win["phase"], "upcoming")
self.assertFalse(win["is_open_now"])
def test_day_after_is_past(self):
today = timezone.now().date()
ev = _event(start_date=today - timedelta(days=2), end_date=today - timedelta(days=1))
win = event_entry_window(ev, now=datetime(today.year, today.month, today.day, 12, 0, tzinfo=JKT))
self.assertEqual(win["phase"], "past")
self.assertTrue(win["is_expired"])
def test_valid_until_offset_is_plus_seven(self):
d = timezone.now().date()
ev = _event(start_date=d, end_date=d)
vu = event_valid_until(ev)
self.assertEqual(vu.utcoffset(), timedelta(hours=7))
self.assertEqual(vu.hour, 23)
class TicketScopeBusinessDayTests(TestCase):
def setUp(self):
self.user, self.member = make_member("+6281110001111", "Date User")
self.client = member_client("+6281110001111")
def _ticket(self, event, status=Ticket.Status.READY, code="DT-1"):
order = Order.objects.create(code="ORD-" + code, user=self.user, member=self.member, event=event, status=Order.Status.PAID)
tier = TicketTier.objects.create(event=event, code="fest-" + code, name="Festival", price=100_000, quota=100)
return Ticket.objects.create(code=code, order=order, tier=tier, event=event, holder_name="Date User", status=status)
def _fixed_date(self):
# 20 Oktober 2026 (tanggal tetap) untuk mengunci skenario timezone.
return datetime(2026, 10, 20, 1, 0, tzinfo=JKT) # 2026-10-19 18:00 UTC
def test_last_day_active_near_local_midnight(self):
d = self._fixed_date().date() # 2026-10-20
ev = _event(start_date=d, end_date=d, slug="dt-lastday")
t = self._ticket(ev, code="DT-LAST")
with mock.patch("django.utils.timezone.now", return_value=self._fixed_date()):
r = self.client.get("/api/v1/tickets/?scope=active")
self.assertEqual(r.status_code, 200)
self.assertIn(t.pk, [row["id"] for row in r.data["results"]])
row = next(x for x in r.data["results"] if x["id"] == t.pk)
self.assertTrue(row["entry_eligible"])
self.assertTrue(row["entry_window"]["is_open_now"])
def test_prev_day_event_is_history_near_local_midnight(self):
# now = 2026-10-20 01:00 WIB (= 2026-10-19 UTC). Event end 2026-10-19 -> sudah lewat.
d = self._fixed_date().date()
ev = _event(start_date=d - timedelta(days=1), end_date=d - timedelta(days=1), slug="dt-prevday")
t = self._ticket(ev, code="DT-PREV")
with mock.patch("django.utils.timezone.now", return_value=self._fixed_date()):
active = self.client.get("/api/v1/tickets/?scope=active")
history = self.client.get("/api/v1/tickets/?scope=history")
self.assertNotIn(t.pk, [row["id"] for row in active.data["results"]])
self.assertIn(t.pk, [row["id"] for row in history.data["results"]])
def test_next_day_event_active_near_local_midnight(self):
d = self._fixed_date().date()
ev = _event(start_date=d + timedelta(days=1), end_date=d + timedelta(days=1), slug="dt-nextday")
t = self._ticket(ev, code="DT-NEXT")
with mock.patch("django.utils.timezone.now", return_value=self._fixed_date()):
r = self.client.get("/api/v1/tickets/?scope=active")
self.assertIn(t.pk, [row["id"] for row in r.data["results"]])
def test_checked_in_ticket_not_active(self):
d = timezone.now().date()
ev = _event(start_date=d, end_date=d, slug="dt-checked")
t = self._ticket(ev, status=Ticket.Status.CHECKED_IN, code="DT-CHK")
r = self.client.get("/api/v1/tickets/?scope=active")
self.assertNotIn(t.pk, [row["id"] for row in r.data["results"]])
def test_payload_exposes_entry_window_on_event_list(self):
d = timezone.now().date()
_event(start_date=d, end_date=d, slug="dt-list")
r = self.client.get("/api/v1/events/")
self.assertEqual(r.status_code, 200)
rows = r.data["results"] if isinstance(r.data, dict) else r.data
row = next(x for x in rows if x["slug"] == "dt-list")
self.assertIn("entry_window", row)
self.assertIn("valid_until", row["entry_window"])
self.assertIn("is_open_now", row["entry_window"])
This diff is collapsed.
......@@ -5,7 +5,6 @@ from __future__ import annotations
from decimal import Decimal, InvalidOperation
from django.db.models import Q
from django.utils import timezone
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
......@@ -14,6 +13,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.permissions import AnyStaff, FinanceApproveStaff, IsMember
from apps.common.timewindow import business_today
from apps.common.utils import generate_qr_token
from . import midtrans, services
......@@ -251,13 +251,14 @@ class TicketViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
qs = super().get_queryset().filter(order__user=self.request.user)
scope = self.request.query_params.get("scope")
today = business_today()
if scope == "active":
qs = qs.filter(status__in=[Ticket.Status.READY, Ticket.Status.VALIDATED], event__end_date__gte=timezone.now().date())
qs = qs.filter(status__in=[Ticket.Status.READY, Ticket.Status.VALIDATED], event__end_date__gte=today)
elif scope == "history":
qs = qs.filter(Q(status__in=[
Ticket.Status.CANCELLED, Ticket.Status.ATTENDED,
Ticket.Status.CHECKED_IN, Ticket.Status.EXPIRED,
]) | Q(event__end_date__lt=timezone.now().date()))
]) | Q(event__end_date__lt=today))
return qs
......
......@@ -66,6 +66,7 @@ INSTALLED_APPS = [
"apps.content",
"apps.notifications",
"apps.finance",
"apps.spklu",
]
MIDDLEWARE = [
......@@ -152,6 +153,10 @@ REST_FRAMEWORK = {
"PAGE_SIZE": 20,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z",
"DEFAULT_THROTTLE_RATES": {
"spklu_user": os.getenv("SPKLU_THROTTLE_USER", "30/min"),
"spklu_ip": os.getenv("SPKLU_THROTTLE_IP", "120/min"),
},
}
SIMPLE_JWT = {
......@@ -183,6 +188,7 @@ SPECTACULAR_SETTINGS = {
{"name": "content", "description": "News/artikel, banner, media"},
{"name": "notifications", "description": "Notifikasi pengguna"},
{"name": "finance", "description": "Aturan harga, bagi hasil, laporan keuangan"},
{"name": "spklu", "description": "SPKLU Terdekat (lokasi pengisian kendaraan listrik)"},
{"name": "dashboard", "description": "Statistik & reporting untuk admin"},
],
"ENUM_NAME_OVERRIDES": {
......@@ -206,6 +212,25 @@ MIDTRANS_SNAP_BASE = os.getenv(
"MIDTRANS_SNAP_BASE", "https://app.midtrans.com" if env_bool("MIDTRANS_IS_PRODUCTION") else "https://app.sandbox.midtrans.com"
)
# ---------------------------------------------------------------- SPKLU Terdekat
# Sumber utama: impor OSM (Overpass) + data manual terverifikasi. Tidak ada
# panggilan Google Places pada request pencarian.
SPKLU_OSM_OVERPASS_URL = os.getenv(
"SPKLU_OSM_OVERPASS_URL", "https://overpass-api.de/api/interpreter"
)
SPKLU_OSM_DEFAULT_BBOX = os.getenv(
"SPKLU_OSM_DEFAULT_BBOX", "-7.90,105.00,-5.85,108.90"
)
SPKLU_OSM_TIMEOUT_SECONDS = max(1, int(os.getenv("SPKLU_OSM_TIMEOUT_SECONDS", "60")))
SPKLU_OSM_MAX_RETRIES = max(0, int(os.getenv("SPKLU_OSM_MAX_RETRIES", "2")))
SPKLU_OSM_BACKOFF_SECONDS = max(0.0, float(os.getenv("SPKLU_OSM_BACKOFF_SECONDS", "2")))
SPKLU_OSM_MAX_ELEMENTS = max(1, int(os.getenv("SPKLU_OSM_MAX_ELEMENTS", "5000")))
SPKLU_OSM_USER_AGENT = os.getenv(
"SPKLU_OSM_USER_AGENT", "IMI-Jabar-SPKLU/1.0 (+https://imijabar.or.id; data import)"
)
SPKLU_DATASET_STALE_DAYS = max(1, int(os.getenv("SPKLU_DATASET_STALE_DAYS", "30")))
SPKLU_MIN_INTERVAL_SECONDS = max(0, int(os.getenv("SPKLU_MIN_INTERVAL_SECONDS", "2")))
# ---------------------------------------------------------------- CORS
CORS_ALLOWED_ORIGINS = env_list(CORS_ALLOWED_ORIGINS_ENV := "CORS_ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:5173,http://localhost:8000,http://127.0.0.1:5173")
CORS_ALLOW_CREDENTIALS = True
......
......@@ -14,6 +14,7 @@ api_v1_patterns = [
path("", include("apps.content.urls")),
path("", include("apps.notifications.urls")),
path("", include("apps.finance.urls")),
path("", include("apps.spklu.urls")),
]
urlpatterns = [
......
# Prompt Antigravity — mobile SPKLU Terdekat
Tugas ini masuk ANTREAN SESUDAH pekerjaan perbaikan validitas E2E mobile yang sekarang in progress. Jangan menghentikan, mengganti scope atau mencampur bukti pengujian lama dengan fitur baru. Selesaikan checkpoint dan laporan koreksi E2E terlebih dahulu. Mulai integrasi SPKLU setelah docs/features/spklu/API_CONTRACT.md dan HANDOFF_TO_ANTIGRAVITY.md dari OpenCode tersedia/final. Jangan menebak route/schema atau mengubah backend/web-admin.
Kerjakan mobile Flutter di /Users/trendy/Documents/IMIapps/mobile. Pertahankan working tree, session isolation I01–I04, checkout/payment/QR dan branding existing; jangan reset, commit/push/deploy atau menimpa baseline audit.
## Alur pengguna
1. Tambahkan pintasan SPKLU Terdekat pada Home sesudah login, pada area shortcut/layanan yang sesuai pola aplikasi. Tidak menggeser fungsi tiket penting atau mengubah Home secara menyeluruh.
2. Baru ketika pintasan diketuk, tampilkan tujuan penggunaan lokasi dan minta permission foreground/when-in-use jika belum diberikan. Jangan prompt GPS saat login/Home load. Ambil koordinat perangkat sekali, dengan timeout/accuracy/timestamp yang wajar, lalu panggil backend authenticated.
3. Tampilkan daftar hasil terdekat: nama, alamat bila ada, perkiraan jarak garis lurus m/km, sumber/attribution dan aksi Buka Maps. Backend mengurutkan hasil; jangan menyebut jarak berkendara atau ETA. Bila radius kosong, tawarkan perluas pencarian secara eksplisit sampai maksimum kontrak, jangan melakukan polling/panggilan otomatis terus-menerus.
4. Ketuk station membuka rute berkendara pada aplikasi Maps eksternal. Jangan meminta origin kedua jika aplikasi Maps dapat memakai lokasi saat ini. Google-sourced result → Google Maps saja sesuai allowed_navigation_providers. Independent licensed result → pilihan Google Maps/Apple Maps pada iOS bila didukung; Android gunakan Maps handler yang sesuai. Jangan mengirim konten Google Places ke Apple Maps.
## Permission, privasi dan state
- Tangani permission denied, permanently denied, GPS off, approximate location, timeout, internet mati, session expired, provider unavailable/quota, empty dan partial results. Sertakan Coba lagi/Buka Pengaturan hanya pada keadaan yang tepat. Kembali dari Settings harus recheck status tanpa loop permission.
- Approximate location boleh dipakai dengan penjelasan akurasi; jangan menuntut precise bila tidak diperlukan. Hasil lama harus diberi label jika masih ditampilkan saat refresh dan origin berubah.
- Tidak memakai background location, tidak menyimpan riwayat/koordinat user, tidak mengirim koordinat ke analytics/log. Batalkan/abaikan response stale saat user logout/beralih akun/meninggalkan layar; jangan setState setelah dispose. Hindari double tap/request duplicate dan infinite refresh ketika rebuild.
- Gunakan package geolocation/url launching yang sesuai Flutter dan dependensi repo; baca dokumentasi aktual. Tambahkan Android foreground location permissions dan iOS usage-description berbahasa Indonesia. Tidak menambah always/background permissions. Untuk web, jelaskan secure context/localhost dan geolocation API support.
## Maps dan sumber
- Gunakan hanya destination coordinate/ID valid dari kontrak. URL dibangun dengan URI encoding dan allowlist scheme/host, bukan arbitrary URL API tanpa validasi.
- Google Maps universal directions URL mendukung api=1, destination dan destination_place_id bila tersedia, travelmode=driving. Tidak perlu API key Google di mobile untuk URL ini.
- Apple Maps memakai format resmi yang sesuai dukungan OS; verifikasi di dokumentasi Apple saat implementasi. Ikuti allowed_navigation_providers. Jika app tidak tersedia gunakan fallback browser yang masih diizinkan, dan beri error yang dapat dipulihkan jika launcher gagal; jangan fallback Google content ke Apple.
- Tampilkan Google Maps attribution resmi dan third-party attribution sesuai handoff. Jangan gunakan logo Google buatan sendiri. Jangan mengarang PLN/operator, buka24jam, charger tersedia, harga atau realtime occupancy dari data kosong.
## UI/UX
Daftar sederhana, readable, loading/empty/error jelas, tap target minimal48logicalpx, semantics label dan text scaling. Refresh adalah aksi eksplisit. Tidak perlu embedded map atau dekorasi/desain ulang. Home hanya pintasan; permintaan lokasi baru terjadi setelah aksi pengguna.
## Pengujian dan bukti
Widget/unit: permission states, GPS off, approximate/timeout, auth401, empty/partial/error, formatting jarak, origin update, stale response/session switch, provider-specific navigation, URI safety dan no duplicate requests. Jangan melemahkan tes pembayaran/session existing.
Browser/perangkat E2E: login melaluiUI → klik pintasan → permission/lokasi → request aplikasi ke backend → daftarurutjarak → klik station → buktikan URI/intent destination benar. Lokasi boleh disimulasikan browser dengan label AUDIT-SIMULATED; provider mock juga diberi label. Setupfixture dan read-only verifikasi server boleh via API; aksi search/navigation yang sedang diuji wajib dariUI. Tidak boleh mengganti klik UI dengan fetch lalu menyebutnya UI action. Elemen tidak ditemukan/assertionfalse = FAIL; screenshot bukan assertion.
Pisahkan browser URL launch dari bukti aplikasi Maps native benar-benar terbuka. Jika perangkat Android/iOS atau Google live API tidak tersedia, tandai NOT VERIFIED. Pastikan default rilis tidak menunjuk backend audit. Jalankan flutter analyze dan semua tes; build APK setelah sourcefinal, simpan buildlog, ukuran, SHA256 dan source manifest. Jika source berubah setelahbuild, rebuild.
Buat docs/features/spklu/mobile/ berisi WORK_STATE.md, REPORT.md, checklist acceptance, evidence/log/screenshot, build metadata dan backend dependencies. Scope/skor SPKLU terpisah dari165kriteria audit lama sampai rubrik tambahan disepakati. Akhiri status CLOSED/PARTIAL/BLOCKED dan batas liveprovider/native yang benar, tanpa klaim100% dari widgettests saja.
Referensi:
- https://developers.google.com/maps/documentation/urls/get-started
- https://developer.apple.com/documentation/mapkit/unified-map-urls
- https://developers.google.com/maps/documentation/places/web-service/policies
- https://cloud.google.com/maps-platform/terms/maps-service-terms
# Prompt OpenCode — backend SPKLU Terdekat
Implementasikan backend fitur baru SPKLU Terdekat untuk pengguna mobile IMI Jawa Barat yang sudah login. Bekerja di /Users/trendy/Documents/IMIapps. Scope Anda backend, migrasi, konfigurasi, dokumentasi kontrak dan pengujian; jangan mengubah mobile/APK. Pertahankan working tree dan pekerjaan audit yang sedang berlangsung; jangan reset, commit/push/deploy atau menimpa baseline audit.
## Perilaku produk
Pada Home sesudah login tersedia pintasan SPKLU Terdekat. Saat diketuk, mobile meminta lokasi foreground satu kali, mengirim koordinat ke backend, menampilkan SPKLU sekitar yang diurutkan berdasarkan jarak garis lurus. Pengguna memilih hasil lalu membuka aplikasi Maps eksternal untuk rute berkendara. Jarak pencarian bukan jarak tempuh/ETA. Tidak perlu embedded map, tracking background atau navigasi di dalam aplikasi.
## Data Google dan database
1. Gunakan API resmi Google Places API (New), bukan scraping Google Search/Maps. Nearby Search dengan tipe electric_vehicle_charging_station, rankPreference DISTANCE, field mask minimal, languageCode id dan radius terkontrol. Verifikasi parameter dan ketentuan resmi terbaru sebelum implementasi.
2. Data hasil kategori charging station tidak selalu SPKLU PLN; label sebagai lokasi pengisian kendaraan listrik/SPKLU, jangan mengarang operator PLN, status tersedia, harga, daya, connector atau realtime occupancy.
3. DB harus membedakan sumber dan provenance. Simpan provider/place_id unik serta metadata internal yang diizinkan. Jangan menyimpan permanen nama/alamat/foto/raw response Google secara otomatis. Place ID boleh disimpan; koordinat Google hanya sementara sesuai ketentuan yang berlaku, saat ini maksimal30hari, dengan expiry/purge yang benar. Jangan menggunakan TTL30hari sebagai izin menyimpan semua field lain.
4. Sediakan model data station mandiri untuk input/import sumber yang memang memiliki izin penyimpanan dan navigasi lintas penyedia: nama, alamat, lat/lng, operator opsional, source/source_reference, status aktif, verified_at dan bukti/provenance lisensi. Field yang tidak diketahui nullable. Jangan menyalin hasil Google lalu memberi label manual/independent. Gunakan Django admin existing untuk CRUD bila sesuai pola repo; tidak perlu halaman web-admin baru.
5. Jangan persist koordinat pencarian pengguna atau riwayat lokasinya. Sanitasi log/request tracing. Pertahankan attribution Google Maps dan third-party attribution pada respons untuk ditampilkan mobile; dokumentasikan kebutuhan privacy/terms. Konten Google Places tidak boleh dipakai bersama peta non-Google: provider Google → Google Maps saja; independent licensed → Google/Apple sesuai hak sumber. Sertakan allowed_navigation_providers per hasil yang diturunkan server dari provenance, bukan dari input client.
## Kontrak API usulan
POST /api/v1/spklu/nearby/ (sesuaikan pola router tanpa mengganggu route existing), authenticated member. Body latitude, longitude, radius_m default10000, limit default10. Validasi lat[-90,90],lng[-180,180], finite numbers, radius positif maksimum50000, limit1..20; tolak bool/NaN/infinity dan koordinat invalid, nilai0 valid. Jangan membatasi Jawa Barat diam-diam.
Respons normal: results[], radius_m, distance_basis=straight_line, coverage=nearby_results_not_exhaustive, attribution. Per hasil: stable reference/id, source, provider_place_id nullable, name, address nullable, latitude/longitude, distance_m, operator/status/connector hanya bila ada data sah, fetched_at bila relevan, attribution, allowed_navigation_providers. Jangan expose API key/raw provider payload. Tetapkan kontrak final exact beserta contoh aktual sebelum handoff.
Search sumber independent dapat memakai Haversine+bounds yang benar untuk skala kecil tanpa mewajibkan PostGIS baru; urutkan ascending dan radius filter benar. Gabungkan hasil Google/independent hanya dengan dedup yang jelas, jangan salah menggabungkan station berbeda yang berdekatan. Jangan mengklaim semua station tercakup dari hasil provider yang dibatasi. Pertahankan provenance masing-masing field.
## Operasional dan kegagalan
- API key server-side env saja, restrictions dan billing/quota setup didokumentasikan. Jangan menaruh key di client atau log. Jangan melakukan panggilan live berbiaya massal saat test.
- Timeout provider, retry terbatas, throttle per user/IP dan batas request. Hindari upstream request berulang karena tap ganda. Terapkan cache hanya pada field/use yang memang diizinkan, bukan raw response cache otomatis.
- Bedakan200 empty valid vs401 vs400 vs429 aplikasi vs503 provider unavailable/quota/config. Jika provider gagal tetapi ada hasil independent, boleh200partial dengan warning/source jelas; jangan menyatakan cache kedaluwarsa sebagai fresh. Tanpa key tetap dapat memakai data independent sah; tanpa keduanya return konfigurasi belum tersedia, bukan hasil kosong palsu.
- Tidak perlu menyimpan titik pengguna untuk caching/rate limiting; jangan log precise origin. Bersihkan Google coordinate cache yang expired melalui command/task yang sesuai repo; expired data tidak dipakai saat search meskipun task belum berjalan.
## Acceptance dan handoff
Test auth, validasi, radius/ordering, nilai0, DB dedup/idempotent upsert, provenance/navigation policy, expiry/purge, timeout/quota, partial vs empty, attribution dan larangan secret/location logging. Mock provider diberi label; live provider NOT VERIFIED jika key tidak tersedia. Jalankan backend suite dan migration consistency tanpa melemahkan tes existing.
Buat docs/features/spklu/ berisi REQUIREMENTS.md (fitur tambahan PRD, tanpa mengubah denominator audit lama), API_CONTRACT.md FINAL, HANDOFF_TO_ANTIGRAVITY.md, WORK_STATE.md, setup/env.example tanpa secret, evidence dan manifest SHA256. Jelaskan migrasi, contoh request/response/error, provider setup, allowed navigation, atribusi, lokasi dan sumber data. Catat dependency key/billing/data source secara konkret. Jangan mengklaim siap live hanya karena mocktests lulus.
Referensi primer yang harus dibaca ulang:
- https://developers.google.com/maps/documentation/places/web-service/nearby-search
- https://developers.google.com/maps/documentation/places/web-service/policies
- https://cloud.google.com/maps-platform/terms/maps-service-terms (Places API, khususnya storage/non-Google maps)
- https://developers.google.com/maps/documentation/urls/get-started
# Prompt Antigravity, lanjutkan perbaikan identitas
Perbaikan sebelumnya telah diverifikasi Codex.46 tes existing lulus, tetapi tiga regresi independen gagal. Jangan mengklaim fix selesai berdasarkan suite existing saja.
Baca docs/audit/2026-10-09-mobile-identity-verification/REPORT.md dan raw log. Gunakan identity_regression_test.dart sebagai acceptance tests, tanpa melemahkan assertion. Pertahankan backend/admin dan working tree agen lain; fokus mobile.
1. I01: AppState.login harus memutus sesi lama secara konsisten sebelum mencoba identitas baru. Token, currentUser, data privat dan attempt harus sesuai kebijakan logout/switch. Login gagal tidak boleh menyisakan access/refresh akun lama atau isAuthenticated true. Uji kegagalan401, timeout dan payload login tidak lengkap; sukses hanya bila sesi baru valid. Jangan membuat sukses palsu setelah loadProfile401/403 menghapus sesi.
2. I02: invalidasi request in-flight dari sesi sebelumnya. Tambahkan session epoch/generation atau solusi ekuivalen yang berubah saat logout/switch/login dimulai. Guard semua response yang memutasi data privat: profile, tickets, notifications, login/token persistence serta error401/403. ResponseA yang datang setelah logout/loginB tidak boleh menghidupkanA, mengisi tiket/notifikasiA atau menghapus sesiB. Token persistence harus aman bila request loginA/B selesai tidak berurutan. Jangan hanya memeriksa mounted atau currentUser null.
3. I03: bekukan seluruh create-order payload setelah submission pertama. Snapshot harus persisted sebelum network dan dipakai untuk setiap replay, termasuk lost response tanpa order_id. Snapshot buyer name/phone/email, terms, seluruh lines termasuk session/holder, discount dan client_ref harus immutable/deep copied. Profil baru atau input yang diedit tidak boleh mengubah attempt lama. UI harus menjelaskan identitas order existing dan menolak/memisahkan edit menjadi draft baru sampai cancel/final result aman.409 tidak mengizinkan key/order baru; jangan otomatis bayar ulang. Restore tidak boleh menimpa identitas submitted attempt dengan profil terkini.
Tambahkan tes untuk logout→loginB dengan responseA terlambat, response401A setelahBlogin, dua login berurutan yang responsnya terbalik, lost create response lalu edit/restart/terms berubah, serta draft baru memakai profil terbaru. Jalankan tes existing46 ditambah tiga tes independen dan tambahan meaningful tests. Setelah perbaikan, semuanya harus lulus.
Verifikasi identitas pada request create-order, respons order dan payment screen; native/Snap customer_details bila perangkat/provider tersedia. Tetap bedakan bukti mock dari riil. Jangan log data/token/password riil. Jangan menambahkan fallback Rizky/AnggotaIMI/VA contoh.
Jalankan analyze, test dan build release setelah perubahan terakhir. Simpan source hash, APK hash, build/test logs dan status native/provider ke folder audit baru; update laporan dengan root cause yang benar-benar sesuai source. Hapus klaim kompatibilitas seluruh perangkat yang belum diuji. APK baru harus jelas lokasinya; root imi-jabar-release.apk lama jangan disalahartikan sebagai build terbaru.
Tidak ada commit/push/deploy. Setelah selesai berikan source/APK/evidence untuk audit independen ulang, tanpa menaikkan skor PRD sendiri.
# Verifikasi independen perbaikan identitas pembeli
Tanggal: 9 Oktober 2026. Scope: review hasil Antigravity mobile-identity-fix dan regresi identitas/sesi/checkout. Ini bukan audit seluruh kriteria PRD atau bukti native/Snap riil. Source mobile/backend/admin tidak diedit dalam pemeriksaan ini; tes independen disimpan dalam folder audit.
## Hasil
46 tes existing lulus ketika dijalankan ulang, tetapi tiga tes regresi independen gagal. Hasil gabungan: 46 passed, 3 failed, exit1. Perbaikan belum layak ditandai selesai. Raw log: flutter-tests-independent.log; reproduksi: identity_regression_test.dart.
APK mobile/build/app/outputs/flutter-apk/app-release.apk cocok dengan laporan: SHA256 c8dcb1dabf816dc471f611eb4e45910d22170ad9b18492214097a3e345a54f74, ukuran27097616bytes. Seluruh49 berkas pada manifest Antigravity cocok; tidak ditemukan mobile/lib source dengan mtime lebih baru daripada APK. Hash/timestamp ini merupakan verifikasi integritas artefak, bukan bukti aplikasi berjalan pada perangkat atau provenance build lengkap.
## I01, P1: token lama tetap aktif ketika login pengganti gagal
AppState.login mengosongkan currentUser/tickets/notifications, tetapi tidak menghapus token disk sebelum request login atau pada kegagalannya. ApiService.login hanya mengganti token jika sukses. isAuthenticated menerima token yang masih ada meskipun profil sudah null.
Reproduksi tes: simpan token dummy akunA → login akunB mendapat401 → assert login false lulus → assert access token null gagal karena masih AUDIT-OLD-USER. Sesi bisa terlihat terautentikasi dan request berikutnya tetap memakai akunA. Laporan Antigravity menyebut cache reset sebelum API login; source yang diverifikasi hanya membersihkan memori pada titik tersebut.
Referensi: mobile/lib/providers/app_state.dart login65–90, isAuthenticated31; mobile/lib/core/services/api_service.dart login55–86. Tidak ada kredensial riil dalam tes.
## I02, P1: respons profil lama dapat menghidupkan identitas setelah logout
AppState.loadProfile menerima response sukses tanpa mencocokkan sesi/token/user yang memulai request. logout tidak menginvalidasi request yang masih berjalan. Guard yang sama juga tidak tersedia pada loadTickets/loadNotifications.
Reproduksi tes: request /me akunA ditunda → logout selesai → respons /me akunA dilepas → currentUser kembali menjadi UserModel akunA, padahal token sudah dibersihkan. assert currentUser null gagal. Ini dapat memasukkan identitas lama ke checkout kembali; skenario pergantian ke akunB dan response401 lama juga harus diuji saat fix.
Referensi: mobile/lib/providers/app_state.dart94–111,122–133,169–173. Perlu session generation/epoch atau guard ekuivalen untuk semua hasil request milik akun, termasuk error dan pemuatan login.
## I03, P1: payload berubah saat retry dengan key yang sama
CheckoutScreen menyimpan attempt sebelum POST, tetapi setiap retry membangunnya ulang dari controller saat ini. Input tetap dapat diedit. Restore attempt juga memprioritaskan profil terbaru untuk identitas meskipun attempt telah dikirim. Snapshot bukan sumber payload immutable.
Reproduksi widget: POST order pertama dikirim dengan buyer UserA dan holder UserA; respons disimulasikan hilang. Pengguna mengubah buyer/holder lalu menekan Bayar kembali. POST kedua memakai client_ref yang sama, tetapi buyer_name dan lines[0].receiver_name berubah. Assertion equality payload pertama/kedua gagal; persamaan client_ref sudah lulus. Backend dapat me-replay order asli, sehingga form terbaru berbeda dari identitas order sebenarnya.
Referensi: mobile/lib/screens/ticketing/checkout_screen.dart114–164,197–229,342–377,876–917. Bekukan seluruh request setelah submission pertama; edit hanya boleh pada draft atau attempt baru setelah pembatalan/finalisasi aman. Tampilan order existing harus mengikuti identitas order/frozen attempt, bukan mengganti histori berdasarkan profil baru.
## Cakupan bukti yang belum tersedia
Tes pergantian akun Antigravity hanya memanggil storage dengan key userB lalu mengharapkan null; tidak menjalankan urutan loginA/logout/loginB, in-flight response atau request pembayaran dua identitas. Widget payment summary memakai OrderModel fixture langsung. Karena itu tes tersebut tidak membuktikan customer_details Snap dari order nyata benar. Data buyer backend dan provider riil belum diuji dalam sesi ini.
Source perubahan logout, pemisahan buyer/holder dan penghapusan VA demo tersedia. Temuan ini tidak membatalkan perubahan itu; hasil verifikasi menunjukkan tiga kondisi penting belum tertutup. Nama Rizky masih ada pada fixture tes sebagai data negatif, bukan bukti hardcode runtime.
Skor PRD tidak diubah. Audit keseluruhan dan persetujuan rilis menunggu fix tiga regresi dan bukti integrasi/native yang sesuai. Instruksi lanjutan tersedia PROMPT_ANTIGRAVITY.md.
## Reproduksi
Jalankan dari mobile, gunakan provider HTTP simulasi dalam tes:
```sh
rtk proxy /Users/trendy/Documents/flutter/bin/flutter test --reporter expanded test /Users/trendy/Documents/IMIapps/docs/audit/2026-10-09-mobile-identity-verification/identity_regression_test.dart
rtk proxy /Users/trendy/Documents/flutter/bin/flutter analyze
```
Tes49 total saat ini diharapkan gagal pada tiga regresi sampai implementasi diperbaiki. Jangan mengubah assertion untuk menutupi defect. Source dan APK checksum pemeriksaan tersedia integrity.json; hash evidence/source ada verification-manifest.json.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:imi_jabar_app/core/services/api_service.dart';
import 'package:imi_jabar_app/core/services/storage_service.dart';
import 'package:imi_jabar_app/models/event_model.dart';
import 'package:imi_jabar_app/models/user_model.dart';
import 'package:imi_jabar_app/providers/app_state.dart';
import 'package:imi_jabar_app/screens/ticketing/checkout_screen.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await StorageService.init();
await StorageService.clearAuth();
ApiService.resetClient();
});
tearDown(() async {
await StorageService.clearAuth();
ApiService.resetClient();
});
test('Failed account switch must not retain previous access token', () async {
await StorageService.saveTokens(access: 'AUDIT-OLD-USER', refresh: 'AUDIT-OLD-REFRESH');
final state = AppState()..currentUser = UserModel(id: 1, phone: '+628111111111', fullName: 'User A', role: 'member');
final success = await http.runWithClient(() => state.login('+628122222222', 'invalid-audit-password'), () => MockClient((request) async => http.Response('{"detail":"Login ditolak"}', 401)));
expect(success, isFalse);
expect(StorageService.getAccessToken(), isNull, reason: 'Failed login B must not leave credentials A active');
expect(state.isAuthenticated, isFalse);
});
test('Profile response from before logout must not restore old user', () async {
await StorageService.saveTokens(access: 'AUDIT-OLD-USER', refresh: 'AUDIT-OLD-REFRESH');
final state = AppState()..currentUser = UserModel(id: 1, phone: '+628111111111', fullName: 'User A', role: 'member');
final started = Completer<void>();
final response = Completer<http.Response>();
final load = http.runWithClient(() => state.loadProfile(), () => MockClient((request) async { started.complete(); return response.future; }));
await started.future;
await state.logout();
response.complete(http.Response(jsonEncode({'user': {'id': 1, 'phone': '+628111111111', 'full_name': 'User A', 'roles': ['member']}}), 200));
await load;
expect(state.currentUser, isNull, reason: 'An old in-flight /me response cannot revive logged-out identity');
expect(state.isAuthenticated, isFalse);
});
testWidgets('Lost create response retry must preserve buyer and holder payload', (tester) async {
final event = EventModel.fromJson({'id': 1, 'title': 'Audit Event', 'slug': 'audit-event', 'status': 'published', 'start_date': '2026-11-01', 'end_date': '2026-11-02', 'tiers': []});
final tier = TicketTierModel.fromJson({'id': 2, 'name': 'Festival', 'code': 'festival', 'price': 100000, 'status': 'enabled', 'remaining': 100, 'benefits': []});
final posted = <Map<String, dynamic>>[];
final quote = {'id': 0, 'code': '', 'event_title': 'Audit Event', 'status': 'quote', 'subtotal': 100000, 'subtotal_display': 'Rp 100.000', 'discount_amount': 0, 'app_fee_amount': 0, 'imi_fee_amount': 0, 'service_fee': 0, 'tax_amount': 0, 'total': 100000, 'total_display': 'Rp 100.000', 'lines': []};
ApiService.client = MockClient((request) async {
if (request.url.path.endsWith('/orders/quote/')) return http.Response(jsonEncode(quote), 200);
if (request.url.path.endsWith('/orders/')) {
posted.add(Map<String, dynamic>.from(jsonDecode(request.body)));
throw http.ClientException('Audit: response lost after request sent');
}
return http.Response('{"detail":"Unexpected audit request"}', 404);
});
final state = AppState()..currentUser = UserModel(id: 1, phone: '+628111111111', fullName: 'User A', email: 'a@example.invalid', role: 'member');
await tester.pumpWidget(ChangeNotifierProvider<AppState>.value(value: state, child: MaterialApp(home: CheckoutScreen(event: event, tier: tier, quantity: 1))));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byType(CheckboxListTile));
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
final pay = find.widgetWithText(ElevatedButton, 'Bayar Sekarang');
await tester.ensureVisible(pay);
await tester.tap(pay);
await tester.pumpAndSettle();
expect(posted.length, 1);
final buyer = find.widgetWithText(TextFormField, 'Nama Lengkap Pembeli *');
await tester.ensureVisible(buyer);
await tester.enterText(buyer, 'Changed after unknown result');
final holder = find.widgetWithText(TextFormField, 'Nama Pemegang Tiket (Festival) *');
await tester.ensureVisible(holder);
await tester.enterText(holder, 'Changed holder after unknown result');
await tester.pumpAndSettle();
await tester.ensureVisible(pay);
await tester.tap(pay);
await tester.pumpAndSettle();
expect(posted.length, 2);
expect(posted[1]['client_ref'], posted[0]['client_ref']);
expect(posted[1], equals(posted[0]), reason: 'Same idempotency key requires frozen original payload, not edited controllers');
});
}
{
"created_at": "2026-10-09T12:12:24.887428+00:00",
"reported_source_count": 49,
"source_mismatches": [],
"apk_path": "mobile/build/app/outputs/flutter-apk/app-release.apk",
"apk_sha256": "c8dcb1dabf816dc471f611eb4e45910d22170ad9b18492214097a3e345a54f74",
"apk_matches_report": true,
"apk_size_bytes": 27097616,
"mobile_lib_newer_than_apk": [],
"backend_admin_changes_since_handoff": [],
"root_apk_sha256": "25a3065772f97619d0711f6cca5a36102c2d700cdcabb5f82ffda4ae7e84153e",
"tests": {
"existing_passed": 46,
"independent_regressions_failed": 3,
"provider": "HTTP mocked; no real transactions"
}
}
\ No newline at end of file
{
"checked_at": "2026-10-09T12:12:24.887428+00:00",
"source": {
"mobile/lib/core/constants/app_constants.dart": "d6e1e0a4047fd035707c91f39de89675c587405221f9e28b362257e84b10d89b",
"mobile/lib/core/services/api_service.dart": "c4d4a78bda8472069f928f87d174e80c1de4f1c1064be6624116bb2ed05f7ec4",
"mobile/lib/core/services/storage_service.dart": "f0b2cbe35a231d8cf0c9b8a8a47069985f5897889a37755b81da499aed468933",
"mobile/lib/core/theme/app_theme.dart": "306cb2d72c87bacbcecfaa76b704e5402fffe86db7ba935b86b42dcd933560c4",
"mobile/lib/core/utils/deep_link_router.dart": "c2f859be3d229dbfc0a326647ffd0ddcf029d32ac7c96323f21c869a4dc4109c",
"mobile/lib/core/utils/formatters.dart": "6b65b1b6d1e481214df271c53265e50a299d044585d8c83b807ad5e123142a6a",
"mobile/lib/main.dart": "ec19e1a396f02cd81ed2cd7a1d96e9483be2deaa0f777a21748eb73488f8713a",
"mobile/lib/models/checkout_attempt_model.dart": "92be6a8369925118b0576e1560d61e8642dd2c68c8c63cada4f147741c670a30",
"mobile/lib/models/content_model.dart": "67ca1bfb394afd1bdb03f41a4b46390bb48b1d600140847087d6077857d0f70c",
"mobile/lib/models/event_model.dart": "0549366c58a58bc93e3cfff0876cf2fceee1394970a41c67521193b802a98b0c",
"mobile/lib/models/notification_model.dart": "f1206a33b97b6a5921894777dcc982a73b51d1fa382ece46866fb74c495d673c",
"mobile/lib/models/order_model.dart": "140837a78aa648c9be25207e79f9c45400d5d24d1dd973dcf08dc7b4560414f9",
"mobile/lib/models/partner_model.dart": "c42ef14a4fd17edd33ce50da489474fa0d8ecdee8b1846577242d4ebae2d9507",
"mobile/lib/models/ticket_model.dart": "439b2887de45003cd0509294783a7a279d98dadcc999da15168c28ee72185fa1",
"mobile/lib/models/user_model.dart": "28d879a2bdd2d2b1946591a93519a6ddb0da338f8d325ca4f97a3bbcebca6e12",
"mobile/lib/providers/app_state.dart": "849d750ff15c085d0572b2d6c97ea3b3fa887d0b2424b22adbe955b5883c82d9",
"mobile/lib/screens/auth/forgot_password_screen.dart": "4fd1f8d84675020e03c22dc30b72541c0da5732c2c3722c0e8badce01ffe2fae",
"mobile/lib/screens/auth/login_screen.dart": "f4477938c4703152cc9e33cbf59448e53cf6ee3427129dfdab8cd231dcf94a6c",
"mobile/lib/screens/auth/register_screen.dart": "73756a6909ee3c6f6dc7faa7ce2da75c8e4e7140ff1e60242bc32c6b92a471aa",
"mobile/lib/screens/events/event_calendar_screen.dart": "9d60d8fe1697ac400af71428a1701888487da8720c94fdfafc22aa9aa26c3964",
"mobile/lib/screens/events/ticket_tier_screen.dart": "3e7fe58d2be5836d33f762f4da9f57da1ddf21e3fed22389204bdfeed83478e9",
"mobile/lib/screens/home/article_detail_screen.dart": "ca2875c023f1f20631d9e3106155c9f2d5e4a97ea8c6a1336db5d6595057edce",
"mobile/lib/screens/home/home_screen.dart": "5f8f675937cb86c1e95e3df6552e6ffea135065983d0ab208b7b143a194c6207",
"mobile/lib/screens/kta/kta_digital_screen.dart": "8c2345cbfe8d4768dccaa992adf738e1de50a6cb6c5b4b7e1402d3c72ef8abd4",
"mobile/lib/screens/main_navigation_screen.dart": "c9208043e2a8be73889167cc10d20cf17f5dcd61f6ff97a638472509f609d4c2",
"mobile/lib/screens/media/media_gallery_screen.dart": "62452a90391bf8221a62b6df57bb68a4e6773014af01d7df93fe0658dda614e5",
"mobile/lib/screens/membership/membership_info_screen.dart": "3b5432c131cc31b35c925c1124a7dfc2b869a81c62bddcaa622647edbed86d87",
"mobile/lib/screens/notifications/notifications_screen.dart": "6fa16eff031c51120443bf28b55f01f9ae250760d9d08391bbd2621b256af374",
"mobile/lib/screens/partners/partner_catalog_screen.dart": "fca352a68a2cb6179cedd9a013336a1a19dbca439f6254c79f8397a5ebf4946b",
"mobile/lib/screens/partners/voucher_detail_screen.dart": "a68077c096e5a0e76752004b6da4b15eec1da4afa5a7acdaaabe17cc6cc23993",
"mobile/lib/screens/profile/edit_profile_screen.dart": "681683b52c2cfc6dbdd866015c36ba2281e872e241968e71fa1d8589e2375fb0",
"mobile/lib/screens/profile/my_vouchers_screen.dart": "98a04bc1b39b60172e2c701ce90d0a1cdcf22e9b7d3d54e2e6619d8e3ef1d639",
"mobile/lib/screens/profile/notification_settings_screen.dart": "1f29b73f8b01ac0b361ef96a199f7fce9b2ab88bd04d2a581519b78b6b3551da",
"mobile/lib/screens/profile/profile_screen.dart": "3f2091e7221c9837e76bcaf5963ed95dfef9e4b748500a63bec26b4a78097b9b",
"mobile/lib/screens/splash/welcome_screen.dart": "edb95f2122869cff4cd14d15151d4fcff922a1024ad595117499399b8bc8a3a1",
"mobile/lib/screens/ticketing/checkout_screen.dart": "7874c1956fd71ff24f5364e55575ad55abd1978de3d878f10beace0779c4e61f",
"mobile/lib/screens/ticketing/my_tickets_screen.dart": "5d08cb311f59de64fce9708056e3fd07840677ae05ea07c05da7deb4dde6a01b",
"mobile/lib/screens/ticketing/payment_instruction_screen.dart": "ce72c0ce27e49a00cb94a0a16cbd64195be82179bfd1c5f94be592be43e318d4",
"mobile/lib/screens/ticketing/payment_success_screen.dart": "15f90e0d9d6655d0f99ee1dd2e8a9cb2a5d0ecdaa96b1c5e88137213d23f0c3f",
"mobile/lib/screens/ticketing/ticket_qr_screen.dart": "81d145e29b12f3f82d5a84484eae875446a2498043f218e9ed04b292f669abd5",
"mobile/pubspec.yaml": "8f02d5dd24481d8fa97b88ea1b7f82698a44720c1bde779d1f5a72be9990d5f9",
"mobile/test/buyer_identity_flow_test.dart": "5a98e782b66cdeea8649d6260bcba3404c4edd4fafded6178da9798555e7ec06",
"mobile/test/final_audit_suite_test.dart": "bef275c09ccfca5451040f972547374af2d4f30682a9e8dbe5b44feae53f387b",
"mobile/test/order_model_test.dart": "572a81aef44d64a1c90972fbb1343ce907a1fdf4199359ce62b4e6b82d0dcba5",
"mobile/test/payment_recovery_test.dart": "bf501bd0a882f6fb35b502e539503eebe6ce805e80973d00a2f3f61cde912c9e",
"mobile/test/register_otp_flow_test.dart": "6ef7bb8b5f1dd8e0c3476494a86aafa366ccfc9533cfa39f36a509d198af897f",
"mobile/test/ticket_validation_test.dart": "b7435df8d1bbaab7768e788f72d8429f5566ee87f1ff71a402af3745f3b3ab55",
"mobile/test/user_model_test.dart": "2d250ffe36c32e367edaa434858f3214fe243bb78ee87fb4c0247dff596bd4af",
"mobile/test/widget_test.dart": "5a0fbdf61ba8260076f7cf2677967cd77ccd7e056bcbad88c36d066e598e023b"
},
"evidence": {
"REPORT.md": "76be7620f90207e8d58bf4c20ea4cb57bc4076d54e0adcb14ac8ddbbccf58f1b",
"PROMPT_ANTIGRAVITY.md": "d6d7015c044826b5ac8715dd7b25568799f95625e3f53abfb6e44e7723711520",
"flutter-tests-independent.log": "5a38e7d03466a90529831219cc310cb8ca79c031e03b5eaa4a5f1b73c51086bd",
"verify_integrity.py": "ff4157e404142cb902a492763885e9325cf33299e60d4203403ae831416d1cc7",
"identity_regression_test.dart": "ca10f45652a9bd4508fe9e0fe066d281164ee4619c3d3efeb3e2b1a7d23c41d0",
"flutter-analyze-independent.log": "bce4cbefebb908a4937420ca1c175cae3938021642565dbfcfe6d32f1d73ec51",
"integrity.json": "5f73d0c8c48d5a28f0a683ac49d4e5aa8f9c88a94682c686930bb68342c5215d"
}
}
\ No newline at end of file
import hashlib
import json
from pathlib import Path
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[3]
OUT = Path(__file__).resolve().parent
def sha(file):
return hashlib.sha256(file.read_bytes()).hexdigest()
reported = json.loads((ROOT/'docs/audit/2026-10-09-mobile-identity-fix/source-manifest.json').read_text())
reported_apk = json.loads((ROOT/'docs/audit/2026-10-09-mobile-identity-fix/apk-info.json').read_text())
apk = ROOT/reported_apk['apk_path']
prior = json.loads((ROOT/'docs/audit/2026-10-09-opencode/source-manifest.json').read_text())['files']
out = {
'created_at': datetime.now(timezone.utc).isoformat(),
'reported_source_count': len(reported),
'source_mismatches': [name for name, expected in reported.items() if not (ROOT/name).exists() or sha(ROOT/name) != expected],
'apk_path': reported_apk['apk_path'],
'apk_sha256': sha(apk),
'apk_matches_report': sha(apk) == reported_apk['sha256'],
'apk_size_bytes': apk.stat().st_size,
'mobile_lib_newer_than_apk': [str(file.relative_to(ROOT)) for file in (ROOT/'mobile/lib').rglob('*.dart') if file.stat().st_mtime > apk.stat().st_mtime],
'backend_admin_changes_since_handoff': [name for name, expected in prior.items() if name.startswith(('backend/', 'web-admin/', 'web/')) and (not (ROOT/name).exists() or sha(ROOT/name) != expected)],
'root_apk_sha256': sha(ROOT/'imi-jabar-release.apk') if (ROOT/'imi-jabar-release.apk').exists() else None,
'tests': {'existing_passed': 46, 'independent_regressions_failed': 3, 'provider': 'HTTP mocked; no real transactions'},
}
(OUT/'integrity.json').write_text(json.dumps(out, indent=2))
manifest = {'checked_at': out['created_at'], 'source': {name: sha(ROOT/name) for name in reported if (ROOT/name).exists()}, 'evidence': {file.name: sha(file) for file in OUT.iterdir() if file.is_file() and file.name != 'verification-manifest.json'}}
(OUT/'verification-manifest.json').write_text(json.dumps(manifest, indent=2))
assert not out['source_mismatches'] and out['apk_matches_report']
print(json.dumps(out, indent=2))
This diff is collapsed.
# Reproduksi
Semua perintah dijalankan dari `backend/` dengan `.venv` (`backend/.venv/bin/python`). Node: `/opt/homebrew/bin/node` (Node 26, global WebSocket). Chrome: `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`.
## 1. Suite backend penuh
```
cd backend
.venv/bin/python manage.py test apps # 133 tests OK
.venv/bin/python manage.py makemigrations --check # bersih
```
## 2. Probe audit 10-08 (test-runner, DB test)
```
.venv/bin/python - <<'EOF'
import os, sys, django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
base = "/Users/trendy/Documents/IMIapps/docs/audit"
dirs = {
"full_probes": "2026-10-08-full",
"webhook_overlap_probe": "2026-10-08-verifikasi-coordination",
"payment_overlap_probe": "2026-10-08-verifikasi-final",
"payment_payload_probe": "2026-10-08-verifikasi",
"snap_evidence_probe": "2026-10-08-payment-fix",
}
for mod, d in dirs.items():
p = os.path.join(base, d)
if p not in sys.path:
sys.path.insert(0, p)
from django.test.runner import DiscoverRunner
raise SystemExit(bool(DiscoverRunner(verbosity=1).run_tests(list(dirs))))
EOF
# => 5 testes OK
```
## 3. HTTP E2E terisolasi (DB audit port 8110)
Gunakan salinan script di `/tmp` (agar evidence tidak menimpa audit 08) dan DB audit baru:
```
cp docs/audit/2026-10-08-full/audit_server.py /tmp/audit_server_opencode.py
sed -i '' "s|/private/tmp/imi-full-audit-20261008.sqlite3|/private/tmp/imi-opencode-20261009.sqlite3|" /tmp/audit_server_opencode.py
sed -i '' "s|ROOT = Path(__file__).resolve().parents\[3\]|ROOT = Path('/Users/trendy/Documents/IMIapps')|" /tmp/audit_server_opencode.py
cd backend && .venv/bin/python /tmp/audit_server_opencode.py --setup # migrate + seed
(.venv/bin/python /tmp/audit_server_opencode.py >/tmp/imi-8110.log 2>&1 &) # runserver 8110
cp docs/audit/2026-10-08-full/api_e2e.py /tmp/imi-api-e2e.py
mkdir -p /private/tmp/evidence
/usr/bin/python3 /tmp/imi-api-e2e.py # 29 langkah HTTP
```
## 4. E2E UI (CDP)
Servis yang dibutuhkan:
```
(cd web && python3 -m http.server 8381 &) # landing + partner portal
(cd web-admin && python3 -m http.server 5173 &) # SPA admin
(cd backend && .venv/bin/python manage.py runserver 127.0.0.1:8001 &)
chrome --headless=new --no-sandbox --disable-gpu --remote-debugging-port=9333 about:blank &
```
Fixture API: user content `+6281555500999 / Pass12345!` (role content); maka upload media di `/media/manage/` dan buat MembershipInfo di `/membership-info/manage/`.
```
node docs/audit/2026-10-09-opencode/evidence/scripts/admin-smoke.js 9333
node docs/audit/2026-10-09-opencode/evidence/scripts/finance-tabs-smoke.js 9333
node docs/audit/2026-10-09-opencode/evidence/scripts/finance-tier-smoke.js 9333
node docs/audit/2026-10-09-opencode/evidence/scripts/mobile-390-smoke.js 9333
```
Setiap PASS membutuhkan response benar + tersimpan + reload kondisi akhir.
## 5. Verifikasi lifecycle model via API (pasca E2E tier)
```
TOK=<login admin +6281200000001/Admin123! -> tokens.access>
POST /api/v1/revenue-sharing/models/<id>/approve/ -> status=approved
POST /api/v1/revenue-sharing/models/<id>/activate/ -> status=active
POST /api/v1/revenue-sharing/models/<id>/deactivate/ -> status=deactivated
```
## Catatan
- DB dev di mesin ini di-restore ke snapshot 2026-10-09; pastikan fixture ter-seed ulang sebelum smoke.
- Jangan jalankan `api_e2e.py` asli dari repo — `finally` menulis ke `docs/audit/2026-10-08-full/evidence/api-e2e.json` (baseline tidak boleh ditimpa).
\ No newline at end of file
# Status Remediasi A01–A16 — 2026-10-12 (scope OpenCode: backend + web-admin + web publik)
> Ownership sesuai `docs/audit/2026-10-09-remediation-prompts/01-OPENCODE.md`. Mobile = Antigravity.
> Baseline audit 2026-10-08: Finance 82,9/100 (29/35), Umum 68,8/100 (77/112), Tambahan 75/100 (13,5/18). Tidak ada denominator yang diubah.
| Kode | Ringkasan | Status | Bukti kunci |
|------|-----------|--------|-------------|
| A01 | Payment verified tidak ditimpa response create terlambat; transisi atomic/monotonic; webhook/create/retry/cancel; ticket issuance sekali | ✅ TERKONFIRMASI | `webhook_overlap_probe` ✓, `payment_overlap_probe` ✓, `snap_evidence_probe` ✓; ticketing suite 112 test OK |
| A02 | (per audit) koordinasi pembayaran | ✅ | payoffactor commit `409cdaa`; `docs/audit/2026-10-08-payment-coordination/PAYMENT_COORDINATION_CONTRACT.md` final |
| A03 | (per audit) | ✅ | tercakup suite ticketing `test_payment_contract.py` |
| A04 | Kontrak `select()` label/value benar; dropdown share_type/calculation_basis/reconciliation source/adjustment type/tier status | ✅ | `web-admin/js/ui.js` + `finance.js`; dialog tiered dipilih lewat UI (E2E) |
| A05 | Input referensi transaksi adjustment benar (tidak undefined.value); adjustment per-order/per-party, reason, approval | ✅ | `finance.js` tab Adjustment; tab render bersih; tested `apps.finance` 35 OK |
| A06 | Create/version model invalid atomic — HTTP400 tidak meninggalkan draft/trailing | ✅ | `full_probes.InvalidModelAtomicityProbe` ✓; `apps.finance` 35 OK |
| A07 | Optional blank/null partner & benefit konsisten; form minimal & lengkap tersimpan | ✅ | `apps.partners` tests ✓ |
| A09 | Schema notification body/kind/deep_link/read-state; list/unread/mark-read pemilik; repeated mark-read aman; fixture 5 kategori PRD | ✅ | `notifications/0002_alter_notification_kind.py`; `apps.notifications` tests ✓; friendlier payload ada di handoff |
| A11 | Root mount finance konsisten; tab tetap 9 setelah save/reload/pindah tab/buka modal | ✅ | Admin smoke `finance tab bar = 9 tabs (A11)` PASS; finance-tabs-smoke 9/9 PASS |
| A12 | Label filter/search/select, fokus modal, inline errors, loading/empty/error/retry, target 44px; desktop + 390px | ✅ (desktop+390px web) | `mobile-390-smoke.js` semua PASS; login/finance/content teruji manual |
| A13 | RBAC anggota/PII vs role: permission positive/negative; matriks proposal | ✅ backend + proposal; keputusan bisnis ditandai | `accounts/tests_rbac.py` 18 test OK; `A13_ROLE_MATRIX_PROPOSAL.md` |
| A15 | Audit log actor/waktu/entity/action/reason/before-after; legacy ditandai jujur | ✅ | Tab "Audit Log" tampil `staff imi · recon.created … alasan tak tercatat` (legacy jujur); `apps.finance` 35 OK |
| A16 | Semua kriteria 0/50 backend/admin/publik: media galeri, membership info, party/account mgmt, workflow approval/recon/adjustment via UI, tiered sharing, discount payer/sponsor | ✅ | Media+membership: API & UI & publik; discount & tiered: API+UI+E2E lifecycle; workflow: finance-tabs-smoke 9/9; api_e2e HTTP flow |
### Tambahan pembuktian sesi ini (2026-10-12)
- Suite penuh `manage.py test apps` → **133 tests OK** `(46,0s)`; finance 35, ticketing 112 (termasuk `test_payment_contract.py`), content 13, accounts rbac 18, events/notifications/partners.
- Probe audit 08: `full_probes`, `webhook_overlap_probe`, `payment_overlap_probe`, `payment_payload_probe`, `snap_evidence_probe` → **5/5 OK** (isolated test DB).
- HTTP E2E `api_e2e.py` (DB audit terisolasi port 8110): **29 langkah**, quote→order→payment→confirm(paid)→tickets→check-in, model create+approve+activate, settlement approve→process→mark_paid→reconcile→close semua HTTP 200. Evidence di `evidence/http-e2e-20261012.json`.
- E2E UI finance tiered (klik nyata): create modal → partisipan tiered → tier lines `0|100000000|percentage|5` & `100000000||percentage|7` → submit → model tersimpan `share_type=tiered`, `participants[0].tiers` benar, `status=draft` → approve `status=approved` → activate `status=active` → deactivate `status=deactivated` (guard "Hanya model aktif yang dapat dinonaktifkan" benar).
- E2E UI admin: login content staff → kardus Galeri Foto & Video + item media, Informasi Membership + item, kardus Program Diskon + Data Party & Rekening, tab finance = 9, tanpa console errors. Evidence: `evidence/log-admin-smoke.txt`, screenshot `evidence/admin-content-finance-20261012.png`.
- Mobile 390px: landing + partner portal + admin login tanpa horizontal overflow, konten kunci tampil, tanpa console errors (10/10). Evidence: `evidence/log-mobile-390.txt`.
### Residual / batas yang tidak dibuktikan sesi ini
1. **Mobile (Flutter) tidak diverifikasi** di mesin ini — serahkan ke Antigravity mengikuti `HANDOFF_TO_ANTIGRAVITY.md`.
2. **Provider sandbox (Midtrans/Snap/payout)**: diisolasi; simulated di test (`AUDIT-SIMULATED`). Tidak ada transaksi produksi (dilarang). Status sebenarnya harus diverifikasi oleh Antigravity dengan kredensial sandbox.
3. **PostgreSQL concurrency**: diverifikasi via probe deterministik (webhook-overlap, payment-overlap) pada test runner; eksekusi ulang harus tetap dijaga assertion lama (`test_payment_contract`, `api_e2e`).
4. DB dev di-restore snapshot 2026-10-09; fixture smoke sesi ini (user content `+6281555500999`, media, membership, model tiered "Model UI Tiered Smoke") di-seed ulang setelah restore. Semua berdampak ringan di DB lokal.
5. Skor: **tidak ada skor mobile yang dinaikkan** atas perubahan backend; tidak ada skor buatan; laporan per-ubah-an yang terbukti, residual, dan keputusan yang belum diputus di atas.
\ No newline at end of file
[
{
"path": "/auth/login",
"method": "POST",
"status": 200
},
{
"path": "/auth/login",
"method": "POST",
"status": 200
},
{
"path": "/me",
"method": "GET",
"status": 200
},
{
"path": "/me",
"method": "PATCH",
"status": 200
},
{
"path": "/notification-preferences",
"method": "PATCH",
"status": 200
},
{
"path": "/notification-preferences",
"method": "GET",
"status": 200
},
{
"path": "/wallet/",
"method": "GET",
"status": 200
},
{
"path": "/finance/parties/",
"method": "GET",
"status": 200
},
{
"path": "/revenue-sharing/models/",
"method": "POST",
"status": 201
},
{
"path": "/revenue-sharing/models/2/approve/",
"method": "POST",
"status": 200
},
{
"path": "/revenue-sharing/models/2/activate/",
"method": "POST",
"status": 200
},
{
"path": "/events/",
"method": "GET",
"status": 200
},
{
"path": "/ticket-tiers/?event=1",
"method": "GET",
"status": 200
},
{
"path": "/orders/quote/",
"method": "POST",
"status": 200
},
{
"path": "/orders/",
"method": "POST",
"status": 201
},
{
"path": "/orders/1/payment/",
"method": "POST",
"status": 201
},
{
"path": "/orders/1/confirm/",
"method": "POST",
"status": 200
},
{
"path": "/tickets/?scope=active",
"method": "GET",
"status": 200
},
{
"path": "/scan/check-in",
"method": "POST",
"status": 200
},
{
"path": "/settlements/generate/",
"method": "POST",
"status": 201
},
{
"path": "/settlements/1/approve/",
"method": "POST",
"status": 200
},
{
"path": "/settlements/1/process/",
"method": "POST",
"status": 200
},
{
"path": "/settlements/1/mark_paid/",
"method": "POST",
"status": 200
},
{
"path": "/settlements/1/reconcile/",
"method": "POST",
"status": 200
},
{
"path": "/settlements/1/close/",
"method": "POST",
"status": 200
},
{
"path": "/reports/revenue/",
"method": "GET",
"status": 200
},
{
"path": "/reports/settlement/",
"method": "GET",
"status": 200
},
{
"path": "/reports/reconciliation/",
"method": "GET",
"status": 200
},
{
"result": "passed",
"quote_total": 257500,
"payment": "simulated",
"payout": "manual status simulation",
"ui": "API flow; separate UI evidence"
}
]
\ No newline at end of file
PASS | admin: login form shown when logged out
PASS | admin: login with content user shows dashboard | IMI IMI Jabar Admin ▦ Dashboard ❖ Event Management ▤ Content Management ▰ Slider Iklan ◈ P
PASS | admin: content page shows media gallery card
PASS | admin: content page shows media item from API
PASS | admin: content page shows membership card
PASS | admin: content page shows membership item
PASS | admin: finance page shows Program Diskon card
PASS | admin: finance page shows Data Party & Rekening card
PASS | admin: finance tab bar = 9 tabs (A11) | tabs=9
PASS | admin: screenshot saved | admin-content-finance-20261012.png
PASS | admin: no console errors
total: 12.541s
PASS | finance tab: Harga & Aturan | clicked=ok errs=0
PASS | finance tab: Model Bagi Hasil | clicked=ok errs=0
PASS | finance tab: Transaksi | clicked=ok errs=0
PASS | finance tab: Adjustment | clicked=ok errs=0
PASS | finance tab: Settlement | clicked=ok errs=0
PASS | finance tab: Rekonsiliasi | clicked=ok errs=0
PASS | finance tab: Laporan | clicked=ok errs=0
PASS | finance tab: Statement Party | clicked=ok errs=0
PASS | finance tab: Audit Log | clicked=ok errs=0
PASS | finance: nav to finance page | ok
PASS | finance: models tab active | ok
PASS | finance: create modal open | ok
PASS | finance: tiered option exists in modal | sels=1
PASS | finance: model form configured | {"levelSels":["settlement_frequency","approve"],"partyOpts":["1","2"],"share":"ok","basis":"ok","tiers":"ok"}
PASS | finance: model submitted | ok
PASS | finance: model appears after submit
PASS | finance: no console errors
PASS | mobile 390: landing no horizontal overflow | scrollW=390 vw=390
PASS | mobile 390: landing key content
PASS | mobile 390: landing no console errors
PASS | mobile 390: landing media grid rendered | cards=2
PASS | mobile 390: portal no horizontal overflow | scrollW=390 vw=390
PASS | mobile 390: portal key content
PASS | mobile 390: portal no console errors
PASS | mobile 390: admin login works
PASS | mobile 390: admin no horizontal overflow | scrollW=390 vw=390
PASS | mobile 390: admin no console errors
// CDP smoke: web-admin login (content role) -> Content (media + membership) -> Finance cards + 9 tabs.
// Usage: node admin-smoke.js [cdpPort]
const sleep = ms => new Promise(r => setTimeout(r, ms));
const PORT = process.argv[2] || "9333";
console.time("total");
async function main() {
const list = await fetch(`http://127.0.0.1:${PORT}/json/list`).then(r => r.json());
const target = list.find(t => t.type === "page");
if (!target) throw new Error("no page target");
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(res => ws.onopen = res);
let id = 0;
const pending = new Map();
const errors = [];
ws.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.method === "Runtime.exceptionThrown") errors.push(String(m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails.text));
if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") errors.push(m.params.args.map(a => String(a.value || a.description)).join(" "));
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
};
const send = (method, params = {}) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, m => m.error ? rej(new Error(m.error.message)) : res(m.result));
ws.send(JSON.stringify({ id: mid, method, params }));
});
const evl = async e => {
const r = await send("Runtime.evaluate", { expression: "(" + e + ")()", awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) return "EVALERR:" + JSON.stringify(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const log = (n, ok, d) => console.log(`${ok ? "PASS" : "FAIL"} | ${n}${d ? " | " + d : ""}`);
await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
await send("Page.navigate", { url: "http://127.0.0.1:5173/" });
await sleep(2400);
const hasLogin = await evl(`async () => !!document.querySelector('input[type="password"], input[name="password"], .login-card')`);
log("admin: login form shown when logged out", hasLogin === true);
if (hasLogin === true) {
await evl(`async () => {
const setVal = (el, v) => { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); };
const ins = document.querySelectorAll('input');
setVal(ins[0], '+6281555500999');
setVal(ins[1], 'Pass12345!');
const btn = [...document.querySelectorAll('button, input[type=submit]')].find(b => (b.innerText || b.value || '').includes('Masuk'));
if (btn) btn.click();
return 'ok';
}`);
await sleep(4000);
} else {
log("admin: session already active, skipping login", true);
}
const navText = await evl(`async () => document.body.innerText.slice(0, 1200)`);
log("admin: login with content user shows dashboard", String(navText).includes("Content Management"), navText.slice(0, 90).replace(/\n/g, " "));
await evl(`async () => {
const b = [...document.querySelectorAll('button, a, [role="tab"], .nav-item')].find(x => x.innerText && x.innerText.includes('Content Management'));
if (b) { b.click(); return 'ok'; } return 'not found';
}`);
await sleep(3000);
const contentText = await evl(`async () => document.body.innerText.toLowerCase()`);
log("admin: content page shows media gallery card", String(contentText).includes("galeri foto & video"));
log("admin: content page shows media item from API", String(contentText).includes("highlight putaran 1"));
log("admin: content page shows membership card", String(contentText).includes("informasi membership (publik)"));
log("admin: content page shows membership item", String(contentText).includes("keanggotaan imi jabar"));
await evl(`async () => {
const b = [...document.querySelectorAll('button, a, [role="tab"], .nav-item')].find(x => x.innerText && x.innerText.includes('Harga & Bagi Hasil'));
if (b) { b.click(); return 'ok'; } return 'not found';
}`);
await sleep(3000);
const finText = await evl(`async () => document.body.innerText.toLowerCase()`);
log("admin: finance page shows Program Diskon card", String(finText).includes("program diskon"));
log("admin: finance page shows Data Party & Rekening card", String(finText).includes("data party & rekening"));
const tabCount = await evl(`async () => document.querySelectorAll('.tabbar button, .toolbar.tabbar button').length`);
log("admin: finance tab bar = 9 tabs (A11)", tabCount === 9, "tabs=" + tabCount);
try {
const shot = await send("Page.captureScreenshot", { format: "png" });
require("fs").writeFileSync(require("path").join(__dirname, "..", "admin-content-finance-20261012.png"), Buffer.from(shot.data, "base64"));
log("admin: screenshot saved", true, "admin-content-finance-20261012.png");
} catch (e) { log("admin: screenshot failed", false, e.message); }
log("admin: no console errors", errors.length === 0, errors.slice(0, 2).join(" ;; "));
ws.close();
console.timeEnd("total");
process.exit(0);
}
main().catch(e => { console.error("FATAL", e); process.exit(1); });
\ No newline at end of file
// Finance workflow UI smoke: all 9 tabs render, no permission/error cards, no console errors.
// Usage: node finance-tabs-smoke.js [cdpPort]
const sleep = ms => new Promise(r => setTimeout(r, ms));
const PORT = process.argv[2] || "9333";
async function main() {
const list = await fetch(`http://127.0.0.1:${PORT}/json/list`).then(r => r.json());
const ws = new WebSocket(list.find(t => t.type === "page").webSocketDebuggerUrl);
await new Promise(res => ws.onopen = res);
let id = 0; const pend = new Map();
const tabErrs = {};
ws.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") { const cur = tabErrs.__cur || "?"; (tabErrs[cur] = tabErrs[cur] || []).push(m.params.args.map(a => String(a.value || a.description)).join(" ")); }
if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); }
};
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pend.set(i, x => x.error ? rej(new Error(x.error.message)) : res(x.result)); ws.send(JSON.stringify({ id: i, method, params })); });
const evl = async e => { const r = await send("Runtime.evaluate", { expression: "(" + e + ")()", awaitPromise: true, returnByValue: true }); return r.result.value; };
const log = (n, ok, d) => console.log(`${ok ? "PASS" : "FAIL"} | ${n}${d ? " | " + d : ""}`);
await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
await send("Page.navigate", { url: "http://127.0.0.1:5173/" });
await sleep(2400);
if (!(await evl(`async () => document.body.innerText.includes('Harga & Bagi Hasil')`))) {
await evl(`async () => {
const setVal = (el, v) => { el.value = v; el.dispatchEvent(new Event('input', {bubbles:true})); el.dispatchEvent(new Event('change', {bubbles:true})); };
const ins = document.querySelectorAll('input');
setVal(ins[0], '+6281200000001'); setVal(ins[1], 'Admin123!');
const b = [...document.querySelectorAll('button')].find(x => (x.innerText || '').includes('Masuk'));
if (b) b.click(); return 'ok';
}`);
await sleep(3800);
}
await evl(`async () => { const b=[...document.querySelectorAll('button,a')].find(x=>x.innerText && x.innerText.includes('Harga & Bagi Hasil')); if(b){b.click(); return 'ok';} return 'none'; }`);
await sleep(2600);
const tabs = ["Harga & Aturan", "Model Bagi Hasil", "Transaksi", "Adjustment", "Settlement", "Rekonsiliasi", "Laporan", "Statement Party", "Audit Log"];
for (const t of tabs) {
tabErrs.__cur = t;
const clicked = await evl(`async () => {
const b = [...document.querySelectorAll('.toolbar.tabbar button')].find(x => x.innerText && x.innerText.replace(/\\s+/g, ' ').includes(${JSON.stringify(t)}));
if (b) { b.click(); return 'ok'; } return 'missing';
}`);
await sleep(2800);
const txt = await evl(`async () => document.body.innerText.toLowerCase()`);
const bad = String(txt).includes("anda tidak memiliki izin") || String(txt).includes("gagal memuat") || String(txt).includes("error");
log("finance tab: " + t, clicked === "ok" && !bad, "clicked=" + clicked + " errs=" + (tabErrs[t] || []).length);
}
ws.close();
process.exit(0);
}
main().catch(e => { console.error("FATAL", e); process.exit(1); });
\ No newline at end of file
This diff is collapsed.
{
"artifact": "mobile/build/app/outputs/flutter-apk/app-release.apk",
"sha256": "73e380641ae84b9ac2bba73843c48a0bd0f25e8473364b65489a1549e911e3e7",
"size_bytes": 27106796,
"size_human": "27.1 MB",
"build_type": "release",
"timestamp": "2026-10-12T11:55:07+07:00",
"flutter_version": "3.24.3 (channel stable)",
"dart_version": "3.5.3"
}
Analyzing mobile...
No issues found! (ran in 3.2s)
{
"artifact": "mobile/build/app/outputs/flutter-apk/app-release.apk",
"sha256": "8328b6d1fab9fedcb72d179e42ac3189c7e47f7fa2da531da7bb5ab68ec30aec",
"size_bytes": 27107178,
"size_human": "27.1 MB",
"build_type": "release",
"timestamp": "2026-10-12T12:29:43+07:00",
"flutter_version": "3.24.3 (channel stable)",
"dart_version": "3.5.3"
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
const {chromium}=require('/Users/trendy/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('fs'),path=require('path'),assert=require('assert');const OUT=path.join(__dirname,'evidence'),result={steps:[],errors:[],pass:false};
(async()=>{const b=await chromium.launch({headless:true});try{const p=await b.newPage();p.on('pageerror',e=>result.errors.push(e.message));await p.goto('http://127.0.0.1:5330/');await p.locator('#id').fill('+6281200000001');await p.locator('#pw').fill('Admin123!');await p.getByRole('button',{name:'Masuk',exact:true}).click();await p.waitForSelector('.nav');await p.goto('http://127.0.0.1:5330/#/members');await p.getByRole('searchbox',{name:'Cari member',exact:true}).fill('Audit Member Admin');await p.waitForTimeout(500);await p.locator('tr').filter({hasText:'Audit Member Admin'}).getByRole('button',{name:'Detail',exact:true}).click();let m=()=>p.locator('.modal').last();assert((await m().innerText()).includes('Nomor Member'));result.steps.push({name:'member-detail-card',status:'PASS'});for(const [action,confirm] of [['Verifikasi','Ya, verifikasi'],['Tangguhkan','Ya, tangguhkan'],['Aktifkan','Ya, aktifkan'],['Terbitkan Lisensi','Ya, terbitkan']]){await m().getByRole('button',{name:action,exact:true}).click();const wait=p.waitForResponse(r=>r.url().includes('/admin/members/')&&r.url().endsWith('/action/'));await m().getByRole('button',{name:confirm,exact:true}).click();const response=await wait;const body=await response.json();assert.equal(response.status(),200,JSON.stringify(body));result.steps.push({name:'member-'+action,status:'PASS'});await p.waitForTimeout(300)}await p.screenshot({path:path.join(OUT,'admin-member-workflow.png'),fullPage:true});result.pass=!result.errors.length;}catch(e){result.fatal=e.message;}finally{await b.close();fs.writeFileSync(path.join(OUT,'admin-member.json'),JSON.stringify(result,null,2));if(!result.pass)process.exitCode=1;}})();
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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