OranjeTrip — End-to-End Ride-Hailing Platform
A production ride-hailing platform for the Netherlands that I built alone: a Flutter rider app, a dual-role Flutter driver + admin app, and the .NET 10 backend behind both — Stripe payments, a wallet ledger, scheduled and multi-stop trips, and real-time dispatch.
- My role
- Full-Stack Engineer (rider app, driver/admin app, backend)
- Period
- April 2026 – Present
- Related experience
- HOMSI
OranjeTrip is a ride-hailing platform for the Dutch market, and I am its only engineer. It started as a rider app with a finished booking UI but no real backend: prices were mocked, sign-in was faked, and nothing was ever booked. I designed and built the ASP.NET Core (.NET 10) backend that made it a real product, then built the driver + admin app on top of it.
At a glance
- Backend: ~43k lines of C# — 168 REST endpoints in 22 controllers, 52 EF Core migrations on PostgreSQL, a SignalR trip hub, and eight background services.
- Clients: a Flutter rider app (~60k lines) and a Flutter driver + admin app (~57k lines), both in nine languages (Dutch, English, Arabic, German, French, Spanish, Polish, Romanian, and Ukrainian).
- Tests: ~9k lines of backend tests — domain and application unit tests, plus API integration tests against a real PostgreSQL in Testcontainers.
- Delivery: Docker Compose in production behind Caddy (automatic HTTPS), and Codemagic CI/CD to Google Play’s production and closed-testing tracks.
The trip engine
The Trip aggregate is the core of the system: an 11-state machine from PendingQuote
through AwaitingPayment, Accepted, EnRoute, Arrived, and InProgress to
Completed, Cancelled, PaymentFailed, or Refunded. Every transition is a domain method
that returns a Result, so an invalid move is refused in one place.
- Payment-first booking. A card trip is paid when it is booked, not when it ends. The
fare becomes a Stripe PaymentIntent, and the trip waits in
AwaitingPaymentuntil a verified webhook confirms it. Only then does it reach dispatch. Riders can also pay from their wallet or in cash. - Scheduled trips. Future bookings get staged reminders, and a background service activates each one on time. Another service notices when no driver has taken a trip and asks the rider to wait or cancel.
- Multi-stop trips and mid-trip changes. Riders can change stops, passenger count, or bags. The new price is previewed before it applies, and the difference is charged or refunded automatically. Unconfirmed changes expire on their own.
- Waiting fees. Waiting is metered with a free grace period (longer for airport pickups). The per-minute rate is locked when waiting starts, so later price changes cannot change it.
- Cancellation policy in one place. Fees and refund splits depend on who cancels and when. One domain class defines them, and the backend, the tests, and the rider-facing text all read from it.
Payments done properly
- Every Stripe call has an idempotency key built from a domain ID — the quote, the trip, or the wallet transaction. A retry after a timeout can never charge twice.
- Signed webhooks. The raw body is checked against the
Stripe-Signatureheader before anything else happens. Every payment transition does nothing if it already happened, so webhooks Stripe sends twice cause no side effects. - Saved cards and off-session charges. SetupIntents and ephemeral keys power saved cards. Waiting fees and fare changes are charged off-session, without asking the rider to pay again.
- Refunds that can’t get lost. A background service finds refunds Stripe accepted but never confirmed — the webhook was lost — and settles them directly with Stripe.
- A real wallet ledger. The balance only changes together with a ledger row in the same transaction, using optimistic concurrency with reload and retry. At booking, a trip places a hold on the wallet; it is committed when the trip completes or released when it is cancelled. Top-ups are credited only by a verified webhook.
- Invoices. Numbers are sequential per month, allocated from a row-locked counter in a serializable transaction. PDFs are rendered with QuestPDF and support right-to-left text.
Identity and security
- OTP built in-house, not bought as a service. Codes are hashed with HMAC-SHA256; the plain code is never stored or logged. Codes expire, resends have a cooldown, and failed attempts are capped. A code is saved only after it was actually delivered. SMS goes through CM.com and email through Titan, as plain delivery channels.
- JWT sessions with refresh-token rotation and server-side revocation. Riders, drivers, and admins are separate roles, and Google sign-in goes through Firebase.
- Rate limits per user, with separate policies for API calls, SignalR connections, and OTP requests.
- App-store review sign-in. A dedicated reviewer login lets store reviewers sign in without a real phone. It is off unless configured through secrets.
Real-time and background work
- A SignalR trip hub pushes each lifecycle event (
DriverAssigned,EnRoute,Arrived,TripStarted,TripCompleted,TripCancelled, scheduled-trip reminders) to exactly the rider, driver, and admin groups involved. FCM covers apps in the background. - Transactional outbox. Domain events are saved in the same transaction as the change, then published by a dispatcher. A trip can never commit without its notification, or send one without committing.
- Eight background services: scheduled-trip activation, no-driver detection, trip-edit expiry, refund reconciliation, outbox dispatch, and cleanup of OTP codes, refresh tokens, and trip chats.
- Live location tracking was built — Haversine ETA and road-following routes from Google Directions, throttled and cached per trip. It was later switched off by a product decision and kept ready to switch back on.
- Remote client config controls both apps from the server: feature flags (Stripe, real-time), currency, rider discounts, and minimum versions that force an update.
The two apps
- Rider app (Flutter). Map-based booking with Google Maps and place search, quotes for each vehicle type, Stripe Payment Sheet and saved cards, the wallet, scheduled trips, trip history, in-trip chat, refund requests, and PDF receipts.
- Driver + admin app (Flutter). One app, two roles. Drivers get KYC onboarding and run their trips. Admins get an operations console: they assign a trip to a driver or take it themselves, review driver documents, manage vehicle types and pricing, handle refunds, compensation claims, and customer incidents, and play back trip recordings — all over the same REST and SignalR backend the rider app uses.
Backend architecture
- Clean Architecture + DDD + CQRS (MediatR) in vertical slices, with pipeline behaviours
for validation, caching, and performance. Errors travel as
Result<T>and become localized RFC 7807 problem responses. - EF Core on PostgreSQL with UTC everywhere, JSONB for translated text, and interceptors that add audit fields and audit-log rows automatically. Cached queries are split by language.
- Observability: Serilog with correlation IDs sent to Seq, and OpenTelemetry metrics collected by Prometheus and shown in Grafana. Everything ships in one production Docker Compose stack, and the API applies its migrations on startup.
Why full-stack ownership pays off
A rider’s map froze after “driver accepted.” Seen only from the app, it looked like a SignalR subscription bug; seen only from the server, it looked like a broadcast bug. Because I own both, I traced it in minutes to a mismatch in how connections were grouped. With separate teams, that would have taken days of back-and-forth. OranjeTrip is my clearest proof of end-to-end ownership: I design, build, and run both the apps and the backend of a real-time payments system.