Fin 2014, les VTC explosent en France. Uber fait la une, les taxis manifestent, et la loi Thévenoud vient d’être votée pour réguler le secteur.

Mais tout ça, c’est Paris. En Corse, il n’existe rien d’équivalent. Pas d’app de réservation, pas de commande en un clic. Impérial Navette gère une flotte de berlines et de minibus entre Ajaccio, Bastia, Porto-Vecchio et les aéroports. Tout se fait par téléphone. Les hôtels appellent pour réserver un transfert aéroport pour leurs clients, les particuliers appellent le matin pour un trajet dans la journée. Ça fonctionne, mais c’est limité par la capacité de la personne au téléphone.

Ils nous contactent avec une idée simple : faire comme Chauffeur Privé, mais pour la Corse. Une app iPhone pour les clients, un back-office pour l’équipe, et une connexion avec leur progiciel de gestion de flotte (LimoVTC/Trajet+) pour que tout le circuit — de la commande à l’attribution du chauffeur — soit automatisé.

Le périmètre : ambitieux pour une petite équipe

Le devis fait sept pages, mais le périmètre est clair. Quatre briques :

L’app iOS pour les clients. Inscription (email, Facebook, téléphone avec vérification SMS), saisie du trajet sur une carte, choix du véhicule (berline ou minibus), options (siège bébé, bagages hors gabarit, cage à chien), paiement par carte, confirmation, et notifications SMS tout au long de la course.

Le backend Rails qui expose une API JSON pour l’app, stocke les utilisateurs, les réservations, les tarifs, et orchestre les communications (SMS, emails, webhooks).

Le back-office ActiveAdmin pour l’équipe d’Impérial Navette : suivi des commandes en temps réel, métriques, gestion des tarifs et des zones.

La connexion LimoVTC (Trajet+), le progiciel de gestion de flotte. Chaque commande passée dans l’app doit être poussée vers Trajet+, et chaque mise à jour côté opérateur (attribution d’un chauffeur, départ de course, fin de course) doit revenir dans notre système pour déclencher les SMS et le paiement.

On ajoute un mini-site web hôtelier : les partenaires hôteliers (le Sofitel, entre autres) doivent pouvoir commander un VTC pour un client directement depuis une interface web dédiée, sans passer par l’app.

Le tout à livrer en trois mois, pour deux développeurs. Ambitieux.

architecture générale — les 4 briquesApp iOSObjective-C · iOS 7/8MapKit · CoreLocationAFNetworking · Adyen SDKSite hôtelierRails views · auth partenairecommande pour un clientBackend Rails 4.1API JSON · ActiveAdminPostgreSQL + PostGISSidekiq (SMS, webhooks)Pundit · ActiveAdminCalcul de tarifsPostGIS · RGeo · zones polygonesHeroku · Unicorn · NginxLimoVTC (Trajet+)Progiciel gestion de flotteAttribution chauffeursAPI REST · JWT · WebhooksAdyenPaiement CB · tokenisationdébit en fin de courseAPI JSONpush commandewebhooks retourdébitSMS client

L’app iOS : Objective-C en terrain connu

On est fin 2014. Swift est sorti en septembre avec iOS 8, mais il a trois mois d’existence. La syntaxe change entre les bêtas, Xcode plante quand on mélange Swift et Objective-C dans certains cas, et les bibliothèques tierces ne supportent pas encore Swift. Pour un projet en production avec un délai de trois mois, on part sur Objective-C. C’est le choix pragmatique — pas le choix excitant.

Le devis prévoit le support d’iOS 7 et 8, testé sur iPhone 4s, 5, 5s, 6 et 6+. L’iPhone 6+ vient de sortir — c’est le premier iPhone avec un écran de 5.5 pouces, et ça veut dire qu’il faut gérer Auto Layout sérieusement. Pas de contraintes hardcodées, pas de frames en pixels. L’époque où on pouvait caler une vue à 320px de large et passer à autre chose est révolue.

La carte et la géolocalisation

