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. 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). - Code SMS (
authStep === 'otp') : code à 6 chiffres, valable 120 secondes (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/verifyOtp sont actuellement simulés côté frontend uniquement — aucun SMS n'est réellement envoyé. Le code attendu est en dur : 000000, en attendant une future intégration du middleware Sopra Banking Amplitude pour la vérification réelle du couple compte/téléphone et l'envoi du SMS. Le commentaire du code invoquait auparavant à tort "la même convention que le bypass 2FA existant du backoffice" — corrigé le 2026-08-13, ce bypass backoffice n'a jamais existé (voir Authentification & 2FA), sans lien avec ce placeholder OTP qui, lui, reste volontairement en place.
Plus important : une fois l'OTP "validé", l'identité du client (lastName, firstName, ville, dateNaissance, lieuNaissance, email) est actuellement remplie avec des données factices codées en dur (SchoolLoanView.vue, dans verifyOtp), pas récupérée depuis un vrai système bancaire. Tant que cette intégration n'est pas faite, le formulaire ne doit pas être considéré comme fonctionnel en production au-delà de la démonstration du parcours.
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, date/lieu de naissance, email, téléphone, n° de compte) : affichés désactivés, avec un badge vert "Vérifié" — ce sont les valeurs capturées/mockées durant 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 encore éditables : Quartier (obligatoire), Maison, B.P.
É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 ne fait rien de réel pour l'instant (voir section 2) — ne pas présenter ce parcours comme prêt pour la production sans avoir vérifié l'état de l'intégration Sopra Banking Amplitude.
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.