Formulaire de demande de prêt scolaire
src/views/public/SchoolLoanView.vue (frontend) — page publique, route /demande-credit-scolaire.
Vérifié ligne à ligne le 2026-08-12, section 2 re-vérifiée le 2026-08-24 suite à l'arrivée de l'OTP réel (voir OTP, SMS & vérification SBA). Si ce que tu observes dans l'app diverge de cette page, corrige la page plutôt que de faire confiance à un souvenir plus ancien.
1. Vue d'ensemble
Le parcours a deux phases distinctes :
- Portail d'authentification par OTP (avant tout accès au formulaire).
- Formulaire en 4 étapes (wizard), avec barre de progression et indicateur "Étape X/4".
À la soumission réussie, le formulaire bascule sur un écran de confirmation et affiche le numéro de ticket via TicketPopup.
Une constante isUnderReview en tête de <script setup> peut masquer tout le parcours derrière un écran "Ce parcours évolue actuellement" (utilisé pendant la refonte ayant ajouté l'OTP). Actuellement false. À vérifier en premier si le formulaire semble "disparu" en prod.
2. Portail d'authentification OTP (avant l'Étape 1)
Étape ajoutée après la première version du formulaire — le client doit désormais prouver qu'il est bien titulaire du compte avant de pouvoir remplir quoi que ce soit.
- Identifiants (
authStep === 'credentials') : n° de compte (Code Agence + N° Compte 11 chiffres + Clé RIB 2 chiffres) et téléphone associé (PhoneInput). Le bouton "Recevoir mon code" n'est actif que si ces champs sont valides (isAuthCredentialsValid). Un bouton "Vider les champs" (clearAuthFields) permet de repartir de zéro sans recharger la page. - Code SMS (
authStep === 'otp') : code à 6 chiffres, valable 120 secondes côté UI (OTP_DURATION_SECONDS), avec renvoi possible une fois expiré.
Une fois le code validé (authPassed = true), l'utilisateur accède au formulaire à l'Étape 0.
requestOtp/resendOtp/verifyOtp appellent réellement le backend (POST /api/otp/send, POST /api/otp/verify, voir OTP, SMS & vérification SBA) — le code 000000 en dur n'existe plus. Le code à 6 chiffres est généré aléatoirement côté serveur, persisté (table otp_codes, expiration 5 min, usage unique) et envoyé par SMS réel via une passerelle Kannel.
La vérification du numéro de compte auprès de la banque (via un middleware "UWM", pont vers Sopra Banking Amplitude) est également devenue réelle le même jour — backend/src/services/sba.service.js interroge désormais un vrai service bancaire, et l'identité du client (lastName, firstName, ville, dateNaissance, lieuNaissance, email) peut donc venir d'une vraie donnée bancaire. Mais : si ce middleware est indisponible (panne, mauvaise config), le service retombe silencieusement sur un mock à deux comptes de test codés en dur (12345678901, 00000000000) — voir le détail et le piège associé dans OTP, SMS & vérification SBA. Le formulaire ne doit donc toujours pas être présumé fonctionnel en production sans avoir vérifié que le middleware UWM est réellement joignable — un test réussi avec l'un des deux comptes connus ne le prouve pas.
Autre changement notable : dateNaissance/lieuNaissance, pré-remplis par la réponse OTP, sont désormais des champs éditables (auparavant disabled) — le client peut corriger une valeur mal renvoyée par SBA plutôt que d'être bloqué dessus.
3. Les 4 étapes
| # | Titre | Contenu |
|---|---|---|
| 0 | Vos informations | Identité + n° de compte affichés en lecture seule ("Vérifié", pré-remplis par l'étape OTP) ; Quartier/Maison/BP restent éditables |
| 1 | Agence & engagement | Profession (+ "Autre"), employeur, date d'embauche, école, agence de traitement, engagement salarial (Oui/Non), champs dynamiques |
| 2 | Pièces Justificatives | Upload des documents requis (voir section 5, la partie la plus complexe du formulaire) |
| 3 | Montant, Récap & Mentions | Simulation (montant/durée/revenus), récapitulatif identité, consentements légaux, bouton d'envoi |
La navigation (nextStep/prevStep) est bloquée tant que l'étape courante n'est pas valide (voir section 6).
Étape 0 — Vos informations
Depuis l'ajout du portail OTP, cette étape n'est plus une saisie libre :
- Identité et n° de compte (nom, prénoms, ville, email, téléphone, n° de compte) : affichés désactivés, avec un badge vert "Vérifié" — ce sont les valeurs renvoyées par le portail OTP (voir section 2). Un texte prévient le client que ces informations proviennent de son dossier UTB et ne sont pas modifiables ici.
- Champs éditables : Quartier (obligatoire), Maison, B.P., ainsi que date et lieu de naissance — pré-remplis par la réponse OTP mais éditables depuis le 2026-08-24 (auparavant désactivés comme le reste de l'identité), pour permettre au client de corriger une valeur mal renvoyée par SBA sans être bloqué dessus.
Étape 1 — Agence & engagement
- Profession : liste fermée
['Fonctionnaire', 'Employé du privé', 'Autre']. Si'Autre'est choisi, un champ texte libre (formData.professionAutre) devient obligatoire — c'est cette valeur précisée qui est envoyée au backend comme profession réelle (finalProfession), pas le mot "Autre". - Cas particulier "Autre" : deux règles se relâchent, avec une mention explicative affichée au client :
- Le champ Employeur reste obligatoire mais son placeholder suggère "Indépendant" ou "Moi-même".
- La vérification "18 ans à la date d'embauche" (
dateError) est désactivée — seules "embauche postérieure à la naissance" et "pas dans le futur" restent actives. Le champ affiche alors "Vous pouvez saisir la date de début de votre activité".
- Engagement salarial (
hasSalaryEngagement,true/false/null) : détermine si le client a déjà un prêt/virement domicilié à l'UTB.false(nouveau client) déclenche l'exigence du document "Attestation de virement irrévocable de salaire" à l'étape suivante. - Champs dynamiques (
requiredFields, chargés depuis/loan-field-requirements/type/scolaire) : configurables côté backoffice, stockés dansdynamicDataet envoyés dansadditional_data.fields.
Étape 3 — Montant, Récap & Mentions
simulation.amountetsimulation.incomedémarrent volontairement vides (null) — le client doit les saisir lui-même.clampAmountborne le montant entre 100 000 etmaxAmount(issu de/loan-parameters) au blur uniquement.- La mensualité (
monthlyPayment) utilise la formule d'amortissement standard (M = P·i/(1-(1+i)^-n)), alignée surSimulateurCreditView.vue. - Le taux d'endettement (
debtRatio, calculé côté client sur la seule mensualité du nouveau prêt) bloque l'envoi au-delà de 60 % (:disabled="debtRatio > 60"), avec message invitant à passer par l'agence. - Deux consentements obligatoires (
consentDataProcessing,consentElectronicSignature) — voir Consentements légaux pour le détail. Il n'existe pas de case "communications marketing" dans le template actuel.
4. Le système de documents (étape 2) — la partie la plus subtile
Le problème de fond
Le client doit fournir plusieurs documents (bulletins de paie, éventuellement une attestation de virement, et des pièces configurables côté backoffice). Deux modes d'upload coexistent, avec un niveau de certitude d'attribution très différent :
- Mobile : chaque document a sa propre ligne avec son propre bouton "Ajouter"/"Remplacer" (
handleSingleDocUpload(key, event)). Le client choisit explicitement à quel document le fichier correspond → attribution certaine. - Desktop : un seul bouton "Sélectionner les documents" permet de choisir plusieurs fichiers d'un coup (
handleSelectAllDocuments). Impossible de garantir l'ordre de sélection dans la boîte de dialogue native → attribution devinée, pas certaine.
Stockage : uploadedDocs (objet indexé par clé)
const uploadedDocs = ref({}) // { [clé_document]: { name, type, base64, url } }
Chaque document requis a une clé stable (payslip_0, payslip_1, payslip_2, attestation_virement, ou une clé dérivée du libellé via docKey() pour les pièces configurables — ex : "Copie du bulletin de solde" → copie_du_bulletin_de_solde). requiredDocItems est un computed recalculé à chaque changement de hasSalaryEngagement, pour rester cohérent si le client revient en arrière modifier sa réponse.
docKey() supprime les accentsdocKey() supprime tous les caractères non-ASCII, accents compris (replace(/[^a-z0-9]+/g, '_')). Un libellé comme "Carte nationale d'identité de la personne à prévenir" devient carte_national_d_identit_de_la_personne_pr_venir — les accents sont perdus définitivement à cette étape. Le backoffice compense en réaffichant le vrai libellé configuré plutôt que la clé technique (voir Fiche détail backoffice, resolveDocLabel).
Sélection groupée desktop : additive, pas écrasante
handleSelectAllDocuments assigne les fichiers sélectionnés aux emplacements encore vides (emptyKeys), dans l'ordre — jamais aux positions déjà remplies. Ça permet au client de fournir ses documents en plusieurs fois sans qu'un second passage n'écrase le premier. Le contrôle "trop de fichiers" compare au nombre d'emplacements encore vides à cet instant, pas au total requis.
Attribution incertaine → clé anonyme à l'envoi
uncertainDocKeys (un simple Set, pas un ref — lu/écrit uniquement en dehors du rendu) trace quelles clés viennent d'une sélection groupée (devinée) plutôt que d'un choix individuel confirmé :
handleSelectAllDocuments→uncertainDocKeys.add(key)pour chaque fichier assigné.handleSingleDocUpload→uncertainDocKeys.delete(key)(choix explicite du client).
À l'envoi (submitForm), un fichier dont la clé est encore dans uncertainDocKeys ne part pas sous son vrai nom, mais sous une clé anonyme a_classer_1, a_classer_2, etc. Un gestionnaire du backoffice attribue ensuite ces documents "à classer" au bon emplacement (voir Fiche détail backoffice, attribution manuelle).
Validation étape 2
isStep2Valid = providedDocsCount.value === requiredDocsCount.value — exactement le bon nombre de documents (pas "au moins"), pour empêcher un envoi partiel de passer inaperçu.
5. Validation — architecture par étape
Chaque étape a son propre computed de validité (isStep0Valid à isStep3Valid), regroupés dans stepValidators. nextStep() bloque la navigation et active showValidationErrors si l'étape courante n'est pas valide. isFormValid (soumission finale) est le ET logique des 4 validateurs.
6. Soumission (submitForm)
Construit un payload POST /api/loan-requests avec :
- Les colonnes "garanties" en racine (
first_name,last_name,phone,email,salaire,amount,duration,charge_mensuel,profession— viafinalProfession,adresse,school,nom_employeur,date_naissance,lieu_naissance,id_agence). additional_data: tout le reste (adresse détaillée, n° de compte, champs dynamiques, engagement salarial, taux appliqué, taux d'endettement, consentements horodatés) + les documents :payslip_0/payslip_1/payslip_2à la racine d'additional_data(uniquement si l'attribution est certaine).documents: objet regroupant les autres pièces (certaines) + les piècesa_classer_N(incertaines).
Le backend (loan.controller.js, createLoanRequest) répartit ensuite ces documents dans la table documents, en unifiant payslips et autres pièces dans le même mécanisme.
7. Dépendances externes
PhoneInput.vue— composant réutilisé sur plusieurs formulaires publics.FeedbackModal.vue/TicketPopup.vue— retours utilisateur génériques.- Endpoints backend appelés au montage :
/loan-document-requirements/type/scolaire,/loan-field-requirements/type/scolaire,/loan-parameters,/agences.
8. Pièges connus pour un futur développeur
- L'OTP/SMS et la vérification bancaire sont tous deux réels, mais la seconde peut retomber sur un mock sans prévenir (voir section 2) — ne pas présenter ce parcours comme fiable en production sans avoir vérifié que le middleware UWM (
backend/src/services/sba.service.js) est réellement joignable ; une démo réussie avec l'un des deux comptes de test ne le garantit pas. isUnderReviewpeut masquer tout le formulaire — si le parcours "a disparu" du site, vérifier cette constante avant de chercher un bug ailleurs.- Ne pas réintroduire une case "communications marketing" sans vérifier que
consent_communicationsest bien à nouveau branché de bout en bout (checkbox + payload) — une version précédente envoyait un champ toujours àfalsesans jamais l'avoir demandé au client, ce qui était trompeur. - Ne pas remplacer
uploadedDocspar un tableau à plat — c'est précisément ce qui causait le bug historique où revenir en arrière modifierhasSalaryEngagementpuis re-uploader écrasait le mauvais document. Le stockage indexé par clé +requiredDocItemsrecalculé dynamiquement est la correction. docKey()perd les accents — ne pas s'étonner si un libellé avec accents ressort mangled dans unstored_name; c'est structurel, pas un bug d'affichage.uncertainDocKeysn'est pas réactif (Setclassique, pasref) — voulu (il n'a jamais besoin de déclencher un re-rendu), mais s'il faut un jour l'afficher dans l'UI (ex : badge "à confirmer"), il faudra le rendre réactif.