L’écran principal de l’app, c’est une carte. Le client voit sa position, saisit un départ et une arrivée, et la carte affiche le trajet. On utilise MapKit et CoreLocation — pas Google Maps SDK, parce qu’en 2014 le SDK Google Maps pour iOS est encore jeune et qu’Apple Maps s’est amélioré depuis le fiasco de 2012. Et surtout, MapKit est gratuit et sans quota.

Le challenge en Corse, c’est la couverture. Le réseau 3G est correct sur la côte, mais dès qu’on monte dans les terres, ça devient aléatoire. On a prévu un mode dégradé : si le réseau est indisponible, l’app affiche un écran qui propose d’appeler NavCar directement. C’est pas glorieux, mais c’est honnête. Mieux vaut un fallback fonctionnel qu’un spinner qui tourne indéfiniment.

La saisie d’adresse utilise l’autocomplétion de CLGeocoder. On filtre les résultats sur la Corse — inutile de proposer des adresses à Marseille. En pratique, la plupart des trajets sont des transferts aéroport : Ajaccio Napoléon Bonaparte ou Bastia Poretta vers un hôtel de la côte. On a préenregistré les points d’intérêt les plus fréquents (aéroports, gares maritimes, hôtels partenaires) pour que l’autocomplétion soit pertinente dès les premières lettres.

Le paiement avec Adyen

Le paiement est un morceau à part entière. On utilise Adyen — pas Stripe, qui en 2014 n’est pas encore disponible en France pour les nouveaux comptes. Adyen est plus orienté entreprise, la documentation est dense, mais le SDK iOS fait le boulot.

Le flow est le suivant : le client enregistre sa carte lors de l’inscription (tokenisation côté Adyen), et le débit est effectué en fin de course, une fois que le chauffeur a confirmé la prise en charge. C’est une autorisation différée — on vérifie la carte au moment de la commande, mais on ne débite qu’après.

Les interfaces de paiement sont développées sur mesure. Adyen propose des écrans prêts à l’emploi, mais ils ne collent pas avec le design de l’app. On a recréé les écrans de saisie de carte (numéro, date d’expiration, cryptogramme) et la liste des cartes enregistrées, en communiquant directement avec l’API Adyen pour la tokenisation.

L’inscription et Facebook Login

Trois modes d’inscription : email/mot de passe, Facebook, ou téléphone avec vérification SMS. Facebook Login est quasi obligatoire en 2014 — c’est le réseau social dominant, et la connexion en un tap réduit la friction à l’inscription. On utilise la gem Koala côté Rails pour valider le token Facebook et récupérer le profil.

La vérification par SMS utilise un code à 4 chiffres envoyé via le backend (Sidekiq + un provider SMS). Le client saisit le code, le backend vérifie, et le numéro est validé. Ce numéro est essentiel : c’est par SMS qu’on envoie les notifications de course (attribution du chauffeur, arrivée imminente).

Le backend Rails : API, tarifs et PostGIS

Le backend tourne sur Rails 4.1 (sorti en avril 2014), Ruby 2.1, PostgreSQL, déployé sur Heroku. La stack classique de l’époque : Unicorn comme serveur d’application, Sidekiq pour les jobs asynchrones, ActiveAdmin pour le back-office, Pundit pour les autorisations.

Le calcul de tarifs par zones géographiques

C’est la partie du backend qui a demandé le plus de réflexion. Les tarifs ne sont pas uniformes : un transfert depuis l’aéroport d’Ajaccio a un prix fixe (pas de majoration de nuit), un trajet en ville est facturé au kilomètre (1.90€/km en berline, 2.10€/km en van, +50% la nuit), et certaines zones touristiques ont des forfaits spécifiques.

Pour déterminer quel tarif appliquer, il faut savoir dans quelle zone se trouvent le départ et l’arrivée. C’est un problème géométrique : “ce point GPS est-il à l’intérieur de ce polygone ?”. Et pour ça, PostGIS est l’outil parfait.

