← All work

Full-Stack

LigaPass — Cross-Platform Football Ticketing Ecosystem

A football ticketing, news, and reviews platform spanning a Django SSR web app and a Flutter mobile client — with real multi-rail Midtrans payments, server-verified Google OAuth, and a resilient three-tier match-data pipeline. Deployed, with signed-APK CI/CD.

RoleFull-stack · 5-person teamWhen2025ContextPlatform-Based Programming · Universitas Indonesia
2
Clients: SSR web + Flutter
3
Midtrans payment rails
7
Django domain apps
5
Team · UI PBP project

The problem

Buying a match ticket, catching club news, and leaving a review shouldn’t mean three different apps. LigaPass was our team’s attempt at one football ecosystem that works identically on the web and on a phone — which, architecturally, means a single backend has to serve two very different clients cleanly.

I worked across the stack on a five-person team; the parts I’ll defend in detail below are the ones I can point to in the code.

Architecture

One Django backend, two front doors. Seven domain apps (authentication, profiles, matches, bookings, news, reviews, main) each expose server-rendered HTML for the web and a parallel set of JSON endpoints the Flutter client consumes through per-feature service classes. Auth state is a Django session cookie, carried into the mobile app via pbp_django_auth’s CookieRequest so the same session works on both surfaces.

A note on honesty: these JSON endpoints are hand-written Django views returning JsonResponse, not Django REST Framework serializers/viewsets. It’s a REST-style API, and I’d rather describe it accurately than dress it up.

The parts worth showing

Real payments, not a mock. The booking flow integrates the Midtrans Core API (sandbox) across three rails — credit card with 3-D Secure, bank-transfer virtual accounts (5 banks), and QRIS — with server-to-server status sync, cancellation, and a signed webhook (midtrans_notification) that’s correctly CSRF-exempt and unauthenticated. Ticket ownership is enforced on every booking view with get_object_or_404(Booking, …, user=request.user). Getting a real multi-rail payment flow correct end-to-end was the most valuable thing I learned here.

Server-verified Google sign-in. Google OAuth tokens are verified server-side with google.oauth2.id_token.verify_oauth2_token(...) before we get_or_create and log the user in — the right place to trust an identity, not the client.

A resilient match-data pipeline. Live scores and fixtures come from an external football API that can rate-limit or fail, so the ingestion layer degrades in three tiers: live API → a normalized JSON cache → a database fixture, each logged and surfaced back to the admin UI. It also standardizes ~40 messy external team names before persisting, caches team/venue lookups in-loop to cut queries, and uses update_or_create + bulk_create for idempotent syncs. This is the most production-minded piece of the codebase and the part I’d most want to talk through.

Defense in depth on user content. A custom SanitizeHTMLMiddleware runs bleach over posted content (news, comments) against a tag/attribute allow-list, forcing rel="nofollow noopener noreferrer" on links — a concrete XSS guard rather than a checkbox.

What shipped, and what didn’t

Shipped and working: the full cart → booking → payment → digital-ticket (barcode) flow, role-based access (user / admin / journalist), threaded comments with likes, reviews, and the match pipeline — deployed on the University of Indonesia’s PWS with a GitHub Actions pipeline that builds a signed release APK (keystore injected from CI secrets).

Honest about the edges: a Django-Channels live-score push was scaffolded but not fully wired (no channel layer configured, no client consumer), so real-time scores are architecture, not a working feature. A mobile auth-refresh interceptor is a placeholder. I’d rather a reviewer hear that from me than find it in the repo.

Reflection

The through-line I took from LigaPass: serving two clients from one backend forces you to design real API boundaries, and payments and identity are where correctness actually counts. The match-ingestion pipeline is the piece I’m proudest of, precisely because it’s built around the assumption that the upstream will fail.