Commit 409cdaa5 authored by gustixjerk's avatar gustixjerk

feat(all): implement end-to-end finance, payment recovery & mobile release

- Backend: finance splitting, reconciliation, settlement batches, and idempotent order/payment contracts
- Mobile: implement VF01 backend cancel guard, VF02 scoped attempt persistence & resume, VF04 verified without token handling, and VF05 comprehensive behavior recovery tests
- Mobile: resolve Galaxy Tab S6 Lite installation issue by eliminating corrupted duplicate native libraries (* 2.so) through clean release rebuild
- Mobile: add OTP registration flow, profile sync, voucher redemption, and e-ticket QR validations
- Web Admin: integrated finance settlement, transactions, reconciliation, and statement management
- Audit & Evidence: add verification reports, API contracts, test logs, and verified source manifests
parent 2a4d40e4
# DESIGN.md — IMI Jawa Barat (public web + partner portal)
Arah gaya: **institusional resmi** (federasi olahraga). Navy putih, tipografi rapi,
layout teratur. Bukan sporty agresif, bukan startup.
Dial: **ENERGY 1 / RHYTHM 2 / MOTION 1**.
Catatan jujur: arah ditulis oleh agen berdasarkan pilihan mood pemilik produk.
Keputusan visual di bawah ini adalah interpretasi agen, bukan identitas brand
resmi yang sudah ditetapkan.
## Identitas
- Nama tampil: teks wordmark "IMI Jawa Barat" (logo asli menyusul, placeholder `[LOGO]` di header).
- Kepribadian: resmi, terpercaya, lugas.
## Palet (maks 3 inti + 1 aksen)
| Warna | Peran | Alasan |
|---|---|---|
| Navy `#0B2545` | inti: header, hero, judul, tombol utama | warna institusi; kontras AA di atas putih |
| Putih `#FFFFFF` + abu `#F4F6F8`, teks sekunder `#4B5563` | alas & teks | netral, tidak bersaing dengan navy |
| Aksen merah `#C1272D` | satu aksen: status penting, fokus CTA | nada merah-putih, dipakai hemat agar hierarki terlihat |
## Tipografi
- Judul: `ui-serif, Georgia, serif` — serif memberi kesan kelembagaan/formal.
- Teks & UI: `system-ui, -apple-system, Segoe UI, sans-serif` — keterbacaan
tanpa dependensi font eksternal (aman offline).
- Skala: judul section 1.5rem, body 1rem / line-height 1.6.
## Struktur halaman (konten menentukan layout)
`web/index.html` (publik, tanpa login): header + anchor nyata → strip banner resmi
→ agenda event → benefit mitra → daftar mitra → artikel → footer.
`web/partner-portal.html`: login (telepon + sandi) → ringkasan saldo party →
statement per periode (ringkasan + tabel revenue + tabel adjustment) → unduh CSV → keluar.
Setiap panel data punya tiga state: memuat, kosong, error (dengan tombol coba lagi).
## Keputusan besar (alasan satu baris)
- Tanpa gambar placeholder: `poster`/`logo` dari API null → tampil blok tipografi (R-23: aset jangan dipalsukan).
- Tanpa angka statistik di homepage: tidak ada sumber data nyata (R-17).
- Motion 1: hanya transisi hover/focus; situs kelembagaan mengutamakan konten.
- RHYTHM 2: grid kartu seragam untuk daftar, strip banner dan tabel statement jadi jeda komposisi.
- Halaman disajikan statis tanpa build step; `API` base dikonfigurasi di awal skrip tiap halaman.
......@@ -24,6 +24,11 @@ JWT_REFRESH_TTL_DAYS=30
# OTP (registrasi/lupa password) — DEV only fallback: OTP akan ditampilkan di response API
OTP_EXPIRY_MINUTES=5
DEV_OTP_ECHO=True
# Gateway OTP HTTP (WhatsApp/SMS provider). Isi untuk produksi; saat terisi,
# DEV_OTP_ECHO default jadi False (kode OTP tidak lagi ikut di response).
OTP_GATEWAY_URL=
OTP_GATEWAY_TOKEN=
OTP_GATEWAY_TIMEOUT=8
# Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com
MIDTRANS_IS_PRODUCTION=False
......
# Generated by Django 4.2.16 on 2026-10-07 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(blank=True, choices=[('super_admin', 'Super Admin'), ('admin', 'Admin'), ('event', 'Event / Panitia'), ('content', 'Content Admin'), ('partnership', 'Partnership Admin'), ('reporting', 'Reporting'), ('finance', 'Finance / Keuangan'), ('auditor', 'Auditor')], default='', max_length=20, verbose_name='Role'),
),
]
......@@ -53,6 +53,8 @@ class User(AbstractUser):
CONTENT = "content", "Content Admin"
PARTNERSHIP = "partnership", "Partnership Admin"
REPORTING = "reporting", "Reporting"
FINANCE = "finance", "Finance / Keuangan"
AUDITOR = "auditor", "Auditor"
username = None # login pakai phone / email / no KTA
phone = models.CharField("Nomor HP", max_length=20, unique=True)
......
......@@ -20,8 +20,33 @@ def add_years(day, years=1):
return day.replace(year=day.year + years, month=2, day=28)
def _dispatch_otp_gateway(phone: str, code: str, purpose: str) -> None:
"""Kirim OTP via gateway HTTP (env OTP_GATEWAY_URL, mis. Twilio/Fonnte/Wablas).
Gateway gagal = error tegas ke user; kode OTP tersimpan tetap bisa
diverifikasi bila pesan sebenarnya sudah terkirim.
"""
import requests
try:
resp = requests.post(
settings.OTP_GATEWAY_URL,
json={"phone": phone, "code": code, "purpose": purpose},
headers=(
{"Authorization": f"Bearer {settings.OTP_GATEWAY_TOKEN}"}
if settings.OTP_GATEWAY_TOKEN
else {}
),
timeout=settings.OTP_GATEWAY_TIMEOUT,
)
resp.raise_for_status()
except requests.RequestException as exc:
raise ValidationError({"detail": "Gateway OTP tidak dapat dihubungi. Coba lagi nanti."}) from exc
def send_otp(phone: str, purpose: str, echo: bool | None = None) -> dict:
"""Mengirim (simulasi) OTP. echo=True => otp dibalikan di response (DEV saja)."""
"""Kirim OTP. Lewat OTP_GATEWAY_URL bila dikonfigurasi (produksi);
echo=True => otp dibalikan di response (hanya lingkungan dev)."""
OtpCode.objects.filter(phone=phone, purpose=purpose, is_used=False).update(is_used=True)
code = generate_otp()
......@@ -32,7 +57,8 @@ def send_otp(phone: str, purpose: str, echo: bool | None = None) -> dict:
expires_at=otp_expiry(),
)
echo = settings.DEV_OTP_ECHO if echo is None else echo
# TODO: integrasi WhatsApp gateway (Twilio/Wablas/Fonnte) produksi
if settings.OTP_GATEWAY_URL:
_dispatch_otp_gateway(phone, code, purpose)
return {"otp_id": otp.id, "expires_at": otp.expires_at, "echo": code if echo else None}
......
......@@ -58,4 +58,16 @@ AdminOnly = IsAdminRole
EventStaff = role_allowed(*ADMIN_ROLES, "event")
ContentStaff = role_allowed(*ADMIN_ROLES, "content")
PartnershipStaff = role_allowed(*ADMIN_ROLES, "partnership")
AnyStaff = role_allowed(*ADMIN_ROLES, "event", "content", "partnership", "reporting")
\ No newline at end of file
ReportingStaff = role_allowed(*ADMIN_ROLES, "reporting")
FinanceStaff = role_allowed(*ADMIN_ROLES, "finance")
AuditorStaff = role_allowed(*ADMIN_ROLES, "auditor")
AnyStaff = role_allowed(*ADMIN_ROLES, "event", "content", "partnership", "reporting", "finance", "auditor")
# Finance (PRD v1.0 §33)
# - Konfigurasi model & fee: Super Admin / Admin / Finance (limited) / Partnership
# - Approve/process settlement & payout: Super Admin / Admin / Finance
# - Reconcile & resolve: Super Admin / Admin / Finance / Auditor
FinanceConfigureStaff = role_allowed(*ADMIN_ROLES, "finance", "partnership")
FinanceApproveStaff = role_allowed(*ADMIN_ROLES, "finance")
FinanceReconcileStaff = role_allowed(*ADMIN_ROLES, "finance", "auditor")
FinanceReadStaff = role_allowed(*ADMIN_ROLES, "finance", "reporting", "auditor")
\ No newline at end of file
......@@ -39,7 +39,7 @@ class EventViewSet(viewsets.ModelViewSet):
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [AllowAny()]
return [IsAdminRole()]
return [_CONTENT_STAFF()]
def get_serializer_class(self):
if self.action in ["list", "retrieve"]:
......@@ -57,7 +57,7 @@ class EventViewSet(viewsets.ModelViewSet):
status = self.request.query_params.get("status")
if status:
qs = qs.filter(status=status)
if self.action == "list":
if self.action in ("list", "retrieve"):
user = self.request.user
is_admin = bool(
user.is_authenticated
......
"""Seed singleton default rows (FinanceSettings, PricingConfig).
Menjamin get_or_create("default") selalu menemukan baris sehingga update
langsung atas baris default tidak hilang (kontrak get_default).
"""
from django.db import migrations
def seed(apps, schema_editor):
FinanceSettings = apps.get_model("finance", "FinanceSettings")
PricingConfig = apps.get_model("finance", "PricingConfig")
FinanceSettings.objects.get_or_create(
key="default",
defaults={
"minimum_payout_amount": 0,
"negative_balance_policy": "carry_forward",
"rounding_method": "half_up",
"gateway_fee_pct": 0,
},
)
PricingConfig.objects.get_or_create(
key="default",
defaults={
"app_fee_pct": 5.0,
"imi_fee_pct": 3.0,
"tax_rate_pct": 10.0,
"service_fee": 5000,
},
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("finance", "0002_party_partyaccount_reconciliationbatch_and_more"),
]
operations = [
migrations.RunPython(seed, noop),
]
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 12:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('finance', '0003_seed_default_settings'),
]
operations = [
migrations.AddField(
model_name='party',
name='portal_user',
field=models.OneToOneField(blank=True, help_text='Akun login portal partner untuk melihat statement sendiri (PRD §28).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='party_profile', to=settings.AUTH_USER_MODEL),
),
]
This diff is collapsed.
"""Pembulatan nominal Rupiah untuk domain finance.
Kebijakan pembulatan dikonfigurasi via FinanceSettings.rounding_method
(half_up / half_down / down). Semua perhitungan uang di services memakai modul ini
agar konsisten dan dapat diaudit.
"""
from __future__ import annotations
from decimal import ROUND_DOWN, ROUND_HALF_DOWN, ROUND_HALF_UP, Decimal
from .models import FinanceSettings
def _to_decimal(value) -> Decimal:
if isinstance(value, Decimal):
return value
return Decimal(str(value))
def _rounding(rounding_method: str | None):
if rounding_method == FinanceSettings.RoundingMethod.HALF_DOWN:
return ROUND_HALF_DOWN
return ROUND_HALF_UP
def round_money(value, rounding_method: str | None = None) -> int:
"""Bulatkan ke rupiah (satuan) sesuai metode (default half_up)."""
d = _to_decimal(value)
rounding = _rounding(rounding_method)
return int(d.quantize(Decimal("1"), rounding=rounding))
def round_hundred(value, rounding_method: str | None = None) -> int:
"""Bulatkan nominal ke kelipatan 100 rupiah terdekat."""
d = _to_decimal(value)
if rounding_method == FinanceSettings.RoundingMethod.DOWN:
return int((d / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_DOWN)) * 100
rounding = _rounding(rounding_method)
return int((d / Decimal("100")).quantize(Decimal("1"), rounding=rounding)) * 100
def active_method() -> str:
"""Metode pembulatan aktif dari setting (default half_up bila belum dibuat)."""
return FinanceSettings.get_default().rounding_method
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
from django.urls import path
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
app_name = "finance"
router = DefaultRouter()
router.register("finance/parties", views.PartyViewSet, basename="party")
router.register("finance/party-accounts", views.PartyAccountViewSet, basename="party-account")
router.register("revenue-sharing/models", views.RevenueSharingModelViewSet, basename="revenue-sharing-model")
router.register("finance/revenue-allocation", views.RevenueAllocationViewSet, basename="revenue-allocation")
router.register("finance/adjustments", views.AdjustmentViewSet, basename="adjustment")
router.register("settlements", views.SettlementBatchViewSet, basename="settlement")
router.register("reconciliations", views.ReconciliationBatchViewSet, basename="reconciliation")
urlpatterns = [
path("", include(router.urls)),
path("finance/pricing-config/preview/", views.PricingPreviewView.as_view(), name="pricing-preview"),
path("finance/pricing-config/", views.PricingConfigView.as_view(), name="pricing-config"),
path("finance/settings/", views.FinanceSettingsView.as_view(), name="finance-settings"),
path("transactions/", views.TransactionLedgerView.as_view(), name="transaction-ledger"),
path("reports/revenue/", views.RevenueReportView.as_view(), name="report-revenue"),
path("reports/settlement/", views.SettlementReportView.as_view(), name="report-settlement"),
path("reports/reconciliation/", views.ReconciliationReportView.as_view(), name="report-reconciliation"),
path("reports/statement/", views.PartnerStatementView.as_view(), name="report-statement"),
path("partner/me/", views.PartnerMeView.as_view(), name="partner-me"),
path("partner/statement/", views.PartnerPortalStatementView.as_view(), name="partner-statement"),
path("audit-log/", views.AuditReportView.as_view(), name="audit-report"),
path("finance/seed-demo/", views.SeedDemoDataView.as_view(), name="finance-seed-demo"),
]
\ No newline at end of file
This diff is collapsed.
......@@ -21,6 +21,14 @@ class MidtransError(Exception):
"""Gagal komunikasi / respon tidak sesuai yang diharapkan dari Midtrans."""
class MidtransAmbiguousError(MidtransError):
"""Request terkirim tetapi hasil create transaksi tidak pasti (timeout / 5xx).
Transaksi mungkin sudah tercatat di provider; klien wajib memverifikasi
status (GET status) sebelum membuat transaksi baru.
"""
def _connect_error(host: str, exc: Exception) -> MidtransError:
hint = (
f"Cek: server ini dapat mengakses internet & resolusi DNS untuk `{host}` "
......@@ -61,14 +69,33 @@ class Midtrans:
headers={"Accept": "application/json"},
timeout=20,
)
except requests.Timeout as exc:
raise MidtransAmbiguousError(
f"Timeout membuat transaksi Snap di {self.snap_base_url}: {exc}. "
"Hasil di provider tidak diketahui."
) from exc
except requests.ConnectionError as exc:
raise MidtransAmbiguousError(
f"Koneksi terputus saat membuat transaksi Snap ({self.snap_base_url}): {exc}. "
"Hasil di provider tidak diketahui."
) from exc
except requests.RequestException as exc:
raise _connect_error(self.snap_base_url, exc) from exc
if resp.status_code >= 500:
raise MidtransAmbiguousError(
f"Midtrans create transaction {resp.status_code}: {resp.text[:300]}. "
"Hasil di provider tidak diketahui."
)
if resp.status_code not in (200, 201):
raise MidtransError(f"Midtrans create transaction {resp.status_code}: {resp.text[:300]}")
data = resp.json()
token = data.get("token", "")
redirect_url = data.get("redirect_url", "")
if not token and not redirect_url:
raise MidtransError(f"Respons Snap tidak berisi token: {resp.text[:300]}")
return {
"token": data.get("token", ""),
"redirect_url": data.get("redirect_url", ""),
"token": token,
"redirect_url": redirect_url,
"transaction_id": data.get("transaction_id", ""),
}
......@@ -104,28 +131,68 @@ class Midtrans:
# ------------------------------------------------------------- helpers
def _build_payload(self, order) -> dict:
"""Susun item_details resmi order -> invariant sum(price×qty) == gross_amount.
Komponen tagihan wajib memakai nilai resmi order: baris tiket, diskon
efektif, app fee, IMI fee, biaya layanan, dan pajak PB1. Midtrans
menolak payload yang jumlah itemnya berbeda dengan gross_amount.
"""
items = []
seen_ids: dict[str, int] = {}
lines_total = 0
for line in order.lines.all():
base_id = str(line.tier.code or f"tier-{line.tier_id}")[:40]
seen_ids[base_id] = seen_ids.get(base_id, 0) + 1
item_id = base_id if seen_ids[base_id] == 1 else f"{base_id}-{seen_ids[base_id]}"
price = int(line.unit_price)
quantity = int(line.quantity)
lines_total += price * quantity
items.append({
"id": line.tier.code,
"id": item_id,
"name": f"{order.event.title} - {line.tier.name}"[:50],
"price": int(line.unit_price),
"quantity": int(line.quantity),
"price": price,
"quantity": quantity,
})
subtotal = int(order.subtotal)
if lines_total != subtotal:
raise MidtransError(
f"Data order tidak konsisten: jumlah baris {lines_total} != subtotal {subtotal}."
)
# diskon efektif dibatasi subtotal agar taxable = max(subtotal - diskon, 0)
# tetap sama dengan komponen fee/pajak yang tercatat pada order.
discount_effective = min(int(order.discount_amount), subtotal)
if discount_effective:
items.append({"id": "discount", "name": "Diskon", "price": -discount_effective, "quantity": 1})
if order.app_fee_amount:
items.append({
"id": "app_fee",
"name": "Biaya layanan aplikasi",
"price": int(order.app_fee_amount),
"quantity": 1,
})
if order.discount_amount:
items.append({"id": "discount", "name": "Diskon", "price": -int(order.discount_amount), "quantity": 1})
if order.imi_fee_amount:
items.append({"id": "imi_fee", "name": "Fee IMI", "price": int(order.imi_fee_amount), "quantity": 1})
if order.service_fee:
items.append({"id": "service", "name": "Biaya layanan", "price": int(order.service_fee), "quantity": 1})
if order.tax_amount:
items.append({"id": "tax", "name": "Pajak (PB1)", "price": int(order.tax_amount), "quantity": 1})
gross = int(order.total)
items_total = sum(item["price"] * item["quantity"] for item in items)
if items_total != gross:
raise MidtransError(
f"Payload Snap tidak seimbang: total item {items_total} != gross_amount {gross}."
)
start = timezone.now()
expiry = order.expires_at if order.expires_at else start + timedelta(minutes=ORDER_HOLD_MINUTES)
return {
"transaction_details": {
"order_id": order.code,
"gross_amount": int(order.total),
"gross_amount": gross,
},
"item_details": items,
"customer_details": {
......
# Generated by Django 4.2.16 on 2026-10-07 10:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0003_order_app_fee_amount_order_imi_fee_amount'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('pending', 'Menunggu Pembayaran'), ('verified', 'Lunas / Terverifikasi'), ('failed', 'Gagal'), ('expired', 'Kedaluwarsa'), ('refunded', 'Dikembalikan'), ('chargeback', 'Keberatan / Chargeback')], default='pending', max_length=12),
),
migrations.CreateModel(
name='PaymentWebhookEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_code', models.CharField(editable=False, max_length=40)),
('transaction_id', models.CharField(blank=True, db_index=True, default='', max_length=80)),
('status_code', models.CharField(blank=True, default='', max_length=10)),
('transaction_status', models.CharField(blank=True, default='', max_length=40)),
('signature_valid', models.BooleanField(default=False)),
('amount_matches', models.BooleanField(default=False)),
('processed', models.BooleanField(default=False)),
('detail', models.CharField(blank=True, default='', max_length=200)),
('raw', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='webhook_events', to='ticketing.order')),
],
options={
'ordering': ['-created_at'],
},
),
]
# Generated by Django 4.2.16 on 2026-10-07 12:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('ticketing', '0004_alter_payment_status_paymentwebhookevent'),
]
operations = [
migrations.CreateModel(
name='Refund',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(editable=False, max_length=40, unique=True)),
('amount', models.PositiveIntegerField()),
('reason', models.TextField(blank=True, default='')),
('status', models.CharField(choices=[('pending', 'Menunggu'), ('approved', 'Disetujui'), ('rejected', 'Ditolak')], default='approved', max_length=12)),
('refunded_at', models.DateTimeField(blank=True, null=True)),
('transaction_reference', models.CharField(blank=True, default='', max_length=80)),
('adjustment_codes', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='ticketing.order')),
],
options={
'ordering': ['-created_at'],
},
),
]
# Generated by Django 4.2.16 on 2026-10-08 04:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0005_refund'),
]
operations = [
migrations.AddField(
model_name='order',
name='client_ref',
field=models.CharField(blank=True, db_index=True, default='', help_text='Idempotency key dari klien (checkout attempt); replay mengembalikan order yang sama.', max_length=64),
),
migrations.AddConstraint(
model_name='order',
constraint=models.UniqueConstraint(condition=models.Q(('client_ref', ''), _negated=True), fields=('user', 'client_ref'), name='unique_order_client_ref_per_user'),
),
]
# Generated by Django 4.2.16 on 2026-10-08 06:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0006_order_client_ref_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='attempt_started_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='mulai percobaan'),
),
migrations.AddField(
model_name='payment',
name='attempt_state',
field=models.CharField(blank=True, choices=[('', 'Tidak diketahui (percobaan lama)'), ('in_progress', 'Percobaan berjalan'), ('unknown', 'Hasil percobaan tidak pasti'), ('done', 'Percobaan selesai')], default='', help_text='in_progress = percobaan create provider sedang berjalan; unknown = hasil tidak pasti; done = sukses.', max_length=12, verbose_name='status percobaan'),
),
]
......@@ -18,6 +18,13 @@ class Order(models.Model):
code = models.CharField(max_length=40, unique=True, editable=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders")
client_ref = models.CharField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="Idempotency key dari klien (checkout attempt); replay mengembalikan order yang sama.",
)
member = models.ForeignKey(
"accounts.Member", null=True, blank=True, on_delete=models.SET_NULL, related_name="orders"
)
......@@ -59,6 +66,13 @@ class Order(models.Model):
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "client_ref"],
condition=~models.Q(client_ref=""),
name="unique_order_client_ref_per_user",
)
]
def __str__(self):
return self.code
......@@ -92,12 +106,22 @@ class Payment(models.Model):
CC = "cc", "Kartu Kredit/Debit"
SNAP = "snap", "Snap Midtrans"
class AttemptState(models.TextChoices):
"""Klaim percobaan create transaksi Snap (VF03): baris Payment menjadi
kunci serialisasi sebelum side effect provider dipanggil."""
LEGACY = "", "Tidak diketahui (percobaan lama)"
IN_PROGRESS = "in_progress", "Percobaan berjalan"
UNKNOWN = "unknown", "Hasil percobaan tidak pasti"
DONE = "done", "Percobaan selesai"
class Status(models.TextChoices):
PENDING = "pending", "Menunggu Pembayaran"
VERIFIED = "verified", "Lunas / Terverifikasi"
FAILED = "failed", "Gagal"
EXPIRED = "expired", "Kedaluwarsa"
REFUNDED = "refunded", "Dikembalikan"
CHARGEBACK = "chargeback", "Keberatan / Chargeback"
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="payment")
method = models.CharField(max_length=10, choices=Method.choices)
......@@ -119,6 +143,17 @@ class Payment(models.Model):
payment_type = models.CharField(max_length=40, blank=True, default="")
qr_string = models.CharField("QRIS string", max_length=200, blank=True, default="")
# Klaim attempt create (VF03): in_progress = worker memegang panggilan provider.
attempt_state = models.CharField(
"status percobaan",
max_length=12,
choices=AttemptState.choices,
blank=True,
default=AttemptState.LEGACY,
help_text="in_progress = percobaan create provider sedang berjalan; unknown = hasil tidak pasti; done = sukses.",
)
attempt_started_at = models.DateTimeField("mulai percobaan", null=True, blank=True)
gateway_latency_ms = models.PositiveIntegerField(null=True, blank=True)
raw_response = models.JSONField(default=dict, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
......@@ -129,6 +164,32 @@ class Payment(models.Model):
return f"{self.get_method_display()} {self.amount}"
class PaymentWebhookEvent(models.Model):
"""Catatan setiap notifikasi webhook pembayaran (audit idempotensi, F02).
Setiap payload disimpan mentah + hasil verifikasi. Pemrosesan idempoten
dijamin lewat guard transisi status di services (verified tidak diturunkan).
"""
order = models.ForeignKey(Order, null=True, blank=True, on_delete=models.SET_NULL, related_name="webhook_events")
order_code = models.CharField(max_length=40, editable=False)
transaction_id = models.CharField(max_length=80, blank=True, default="", db_index=True)
status_code = models.CharField(max_length=10, blank=True, default="")
transaction_status = models.CharField(max_length=40, blank=True, default="")
signature_valid = models.BooleanField(default=False)
amount_matches = models.BooleanField(default=False)
processed = models.BooleanField(default=False)
detail = models.CharField(max_length=200, blank=True, default="")
raw = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.order_code} {self.transaction_status} ({self.status_code})"
class Ticket(models.Model):
class Status(models.TextChoices):
READY = "ready", "Siap Scan Gate"
......@@ -193,3 +254,38 @@ class CheckIn(models.Model):
def __str__(self):
return f"{self.get_status_display()} {self.scan_code}"
class Refund(models.Model):
"""Refund sebagian/penuh (PRD §13).
Membuat Adjustment (append-only), bukan mengubah transaksi sumber.
"""
class Status(models.TextChoices):
PENDING = "pending", "Menunggu"
APPROVED = "approved", "Disetujui"
REJECTED = "rejected", "Ditolak"
code = models.CharField(max_length=40, unique=True, editable=False)
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="refunds")
amount = models.PositiveIntegerField()
reason = models.TextField(blank=True, default="")
status = models.CharField(max_length=12, choices=Status.choices, default=Status.APPROVED)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
refunded_at = models.DateTimeField(null=True, blank=True)
transaction_reference = models.CharField(max_length=80, blank=True, default="")
adjustment_codes = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.code} {self.amount}"
@property
def amount_display(self) -> str:
return humanize_rupiah(self.amount)
\ No newline at end of file
......@@ -36,20 +36,25 @@ class OrderSerializer(serializers.ModelSerializer):
tax_display = serializers.SerializerMethodField()
app_fee_display = serializers.SerializerMethodField()
imi_fee_display = serializers.SerializerMethodField()
payment = serializers.SerializerMethodField()
class Meta:
model = Order
fields = [
"id", "code", "event", "event_title", "status", "status_display",
"id", "code", "client_ref", "event", "event_title", "status", "status_display",
"subtotal", "subtotal_display", "discount_amount", "discount_display",
"app_fee_amount", "app_fee_display", "imi_fee_amount", "imi_fee_display",
"service_fee", "tax_amount", "tax_display", "total", "total_display",
"currency", "member_discount_pct", "voucher_discount_amount", "buyer_name",
"buyer_email", "buyer_phone", "payment_method", "expires_at", "paid_at",
"created_at", "lines",
"created_at", "lines", "payment",
]
read_only_fields = fields
def get_payment(self, obj):
payment = getattr(obj, "payment", None)
return PaymentSerializer(payment).data if payment else None
def get_subtotal_display(self, obj):
return humanize_rupiah(obj.subtotal)
......@@ -68,11 +73,28 @@ class OrderSerializer(serializers.ModelSerializer):
class CreateOrderSerializer(serializers.Serializer):
event_id = serializers.IntegerField()
client_ref = serializers.CharField(max_length=64, required=False, allow_blank=True, default="")
buyer_name = serializers.CharField(max_length=150, required=False)
buyer_email = serializers.EmailField(required=False, allow_blank=True)
buyer_phone = serializers.CharField(max_length=20, required=False)
same_as_buyer = serializers.BooleanField(default=True)
terms_accepted = serializers.BooleanField(default=False)
terms_accepted = serializers.BooleanField()
voucher_code = serializers.CharField(max_length=40, required=False, allow_blank=True)
lines = serializers.ListField(child=serializers.DictField(), allow_empty=False)
def validate_terms_accepted(self, value):
if not value:
raise serializers.ValidationError("Anda wajib menyetujui syarat & ketentuan.")
return value
class QuoteOrderSerializer(serializers.Serializer):
"""Payload quote: sama dengan create, tanpa terms (belum komit pembelian)."""
event_id = serializers.IntegerField()
buyer_name = serializers.CharField(max_length=150, required=False)
buyer_email = serializers.EmailField(required=False, allow_blank=True)
buyer_phone = serializers.CharField(max_length=20, required=False)
voucher_code = serializers.CharField(max_length=40, required=False, allow_blank=True)
lines = serializers.ListField(child=serializers.DictField(), allow_empty=False)
......@@ -91,6 +113,7 @@ class PaymentSerializer(serializers.ModelSerializer):
"amount_display", "provider", "reference", "nmid", "merchant_name", "va_number",
"account_name", "snap_token", "snap_redirect_url", "transaction_id", "payment_type",
"qr_string", "snap_client_key", "snap_is_production",
"attempt_state",
"paid_at", "expired_at", "created_at",
]
read_only_fields = fields
......
This diff is collapsed.
This diff is collapsed.
......@@ -12,4 +12,5 @@ router.register("checkins", views.CheckInHistoryViewSet, basename="check-in")
urlpatterns = router.urls + [
path("scan/check-in", views.CheckInScanView.as_view(), name="check-in-scan"),
path("payments/midtrans/notification", views.MidtransNotificationView.as_view(), name="midtrans-notification"),
path("refunds", views.RefundCreateView.as_view(), name="refund-create"),
]
\ No newline at end of file
......@@ -2,6 +2,8 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from django.utils import timezone
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
......@@ -10,11 +12,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.permissions import AnyStaff, IsMember
from apps.common.permissions import AnyStaff, FinanceApproveStaff, IsMember
from apps.common.utils import generate_qr_token
from . import midtrans, services
from .models import CheckIn, Order, Payment, Ticket
from .models import CheckIn, Order, Payment, PaymentWebhookEvent, Ticket
from .serializers import (
CheckInSerializer,
ConfirmPaymentSerializer,
......@@ -22,6 +24,7 @@ from .serializers import (
CreatePaymentSerializer,
OrderSerializer,
PaymentSerializer,
QuoteOrderSerializer,
TicketSerializer,
)
......@@ -44,14 +47,30 @@ class OrderViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.De
def create(self, request, *args, **kwargs):
ser = CreateOrderSerializer(data=request.data)
ser.is_valid(raise_exception=True)
order = services.create_order(request.user, ser.validated_data)
return Response(OrderSerializer(order).data, status=status.HTTP_201_CREATED)
payload = dict(ser.validated_data)
client_ref = (payload.pop("client_ref", "") or "").strip() or str(
request.headers.get("Idempotency-Key", "")
).strip()
order, created = services.create_order(request.user, payload, client_ref=client_ref)
return Response(
OrderSerializer(order).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
def destroy(self, request, *args, **kwargs):
order = self.get_object()
services.ensure_releasable(order)
services.release_order(order)
return Response({"detail": "Pesanan dibatalkan, kuota dirilis."})
@action(detail=False, methods=["post"])
def quote(self, request):
"""Hitung rincian harga checkout tanpa membuat order (PRD flow konfirmasi)."""
ser = QuoteOrderSerializer(data=request.data)
ser.is_valid(raise_exception=True)
quote = services.quote_order(request.user, ser.validated_data)
return Response(quote)
@action(detail=True, methods=["post"])
def payment(self, request, pk=None):
"""Buat/ambil transaksi Snap Midtrans (token + redirect URL utk Snap UI)."""
......@@ -62,6 +81,14 @@ class OrderViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.De
payment = services.create_snap_payment(order)
except midtrans.MidtransError as exc:
raise ValidationError({"detail": str(exc)}) from exc
if not payment.snap_token and payment.status != Payment.Status.VERIFIED:
# Percobaan create masih berjalan / status provider belum pasti:
# kontrak 409 (pertahankan order & attempt, ulangi request yang sama).
raise services.PaymentConflict(
"Pembayaran sedang diproses atau status transaksi di provider "
"belum pasti. Ulangi POST pembayaran pada order yang sama; "
"jangan membuat pesanan baru."
)
return Response(PaymentSerializer(payment).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
......@@ -82,6 +109,48 @@ class OrderViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.De
return Response(OrderSerializer(order).data)
class RefundCreateView(APIView):
"""Refund sebagian/penuh oleh staf keuangan (PRD §13)."""
permission_classes = [FinanceApproveStaff]
def post(self, request):
order_code = str(request.data.get("order_code") or "").strip()
if not order_code:
raise ValidationError({"order_code": "Wajib mengisi kode order."})
order = Order.objects.filter(code=order_code).first()
if not order:
raise ValidationError({"order_code": "Order tidak ditemukan."})
raw_amount = request.data.get("amount")
try:
amount = int(raw_amount)
except (TypeError, ValueError):
raise ValidationError({"amount": "Nominal refund harus bilangan bulat (rupiah)."})
reason = str(request.data.get("reason") or "").strip()
refund = services.create_refund(
order,
amount,
reason,
request.user,
str(request.data.get("transaction_reference") or ""),
)
refunded = sum(order.refunds.values_list("amount", flat=True))
return Response(
{
"code": refund.code,
"order": order.code,
"amount": refund.amount,
"reason": refund.reason,
"approved_by": refund.approved_by_id,
"refunded_at": refund.refunded_at,
"adjustment_codes": refund.adjustment_codes,
"refundable_remaining": order.total - refunded,
"order_status": order.status,
},
status=status.HTTP_201_CREATED,
)
class MidtransNotificationView(APIView):
"""Webhook notifikasi status pembayaran dari Midtrans (tidak butuh auth).
......@@ -97,15 +166,64 @@ class MidtransNotificationView(APIView):
if not data.get("order_id"):
return Response({"status": "ignored"}, status=status.HTTP_200_OK)
if not midtrans.client.verify_notification_signature(data):
return Response({"detail": "Invalid signature."}, status=status.HTTP_400_BAD_REQUEST)
signature_valid = midtrans.client.verify_notification_signature(data)
order = Order.objects.filter(code=str(data["order_id"])).first()
if not order:
PaymentWebhookEvent.objects.create(
order=None,
order_code=str(data["order_id"]),
transaction_id=str(data.get("transaction_id") or ""),
status_code=str(data.get("status_code") or ""),
transaction_status=str(data.get("transaction_status") or ""),
signature_valid=signature_valid,
processed=False,
detail="unknown-order",
raw=data,
)
return Response({"status": "unknown-order"}, status=status.HTTP_200_OK)
if not signature_valid:
PaymentWebhookEvent.objects.create(
order=order,
order_code=order.code,
transaction_id=str(data.get("transaction_id") or ""),
status_code=str(data.get("status_code") or ""),
transaction_status=str(data.get("transaction_status") or ""),
signature_valid=False,
processed=False,
detail="invalid-signature",
raw=data,
)
return Response({"detail": "Invalid signature."}, status=status.HTTP_400_BAD_REQUEST)
# validasi jumlah sebelum eksekusi (F05): hasil disimpan utk audit
try:
order_total = order.total
gross_ok = False
raw_gross = data.get("gross_amount")
if raw_gross is not None:
try:
gross_ok = int(Decimal(str(raw_gross))) == order_total
except (InvalidOperation, TypeError, ValueError):
gross_ok = False
event = PaymentWebhookEvent.objects.create(
order=order,
order_code=order.code,
transaction_id=str(data.get("transaction_id") or ""),
status_code=str(data.get("status_code") or ""),
transaction_status=str(data.get("transaction_status") or ""),
signature_valid=True,
amount_matches=gross_ok,
detail="amount-ok" if gross_ok else "amount-mismatch",
raw=data,
)
services.apply_payment_notification(order, data)
event.processed = True
event.save(update_fields=["processed"])
except ValidationError as exc:
if signature_valid and not gross_ok:
return Response(exc.detail, status=status.HTTP_400_BAD_REQUEST)
return Response({"status": "rejected"}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
return Response({"status": "ok"}, status=status.HTTP_200_OK)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
......@@ -135,7 +253,10 @@ class TicketViewSet(viewsets.ReadOnlyModelViewSet):
if scope == "active":
qs = qs.filter(status__in=[Ticket.Status.READY, Ticket.Status.VALIDATED], event__end_date__gte=timezone.now().date())
elif scope == "history":
qs = qs.filter(status__in=[Ticket.Status.ATTENDED, Ticket.Status.CHECKED_IN, Ticket.Status.EXPIRED])
qs = qs.filter(status__in=[
Ticket.Status.CANCELLED, Ticket.Status.ATTENDED,
Ticket.Status.CHECKED_IN, Ticket.Status.EXPIRED,
])
return qs
......
......@@ -128,7 +128,12 @@ AUTHENTICATION_BACKENDS = [
JWT_ACCESS_TTL_MINUTES = int(os.getenv("JWT_ACCESS_TTL_MINUTES", "60"))
JWT_REFRESH_TTL_DAYS = int(os.getenv("JWT_REFRESH_TTL_DAYS", "30"))
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
DEV_OTP_ECHO = env_bool("DEV_OTP_ECHO", default=True)
# Gateway OTP HTTP (WhatsApp/SMS provider). Kosong = simulasi lokal (DEV_OTP_ECHO).
OTP_GATEWAY_URL = os.getenv("OTP_GATEWAY_URL", "")
OTP_GATEWAY_TOKEN = os.getenv("OTP_GATEWAY_TOKEN", "")
OTP_GATEWAY_TIMEOUT = int(os.getenv("OTP_GATEWAY_TIMEOUT", "8"))
# Echo OTP hanya saat tidak ada gateway, kecuali DEV_OTP_ECHO diset eksplisit.
DEV_OTP_ECHO = env_bool("DEV_OTP_ECHO", default=not OTP_GATEWAY_URL)
# ---------------------------------------------------------------- REST
REST_FRAMEWORK = {
......
# Audit Revisi — IMI Jawa Barat (R01–R20)
Skor dinilai ulang setelah remediasi R01–R20. **Folder ini terpisah** dari
`docs/audit/2026-10-07/` — baseline dan laporan audit independen tidak diubah
(`scores.json` baseline, `reaudit_probes.py`, assertion tetap sama).
## Hasil
| Rubrik | Baseline | Audit ulang | **Revisi** |
|---|---|---|---|
| Finance §39 (35 kriteria) | 15,7 | 67,1 | **87,1** |
| Aplikasi umum (112 subfungsi) | 59,8 | 46,0 | **54,5** |
| Finance tambahan (18 topik) | — | — | **77,8** |
Temuan: **19 dari 20 Tertutup**, 1 Tertutup sebagian (R20 — sisa terbuka:
push delivery, provider OTP/payout nyata, media foto/video admin, informasi
membership publik, E2E perangkat native, base URL per deployment).
Kenaikan berbukti, bukan persentase kode selesai. **Belum siap rilis untuk
klaim produksi penuh.**
## Isi folder
- `REPORT.md` — laporan revisi lengkap: perbandingan tiap fungsi
(baseline → reaudit → revisi), status R01–R20 + verifikasi, critical test
matrix §45, sisa pekerjaan, cara reproduksi.
- `scores.json` — skor revisi (rubrik identik: 35 / 112 / 18; credit 1 / 0,5 / 0).
- `function-scores.csv` — tabel per fungsi, 165 baris.
- `build_revision_report.py` — generator skor (jalankan ulang bila evidence berubah).
- `source-manifest.json` — SHA256 tiap file `backend/apps`, `mobile/lib`,
`web-admin/js`, `web/` saat skor dihitung.
- `evidence/` — bukti mentah (lihat di bawah).
## Evidence
| File | Isi |
|---|---|
| `backend-apps.log` | `manage.py test apps` — **73/73 OK** |
| `probes-audit.log` | probe baseline `audit_probes` — **14/14 OK** (tanpa pelemahan) |
| `probes-reaudit.log` | probe independen `reaudit_probes` — **20/20 OK** |
| `probes-revision.log` | probe `revision_probes` — **13/13 OK** |
| `mobile-analyze.log` | `flutter analyze` — 0 error |
| `mobile-test.log` | `flutter test` — **9/9 lulus** |
| `admin-cdp-pages.log` | 9 halaman web-admin via CDP, data nyata, **0 console error** |
| `web-e2e-cdp.log` | E2E CDP situs publik + portal partner — **28/28 PASS** |
| `web-style-gate.log` | style gate (kontras, tap target, tipografi, overflow) — **17/17 PASS** |
| `shot-web-*.png` | screenshot landing/portal (desktop + mobile) |
47 probe digabung (14+20+13) dan seluruhnya `ok` pada `scores.probe_results`.
## APK
- Path: `mobile/build/app/outputs/flutter-apk/app-release.apk`
- SHA256: `022ffb48d92e9ea95166a9e755b57a28ae07d9aa75c1b8a3655c5bbe924fbca1`
- Ukuran: 33.338.413 bytes · dibangun 7 Okt 19:53 WIB
- Source revision: HEAD `2a4d40e` + working tree (belum commit); `mobile/lib`
tidak berubah setelah build (terverifikasi via `find -newer`).
## Reproduksi
```sh
# Backend + suite (port 8001)
cd backend && .venv/bin/python manage.py test apps
PYTHONPATH=../docs/audit/2026-10-07 .venv/bin/python manage.py test \
audit_probes reaudit_probes revision_probes
# Web statis (public + portal pada :5174, web-admin pada :5173)
/usr/bin/python3 -m http.server 5174 --directory web
# CDP (Chrome dengan port debugging; harness di /tmp/opencode)
node cdp-web.mjs # E2E 28 skenario
node cdp-style.mjs # style gate 17 pemeriksaan
node cdp-admin-text.mjs # render 9 halaman admin
# Skor
backend/.venv/bin/python docs/audit/2026-10-07-revisi/build_revision_report.py
```
## Akun demo
| Peran | Nomor | Kata sandi |
|---|---|---|
| Admin web | `+6281200000001` | `Admin123!` |
| Partner (portal) | `+6289900000001` | `Partner123!` (party `IMI-JABAR`) |
## Catatan data demo
Agar evidence statement/portal nyata, dev DB di-seed **data demo** (jujur,
bukan angka produksi): 2 order `paid` (6 Okt) diberi `RevenueAllocation`
80% IMI-JABAR / 20% Platform + 1 `Adjustment` approved Rp50.000
("Koreksi fee mitra (demo seed)"). Statement live kini: revenue share
Rp344.560, adjustment Rp50.000, closing Rp394.560. Data ini hanya ada di
`backend/data/db.sqlite3` (dev), tidak menyentuh kode.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default'...
Found 73 test(s).
System check identified no issues (0 silenced).
.........................................................................
----------------------------------------------------------------------
Ran 73 tests in 29.179s
OK
Destroying test database for alias 'default'...
Analyzing mobile...
No issues found! (ran in 1.4s)
00:00 +0: loading /Users/trendy/Documents/IMIapps/mobile/test/order_model_test.dart 00:01 +0: loading /Users/trendy/Documents/IMIapps/mobile/test/order_model_test.dart 00:01 +0: /Users/trendy/Documents/IMIapps/mobile/test/order_model_test.dart: ... Quote & Price Calculation Tests Parses full backend pricing quote including app fee, imi fee, pb1 tax, and service fee 00:01 +1: /Users/trendy/Documents/IMIapps/mobile/test/user_model_test.dart: UserModel parsing tests for backend /me response contract Correctly parses nested wrapper user and member objects 00:01 +2: /Users/trendy/Documents/IMIapps/mobile/test/user_model_test.dart: UserModel parsing tests for backend /me response contract Correctly parses nested wrapper user and member objects 00:01 +3: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Renders active QrImageView when ticket status is ready 00:01 +4: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Renders active QrImageView when ticket status is ready 00:02 +4: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Renders active QrImageView when ticket status is ready 00:03 +4: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Renders active QrImageView when ticket status is ready 00:03 +5: /Users/trendy/Documents/IMIapps/mobile/test/widget_test.dart: App smoke test 00:03 +6: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Does NOT render QrImageView and shows error card when ticket is cancelled 00:03 +7: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Does NOT render QrImageView and shows error card when ticket is cancelled 00:03 +7: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Does NOT render QrImageView and shows warning card when ticket is refunded 00:03 +8: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Does NOT render QrImageView and shows warning card when ticket is refunded 00:03 +8: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Shows check-in overlay badge when ticket status is checked_in 00:03 +9: /Users/trendy/Documents/IMIapps/mobile/test/ticket_validation_test.dart: TicketQrScreen Status Validation Tests Shows check-in overlay badge when ticket status is checked_in 00:03 +9: All tests passed!
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default'...
Found 14 test(s).
System check identified no issues (0 silenced).
..............
----------------------------------------------------------------------
Ran 14 tests in 5.194s
OK
Destroying test database for alias 'default'...
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default'...
Found 20 test(s).
System check identified no issues (0 silenced).
....................
----------------------------------------------------------------------
Ran 20 tests in 10.728s
OK
Destroying test database for alias 'default'...
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default'...
Found 13 test(s).
System check identified no issues (0 silenced).
.............
----------------------------------------------------------------------
Ran 13 tests in 6.075s
OK
Destroying test database for alias 'default'...
PASS | landing: events rendered from API | cards=2
PASS | landing: banners rendered | items=2
PASS | landing: benefits rendered | items=1
PASS | landing: partners rendered | items=2
PASS | landing: articles rendered | items=2
PASS | landing: no console errors | []
PASS | landing: no horizontal overflow (desktop)
PASS | landing: no horizontal overflow (mobile 375px) | scrollWidth=375
PASS | landing: mobile menu button visible
PASS | landing: mobile menu opens
PASS | landing: Escape closes mobile menu
PASS | landing: every header nav link has a real target
PASS | portal: login form shown when logged out
PASS | portal: wrong password shows error | Nomor KTA/HP/email atau password salah.
PASS | portal: login with partner account shows dashboard
PASS | portal: party identity loaded | IMI Jabar (IMI-JABAR)
PASS | portal: balance grid has 5 cells | cells=5
PASS | portal: closing balance shows real amount | Rp 394.560
PASS | portal: statement shows revenue table with data | Bagi hasil per sumber
SUMBER NILAI
Kejurnas Road Race #1 Sentul Rp 344.560
Adjustment
KETERANGAN NILAI
Koreksi fee mitra
PASS | portal: statement has data rows | rows=2
PASS | portal: CSV export endpoint reachable | 200:text/csv
PASS | portal: CSV button click produces no console errors | []
PASS | portal: period apply reloads statement | len=142
PASS | portal: logout clears session and returns to login
PASS | portal: staff account is not accepted as partner | Akun ini adalah akun staf. Gunakan akun partner yang terhubung ke party.
PASS | portal: mobile login shows dashboard
PASS | portal: no horizontal overflow (mobile 375px) | scrollWidth=375
PASS | portal: no console errors across session | []
=== SUMMARY: 28/28 PASS ===
PASS | landing: h1/h2 serif | ui-serif, Georgia, "Times New Roman", serif
PASS | landing: body system sans
PASS | landing: red rule accent | rgb(193, 39, 45)
PASS | landing: navy header | rgb(11, 37, 69)
PASS | landing: all sampled text contrast >= 4.5 | []
PASS | landing: no element overflows viewport | []
contrast ratios: [{"sel":"p","ratio":11.36,"size":16,"weight":"400"},{"sel":"li","ratio":13.55,"size":16,"weight":"400"},{"sel":"h1","ratio":15.39,"size":40,"weight":"700"},{"sel":"h2","ratio":14.2,"size":24,"weight":"700"}]
PASS | landing mobile: no element overflows viewport | []
PASS | landing mobile: tap targets >= 44px | []
PASS | portal login: h1 serif | ui-serif, Georgia, "Times New Roman", serif
PASS | portal login: red rule accent | rgb(193, 39, 45)
PASS | portal login: all sampled text contrast >= 4.5 | []
PASS | portal login: no element overflows viewport | []
contrast ratios: [{"sel":"p","ratio":7.56,"size":14.72,"weight":"400"},{"sel":"label","ratio":14.68,"size":13.6,"weight":"600"},{"sel":"a.header-back","ratio":11.36,"size":14.08,"weight":"400"},{"sel":".state","ratio":6.98,"size":15.2,"weight":"400"},{"sel":".lead","ratio":7.56,"size":14.72,"weight":"400"},{"sel":"h1","ratio":15.39,"size":22.4,"weight":"700"},{"sel":"h2","ratio":15.39,"size":18.4,"weight":"700"},{"sel":".btn-navy","ratio":15.39,"size":15.2,"weight":"600"},{"sel":".btn-quiet","ratio":15.39,"size":15.2,"weight":"600"}]
PASS | portal dash: h1/h2 serif | ui-serif, Georgia, "Times New Roman", serif
PASS | portal dash: all sampled text contrast >= 4.5 | []
PASS | portal dash: no element overflows viewport | []
contrast ratios: [{"sel":"p","ratio":7.56,"size":14.72,"weight":"400"},{"sel":"td","ratio":14.68,"size":14.72,"weight":"400"},{"sel":"th","ratio":6.98,"size":12.48,"weight":"700"},{"sel":"label","ratio":14.68,"size":13.6,"weight":"600"},{"sel":"a.header-back","ratio":11.36,"size":14.08,"weight":"400"},{"sel":".state","ratio":6.98,"size":15.2,"weight":"400"},{"sel":".lead","ratio":7.56,"size":14.72,"weight":"400"},{"sel":"h1","ratio":15.39,"size":22.4,"weight":"700"},{"sel":"h2","ratio":15.39,"size":18.4,"weight":"700"},{"sel":".balance-cell .label","ratio":7.56,"size":12.48,"weight":"700"},{"sel":".balance-cell .value","ratio":15.39,"size":20,"weight":"700"},{"sel":".btn-navy","ratio":15.39,"size":15.2,"weight":"600"},{"sel":".btn-quiet","ratio":15.39,"size":15.2,"weight":"600"},{"sel":".party-code","ratio":6.98,"size":13.12,"weight":"400"},{"sel":".who","ratio":6.98,"size":13.6,"weight":"400"}]
PASS | portal dash mobile: no element overflows viewport | []
PASS | portal dash mobile: tap targets >= 44px | []
=== STYLE GATE: 17/17 PASS ===
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
[stdin]:955
h("tr", {}, h("td", { class: "k" }, p.party_name), h("td", { class: "v", style: "text-align:right" }, money(p.total)))))) : null,
^
SyntaxError: missing ) after argument list
at checkSyntax (node:internal/main/check_syntax:72:5)
at node:internal/main/check_syntax:45:5
at ReadStream.<anonymous> (node:internal/process/execution:205:5)
at ReadStream.emit (node:events:509:28)
at endReadableNT (node:internal/streams/readable:1736:12)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
Node.js v24.19.0
This diff is collapsed.
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default'...
Found 73 test(s).
System check identified no issues (0 silenced).
.........................................................................
----------------------------------------------------------------------
Ran 73 tests in 28.245s
OK
Destroying test database for alias 'default'...
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Analyzing mobile...
error • The return value is missing after 'return' • lib/screens/ticketing/checkout_screen.dart:108:19 • return_without_value
1 issue found. (ran in 1.4s)
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default'...
Found 14 test(s).
System check identified no issues (0 silenced).
..............
----------------------------------------------------------------------
Ran 14 tests in 5.113s
OK
Destroying test database for alias 'default'...
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