PostGIS est l’extension spatiale de PostgreSQL. Elle ajoute des types de données géographiques (points, lignes, polygones) et des fonctions pour les manipuler (intersection, distance, inclusion). En Ruby, la gem rgeo fournit les objets géométriques, et activerecord-postgis-adapter connecte le tout à ActiveRecord.

On définit chaque zone tarifaire comme un polygone géographique stocké en base :

class ZoneTarifaire < ActiveRecord::Base
  # La colonne `perimetre` est de type :st_polygon (PostGIS)

  validates :nom, presence: true
  validates :perimetre, presence: true
  validates :tarif_fixe_berline, numericality: { greater_than: 0 }, allow_nil: true
  validates :tarif_fixe_van, numericality: { greater_than: 0 }, allow_nil: true

  scope :contenant, ->(point) {
    where("ST_Contains(perimetre, ST_SetSRID(ST_Point(?, ?), 4326))",
          point.longitude, point.latitude)
  }

  def forfait?
    tarif_fixe_berline.present?
  end
end

La requête ST_Contains est le cœur du mécanisme. Elle demande à PostGIS : “ce point (longitude, latitude) est-il contenu dans le polygone perimetre ?” — et PostgreSQL répond en utilisant un index spatial GiST, ce qui rend la requête quasi-instantanée même avec des dizaines de zones.

PostGIS — zones tarifaires et calcul de tarifBastiaAjaccioPorto-VecchioZone aéroport AJAtarif_fixe: trueZone aéroport BIAtarif_fixe: trueZone touristiquetarif_km majoré# Calcul du tarif pour une courseclass TarifCalculator def initialize(depart, arrivee, type) @depart = depart @arrivee = arrivee @type = type end def calculer # PostGIS : le départ est-il # dans une zone à forfait ? zone = ZoneTarifaire .contenant(@depart) .first if zone&.forfait? forfait_pour(zone) else tarif_au_km(distance) end endend

Le service de calcul de tarif encapsule toute la logique :

class TarifCalculator
  TARIF_KM = { berline: 1.90, van: 2.10 }.freeze
  MAJORATION_NUIT = 1.5
  SUPPLEMENT_SIEGE_BEBE     = 7
  SUPPLEMENT_CAGE_CHIEN     = 10
  SUPPLEMENT_BAGAGE_SPECIAL = 10

  def initialize(depart:, arrivee:, type_vehicule:, options: [], heure: Time.current)
    @depart  = depart
    @arrivee = arrivee
    @type    = type_vehicule.to_sym
    @options = options
    @heure   = heure
  end

  def calculer
    base = tarif_base
    base *= MAJORATION_NUIT if nuit? && !zone_aeroport?
    base + supplements
  end

  private

  def tarif_base
    zone = ZoneTarifaire.contenant(@depart).first
    if zone&.forfait?
      @type == :berline ? zone.tarif_fixe_berline : zone.tarif_fixe_van
    else
      distance_km * TARIF_KM.fetch(@type)
    end
  end

  def distance_km
    # PostGIS : distance géodésique en mètres, convertie en km
    ActiveRecord::Base.connection.select_value(
      "SELECT ST_Distance(
        ST_SetSRID(ST_Point(#{@depart.longitude}, #{@depart.latitude}), 4326)::geography,
        ST_SetSRID(ST_Point(#{@arrivee.longitude}, #{@arrivee.latitude}), 4326)::geography
      )"
    ).to_f / 1000
  end

  def zone_aeroport?
    ZoneTarifaire.contenant(@depart)
      .where(type_zone: 'aeroport').exists?
  end

  def nuit?
    @heure.hour < 7 || @heure.hour >= 21
  end

  def supplements
    total = 0
    total += SUPPLEMENT_SIEGE_BEBE     if @options.include?('siege_bebe')
    total += SUPPLEMENT_CAGE_CHIEN     if @options.include?('cage_chien')
    total += SUPPLEMENT_BAGAGE_SPECIAL if @options.include?('bagage_hors_gabarit')
    total
  end
end

Ce qui rend PostGIS naturel en Ruby, c’est la combinaison avec RGeo. Les objets géographiques sont des first-class citizens dans ActiveRecord : on peut les manipuler comme n’importe quel attribut, les valider, les comparer. Et les requêtes spatiales (ST_Contains, ST_Distance) s’intègrent dans les scopes ActiveRecord comme n’importe quelle clause WHERE.

La migration qui crée la table des zones tarifaires :

class CreateZonesTarifaires < ActiveRecord::Migration
  def change
    create_table :zone_tarifaires do |t|
      t.string   :nom, null: false
      t.string   :type_zone  # 'aeroport', 'gare_maritime', 'touristique'
      t.st_polygon :perimetre, geographic: true, srid: 4326, null: false
      t.decimal  :tarif_fixe_berline, precision: 8, scale: 2
      t.decimal  :tarif_fixe_van, precision: 8, scale: 2
      t.timestamps
    end

    add_index :zone_tarifaires, :perimetre, using: :gist
  end
end

L’index GiST sur le périmètre est ce qui rend les requêtes ST_Contains rapides. Sans cet index, PostgreSQL ferait un scan séquentiel sur tous les polygones pour chaque calcul de tarif. Avec l’index, c’est quasi-instantané.

La synchronisation avec LimoVTC : le circuit des webhooks

C’est la partie la plus sensible du projet. LimoVTC (Trajet+) est le progiciel de gestion de flotte qu’utilise Impérial Navette au quotidien. C’est là que les opérateurs voient les courses, attribuent les chauffeurs, suivent les véhicules. Notre app doit dialoguer avec ce système en temps réel.

Le protocole est en deux temps :

Le push (app → LimoVTC) : quand un client passe une commande, notre backend construit un payload JWT et le pousse vers l’API REST de LimoVTC. Le payload contient le client, les adresses de départ et d’arrivée, l’heure, le type de véhicule, les options. L’API retourne un identifiant de mission qu’on stocke en base.

Le webhook retour (LimoVTC → app) : quand un opérateur attribue un chauffeur, quand le chauffeur part, quand la course est terminée — chaque changement d’état déclenche un webhook vers notre endpoint. On reçoit un POST avec un payload encodé en JWT (la même mécanique qu’à l’aller), on le décode, on met à jour l’état de la réservation en base, et on déclenche les actions correspondantes.

circuit complet d'une commande — push et webhooks retourClient (app)Backend RailsLimoVTCOpérateurPOST /api/reservationsJWT push → set-ressourceSidekiq: LimoVtcPushJobattribue chauffeurwebhook: chauffeur_attribueSMS: “votre chauffeur arrive à 10h30”Sidekiq: SmsSenderJobwebhook: course_termineeSidekiq: AdyenChargeJobEmail: facture + débit CB

Le contrôleur de webhooks côté Rails :

class Webhooks::LimoVtcController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_jwt_signature

  def receive
    event   = decoded_payload['event']
    mission = decoded_payload['mission_id']

    reservation = Reservation.find_by!(limo_mission_id: mission)

    case event
    when 'chauffeur_attribue'
      reservation.attribuer_chauffeur!(
        nom:       decoded_payload['chauffeur_nom'],
        telephone: decoded_payload['chauffeur_tel'],
        eta:       decoded_payload['eta_minutes']
      )
      SmsSenderJob.perform_later(reservation, :chauffeur_attribue)

    when 'chauffeur_en_approche'
      SmsSenderJob.perform_later(reservation, :chauffeur_en_approche)

    when 'course_terminee'
      reservation.terminer!
      AdyenChargeJob.perform_later(reservation)
      ReservationMailer.facture(reservation).deliver_later
    end

    head :ok
  rescue ActiveRecord::RecordNotFound
    head :not_found
  end

  private

  def verify_jwt_signature
    JWT.decode(request.raw_post, ENV.fetch('LIMO_VTC_SECRET'), true, algorithm: 'HS256')
  rescue JWT::DecodeError
    head :unauthorized
  end

  def decoded_payload
    @decoded_payload ||= JWT.decode(
      request.raw_post,
      ENV.fetch('LIMO_VTC_SECRET'),
      true,
      algorithm: 'HS256'
    ).first
  end
end

Le skip_before_action :verify_authenticity_token est nécessaire parce que le webhook est un POST qui ne vient pas d’un formulaire Rails — il n’a pas de token CSRF. La vérification de la signature JWT remplace cette protection : si le secret ne correspond pas, on rejette la requête.

L’API de LimoVTC a ses particularités. Le payload JWT est la requête (pas un header d’authentification), les champs utilisent des abréviations françaises de trois lettres, et certaines conventions viennent clairement du monde PHP. On a encapsulé tout ça dans un service dédié pour ne pas contaminer le reste du code. On écrira un article plus détaillé sur les patterns d’intégration de cette API — les leçons apprises ici nous resserviront sur d’autres projets.

Les SMS : le nerf de la guerre

Pas de push notifications. Ou plutôt : on les a, mais elles ne sont pas fiables en 2015. Les push ne passent pas si l’app n’est pas installée, si l’utilisateur les a désactivées, ou si le téléphone est en mode avion. Pour un VTC, l’information “votre chauffeur arrive dans 5 minutes” doit être garantie. On utilise les SMS.

Le flow de notifications pour une course standard :

  1. Commande confirmée → SMS au client : “Commande confirmée ! D’ici 10 min, vous allez recevoir un SMS avec l’heure d’arrivée du chauffeur et son numéro de téléphone.”
  2. Chauffeur attribué (webhook LimoVTC) → SMS au client avec le nom du chauffeur, son téléphone, et l’heure d’arrivée estimée.
  3. 5 minutes avant l’arrivée → SMS au client : “Votre chauffeur arrive dans 5 minutes. Préparez-vous.”
  4. Prise en charge → Email au client avec la facture et le montant débité.

Côté chauffeur, un SMS à la création de la course pour lui confirmer que le paiement par carte est OK — sinon, il encaisse en espèces.

Les enseignements

NavCar a été notre premier projet de “marketplace temps réel” — un système où l’app, le backend, un progiciel tiers et des acteurs humains (opérateurs, chauffeurs) doivent se coordonner en temps réel. C’est un cran au-dessus du CRUD classique.

Ce qu’on retient :

PostGIS est un superpouvoir. Le calcul de tarifs par zones géographiques, qui aurait été un cauchemar en logique applicative (des if/else avec des coordonnées en dur), devient naturel avec des polygones et ST_Contains. Et la performance est excellente grâce aux index spatiaux.

Les webhooks ne suffisent pas. Un système de webhooks sans mécanisme de réconciliation est fragile. Le polling de sécurité en backup a rattrapé plusieurs événements perdus pendant les premiers mois de production.

L’app native en Objective-C, c’est du travail. Pour une petite équipe, coder chaque écran à la main — pas de storyboard, pas de framework UI — c’est long. On a passé la moitié du temps de développement iOS sur des détails d’interface : les animations de la carte, le formulaire de saisie de carte bancaire, la gestion du clavier sur les petits écrans (iPhone 4s). C’est ce qui nous poussera vers des solutions hybrides (Ionic) pour les projets suivants, avant de revenir au natif quand Swift aura mûri.

Heroku simplifie tout. Pour un projet de cette taille, le git push heroku master vaut son pesant d’or. Pas de serveur à provisionner, pas de déploiement à scripter, les add-ons PostgreSQL et Redis (pour Sidekiq) sont à un clic.

Le projet a été livré fin mars 2015, dans les délais. L’app a tourné pendant la saison touristique 2015 — avec des pointes de demande en juillet-août, quand les touristes débarquent à l’aéroport et cherchent un VTC pour rejoindre leur hôtel sur la côte sud.


L’intégration avec l’API LimoVTC nous a beaucoup appris. On a formalisé ces apprentissages dans un article dédié : Intégrer une API tierce dans une app Rails, qui détaille les patterns de Service Object et la gestion du JWT qu’on a mis en place.