Manuel d'instructions de cette app. À LIRE avant toute intervention, et à METTRE À JOUR après chaque modification (ajouter une ligne au CHANGELOG en bas).
Dernière MAJ : 2026-09-12 · Statut : en ligne (v1)
/contrats, admin + superviseur) : nouvelle invitation client, création de contrat pour un dossier existant, suivi des statuts de signature. Remplace le portail Younes de rp-onboarding (retiré le 2026-09-02). Voir §5./root/workspace/apps/rp-dashboardrp-dashboard (/etc/systemd/system/rp-dashboard.service, EnvironmentFile .env, Restart=on-failure).127.0.0.1)./etc/nginx/sites-enabled/ops-resident.panelbay.com.conf (HTTPS Let's Encrypt).ops-resident.panelbay.com via Cloudflare (DNS-only), créé par le skill expose-webapp.systemctl restart rp-dashboardjournalctl -u rp-dashboard -n 50/, /api/dashboard) : login équipe unique. Page /login, mot de passe = TEAM_PASSWORD (.env), cookie de session HttpOnly signé HMAC (DASH_SECRET), valable 30 jours. Déconnexion : /logout./c/:token) : public par token (upload sans mot de passe), inchangé./manager) : mot de passe propre MANAGER_PASSWORD (module docs.js).data/snapshot.json, régénéré depuis Airtable par scripts/gen_snapshot.py (lit AIRTABLE_API_KEY dans /root/aios/.env, base appGzbfkhHiO95jd1).python3 scripts/gen_snapshot.py (réécrit snapshot.json ; le front le sert immédiatement, pas de restart). Un backup horodaté snapshot.json.bak-* est conseillé avant.rp-dashboard-refresh.timer relance gen_snapshot.py toutes les 3h. Bouton "Rafraîchir" dans l'UI (POST /api/refresh, protégé) pour forcer à la demande.scripts/digest_notify.py (cron 08:00 Asunción) régénère puis pousse un résumé des alertes actionnables dans le canal notifications via /root/aios/lib/notify.py. Log : data/digest.log.Estado de cédula = Listo para retiro), permanentes à déposer (Fecha de expiración temporal < ~120 j sans dépôt permanente), éligibles permanente / upsell (temporaire déposée depuis plus de 12 mois mais moins de 24 mois, sans permanente : client encore DANS le créneau avant l'échéance des 2 ans ; bornes PERMANENTE_ALT_MIN_MONTHS = 12 / PERMANENTE_ALT_MAX_MONTHS = 24), soldes impayés (Saldo pendiente > 0), onboardings bloqués (Casos Estado = Documentos pendientes), domiciliation à renouveler (Fecha Vencimiento Contrato Domiciliacion ≤ 60 j), + 2 cartes "données à compléter" (absence territoire, docs obligatoires).Vencimiento de cédula / Vencimiento del pasaporte absents de la base), RUC en trámite (Estado del RUC quasi vide). À câbler quand ces champs seront ajoutés/remplis côté Airtable./root/workspace/AUDIT-dashboard-resident-paraguay.md (20 restent à câbler).data/documents.db, fichiers dans data/uploads/<token>/. Source de vérité locale, à synchroniser vers Airtable Documentos quand un token écriture sera dispo.GET / (protégé), GET /login, POST /login, GET /logout, GET /api/dashboard (protégé), GET /healthz.GET /c/:token, GET /api/c/:token, POST /api/c/:token/upload/:docId.GET /manager, POST /api/m/login, GET /api/m/pending, GET /api/m/summary, GET /api/m/clients, POST /api/m/clients, GET /api/m/file/:docId, POST /api/m/review/:docId.GET /api/ventas/meta — listes déroulantes (packs, vendeurs, devises, méthodes, receveurs)POST /api/ventas — crée une vente (Cliente + Caso + acompte) direct dans Airtable. Depuis 2026-09-11 (10), crée TOUJOURS la ligne de solde en attente pour le reste dû, plus seulement quand la case "cuotas" est cochée. Sauté (avec warning) si l'acompte et le prix sont dans des devises différentes. S4 (2026-09-11) : quand un plan échelonné est saisi (case cuotas + cuotas_n >= 2), crée UNE ligne Cuotas Pendiente PAR échéance (montants ~égaux, la dernière absorbe l'arrondi ; devise-aware : Guaranies sans décimales via splitCuotas), avec une Fecha de vencimiento mensuelle à partir de cuota_fecha (1re échéance). Sans échéancier : une seule ligne Saldo. Ainsi chaque échéance peut être encaissée séparément via « Encaisser ». Idempotence (2026-09-11) : le corps accepte idempotency_key (UUID généré par le front à l'ouverture du dialogue, stable entre deux clics). Un 2e appel avec la même clé attend/renvoie le résultat du 1er au lieu de recréer les lignes Pagos (anti double-clic) ; renvoyé avec deduplicated:true, audité result:'dedup'. Idem sur /api/ventas/pago. Store en mémoire (process unique), TTL 10 min, un échec libère la clé.GET /api/ventas/pendientes?caso_id=rec… — lignes Pagos Pendiente d'un dossier, une par une (pour le dialogue « Encaisser »). Auth équipe.POST /api/ventas/encaisser — admin + superviseur (gate par route). Encaisse UNE ligne précise : {pago_id, metodo, receptor, fecha?, monto_gs?, notas?, dossier?} → bascule cette ligne Pendiente→Pagado avec Fecha de pago, Método de pago, Receptor del pago (méthode + receveur obligatoires), Monto recibido (Gs). Ne touche qu'à la ligne visée (les autres cuotas restent dues). Idempotent : si déjà Pagado, renvoie already:true sans réécrire.ventaForm submit, public/index.html), une seconde boîte de dialogue (#dlgContrat) propose de créer directement le contrat pour le dossier qui vient d'être payé (caso_id déjà connu, pas de recherche à refaire). Affichée uniquement si l'utilisateur connecté est admin/superviseur (ME.role, silencieux pour editeur qui n'a pas les droits /api/contrats/*). Reprend le formulaire "dossier déjà dans Airtable" de /contrats (signataires pré-cochés par défaut, formule actuelle/service/legacy, tarif dégressif famille) directement dans le tour de contrôle, sans changer de page. Voir §5 "Contrats" pour le détail du formulaire repris.rdv.js, bouton header « 🗓 Nouveau RDV »)revenue_forecast.py (cockpit) matche à 100% au lieu de deviner par le nom (prénom vs nom de famille, fautes de frappe...). Voir docs/../../cockpit/backend/revenue_forecast.py.GET /api/rdv/meta — types de RDV proposés (TIPOS_RDV = Residence T / Renouvellement residence T, les seuls qui alimentent la prévision de trésorerie)GET /api/rdv/search?q= — recherche d'un dossier existant (réutilise l'index de ventas.js, mêmes champs/cache)POST /api/rdv — {caso_id, nombre, date, tipo, nota?} : appelle scripts/create_rdv.py (Python, compte resident.paraguay@gmail.com, scope calendar.events déjà accordé) qui crée l'événement avec extendedProperties.private.caso_id = l'ID Airtable du dossier, invisible dans l'UI Google Calendar. Journalisé rdv.create. Le dossier DOIT déjà exister (pas de création de client depuis ce formulaire, contrairement à « Nouvelle vente »).procedures.js + public/tramites.html)GET /tramites (team-gated toute session). Header : lien « 🗂 Trámites » (visible pour tous les rôles).GET /api/procedures/meta — structure des 5 étapes + options de statut + couleurs + writableGET /api/procedures/board — tous les clients (Clientes) avec l'état des 5 étapes (tableau global). ?fresh=1 force la relecture Airtable.GET /api/procedures/client/:id — valeurs fraîches d'une fichePOST /api/procedures/client/:id — modifie UN champ d'étape ({key,value}), PATCH ciblé Airtable (AIRTABLE_WRITE_TOKEN), journalisé tramite.update. Valide dates (AAAA-MM-JJ) et statuts (option autorisée) ; valeur vide = efface le champ.POST /api/procedures/client/:id/complete — clôture d'un trámite ({step, estado, fecha?}) : pose le statut final choisi + la date (défaut = aujourd'hui) en un seul PATCH, journalisé tramite.complete. estado doit faire partie des finals de l'étape (meta.steps[].done.finals). La résidence temporale a 2 finaux (retirada y almacenada / por el cliente) = choix obligatoire, pas de défaut.GET /api/procedures/verif — liste « à vérifier » : trámites lancés (date de dépôt) mais ni clôturés ni « Listo para retiro », dus = date manuelle échue OU (à défaut) dépôt âgé de AUTO_VERIF_DAYS..AUTO_VERIF_MAX_DAYS jours (30..180). Renvoie items (client, étape, âge/mode auto|manual, next_date) + count + autoDays.POST /api/procedures/verif/:id/:step — pose/repousse/efface la date de prochaine vérif (stockée en LOCAL, data/procedures.db, table verif) : body {next_date} (AAAA-MM-JJ ou ''), {days:N} (aujourd'hui+N), ou {clear:true}. Journalisé tramite.verif. L'alerte d'une étape est effacée automatiquement quand elle est clôturée (statut final/Listo para retiro, ou date de retiro/activación posée).contrats.js + public/contrats.html, /contrats, admin + superviseur uniquement)rp-onboarding (login + création d'invitation), centralisé ici, et ajoute la création manuelle de contrat pour un dossier déjà existant (le funnel client automatique reste inchangé : soumission du formulaire → aios-automations → rp-contracts). 1. « Nouveau dossier — la ou les personnes ne sont pas encore dans la base » (défaut) : PAS de recherche Airtable. Younes saisit juste le nom du dossier (texte libre, ex. « Famille Dupont » ou « Jean Martin ») et le nombre de personnes (1 ou plus), choisit une formule parmi les 5 actuelles UNIQUEMENT (pas de legacy ici, le mode intake n'existe que pour les formules famille), voit le total (dégressif si ≥2 personnes) calculé en direct (GET /api/contrats/pricing, person_count déjà connu). POST /api/contrats avec {draft:true, person_count, placeholders:{dossier_name,...}} → proxy rp-contracts renvoie un seul lien d'intake (intake_url, PAS de signers) que Younes envoie au dossier (n'importe lequel des membres, ou la personne seule, peut l'ouvrir et remplir l'identité) ; à la soumission côté rp-contracts, chaque personne reçoit ensuite automatiquement son propre lien de signature — voir MANUAL rp-contracts §5bis pour le détail du flux intake.
2. « Dossier déjà dans Airtable — je crée le contrat directement » : (1) recherche un dossier Airtable existant par nom/email/téléphone (Casos + Clientes liés) ; (2) Younes coche un ou plusieurs signataires parmi les clients du dossier ; (3) Younes choisit la formule parmi les 5 formules actuelles (residence-temporaire, residence-fiscalite, residence-integrale, residence-permanente, investor-pass) affichées en avant-plan, avec un bouton « + » qui révèle les 4 anciennes formules pour les cas particuliers ; (4) pour une formule actuelle avec plusieurs signataires, le total dégressif famille se calcule automatiquement (GET /api/contrats/pricing) et s'affiche pré-rempli mais éditable ; pour une ancienne formule, un seul signataire est autorisé ; (5) POST /api/contrats envoie un payload signers:[...] (plusieurs liens de signature en retour) ou l'ancien payload placeholders mono-signataire selon la formule.
Chargement paresseux (loadPacks()/searchDossiers() déclenchés uniquement au premier passage sur « Dossier existant »/« Nouveau dossier », pas au chargement de la page).
Note historique : le mode « Nouveau client » (lien complet auto-généré via POST /api/invitations proxy rp-onboarding, pack temporaire/fiscal/investor du FUNNEL CLIENT) a existé du 2026-08-xx au 2026-09-02 puis a été retiré de l'UI et du proxy contrats.js ; les routes backend rp-onboarding /api/invitations* restent en place (funnel client automatique inchangé, voir MANUAL rp-onboarding §7) mais ne sont plus appelées depuis ce dashboard.
GET /api/contrats → rp-contracts GET /api/contracts) avec statut (draft/sent/partially_signed/signed), formule, langue, référence dossier, progression de signature (« X/Y signé » pour un contrat famille, « 1 (ancien format) » pour un contrat legacy), dates. draft = dossier créé en mode "nouveau dossier", intake pas encore soumis. Lecture seule, pas de lien de signature ni d'intake exposé ici (capability link réservé au client/à la famille).requireRole('admin','superviseur') au niveau server.js) :GET /api/contrats/dossiers?q= — recherche de dossiers (Airtable, cache 60s)GET /api/contrats/dossiers/:casoId — détail d'un dossier + ses clients. Force une relecture Airtable (loadAll(true)) si le dossier n'est pas dans le cache 60s avant de répondre 404 (ajouté 2026-09-05, corrige un dossier tout juste créé par "Nouvelle vente" qui pouvait être invisible jusqu'à 60s si le cache avait été chauffé juste avant).GET /api/contrats/packs — formules disponibles (proxy rp-contracts), chaque entrée avec family:true/falseGET /api/contrats/pricing?pack=¤cy=&person_count= — grille + dégressif famille (proxy rp-contracts), 404 pour une ancienne formule. Ajouté 2026-09-02 (2).GET /api/contrats — liste des contrats + statut + progression (proxy rp-contracts)POST /api/contrats — crée un contrat : draft:true (dossier tout neuf, 1 ou plusieurs personnes, renvoie un intake_url au lieu de signers), ou dossier existant (1 ou plusieurs signataires connus), selon la formule (proxy rp-contracts)dossier_token (texte libre, = Token de invitación du Caso si dispo, sinon ID del caso), pas de clé étrangère réelle côté rp-contracts (voir son MANUAL §7). Pas d'écriture Airtable depuis ce module : le suivi (contrat signé, référence) reste à reporter à la main dans Airtable si besoin (champ Clientes.Contrato firmado / Referencia de contrato), non automatisé pour l'instant.payments.js + public/paiements-gestion.html, /paiements-gestion, admin + superviseur)/paiements (validation post-dépôt avec justificatif fichier) : ici c'est la maintenance du prévisionnel.GET /paiements-gestion + lien header « 🧾 Échéances » (visible admin+superviseur). Tous les endpoints ci-dessous sont PAY_ROLES (requireRole('admin','superviseur')) PAR ROUTE.GET /api/payments/pagos?estado= — liste des lignes Pagos (jointure Caso→Clientes pour dossier/client). estado ∈ Pendiente (défaut = échéances à venir), Pagado, Fallido, Reembolsado, todos. Renvoie items, changes (60 dernières modifs), monedas. Réutilise le cache loadRaw (120 s).POST /api/payments/pago/:pagoId/edit — {monto?, moneda?, fecha_venc?, justification} : seulement si la ligne est Pendiente. Justificatif obligatoire. PATCH Airtable des champs réellement changés (Monto/Moneda/Fecha de vencimiento) + ligne tracée ajoutée au champ Notas. Chaque changement est enregistré dans pago_changes (local) + audit paiement.modif.POST /api/payments/pago/:pagoId/cancel — {justification} : seulement si Pendiente. Justificatif obligatoire. Bascule Estado del pago → Fallido (annulation RÉVERSIBLE, choix Matt 2026-09-12 : rien n'est supprimé d'Airtable ; la ligne sort du prévisionnel qui ne compte que les Pendiente) + note tracée. Enregistré pago_changes + audit paiement.annulation.POST /api/payments/pago/:pagoId/split — {parts:[{monto, fecha_venc?}...], justification} : seulement si Pendiente. Divise l'échéance en 2 à 24 parts. La somme des parts doit égaler le montant d'origine (validé côté serveur en unités mineures, devise-aware : Guaranies sans décimale). Crée N lignes Pagos Cuotas Pendiente (héritent devise + Caso Vinculado), puis annule l'échéance d'origine (→ Fallido, réversible). Rollback best-effort (supprime les parts créées) si une création échoue. Enregistré pago_changes (action division) + audit paiement.division. Le front pré-répartit à parts égales + échéances mensuelles depuis une 1re date, chaque part restant ajustable (total live vérifié).pago_changes (data/payments.db) : ts, pago_id, caso_id, caso_name, client, action(modif|annulation|division), field, old_value, new_value, justification, user_name, airtable_ok, airtable_msg. Source de l'historique affiché ET du rapport hebdo. Neutralisé en mode test (comme audit/validations).scripts/weekly_changes_report.py lit pago_changes des 7 derniers jours et envoie un récap dans l'inbox notifications AIOS via /root/aios/lib/notify.py (silencieux si rien). Cron : lundi 8h (crontab -l, log /root/aios/logs/rp-payment-changes-report.log)..env (chmod 600) : TEAM_PASSWORD, DASH_SECRET, MANAGER_PASSWORD, PORT. Ne jamais committer ni exposer./root/aios/.env (AIRTABLE_API_KEY, lecture seule), pas dupliquée ici.AIRTABLE_WRITE_TOKEN (/root/aios/.env, ajouté le 2026-08-19) : PAT Airtable scopé data.records:read+write sur la SEULE base RP appGzbfkhHiO95jd1. Utilisé par payments.js (bascule Pagos Pendiente→Pagado), ventas.js (création Cliente/Caso/Pago) et procedures.js (PATCH des champs de trámites sur Clientes). Distinct de la clé lecture seule (moindre privilège). Ne jamais committer, ne jamais exposer au navigateur.contrats.js (ajouté le 2026-09-02) réutilise AIRTABLE_API_KEY (lecture seule, dossiers/clients) et lit CONTRACTS_API_TOKEN (repli AUTOMATIONS_TOKEN) dans /root/aios/.env pour s'authentifier en service-à-service auprès de rp-onboarding (création d'invitation) et rp-contracts (création/liste de contrats). URLs internes surchargeables via RP_ONBOARDING_URL/RP_CONTRACTS_URL (défaut http://127.0.0.1:8320/8365). Aucune écriture Airtable depuis ce module.gen_snapshot.py. Prochaine étape possible : timer systemd qui relance le script toutes les X h.TEAM_PASSWORD/DASH_SECRET dans .env impose systemctl restart rp-dashboard. Changer DASH_SECRET invalide toutes les sessions équipe en cours.express.static sert avec {index:false} : la home passe toujours par le gate requireTeam.public/app-reload.js + GET /api/version) : toute session ouverte se recharge seule (écran de chargement, sans déconnexion) dès qu'un fichier de public/ change et que le service est redémarré. Déployer une MAJ UI = éditer les fichiers public/ puis systemctl restart rp-dashboard, rien d'autre. Le fingerprint ne couvre que le front ; pour forcer un reload sans changer d'UI, touch public/index.html.testmode.js, admin only) : par SESSION admin (cookie rptest), jamais global — l'équipe écrit normalement pendant qu'un admin teste. Neutralise toute écriture (fetch mutant + agenda + SQLite locales) via un contexte AsyncLocalStorage posé dans un middleware app.use AVANT les routes. Toute nouvelle route qui écrit via fetch (POST/PATCH/DELETE) est donc neutralisée automatiquement ; une écriture par un AUTRE moyen (exec, SQLite) doit ajouter son propre garde if (testmode.on()) return;. Le mode expire seul après 24 h ; la bannière jaune est toujours affichée quand il est actif.GET /api/contrats/:id/pdf (contrats.js, gate admin+superviseur) qui streame le binaire de rp-contracts GET /api/contracts/:id/pdf?token= (PDF signé si tout le monde a signé, sinon aperçu de l'état courant ; Content-Type + Content-Disposition transmis tels quels). public/contrats.html : lien <a class="btn small" href="/api/contrats/${id}/pdf" target="_blank">PDF</a> ajouté avant « Voir liens » sur chaque ligne (le cookie de session porte l'auth, title indique signé vs aperçu). Testé : node --check contrats.js OK, endpoint upstream 4813... → HTTP 200, 4 pages PDF ; service redémarré, actif. Le PDF signé d'un contrat existant (Geoffrey Ruffin, residence-fiscalite) a aussi été exporté à la demande dans workspace/contrats-rp/.METODOS (ventas.js) passe à ['Transferencia bancaria','Lien Bancard','Carte bancaire','Efectivo','Otro'] → la méthode apparaît dans les deux menus déroulants VMETA.metodos (acompte v_ac_metodo, encaissement e_metodo). Côté Airtable, le champ Método de pago (singleSelect, fldTRPpiOlH8wyXeN, table Pagos) n'avait pas l'option et ne pouvait pas être modifié programmatiquement (le AIRTABLE_WRITE_TOKEN n'a pas le scope schéma → 403 ; le connecteur Airtable de Claude ne met à jour que les formules). Solution : atCreate/atPatch acceptent désormais un {typecast:true} optionnel, activé uniquement sur les 3 écritures Pagos qui portent une méthode (acompte, paiement client existant, encaissement) → Airtable crée l'option singleSelect à la première vraie écriture. Sûr car la méthode est validée contre METODOS avant l'écriture (pas d'option parasite possible). Les autres écritures (Clientes/Casos, lignes Pendiente sans méthode) gardent le garde-fou strict sans typecast. Testé : node --check OK, service redémarré /healthz 200, METODOS exposé vérifié = ["Transferencia bancaria","Lien Bancard","Carte bancaire","Efectivo","Otro"]. L'option Airtable se matérialisera au 1er encaissement en Carte bancaire (pas de click-test navigateur : Playwright cassé + login équipe multi-utilisateurs requis).public/nav-tabs.js (script partagé injecté sur les 9 pages équipe) : rend une barre d'onglets sous le header (Tour de contrôle / Trámites / Contrats / Paiements / Échéances / Réconciliation / RDV / Journal / Comptes), gated par rôle via /api/me, onglet courant surligné, + le compteur « trámites à vérifier » (badge rouge, /api/procedures/verif) déplacé du header vers l'onglet Trámites. Le script force header{position:static} et rend la barre d'onglets collante (position:sticky;top:0) pour qu'elle reste visible au scroll. public/index.html : les 8 liens de nav retirés du header (navTramites/navContrats/navPaiements/navPaiementsGestion/navReconcil/navJournal/navUsers/navRdvList + loadVerifBadge), les boutons d'action restent dans le header (Nouveau RDV, Encaisser, Nouvelle vente, Rafraîchir, Déconnexion). Les 8 autres pages : ajout de <script src="/nav-tabs.js" defer>. Testé : nav-tabs.js servi 200, aucune référence orpheline aux ID retirés, service redémarré /healthz 200 (l'auto-reload rechargera les sessions ouvertes). Ligne de paiement de TEST créée dans Airtable (Pagos recbdoe4GBehK0ddZ : Ajuste manual, 3000 EUR, Pendiente, échéance 15/11/2026, non rattachée à un dossier réel → s'affiche « (dossier) », note « LIGNE DE TEST AIOS ... à supprimer/annuler après essais ») pour que Matt teste modifier/diviser/annuler sans toucher un vrai client.public/paiements-gestion.html) + modale : nombre de parts (2-24) + 1re date → pré-répartition à parts égales + échéances mensuelles (helpers front equalSplit devise-aware + addMonthsISO), chaque part (montant + date) restant éditable, avec un total live qui doit égaler le montant d'origine (bouton désactivé sinon). Justificatif obligatoire. Backend POST /api/payments/pago/:id/split (payments.js) : valide Pendiente + somme des parts == origine (unités mineures, devise-aware) ; crée N lignes Cuotas Pendiente (héritent Moneda + Caso Vinculado, note tracée), rollback best-effort (DELETE des parts créées) si une création échoue, puis annule l'échéance d'origine (→ Fallido, réversible). Tracé pago_changes (action division) + audit paiement.division ; inclus dans le rapport hebdo (scripts/weekly_changes_report.py, section « Divisions en échéancier »). Testé via mode test sur une vraie ligne (3200 EUR → 3 parts : ok, parts:3, 0 écriture réelle, 0 ligne en base) ; somme fausse → 400 avec écart chiffré ; 1 seule part → 400 ; node --check + py OK ; service redémarré /healthz 200.public/paiements-gestion.html (/paiements-gestion, lien header « 🧾 Échéances », admin+superviseur) : liste filtrable des lignes Pagos (défaut = échéances à venir Pendiente), recherche dossier/client, totaux par devise, historique des modifs affiché. Backend (payments.js) : GET /api/payments/pagos?estado=, POST /api/payments/pago/:id/edit (montant/devise/date, seulement sur Pendiente), POST /api/payments/pago/:id/cancel. Décisions Matt (via question) : (a) « supprimer » = ANNULER en réversible → bascule Estado del pago Pendiente→Fallido (rien n'est effacé d'Airtable ; sort du prévisionnel qui ne compte que les Pendiente) ; (b) rapport hebdo le lundi 8h. Chaque modif/annulation exige un justificatif écrit (≥5 car.), ajoute une ligne tracée au champ Notas de la ligne Pago (visible dans Airtable), et est enregistrée à la fois dans le journal d'audit global (audit.js, actions paiement.modif/paiement.annulation) et dans une nouvelle table locale pago_changes (data/payments.db : avant/après complet + justificatif + auteur), source de l'historique écran et du rapport. Rapport hebdo : scripts/weekly_changes_report.py (lit pago_changes des 7 derniers jours, pousse un récap dans l'inbox notifications AIOS via lib/notify.py, silencieux si aucune modif) ; cron 0 8 * * 1 (log /root/aios/logs/rp-payment-changes-report.log). Réutilise les patterns existants : PAY_ROLES par route, cache loadRaw, écriture AIRTABLE_WRITE_TOKEN (PATCH ciblé), neutralisation mode test automatique (fetch mutant + garde if(testmode.on()) sur recordChange). Testé : node --check payments+server OK, py parse OK, service redémarré /healthz 200 ; API authentifiée younes → 37 échéances à venir listées (dossiers/clients/montants réels) ; garde-fous vérifiés (justif court → 400, pago inconnu → 404, editeur franco → 403) ; chemin de modif complet via mode test sur un vrai id (Monto 3200→3199, airtable ok, 0 écriture réelle, 0 ligne en base) ; rapport hebdo lancé à vide (silencieux, exit 0). Pas de click-test navigateur (Playwright cassé) : couches validées séparément.POST /api/ventas + /api/ventas/pago). Demande Matt (ceinture-bretelles sur la création de vente ; l'encaissement était déjà idempotent par état). ventas.js : helper withIdempotency(key, fn) + Map en mémoire key -> {ts, promise} (TTL 10 min, purge à chaque appel). Un appel avec une idempotency_key déjà vue attend la promesse du 1er traitement et renvoie SON résultat (deduplicated:true, audit result:'dedup') au lieu de rejouer createVenta/addPago : couvre le double-clic simultané (await de la même promesse) ET le re-clic juste après (résultat caché). Un échec libère la clé (vrai réessai possible). Pas de clé fournie (Telegram, anciens clients) = comportement normal. public/index.html : VENTA_IDEM (UUID via crypto.randomUUID) régénéré à chaque ventaReset() (ouverture du dialogue + après un succès), stable entre deux clics du même formulaire, envoyé dans les deux payloads. Le bouton se désactive déjà pendant l'envoi (1re barrière) ; la clé couvre les cas limites (double-tap avant le disable, retry réseau). Testé : unitaire withIdempotency (concurrent → fn 1 seule fois + même résultat ; re-clic → caché + deduplicated:true ; clé différente → réexécution ; échec → clé libérée et réessai ; sans clé → toujours exécuté), node --check ventas OK, inline JS OK, service redémarré /healthz 200.server.js) : GET /api/version (public, no-store) renvoie un fingerprint = max des mtime des assets public/*.html|*.js|*.css → change tout seul dès qu'un fichier front est modifié + restart. express.static passe en Cache-Control: no-cache (revalidation → le reload sert les fichiers frais). Frontend : public/app-reload.js (script partagé injecté sur les 9 pages équipe) mémorise la version au chargement, sonde /api/version toutes les 60 s ; si elle change, il recharge la page avec un plein écran de chargement navy + spinner (« Mise à jour du tableau de bord… votre session reste ouverte »). Comme les sessions sont par cookie, location.reload() ne déconnecte personne. Le rechargement est différé si l'utilisateur est occupé (modale dialog[open] ouverte ou champ avec saisie en cours) : une petite bannière « Nouvelle version disponible / Actualiser maintenant » s'affiche et le reload part dès qu'il est libre (aucune perte de saisie, pas de boucle car la version est re-mémorisée au reload). Pas de service worker sur cette app (rien à invalider). Testé : /api/version répond + no-store ; touch public/index.html change bien la version ; app-reload.js servi 200 no-cache à une session valide et présent dans la page ; node --check OK ; service redémarré, /healthz 200. NB : le fingerprint ne couvre que le FRONT (comme le CRM MOA) ; un déploiement backend seul (sans toucher public/) ne force pas de reload (le front reste compatible). Pour forcer un reload général sans changer d'UI : touch public/index.html.public/testmode.js, visible seulement pour les admins) + bannière jaune permanente tant que le mode est actif. Quand il est activé, le dashboard est identique (les LECTURES restent réelles) mais toutes les écritures sont neutralisées : Airtable (Cliente/Caso/Pago, bascule Pagos, PATCH trámites, upsell), agenda Google (RDV), service rp-contracts (création/suppression de contrat, envoi d'email), et les bases SQLite locales (journal d'audit, verif trámites, validations paiements + justificatif uploadé jeté). Architecture (testmode.js) : le mode est porté par la requête (AsyncLocalStorage), pas global → seul l'admin qui l'active est en mode test, l'équipe (Younes/Israel/Franco) continue d'écrire normalement en même temps (propriété de sécurité clé : le serveur n'honore le mode que si role==='admin', un cookie rptest volé par un non-admin n'a aucun effet). Point d'étranglement unique : installFetchGuard() intercepte tout fetch mutant (méthode ≠ GET/HEAD) émis pendant une requête en mode test et renvoie une réponse simulée → toute future fonctionnalité qui écrit via fetch est automatiquement neutralisée, sans code en plus. État mémorisé dans un cookie signé HMAC rptest (même DASH_SECRET que la session), expiration 24 h. Endpoints : GET /api/testmode ({allowed, on}), POST /api/testmode {on} (admin only). Testé de bout en bout (session admin forgée) : on bascule bien ; une écriture RDV sur id bidon renvoie ok simulé en mode test mais un vrai 404 Google hors mode test (preuve que le garde ne se déclenche qu'en test) ; lectures réelles OK ; 0 ligne ajoutée au journal d'audit ; non-admin = allowed:false + POST 403 + écriture réelle non neutralisée. Service redémarré, /healthz 200. NB : refresh du snapshot (gen_snapshot.py) et lectures ne sont pas gardés (pas d'écriture métier). Gardes SQLite ajoutés dans audit.js/procedures.js/payments.js ; exec agenda gardé dans rdv.js.ventas.js createVenta + public/index.html) : quand « Paiement en cuotas » est coché, un champ « Nombre d'échéances » (v_cuotas_n, défaut 2) + la date de 1re échéance génèrent une ligne Pagos Cuotas Pendiente PAR échéance au lieu d'une ligne fourre-tout, pour pouvoir encaisser échéance par échéance. Découpage via helper exporté splitCuotas(total, n, currency, firstDateISO) : montants ~égaux calculés en unités mineures (pas de dérive flottante), la DERNIÈRE absorbe l'arrondi, devise-aware (Guaranies = 0 décimale, EUR/USD = 2) ; Fecha de vencimiento mensuelles via addMonthsISO (borne le jour à la fin du mois cible : 31 janv +1 mois → 28/29 févr). Sans date de 1re échéance → lignes créées quand même mais sans vencimiento + warning. Aperçu live sous le champ (aligné, devise-aware). 1 échéance ou case décochée = comportement (10) inchangé (une ligne Saldo). Testé (unitaire splitCuotas : sommes exactes EUR & Gs, Gs sans décimales, clamp fin de mois, sans-date ; e2e dryClean : vente 1000 EUR acompte 400 en 3 cuotas → 1 acompte + 3× 200 Pendiente oct/nov/déc, cleanup OK ; non-cuotas → 1 acompte + 1 Saldo). S5 (payments.js) : l'API /api/payments/* (watchlist, validate, justificatif) est requireRole('admin','superviseur') PAR ROUTE (PAY_ROLES), plus requireTeam : un editeur (Israel/Franco) ne peut plus l'appeler (avant, l'écran était gated mais pas l'API). S6 : encaissement idempotent par état (ligne déjà Pagado → already:true, pas de réécriture ni de double comptage) + preuve minimale (méthode + receveur obligatoires). node --check ventas+payments OK, inline JS OK, service redémarré /healthz 200. Reste (mineur, non demandé) : répartir l'acompte par formule sur les packs mixtes.workspace/AUDIT-systeme-paiement-RP.md). Contexte : Younes créait ventes et contrats mais n'avait aucun geste propre pour enregistrer un solde payé ; les soldes complets n'existaient même pas en base (créés à la main depuis un Excel), et les deux chemins existants (/api/ventas/pago qui crée une ligne sans solder la Pendiente ; /paiements qui bascule TOUTES les Pendiente d'un dossier d'un coup, sans méthode/receveur, et n'affiche le dossier que si un dépôt de résidence est déjà daté) exposaient à du double comptage et à des encaissements aveugles. Implémenté (ventas.js + public/index.html) : (1) S1 — createVenta crée désormais TOUJOURS une ligne Saldo (ou Cuotas) Pendiente pour le reste dû (avant : seulement si « cuotas » coché), sautée avec warning si acompte et prix en devises différentes. (2) S2 — nouveau bouton header « 💰 Encaisser » (admin + superviseur), dialogue #dlgEncaisser : recherche du dossier → liste des lignes en attente (GET /api/ventas/pendientes) → Younes coche LA ligne payée → méthode + receveur obligatoires + Gs/date/note → POST /api/ventas/encaisser bascule cette seule ligne Pendiente→Pagado (Fecha de pago, Método, Receptor, Monto recibido (Gs)). Gère nativement solde complet et cuota individuelle ; idempotent (already:true si déjà payé). (3) S3 — la recherche du dialogue atteint n'importe quel dossier (plus de filtre « dépôt daté »). Nouveaux helpers atGet/atPatch, casoPendientes, encaisserPago ; gate requireRole('admin','superviseur') PAR ROUTE (jamais app.use('/', requireRole…), cf. piège cascade). Vérifié : test bout-en-bout sur enregistrements jetables (flip OK, idempotence OK, méthode manquante rejetée, cleanup), node --check ventas + parse inline JS OK, service redémarré /healthz 200, chemin HTTP authentifié admin (/api/me, /api/ventas/pendientes renvoie la vraie ligne du dossier Lopez 1500 EUR). Restes délibérément non faits (à valider avec Matt) : S4 (créer une ligne par échéance à la vente, pas une ligne cuotas fourre-tout), S5 (aligner le rôle de l'API /api/payments/* existante, aujourd'hui requireTeam, sur admin+superviseur). Pas de click-test navigateur (Playwright cassé + login) : les couches sont validées séparément.POST /api/ventas/update-tipo (ventas.js) accepte désormais caso_id + moneda : après avoir mis à jour Tipo de contrato (fiche Cliente), il recalcule le prix total de la vente = somme des prix unitaires (grille rp-contracts, unitPriceFromContracts(), 1 pers) × nb de personnes par formule, puis PATCH Casos.Precio del pack (+ Moneda del precio). Sécurités : ne PATCH le prix que si TOUTES les formules ont une grille (sinon total partiel faux → priceMissing[] renvoyé, prix laissé tel quel) ; devise Guaraníes = pas de grille auto → non recalculé. Réponse enrichie : precio, priceRecalculated, priceMissing. Front (public/index.html, pcGenerateMixed) : envoie caso_id + devise de la vente et affiche le résultat (« Prix de la vente recalculé : X EUR » ou avertissement « à ajuster à la main » si formule/devise sans grille). Popup de confirmation mise à jour (« packs ET prix recalculés »). Le solde restant se déduit automatiquement du nouveau prix (champ calculé Airtable) ; l'acompte versé (Pago) n'est pas touché. Vérifié : grille rp-contracts 1 pers OK (temporaire 1850 / fiscalité 2450 / intégrale 4190 / investor 3000 ; services → 404 = priceMissing) ; node --check ventas + inline JS OK ; service redémarré, /healthz 200. NB : recalcul = grille standard, il écrase un éventuel prix négocié saisi à la main à la vente (assumé : c'est le but quand on corrige une formule).#pc_mixed), chaque ligne a désormais un crayon ✏️ (pcRenderMixedList()) : il ouvre un <select> (toutes les formules + services) pour corriger la formule en cas d'erreur ; changer la formule remappe PC_MIXED (corrige tout le groupe qui prend cette formule) et re-render (fusionne si deux groupes deviennent identiques). (2) Détection de correction (pcMixedModified(), comparaison multiset vs PC_MIXED_ORIG figé à l'ouverture) : si une formule a changé, cliquer « Générer » n'agit plus directement mais ouvre une popup de confirmation (#dlgConfirmMod) prévenant que les contrats seront créés avec les nouvelles formules ET que les packs correspondants seront mis à jour dans le paiement (fiche client), avec un avertissement « en cas de doute, demande à la direction avant de confirmer ». (3) À la confirmation, pcGenerateMixed(true) appelle le nouvel endpoint POST /api/ventas/update-tipo (ventas.js) qui recalcule l'union des Tipo de contrato (via FORMULA_TO_TIPO, filtrée sur les options live) et PATCH la/les fiche(s) Cliente du dossier (ids depuis PC_DOSSIER.personnes), puis génère les contrats draft comme avant (un lien de saisie par formule). Sans modification, génération directe (comportement inchangé). L'acompte n'est toujours pas réparti par formule ; le prix de la vente n'est pas recalculé si une formule change (à ajuster à la main si besoin, non demandé). Testé : node --check ventas + inline JS OK, endpoint monté et gated (auth), service redémarré, /healthz 200.public/index.html : la grille .grid2 du bloc v_newfields est scindée en deux ; entre les deux, v_personas (pleine largeur) puis la case « packs différents » (v_mixed) et sa vue détaillée (v_mixed_box). Pack/Vendeur/Prix/Devise passent dans la 2e grille, en dessous. Aucun ID changé, aucune logique JS touchée (mêmes vSyncMixed/vAutoPrice/submit). Fichier statique (res.sendFile), simple rechargement de page, aucun restart. Vérifié : node --check inline OK, IDs toujours uniques.rp-contracts : un contrat = une seule formule (template légal propre), et le mode « lien de saisie unique » (draft/intake) n'existe que pour les 5 formules Résidence actuelles (FAMILY_PACKS), pas pour les services. Donc packs mixtes = plusieurs contrats. Implémenté (frontend seul, public/index.html) : la vente reporte désormais la formule de chaque personne (saleContextForContract.packs) → PC_MIXED. Dans renderContratForm(), si ≥ 2 formules distinctes (PC_MIXED_ACTIVE), l'UI « une seule formule » (signataires, cartes de formules, blocs de prix, membres) est masquée et une vue packs mixtes (#pc_mixed) s'affiche : liste des formules du dossier, regroupées, avec le nombre de personnes par formule (2 personnes sur la même formule = un seul contrat famille à 2). pc_submit bascule alors en génération multiple : un POST /api/contrats draft par formule Résidence (person_count = nb de personnes de cette formule) → renvoie un lien de saisie (intake) par formule, tous listés dans #pc_result (Younes transmet le bon lien à chaque personne selon sa formule). Les formules services (mono-signataire, identité requise, pas de draft) sont signalées pour création manuelle depuis l'onglet 📄 Contrats (on ne peut pas les auto-générer sans identité). L'acompte du dossier n'est pas réparti par formule (group_deposit_paid vide sur les sous-contrats). Si une seule formule distincte (Younes a coché « packs différents » mais mis la même partout) → flux normal famille inchangé. Aucun changement backend (mêmes endpoints draft/intake déjà en place), fichier statique servi par res.sendFile : simple rechargement de page, aucun restart. Testé : node --check du script inline OK. NB produit : impossible de tout mettre sur UN contrat (formules = documents distincts), le choix retenu est donc un contrat par formule ; validé comme la seule voie cohérente.v_mixed_lbl/v_mixed, masquée à 1 personne). Cochée, elle ouvre une vue détaillée (v_mixed_box / v_mixed_rows, vBuildMixedRows(n)) avec une ligne par personne : « Personne i » + un sélecteur de formule (mêmes options que le pack unique) + un prix. Chaque prix s'auto-remplit via la grille (GET /api/ventas/pricing?...&person_count=1, vMixedRowPrice()) et reste ajustable ; le « Prix total du pack » devient la somme des lignes en lecture seule (vMixedTotal()). En mode mixte, le pack unique et le crayon manuel sont masqués (v_pack_lbl/v_precio_edit), et vAutoPrice() court-circuite (vMixedOn()). Recalcul des lignes au changement de devise (vPriceRefresh() bindé sur v_moneda_precio). Côté écriture (ventas.js createVenta) : nouveau champ payload packs (tableau de formules) → l'union des Tipo de contrato (via FORMULA_TO_TIPO) est posée sur le Cliente ; Precio del pack = la somme envoyée ; audit venta.create reprend la liste des packs. Le pack unique (pack) n'est envoyé qu'en mode non-mixte. Étape contrat : en mixte, aucune formule pré-sélectionnée (formulaForContract=null, la fenêtre contrat étant famille/pack unique, Younes choisit à la main). ventaReset() repart toujours sans packs mixtes. Testé : node --check ventas.js + inline OK ; service redémarré (backend modifié), /healthz 200. NB : chaque personne est tarifée à 1 pers (pas de remise famille en mixte, logique puisque prestations différentes) ; en Guaraníes les lignes restent en saisie manuelle (grille EUR/USD seulement).GET /api/ventas/pricing → rp-contracts /api/pricing, ventas.js). Un crayon ✏️ à côté du champ bascule en saisie manuelle (le champ est en lecture seule tant qu'un prix auto le pilote ; services/anciennes formules sans grille ou devise Guaranies → champ déverrouillé pour saisie libre, avec un hint explicatif). Recalcul auto au changement de formule / nb de personnes / devise (vAutoPrice(), requêtes annulables). (2) Report des montants vers l'étape contrat (PC_SALE) : quand Younes enchaîne sur « créer le contrat », l'acompte et le total saisis dans la vente sont pré-remplis (pc_group_deposit/pc_group_deposit_legacy, pc_group_total, et « Total famille (ajustable) » pc_family_total_override) au lieu d'être re-tapés ; pcUpdatePricing() ne recalcule le total famille QUE si le champ est vide (ne clobber plus la valeur reportée/saisie). Repli sur les valeurs Airtable du dossier si pas de contexte de vente (flux /contrats direct, ou paiement sur dossier existant). (3) Formules en liste compacte : .pack-grid passe de la grille de grosses cartes (repeat(auto-fill,minmax(170px,1fr)), hautes) à une liste verticale de lignes fines (display:flex;flex-direction:column), .pack-card = une ligne (icône + titre + petit tag), gain de place important (sous-titres services raccourcis « 1 signataire » / « ancienne », masqués < 480px). Testé : node --check ventas.js + inline OK ; /api/ventas/pricing?pack=residence-temporaire&person_count=2 → family_total=3516 EUR (remise 5%) ; service redémarré (backend modifié), /healthz 200.<select> « Pack » contient maintenant des libellés longs (formules de contrat, ex. « Contrat d'accompagnement - Résidence Intégrale (...) ») dont la largeur intrinsèque élargissait la colonne et faisait déborder la fenêtre → obligé de scroller à droite pour voir Nom/Email/Vendeur/Devise du prix. Fix CSS (public/index.html) : .vf{min-width:0} + .vf input,.vf select{width:100%;min-width:0;box-sizing:border-box;max-width:100%} (+ text-overflow:ellipsis sur les selects) pour que les colonnes puissent rétrécir sous la largeur du contenu ; les 3 grilles 2-colonnes de la fenêtre de vente passent en classe .grid2 (minmax(0,1fr) minmax(0,1fr), et 1 seule colonne < 520px). Plus aucun scroll latéral, les libellés longs sont tronqués proprement dans le select. (2) Devise du prix par défaut = EUR (enfin appliquée) : le défaut EUR posé par ventaInit() était écrasé juste après par ventaReset() → form.reset(), qui remet chaque <select> sur sa 1re option (Guaranies). Correction : les défauts (v_moneda_precio=EUR, v_ac_moneda=EUR, v_tipo=Saldo, v_estado=Pagado) sont désormais re-posés APRÈS form.reset() dans ventaReset() (gardés-fous options.length). La devise de l'acompte passe aussi à EUR par défaut (était Guaranies) : les ventes RP sont libellées en EUR. node --check du script inline OK. Fichier statique (res.sendFile, pas de cache) : simple rechargement de page, aucun restart. NB : si l'acompte doit rester en Guaranies par défaut, une ligne à ajuster dans ventaReset().Tipo de contrato), sans lien avec le sélecteur de formules du contrat. Désormais le champ « Pack (formule du contrat) » propose les 9 formules rp-contracts (5 Résidence + 4 services, groupées « Formules actuelles » / « Services complémentaires », mêmes que /contrats), tirées de GET /api/ventas/meta (nouveau champ formulas, alimenté côté serveur par fetchFormulas() → rp-contracts /api/packs avec CONTRACTS_API_TOKEN/AUTOMATIONS_TOKEN, cache 5 min ; passe par la meta team-accessible car /api/contrats/packs est gated admin/superviseur et un éditeur n'y a pas accès). Côté écriture (ventas.js createVenta) : le pack reçu est une formule → mappé vers l'étiquette CRM via FORMULA_TO_TIPO (residence-temporaire→Paquete temporal, residence-fiscalite→Paquete Fiscal, residence-integrale→[temporal+Fiscal+Permanente], residence-permanente→Paquete Permanente, investor-pass→Investor Pass, domiciliation→Domiciliación, comptabilite & comptabilite-mensuelle→RUC, permis→∅), filtré sur les options live (rien n'est écrit si pas de correspondance, aucun crash). Rétro-compat : un ancien libellé CRM direct reste accepté. Report vers le contrat (public/index.html) : la vente passe la formule à offerContractFor(caso, nom, N, formule) → PC_PRESELECT → renderContratForm() clique la carte formule correspondante (déclenche tarif dégressif + contrôles) ; si la carte n'est pas affichée (service à 1 signataire en vente famille N>1) on laisse choisir. Un paiement sur dossier existant (mode SEL) ne reporte rien (Younes choisit à la main). Testé : node --check ventas.js + script inline index.html OK ; rp-contracts /api/packs renvoie bien les 9 formules ; vente residence-integrale en dryClean → ok=true, Tipo de contrato = les 3 étiquettes, warnings=[] ; service redémarré, /healthz 200. Mapping CRM ajustable à la demande (esp. intégrale / comptabilité mensuelle / permis).INVALID_MULTIPLE_CHOICE_OPTIONS (option « Younes ») + packs alignés sur les vrais types de contrat + robustesse anti-drift. Symptôme (capture Matt) : enregistrer une vente échouait avec Airtable tbld8zAoTQS1BIqmi 422 ... Insufficient permissions to create new select option "Younes". Cause : les entrées 2026-09-07 avaient changé VENDEDORES (→ inclut « Younes »/« Resident Paraguay »/« MOA ») et RECEPTORES (→ inclut « Younes »/« RP ») côté code, mais ces valeurs n'existaient PAS comme options des champs single-select Airtable Casos.Vendedor et Pagos.Receptor del pago, et le token de données n'a pas le droit de créer une option → l'écriture entière plantait. Corrections dans ventas.js : (1) lecture LIVE du schéma (loadSchemaOptions()/liveOptions() via l'API Meta Airtable avec la clé lecture, cache 5 min) : on ne devine plus les options, on lit les vraies. (2) /api/ventas/meta : le Pack est désormais tiré des types de contrat réellement disponibles (Clientes.Tipo de contrato, 7 options : Paquete temporal, Paquete Fiscal, Paquete Permanente, Residencia permanente sin RUC, RUC, Domiciliación, Investor Pass) — la liste n'était figée qu'à 6 avant (il manquait Domiciliación) ; Vendeurs/Receveurs = union(roster métier, options Airtable) pour toujours proposer le vendeur habituel. (3) Écriture défensive (selectValue()) : une valeur de Vendeur/Receveur absente du schéma Airtable n'est PLUS envoyée (au lieu de faire planter toute la vente) et remonte un warnings[] (affiché après « Vente enregistrée ✓ » dans index.html) invitant à ajouter l'option. Plus aucun crash possible de cette classe, quel que soit le drift futur code↔Airtable. (4) Option « Younes » ajoutée pour de bon aux deux champs Airtable (Casos.Vendedor et Pagos.Receptor del pago) via un enregistrement typecast éphémère créé puis supprimé avec le connecteur Airtable (compte Matt) : la vente de la capture (Younes vendeur + Younes receveur) s'enregistre maintenant proprement, sans warning, avec le vendeur/receveur bien attribués. La devise par défaut du prix (EUR) était déjà en place (entrée 2026-09-07 (2)), inchangée. Testé : reproduction exacte du scénario de la capture en dryClean → ok=true, remaining=1990, warnings=[] ; node --check OK ; rp-dashboard redémarré, /healthz 200. NB : les valeurs « Resident Paraguay »/« MOA » (Vendeur) et « RP » (Receveur) restent proposées mais pas encore créées comme options Airtable → si sélectionnées, la vente s'enregistre quand même (sans le champ) avec un warning ; les ajouter à la demande de Matt le moment venu (même procédé).#dlgContrat) ne proposait qu'1 personne. public/index.html : nouveau champ v_personas (min 1, défaut 1) dans le bloc v_newfields (nouvelle vente uniquement) ; sa valeur est passée à offerContractFor(casoId, displayName, personCount) → PC_PERSONS_COUNT. Quand N>1, la fenêtre de contrat bascule en mode « dossier tout neuf » (draft) : le sélecteur de signataires est remplacé par une note d'intake, seules les formules Résidence (famille) sont proposées (services/anciennes formules masquées, 1 signataire), le tarif dégressif est calculé sur N, et pc_submit poste {draft:true, person_count:N, placeholders:{dossier_name, dossier_token, group_deposit_paid}} → renvoie un lien de saisie unique (chaque personne remplit ses infos puis reçoit son lien de signature). N=1 (ou paiement sur dossier existant) : comportement inchangé (vraies personnes du dossier). Choix produit validé par Matt (intake plutôt que saisie manuelle des N). node --check sur le script inline OK. Fichier statique, aucun restart nécessaire.RECEPTORES (ventas.js) passe de ['Israel','Franco','Matt','Autre'] à ['Israel','Franco','Younes','RP'] (alimente <select id="v_ac_receptor"> via /api/ventas/meta). node --check OK, service redémarré. Les paiements déjà enregistrés gardent leur ancien receptor en base.public/index.html, ventaInit() : g('v_moneda_precio').value passe de Guaranies à EUR (le select propose toujours Guaranies/EUR/USD, seul le défaut change). La devise de l'acompte (v_ac_moneda) reste à Guaranies. Fichier statique (res.sendFile, pas de cache), aucun restart nécessaire.VENDEDORES (ventas.js) passe de ['Myriam','Le Guiz','Matt','Autre'] à ['Younes','Myriam','Resident Paraguay','MOA'] (alimente le <select id="v_vendedor"> via /api/ventas/meta). node --check OK, service redémarré. NB : les ventes déjà enregistrées gardent leur ancienne valeur de vendeur en base (ex. "Le Guiz"/"Matt"), la vue Vendeurs agrège les valeurs réelles Airtable.public/index.html : nouvelle boîte de dialogue #dlgContrat, ouverte automatiquement (offerContractFor(casoId, displayName)) juste après un POST /api/ventas ou /api/ventas/pago réussi, avec le caso_id déjà connu (le paiement vient de le créer/toucher). Étape 1 = proposition simple (« créer le contrat maintenant ? » / « plus tard ») ; étape 2 = reprise du formulaire "dossier déjà dans Airtable" de /contrats (signataires du dossier récupérés via GET /api/contrats/dossiers/:casoId, pré-cochés tous par défaut ce qui n'existait pas dans /contrats, formule actuelle/service complémentaire/legacy, tarif dégressif famille via GET /api/contrats/pricing, soumission POST /api/contrats) : code porté depuis public/contrats.html (mêmes fonctions renommées avec préfixe pc_/PC_), pas de logique dupliquée côté serveur. Gardée strictement aux droits existants : la proposition ne s'affiche que si ME.role (rempli par /api/me, déjà appelé dans render()) vaut admin ou superviseur — silencieuse pour les rôles editeur qui n'ont de toute façon pas accès à /api/contrats/*. Bug latent corrigé au passage dans contrats.js : GET /api/contrats/dossiers/:casoId pouvait répondre 404 sur un dossier tout juste créé si le cache 60s de loadAll() avait été peuplé juste avant (cas exact de ce nouveau flux : le dashboard appelle /api/contrats en tâche de fond, puis Younes crée une vente 10s plus tard) ; le handler force désormais une relecture Airtable (loadAll(true)) avant de conclure à un dossier introuvable. Testé bout en bout en conditions réelles avec un compte superviseur temporaire (créé/supprimé via auth.js, aucune trace laissée) : POST /api/ventas (nouvelle vente, cache contrats chauffé juste avant) → GET /api/contrats/dossiers/:casoId immédiat répond 200 (confirmerait 404 sans le fix) → POST /api/contrats (formule residence-temporaire, 1 signataire) crée bien un contrat avec lien de signature → contrat supprimé (DELETE /api/contrats/:id) puis Cliente/Caso/Pago de test supprimés directement via l'API Airtable (write token). node --check OK sur contrats.js et sur le <script> extrait de index.html. Service redémarré (contrats.js modifié), /healthz OK. Portée volontairement pas plus loin : pas de détection automatique "ce dossier a déjà un contrat" (Younes voit le statut dans l'onglet Contrats si besoin), pas de mapping automatique entre le pack legacy de "Nouvelle vente" (Paquete temporal...) et la formule contrat (residence-temporaire...) : Younes choisit toujours la formule explicitement, comme dans /contrats.checkLegacyPersonLimit() bloque déjà >1 signataire pour !isFamilyPack), donc « Nombre d'adultes engagés », « Part personnelle du client » et « Quote-part d'acompte du client » sont mathématiquement toujours identiques à « Montant total du dossier »/« Acompte déjà versé (dossier) », et « Solde restant dû (client) » n'est que leur différence. public/contrats.html : les 4 inputs (c_adults, c_indiv_amount, c_indiv_deposit, c_indiv_balance) supprimés du bloc c_legacy_pricing (ne restent que « Montant total du dossier » et « Acompte déjà versé », tous deux déjà pré-remplis depuis Airtable precio_pack/deposito_pagado et éditables si Younes doit corriger) ; selectDossier() allégé en conséquence ; au clic sur « Générer le contrat », adults_count est fixé à '1', client_individual_amount/client_deposit_share reprennent directement les 2 champs restants, et client_balance_due est calculé (max(0, total − acompte), arrondi 2 décimales) au lieu d'être ressaisi. Corrige au passage un bug latent : c_adults était pré-rempli avec ds.personnes.length (nombre total de personnes du DOSSIER Airtable) et non le nombre de signataires de CE contrat (toujours 1 pour ces formules) — un dossier de 2 personnes aurait affiché « Nombre d'adultes engagés : 2 » sur un contrat mono-signataire. Aucun changement côté backend (contrats.js, rp-contracts) : la forme du payload envoyé à POST /api/contrats est strictement inchangée, seules les valeurs sont désormais déduites au lieu d'être tapées à la main. Vérifié : grep confirme la disparition des 4 IDs dans le fichier, script <script> extrait et validé (node --check OK), /contrats répond toujours 302 (redirection login) sans session comme attendu. Fichier statique servi par res.sendFile (pas de cache), aucun restart nécessaire.rp-contracts (voir son CHANGELOG, pack residence-integrale) : rien à modifier côté contrats.js/contrats.html, le sélecteur de formules et le calcul dégressif sont 100% dynamiques (GET /api/contrats/packs/pricing), donc la 5e formule apparaît automatiquement dans les deux modes (« Nouveau dossier » et « Dossier existant »). Testé : /api/contrats/packs et /api/contrats/pricing?pack=residence-integrale renvoient bien la nouvelle formule via le proxy dashboard./contrats (impossible de créer un contrat). Cause : server.js montait les routers protégés via app.use('/', requireRole(...), xRouter). Ce mount sur '/' fait tourner requireRole(...) sur TOUTE requête entrante qui traverse la pile Express, pas seulement sur les routes du router en question, quel que soit le chemin réellement demandé (Express exécute les middlewares app.use mounted sur / avant même de tester si une route matche). Résultat : app.use('/', requireRole('admin'), reconciliationRouter) (ligne pensée pour restreindre /reconciliation aux admins) bloquait EN CASCADE toutes les routes déclarées APRÈS lui dans le fichier pour tout non-admin, y compris /vendeurs, /contrats, /tramites — même si ces routes avaient chacune leur propre requireRole plus permissif, jamais atteint. Fix (2 temps, le premier était encore buggé de la même façon avec router.use(requireRole(...)) sans path, retombant dans le même piège au niveau du router) : le gate est retiré des app.use('/', ...) dans server.js et posé par route individuelle (router.get('/api/x', requireRole(...), handler)) dans reconciliation.js, vendeurs.js, contrats.js. Testé : matrice de rôles rejouée en direct (younes/superviseur → /contrats, /api/contrats/*, /vendeurs, /tramites, / tous 200, /reconciliation 403 comme attendu ; franco/editeur → /tramites// 200, /contrats//vendeurs//paiements//reconciliation 403 comme attendu), node --check OK sur les 4 fichiers, service redémarré. Piège à retenir : ne jamais faire app.use(path, requireRole(...), router) ni router.use(requireRole(...)) sans restreindre le path si le router/mount couvre plusieurs routes à droits différents ou est suivi d'autres routers dans la pile — toujours gater route par route.serge.dechosal@gmail.com — vérifié en direct via l'API Airtable, la recherche backend le trouvait déjà par email/nom) mais n'était pas identifiable dans la liste car seul le nom du DOSSIER ("Dechosal", champ Nombre del caso, souvent juste un nom de famille ou un libellé libre) s'affichait, jamais les noms des personnes qu'il contient. public/contrats.html (searchDossiers()) : chaque résultat affiche désormais une ligne avec les noms complets de tous les clients rattachés (ds.personnes.map(p=>p.nom_complet).join(', ')), en plus du nom du dossier ; si le dossier n'a pas de nom propre, le premier nom de client sert de titre. contrats.js (searchDossiers()) : le filtre de recherche couvre en plus Nombre completo (fallback utilisé à l'affichage) et Telefono du client, pas seulement Nombre/Nom/Email. Testé : requête Airtable directe reproduisant la recherche confirmée sur "Dechosal"/"serge.dechosal@gmail.com", node --check OK sur contrats.js et le <script> extrait, service redémarré.public/contrats.html : sélecteur « Situation » repassé de 3 à 2 options (new_dossier en 1er/défaut, existing), bloc block_new_client et son formulaire (i_first/i_last/i_email/.../i_submit, appel POST /api/contrats/invitations) supprimés entièrement ; libellés « Nouveau dossier » et champ « Nom du dossier » reformulés pour couvrir 1 ou plusieurs personnes (déjà techniquement le cas : nd_count avait min="1" depuis sa création, aucun changement de logique côté nd_submit/rp-contracts). contrats.js : route proxy POST /api/contrats/invitations et variable ONBOARDING_URL (devenue inutilisée) supprimées ; les routes rp-onboarding /api/invitations* restent en place côté rp-onboarding (funnel client automatique inchangé) mais ne sont plus jamais appelées. Testé : node --check OK sur contrats.js et sur le <script> extrait de contrats.html, service redémarré (active, /contrats renvoie 302 non-authentifié comme attendu).<select> « Situation » à 3 options (voir §"Contrats"). Nouveau bloc block_new_dossier : nom du dossier (texte libre) + nombre de personnes + formule (4 formules actuelles uniquement, pas de legacy) + tarif dégressif calculé en direct. POST /api/contrats avec draft:true (nouvelle branche dans contrats.js, validée séparément des deux autres modes) → proxy rp-contracts, retourne un lien d'intake unique affiché à Younes pour transmission au dossier. Vue « Contrats » : statut draft ajouté (« en attente des infos »). Testé : node --check OK, dossier "Famille QA Draft" créé côté rp-contracts (2 personnes, tarif -5% déjà affiché), flux intake + double signature vérifiés de bout en bout (voir CHANGELOG rp-contracts).rp-contracts §7bis pour le détail de la grille). public/contrats.html : le person-picker passe d'un choix unique (chip) à des cases à cocher multi-select ; nouveau bloc « Tarif calculé (dégressif famille) » affiché uniquement pour une formule actuelle, qui appelle GET /api/contrats/pricing à chaque changement de personnes/devise/formule et pré-remplit un total ajustable ; les anciennes formules gardent l'ancien bloc de montants 100% manuels et se limitent à 1 signataire (avertissement sinon). contrats.js : nouvelle route proxy GET /api/contrats/pricing ; POST /api/contrats détecte un payload signers:[...] (famille) vs l'ancien payload placeholders (legacy) et transmet tel quel à rp-contracts (aucune logique tarifaire dupliquée ici, source de vérité unique côté rp-contracts). Vue « Contrats » : colonne « Signataires » avec progression (« X/Y signé »), libellés de statut en français incluant partially_signed. Testé : node --check sur le script extrait OK, contrat famille 2 signataires créé/signé de bout en bout côté rp-contracts (voir son CHANGELOG), contrat ancienne formule vérifié sans régression.public/contrats.html : le tab séparé « Créer un contrat » est retiré ; son contenu (recherche dossier + sélection formule) est déplacé dans le panel « Nouvelle invitation », derrière une case à cocher « Le client a déjà fourni tous ses documents (dossier existant) » (décochée par défaut = formulaire d'invitation classique ; cochée = révèle le bloc dossier existant, chargement paresseux de /api/contrats/packs et /api/contrats/dossiers à la 1re coche seulement). Corrigé au passage : bug d'échappement JS (l\\'instant → guillemet mal échappé cassait le parsing de loadContractsList(), présent depuis la création du fichier, jamais exécuté car node --check n'avait été passé que sur les .js, pas sur le <script> inline du .html — extrait et vérifié séparément cette fois). Fichier statique, aucun restart nécessaire. Testé : node --check sur le script extrait OK, case à cocher présente, ancien libellé « Créer un contrat » absent du fichier./contrats, admin+superviseur) : centralise la création d'invitations et de contrats, remplace le portail Younes de rp-onboarding. Demande Matt : automatiser le process de contrat à signer côté RP, portail Younes retiré ("1 bouton suffit"), formules à resynchroniser avec le site actuel (grille tarifaire changée depuis la création des templates initiaux : Temporaire 1890€, +Fiscalité 2490€, Permanente +1990€ en option, Investor Pass 3000€+70k USD). Nouveau fichier contrats.js (gabarit vendeurs.js) : recherche/détail de dossier Airtable (Casos+Clientes, lecture seule), proxy vers rp-onboarding (création d'invitation, jeton de service) et rp-contracts (packs, création et liste de contrats). Nouvelle page public/contrats.html (3 onglets : Nouvelle invitation / Créer un contrat / Contrats). server.js : route + gating requireRole('admin','superviseur'), gabarit identique à Vendeurs/Réconciliation. public/index.html : lien nav « 📄 Contrats » visible admin+superviseur. Contrepartie côté rp-onboarding : portail retiré (voir son MANUAL). Contrepartie côté rp-contracts : 4 nouvelles formules + GET /api/packs + GET /api/contracts (voir son MANUAL). Testé : node --check OK sur les 2 fichiers JS, /contrats redirige vers /login sans session (gating actif), GET /api/packs/GET /api/contracts répondent côté rp-contracts, contrat de test créé et vérifié (template résidence-permanente, placeholders résolus) puis supprimé. RESTE : écriture retour dans Airtable (statut signé, référence contrat) non automatisée ; lien de signature non exposé dans la vue liste (à ajouter si Younes en a besoin pour relancer un client).ventas.js : createVenta() accepte désormais cliente_id (client EXISTANT) — crée un NOUVEAU Caso (nouveau prix = prix négocié, Precio del pack propre à ce Caso) rattaché à la fiche Cliente existante au lieu d'une fiche en double ; rollback (dryClean + erreur) ne supprime JAMAIS un client existant (clienteCreated traqué). Nouvelle fonction searchClientes() + index dédié loadClienteIndex() (recherche directe sur Clientes, distincte de searchCasos() qui indexe par dossier) + route GET /api/ventas/clientes. public/index.html : pendant la saisie « Nouveau client », recherche live (debounce 300ms) sur nom/prénom → si un client similaire existe, bandeau #v_dupe avec lien « lier ce nouveau contrat à ce client » (bascule LINK_CLI, envoie cliente_id) ou « ignorer ». create_rdv.py réécrit avec 4 modes (--list, --update, --delete, création par défaut) : --days N pour un événement étalé sur N jours (convention Google end EXCLUSIF, comportement historique days=1 inchangé, testé start=20/09 days=2 → end=22/09 correct) ; --group-id pour rattacher plusieurs événements d'un même RDV multi-étapes ; --list filtre par privateExtendedProperty=source=rp-dashboard. rdv.js : TIPOS_RDV += RUC, SUACE Investor Pass ; MULTIDAY_TIPOS = Residence T/Renouvellement (durée 2-3j sélectionnable) ; SUBSTEPS = config des RDV multi-événements (RUC : demande en amont + arrivée + retrait DNIT ; SUACE Investor Pass : vérif documents + dépôt sur 2j + retrait+dépôt migraciones), chaque sous-étape devient un vrai événement calendrier distinct avec le même group_id + caso_id ; rollback best-effort si un sous-événement échoue en cours de création. Nouvelles routes GET /api/rdv/list (groupe par group_id) et POST /api/rdv/:eventId (modifie date/note). Nouvelle page public/rdv-list.html (/rdv-list, lien header « 📅 RDV », accessible à toute l'équipe via requireTeam : c'est la portée demandée pour Israel+Younes) : liste les RDV à venir créés depuis ce dashboard, groupés visuellement pour les multi-événements, édition inline date+note par événement, lien Airtable + agenda Google. public/index.html (modale RDV) : sélecteur de durée (1-3j) affiché seulement pour les types multi-jours, champs de date dynamiques un par sous-étape pour RUC/SUACE (texte d'aide explicite). Testé bout en bout en conditions réelles puis nettoyé (aucune trace laissée) : création RUC 3 événements sur le dossier Bitz (vérifiés groupés dans /api/rdv/list), modification de la date d'un des 3, suppression des 3 ; création Residence T sur 2 jours (start/end Google Calendar vérifiés directement via l'API, 20/09→22/09 correct), supprimée ; createVenta({cliente_id, ...}, {dryClean:true}) : Caso+Pago créés puis supprimés, Cliente existant NON supprimé et intact (vérifié en relecture Airtable). node --check OK sur tous les fichiers JS modifiés. Service redémarré, /healthz OK.franco et israel créés (rôle editeur, mots de passe temporaires transmis à Matt en chat, à changer à la 1re connexion) ; Matt (admin) et Younes (superviseur) existaient déjà. (2) Onglet Vendeurs retiré de la navigation (lien supprimé de index.html, route serveur laissée intacte pour un accès direct admin/superviseur si besoin). (3) Journal + Réconciliation restreints à l'admin seul (server.js : requireRole('admin','superviseur') → requireRole('admin') sur /admin/journal, /api/audit, /reconciliation ; nav JS alignée). Onglet Comptes était déjà admin-only, rien à faire. (4) Bug noms trouvé et corrigé : les alertes affichaient le prénom seul (ou parfois le nom de famille seul). Cause identifiée : gen_snapshot.py::name_of() priorisait un vieux champ libre Nombre completo (souvent vide) avant Nombre+Nom combinés, et 3 alertes basées sur les Casos (soldes à encaisser, cuotas, paiements à compléter, onboardings bloqués) utilisaient Nombre del caso (label de dossier, souvent juste le nom de famille, ex. « Bitz ») au lieu du nom du client lié. Fix : name_of() combine désormais Nombre+Nom en priorité ; nouvelle fonction caso_display_name() résout le nom via le(s) Cliente(s) lié(s) au Caso, avec repli sur Nombre del caso seulement si aucun client trouvé. Vérifié sur les 225 items du snapshot régénéré : 0 nom à un seul mot restant (vs 53 avant, dont le cas test Bitz). (5) Formulaire « Nouvelle vente / paiement » (public/index.html) : champ « Montant réel reçu sur le compte (Gs) » retiré de l'UI (le backend le calcule déjà automatiquement quand le paiement est en guaranies, ventas.js inchangé, donc rien perdu) ; bug du « Reste dû » corrigé — c'était une soustraction brute prix-acompte sans vérifier la devise (ex. prix en USD, acompte en Gs → chiffre faux) ; affiche maintenant un avertissement au lieu d'un montant erroné si les devises diffèrent. (6) Bandeau de confirmation persistant ajouté en haut du tableau de bord (#confirmBanner), affiché après une vente/paiement/RDV créé avec succès, fermable par une croix (reste visible tant qu'on ne le ferme pas, contrairement au message éphémère du bouton). (7) Bug Trámites corrigé : le pill de statut « Segunda verificación física » (valeur retirée des menus depuis le 2026-08-21) restait affiché pour des clients dont la cédula était déjà retirée, ce qui n'est légalement possible que si la résidence temporale est terminée. Diagnostic confirmé en base (ex. Corinne Vouriot, Kevin Amann, Romain Delagrange... : Estado de cédula=Retirado mais Estado de residencia jamais mis à jour et Fecha de retiro de residencia temporal vide). Fix procedures.js : nouvelle table IMPLIED_BY (dépendances légales entre étapes : résidence temporale impliquée par cédula/résidence permanente/cédula permanente ; résidence permanente impliquée par cédula permanente) + applyImpliedFinish() qui corrige l'affichage (statut ET disparition de la liste « à vérifier ») sans jamais écrire dans Airtable. node --check OK sur server.js/procedures.js/ventas.js/rdv.js. Testé : login réel israel (rôle editeur) → 403 sur /admin/journal et /reconciliation, /api/dashboard OK. Service redémarré, /healthz OK. Phase 2 (en attente de décisions Matt, pas encore commencée) : comptes rendus multi-événements pour les RDV RUC (3 events) et SUACE Investor Pass (plusieurs events), résidence sur 2-3 jours, édition des RDV déjà créés par Israel/Younes, détection famille déjà cliente / upsell lors d'une « Nouvelle vente » avec option prix négocié.revenue_forecast.py (cockpit) en ratait 5, mais 3 étaient en fait des dossiers existants mal étiquetés (prénom dans l'agenda vs nom de famille dans Airtable, ex. « Niels » vs dossier « Heidbrink - DO » ; faute de frappe « Defresne » vs « Dufresne ») et 1 cachait un vrai solde de 6 460 € jamais compté. Décision Matt : les RDV de dépôt ne se créent plus à la main dans Google Calendar, mais depuis ce dashboard, avec le VRAI dossier Airtable choisi (recherche), pour un matching garanti à 100 %. Nouveau module rdv.js (réutilise l'index de recherche de ventas.js, searchCasos) : GET /api/rdv/meta (types : Residence T / Renouvellement residence T, les seuls qui alimentent la prévision de trésorerie), GET /api/rdv/search, POST /api/rdv ({caso_id, nombre, date, tipo, nota?}, journalisé rdv.create). Le dossier doit déjà exister (pas de création de client depuis ce formulaire). Écriture agenda déléguée à scripts/create_rdv.py (compte resident.paraguay@gmail.com, scope calendar.events déjà accordé) : crée l'événement (Residence T <nom>, journée entière) avec extendedProperties.private.caso_id = l'ID Airtable exact, invisible dans l'UI Google Calendar mais lu par revenue_forecast.py (cockpit) qui fait désormais un lookup EXACT sur cet ID en priorité, fallback sur l'ancien matching par mots seulement pour les événements créés hors dashboard (historique). Bouton header « 🗓 Nouveau RDV » + modale (public/index.html, calque du modèle « Nouvelle vente ») : recherche client → sélection obligatoire → type + date + note optionnelle. Testé bout en bout : création réelle sur le dossier Dufresne (event créé, extendedProperties relu correctement depuis les 2 comptes Google utilisés par l'app, événement supprimé après vérif) ; test de bout en bout sur le cas Heidbrink (solde 6 460 € réel) confirmant que le nouveau lookup ID le classe bien en statut du avec le bon montant (total_usd correct), là où l'ancien matching le loupait totalement (événement de test supprimé après vérif, aucune trace laissée). node --check OK sur rdv.js/server.js/le script inline de index.html. Service redémarré, /healthz OK, aucune erreur au démarrage. Suite Matt à faire dans Google Calendar (hors AIOS, scope OAuth actuel ne couvre pas la gestion des partages) : repasser l'accès de l'équipe sur l'agenda RP en LECTURE SEULE, pour que la création passe exclusivement par ce formulaire.scripts/gen_snapshot.py, la carte absence-territoire (jusque-là un placeholder « données à compléter » figé à 335 j) calcule désormais les clients dont TODAY - {Fecha última salida del territorio} >= 270 : seuils 270 j = à surveiller (warn) / 330 j = URGENT (high), triés du plus longtemps absent d'abord, headline = nb d'urgents ≥330 (sinon nb ≥270), dataGap=true seulement si aucun. Constantes VISIT_WARN_DAYS=270 / VISIT_HIGH_DAYS=330. La carte remonte donc dans le digest matinal (digest_notify.py) et le compteur d'actions comme les autres. NB : le champ date de sortie est rarement rempli (0 client actuellement ≥270 j), donc la couverture dépend de sa saisie (déjà signalé par la carte sante-donnees). Côté Airtable, Matt doit passer les 2 automatisations sur OFF (l'API ne peut pas éteindre une automation active). Ancien job email AIOS scripts/visit_alerts.py (approche abandonnée) supprimé. Snapshot régénéré, front statique (recharger).Verificación, Primera verificación, Segunda verificación física, RUC En tramite) sont RETIRÉS des menus : les dropdowns ne gardent que départ + fin (No requiere acción / Listo para retiro / fin ; RUC Sin RUC/Activo/Suspendido/Cancelado ; résidence temporale ses 2 fins). Une ancienne valeur reste affichée « (ancien) » et sélectionnable (pas de perte de données), mais n'est plus proposée. À la place, moteur d'alertes : un trámite déposé mais non clôturé et non « Listo » devient « à vérifier », combiné = auto quand le dépôt a 30..180 j (AUTO_VERIF_DAYS/AUTO_VERIF_MAX_DAYS, borne haute pour ne pas noyer sous les vieux dossiers jamais marqués finis), OU à une date de prochaine vérif posée à la main (toujours honorée, sans borne). Backend procedures.js : DB locale data/procedures.db (table verif client_id+step→next_date), helpers pendingVerif/isFinished, endpoints GET /api/procedures/verif + POST /api/procedures/verif/:id/:step ({next_date}|{days:N}|{clear}), nettoyage auto de l'alerte à la clôture (dans les endpoints update/complete). Frontend tramites.html : nouvel onglet « 🔔 À vérifier (N) » (liste triée retard manuel d'abord puis dépôt le plus ancien ; par ligne : Ouvrir / +7j / +15j / +30j / ✓ Prêt=met « Listo para retiro »), + par étape dans la fiche une ligne « 🔔 Próxima verificación » (date + +15j/+30j). index.html (tour de contrôle) : badge rouge du nombre à vérifier sur le bouton « 🗂 Trámites » (loadVerifBadge via /api/procedures/verif). Testé (curl + Playwright) : menus sans statuts de vérif (ancien affiché « (ancien) »), liste auto bornée = 36 (vs 236 sans borne), report +30j (sort de la liste), date passée (repasse en manual), clear (revient auto), 0 erreur JS. Aperçus workspace/tramites-preview/verif-list.png, fiche-verif.png. Frontend statique (recharger) ; service redémarré. Option en attente (Matt) : push WhatsApp matinal de la liste à vérifier (instance Evolution aios-main dispo), non câblé pour l'instant.STEPS[].done) : cédula/résidence permanente/cédula permanente → Retirado, RUC → Activo, résidence temporale → 2 choix imposés (Residencia retirada y almacenada / Residencia retirada por el cliente, aucun défaut, l'utilisateur choisit). Backend : endpoint POST /api/procedures/client/:id/complete ({step,estado,fecha?}, valide estado ∈ finals, date défaut = aujourd'hui, journalisé tramite.complete). Frontend tramites.html : doneBar(step) (1 bouton si un seul final, N boutons si plusieurs) + completeStep() qui reflète les 2 champs dans la carte sans recharger. Les champs restent éditables ensuite (on peut corriger la date). Testé : meta expose les done.finals, complete sur cédula permanente (Retirado + date), rejet d'un final non autorisé, nettoyage ; rendu visuel des 2 boutons sur la temporale confirmé (Playwright), 0 erreur JS. Service redémarré.No requiere acción → Verificación → Listo para retiro → Retirado) + une/deux dates + un lien Drive. Constat : l'Airtable était bâti pour UN seul passage → il manquait la variante PERMANENTE (résidence + cédula) et quelques dates. 9 champs créés dans Clientes (tblGscoeyEVybGVHN, via connecteur) : Estado de residencia permanente, Estado de cédula permanente (singleSelect 4 états), Fecha de retiro de residencia temporal, Fecha de retiro de residencia permanente, Fecha de activación de RUC, Fecha de solicitud de cédula permanente, Fecha de retiro de cédula permanente (dates), Enlace Drive residencia temporal, Enlace Drive residencia permanente (url). Les étapes existantes réutilisent les champs déjà là (dépôts temporal/cédula/RUC/permanente, Estado de residencia/Estado de cédula/Estado del RUC, retiro de cédula, email RUC) → une seule source de vérité, la compta et les autres vues restent cohérentes. Nouveau module backend procedures.js : modèle STEPS (5 étapes → champs Airtable), lecture AIRTABLE_API_KEY (cache 60 s), écriture PATCH ciblée AIRTABLE_WRITE_TOKEN avec validation (date AAAA-MM-JJ, statut ∈ options, vide = efface), journalisé tramite.update. Endpoints GET /api/procedures/meta|board|client/:id + POST /api/procedures/client/:id. Page public/tramites.html (thème clair) : tableau global (342 clients × 5 étapes, pills de statut colorées + date, recherche + filtres Listo para retiro / En verificación / trámite abierto, clic sur une ligne → fiche) et fiche client (recherche, 5 cartes d'étape avec édition EN LIGNE = autosave date/statut/lien vers Airtable). Lien header « 🗂 Trámites » (tous rôles). server.js : import + app.use('/', requireTeam, proceduresRouter) + route page. Testé bout-en-bout (Playwright + curl authentifié) : meta (5 étapes, writable), board (342 clients, 18 clés), fiche, PATCH aller-retour sur champ neuf (set → relecture → clear), rejet d'un statut invalide, PATCH sur champ existant (idempotent), page 200, 0 erreur JS console. Aperçu : workspace/tramites-preview/board.png, workspace/tramites-preview/fiche.png. Frontend statique (recharger) ; service redémarré pour procedures.js.Vendedor. Nouveau module backend vendeurs.js (lecture seule AIRTABLE_API_KEY, cache 120 s) : computeSales = Casos AVEC Vendedor renseigné, montant = Precio del pack en Moneda del precio, date = Fecha de firma del contrato, client/pack joints via Clientes ; aggregate = par vendeur { nb ventes, total par devise }, trié par nb de ventes. Route GET /api/vendeurs (filtres from/to/vendedor, team-gated admin+superviseur). Page public/vendeurs.html (thème clair) : cartes par vendeur (nb ventes + total par devise), filtre période + vendeur, tableau des ventes ; bandeau d'info tant que 0 vente attribuée. Lien header « 👥 Vendeurs » (navVendeurs, admin+superviseur). server.js : import + app.use('/', requireRole('admin','superviseur'), vendeursRouter) + route page. NB IMPORTANT : le champ Vendedor est récent → 0 vente attribuée aujourd'hui (271 Casos, tous sans vendeur). La vue est volontairement vide au départ et se remplit automatiquement à chaque vente saisie via « Nouvelle vente » (qui pose Vendedor). Totaux affichés PAR DEVISE (pas de conversion ₲→€ pour éviter un taux arbitraire), classement par nombre de ventes. Testé : gating (401/302 sans auth), API vide (0), puis vente de test attribuée à Myriam (createVenta persistant) → apparait (1 vente, 5 000 000 ₲) → enregistrements supprimés → retour à 0. Frontend statique (recharger) ; service redémarré.ventas.js : lecture Airtable ajoutée (AIRTABLE_API_KEY, fallback write token) ; searchCasos(q) = index Caso enrichi (client, pack, contacts, solde en attente par devise, cache 120 s) filtré par termes normalisés (accents/casse) sur nom+email+tel ; addPago(b) crée UNE ligne Pago sur un caso_id existant (garde-fous : caso rec… requis, type ∈ TIPOS_PAGO = Pago completo/Saldo/Cuotas/Depósito/Ajuste manual, montant requis ; si Pagado → Fecha de pago + Monto recibido (Gs) [auto = montant si devise ₲] ; si Pendiente + échéance → Fecha de vencimiento qui alimente l'alerte cuotas ; méthode/receptor/notas optionnels). Routes GET /api/ventas/search + POST /api/ventas/pago (team-gated, journalisées pago.create) ; /api/ventas/meta expose tipos + estados. Frontend (public/index.html, une seule modale dlgVenta) : en haut, un champ 🔎 recherche client (debounce 250 ms) ; taper ≥ 2 lettres liste les dossiers (client/pack/contacts/solde en attente). Cliquer un résultat bascule en mode client existant : les champs « Nouveau client » (v_newfields) se masquent, un bandeau « ✅ Client existant … [changer] » apparait, les sélecteurs Type + Statut (v_exfields) s'affichent, l'entête devient « Paiement à enregistrer » et le bouton « Enregistrer le paiement » ; le formulaire POST vers /api/ventas/pago. Sans sélection (ou via « changer »), tout revient au mode nouvelle vente classique (POST /api/ventas). Le bloc paiement (montant/devise/date/méthode/reçu par/montant ₲ réel avec auto-report) est partagé ; en mode existant, statut « Pendiente » masque date + ₲ et révèle l'échéance, « Pagado » l'inverse. L'ancien 2e bouton « 💰 Paiement (client existant) » et la modale dlgPago ont été retirés. Testé : syntaxe JS OK ; navigateur réel (Playwright, login admin) : ouverture modale, recherche « rault » → 1 résultat rendu, sélection → bascule mode existant (champs masqués/affichés, entête + bouton corrects, bandeau), statut Pendiente/Pagado (échéance/₲/date), « changer » → retour nouvelle vente ; backend addPago déjà testé (dryClean + POST HTTP réel créé puis supprimé). Frontend statique : recharger la page, pas de redémarrage.Monto recibido (Gs) (créé à l'entrée q). Nouveau module backend reconciliation.js (lecture seule AIRTABLE_API_KEY, cache 120 s ; helper computeRows = jointure Pago→Caso→Clientes pour client/pack ; applyFilters ; toCSV). Endpoints GET /api/reconciliation (rows + facets + totaux : count, total ₲ réel, nb encaissés sans ₲, total facturé par devise) et GET /api/reconciliation.csv (export, séparateur ; + BOM UTF-8 pour Excel FR/ES, 12 colonnes). Les deux team-gated admin+superviseur (comme /paiements). Nouvelle page public/reconciliation.html (thème clair) : barre de filtres (période date paiement, encaissé par, méthode, devise, statut, recherche texte, case « sans montant Gs »), 4 KPIs, tableau, bouton Export CSV qui suit les filtres actifs. Lien header « 📒 Réconciliation » dans index.html (navReconcil, visible admin+superviseur via /api/me). server.js : import + app.use('/', requireRole('admin','superviseur'), reconciliationRouter) + route page. Testé : gating (401 API / 302 page sans auth), login admin → API (360 lignes, totaux par devise EUR/₲/USD, facets méthodes), CSV (BOM + 320 lignes payées, accents intacts), filtre missing=1. NB : la page montre TOUT l'historique (dont 320 encaissés sans ₲, champ neuf) ; c'est un angle différent de l'alerte dashboard paiements-completar qui, elle, ne relance que sur les paiements récents (>= 20/08). La colonne Notes porte déjà beaucoup de contexte de rapprochement (date, montant, qui a reçu). Frontend statique (recharger) ; service redémarré. Suite possible : bouton d'édition inline du montant ₲ depuis la page (aujourd'hui la saisie se fait dans Airtable ou via « Nouvelle vente »).Monto recibido (Gs) créé sur Pagos (devise ₲, précision 0, fldae4vxYQeU4BNEm, via connecteur) = montant réel encaissé sur le compte PY, indépendant du prix client en euros. Formulaire « Nouvelle vente » (public/index.html) : nouveau champ mis en avant (bloc ambré) « 💵 Montant réel reçu sur le compte (en guaraníes) » ; auto-report du montant quand l'acompte est déjà saisi en ₲ (tant que l'utilisateur n'a pas tapé lui-même). ventas.js : acompte.monto_gs écrit dans Monto recibido (Gs) sur la ligne d'acompte (fallback = montant de l'acompte si celui-ci est en ₲). Nouvelle carte dashboard paiements-completar (« Paiements à compléter », sévérité high) dans scripts/gen_snapshot.py : encaissements Pagado SANS Monto recibido (Gs), AUTO-NETTOYANTE (la ligne disparaît dès que le ₲ est saisi). Bornée aux paiements dont Fecha de pago >= GS_TRACKING_SINCE (2026-08-20) pour ne pas déverser les ~189 historiques ; exclut IGNORE_SOLDES_CASOS. Ce champ devient la colonne centrale de la future vue « Réconciliation Giannina ». Testé bout-en-bout : createVenta (acompte ₲ auto-report + acompte EUR avec monto_gs explicite) en dryClean ; cycle de vie de l'alerte (création acompte EUR sans ₲ → carte à 1 → PATCH du ₲ → carte à 0) ; enregistrements de test supprimés. Frontend statique (recharger) ; service redémarré pour ventas.js. Ouvert : les paiements qui n'arrivent jamais en ₲ (ex. Stripe/carte en EUR sur un compte européen) déclenchent quand même l'alerte ; à décider si on exempte certaines méthodes.Fecha de vencimiento créé sur Pagos (date, via connecteur) = échéance prévue d'un paiement en attente. Nouvelle carte dashboard cuotas (« Cuotas à suivre ») dans scripts/gen_snapshot.py : lignes Pagos Tipo=Cuotas au statut Pendiente, triées échéance (RETARD d'abord, puis les plus proches, puis « échéance à renseigner » si pas de date) ; sévérité high s'il y a un retard, sinon warn ; headline = nb en retard / en cours. Exclut IGNORE_SOLDES_CASOS (Desmoulins) comme les cartes soldes. Le formulaire « Nouvelle vente » : quand « cuotas » est coché, un champ date d'échéance apparait et la ligne de solde est créée en Tipo=Cuotas + Fecha de vencimiento (au lieu de Saldo), donc elle alimente directement la carte. À l'ouverture : 8 cuotas en cours (7 hors Desmoulins), toutes « échéance à renseigner » tant que les dates ne sont pas saisies. Testé (createVenta avec cuota_fecha en dryClean + regen snapshot). Frontend statique (recharger) ; service redémarré pour ventas.js.public/index.html). Nouveau module backend ventas.js (même modèle d'écriture que payments.js, token AIRTABLE_WRITE_TOKEN) : createVenta() crée en une fois Cliente + Caso + Pago (acompte, Tipo=Depósito, Pagado), et si « paiement en cuotas » est coché et qu'il reste un solde, une 2e ligne Pago Saldo/Pendiente pour le reste dû (= Precio − acompte). Rollback best-effort si une étape échoue (pas de vente à moitié créée). Endpoints GET /api/ventas/meta (listes déroulantes) et POST /api/ventas (team-gated). 4 champs Airtable créés pour couvrir le process de Younes (via le connecteur, le jeton API n'a pas le droit schéma) : Vendedor (Casos : Myriam/Le Guiz/Matt/Autre), Precio del pack (Casos, nombre), Moneda del precio (Casos : Guaranies/EUR/USD), Receptor del pago (Pagos : Israel/Franco/Matt/Autre, = qui a encaissé, pour la réconciliation Giannina). Piège : Fecha de compra del paquete (Clientes) est un champ CALCULÉ, non inscriptible (la date de vente se pose sur le Caso via Fecha de firma del contrato). Testé bout-en-bout : chaine createVenta en auto-nettoyage (dryClean) + POST HTTP réel authentifié (Cliente+Caso+2 Pagos créés puis supprimés). Service redémarré..list avait un max-height:320px; overflow:auto : au-delà de ~8 lignes la boîte était déjà clippée, donc après clic la liste restait à la même hauteur (juste scrollable en interne) et paraissait ne rien faire. Correctif dans public/index.html : ajout d'une classe .list.expanded{max-height:none;overflow:visible} + box.classList.add('expanded') dans le handler du .more. Désormais un clic déplie réellement toute la liste. Frontend statique : recharger la page (hard refresh), pas de redémarrage.TEAM_PASSWORD, cookie rpops) est REMPLACÉ par des comptes individuels. Nouveau module auth.js (SQLite data/auth.db, table users ; mots de passe hachés scrypt+sel ; 3 rôles : admin / superviseur / editeur ; cookie de session rpsess signé HMAC portant l'uid, utilisateur rechargé et vérifié actif à chaque requête ; middlewares requireAuth + requireRole(...)). Login page réécrite (identifiant + mot de passe, thème clair). Bootstrap : au 1er démarrage sans compte, crée l'admin matt avec un mot de passe temporaire écrit dans data/.admin-bootstrap.txt (chmod 600) + log console. Nouveau module audit.js (SQLite data/audit.db, table audit_log append-only : ts, user, action, cible, champ, ancienne→nouvelle valeur, résultat, IP/UA ; requête filtrée + distinctActions). Pages : /admin/users (admin : créer compte, changer rôle, réinit mot de passe, activer/désactiver — on ne peut pas se désactiver soi-même) ; /admin/journal (admin + superviseur : filtres personne/action/dates/recherche + export CSV). Endpoints : /api/me, /api/users (GET/POST admin), /api/users/:id (PATCH admin), /api/audit (GET admin+superviseur). Gating : /paiements réservé admin+superviseur ; la validation de paiement enregistre désormais l'auteur = utilisateur connecté (req.user) et écrit une entrée d'audit (paiement.valide, Pendiente→Pagado, résultat Airtable). Header du dashboard : badge du nom+rôle, liens conditionnels (Paiements/Journal pour admin+superviseur, Comptes pour admin), Déconnexion — pilotés par /api/me. server.js : express.json() ajouté. Vérifié bout-en-bout : login (bon/mauvais MDP), /api/me, création compte, liste, journal (login + user.create tracés), gating superviseur (403 sur /api/users, 200 sur journal+paiements). Service redémarré. NB : l'ancien TEAM_PASSWORD n'est plus utilisé (à retirer du .env plus tard) ; les sessions rpops existantes sont invalidées (nouveau cookie rpsess).public/paiements.html réécrite dans la charte de la page principale (index.html : thème clair --bg #eef1f5, panneaux blancs, Merriweather+Montserrat, cartes/boutons identiques) au lieu du thème sombre initial. Champ « nom de l'opérateur » supprimé du formulaire (seul Younes aura accès) ; payments.js : validated_by n'est plus requis, défaut « Younes ». Reste du flux inchangé.payments.js (better-sqlite3 data/payments.db, multer upload data/uploads/payments/<casoId>/) + page public/paiements.html (team-gated /paiements). File (GET /api/payments/watchlist) = dossiers avec solde Pagos Pendiente ET dépôt de résidence déjà fait (calcul live Airtable, lecture AIRTABLE_API_KEY, cache 120 s), statut retard (dépôt > 60 j) / a_valider (≤ 60 j) ; 17 dossiers / 33 841 € à l'ouverture. Validation (POST /api/payments/:casoId/validate, multipart) : justificatif OBLIGATOIRE (type ∈ relevé bancaire / virement / dépôt d'espèces) + date d'encaissement + nom de l'opérateur + note ; stocke le fichier + la validation en local, PUIS bascule les lignes Pagos du dossier en Pagado (+ Fecha de pago) dans Airtable via PATCH ciblé avec le nouveau token d'écriture AIRTABLE_WRITE_TOKEN (voir §6). GET /api/payments/:casoId/justificatif sert le fichier (team-gated). Si le PATCH Airtable échoue, la validation locale est conservée et un badge « Airtable non mis à jour » + le message d'erreur s'affichent. Liens ajoutés : bouton « 💳 Surveillance paiements » dans le header du dashboard + entrée portail (groupe RP). Écriture Airtable chirurgicale (seulement les lignes Pagos Pendiente du dossier validé, seulement Estado del pago/Fecha de pago), loggée en base. Vérifié : file 17 dossiers, page 200, garde-fous (sans fichier → 400, sans auth → 401), token write testé en no-op. NB : la 1re vraie validation modifiera Airtable pour de bon (à faire par l'opérateur avec un vrai justificatif). Service redémarré.Pendiente mais dont le dépôt de résidence (temporaire/permanente, champs Clientes) est déjà passé depuis > SUSPECT_DEPOT_DAYS=60 j est SUSPECT (le client paie son solde au dépôt → probablement déjà encaissé, ligne Pagos non basculée). gen_snapshot.py : helper _last_past_deposit(cf) (via cli_by = map Clientes) ; ces dossiers vont dans une nouvelle carte soldes-a-verifier (dataGap) au lieu de soldes-impayes. Résultat : à encaisser 84 405 → 61 340 € (20 dossiers), à vérifier 23 065 € (14 dossiers). Liste détaillée pour l'équipe/compta : /root/workspace/RP-soldes-a-verifier-deja-payes.md (nom, contacts, montant, date de dépôt, instruction ES : repointer et basculer Pagos Pendiente→Pagado). Snapshot régénéré.devise-nonfiable (décision Matt : facturation reste en EUR, encaissement en ₲ assumé, pas un souci). Retrait de la liste devise (dans le bloc de réconciliation) + des compteurs miss_exp/miss_ruc (ne servaient qu'au headline de cette carte) + de la carte d'alerte. suspects (carte impayes-suspects) conservé. Check préalable fait (comparaison ₲→€ reconverti vs prix pack PRIX_EUR) : aucun signal net de perte au change, les écarts s'expliquent par le multi-service/famille et le tag de contrat, pas par le guaraní. Le chantier « bascule 100% guaraníes » est abandonné (prix packs restent affichés en EUR). Snapshot régénéré.ruc-declaraciones (demande Matt : géré à part par la comptable). Bloc de calcul ruc_items (lecture vue Airtable « RUC Active ») + carte d'alerte retirés. miss_ruc (compteur RUC vides) conservé, il alimente encore le headline de la carte devise-nonfiable. Snapshot régénéré, front générique.fr(d) dans gen_snapshot.py (date/ISO → %d/%m/%Y), appliqué aux subs des cartes permanente (vendre/préparer/déposer) et domiciliation. Tri de la domiciliation recalé sur la vraie date (_d) au lieu du texte du sub (le tri lexical cassait avec JJ/MM/AAAA). generatedAt reste ISO dans le JSON (champ machine) ; le front index.html le convertit en JJ/MM/AAAA pour l'affichage « généré le ». Front no-cache (recharger).permanente-expiree (demande Matt : inutile). La carte « Temporaires expirées sans permanente » est retirée (bloc de calcul perm_expiree + carte d'alerte). Les fiches dont l'expiration (réelle ou estimée) est dépassée sont désormais simplement ignorées (continue sur delta < 0), plus surfacées nulle part. Restent les 3 étages : vendre (24) / préparer (61) / déposer (20). Snapshot régénéré, front générique.Saldo pendiente (faux : jamais remis à 0 après paiement) → 12 faux impayés (16 676 €). Vérifié à 100% : les 12 ont 0 ligne Pagos Pendiente et un paiement soldant encaissé (Pago completo / Dépôt+Solde) → 0 dette réelle, 0 chevauchement avec les vrais soldes. Nouvelle logique gen_snapshot.py (bloc 3) : soldes = lignes table Pagos Estado='Pendiente' agrégées par dossier (Caso) pour ne pas double-compter les familles ; conversion indicative USD→EUR (USD_TO_EUR=0.92), affichage par devise d'origine. Carte renommée « Soldes clients à encaisser » (table=casos, id inchangé), note = source Pagos. Résultat : 12 fantômes → 34 dossiers réels / ~84 405 €. La carte impayes-suspects (12 fiches) est conservée : elle sert désormais de liste des fiches dont le champ Saldo pendiente est à nettoyer dans Airtable. Snapshot régénéré, front générique (recharger).sante-donnees (domaine Fiabilité des données, informational: True) : taux de remplissage des champs qui nourrissent les alertes, sur les clients actifs (is_active = hors Etapa prospect/archivé), n'affiche que les champs < 95% + les incohérences (cédulas « retirées » sans date de retrait, impayés suspects, soldes négatifs). Rend l'audit permanent et visible. Le flag informational l'exclut du compteur « actions en attente » du front (index.html : .filter(x=>!x.informational) sur le reduce). (2) Carte onboarding-bloque : affiche « Bloqué depuis X j » (via Fecha de creación du Caso) et trie du plus ancien au plus récent pour relancer les urgents d'abord. (3) name_of() reconnaît désormais Nombre del caso / ID del caso (les Casos n'ont pas de « Nombre completo » : les onboardings s'affichaient « (sans nom) »). Snapshot régénéré, front statique (recharger la page). Reste le point 3 (badge cédulas retirées sans date en cohérence) à voir avec Matt.permanente-deposer + carte eligibles-permanente (calée sur le dépôt, 12-24 mois) par 4 cartes exclusives, toutes mesurées en temps restant avant l'expiration (réelle ou estimée dépôt + 27 mois) : permanente-vendre (12→9 mois avant, sévérité info, « pitcher l'upsell »), permanente-preparer (9→3 mois, warn, « réunir les pièces »), permanente-deposer (3 mois→0, high, fenêtre légale), permanente-expiree (dépassé, warn). Constantes PERMANENTE_SELL_MONTHS=12, PERMANENTE_PREPARE_MONTHS=9, PERMANENTE_WINDOW_DAYS=90. Buckets exclusifs (un client dans un seul étage, le plus urgent) via les dates de coupe cut_deposer/cut_preparer/cut_vendre (add_months). Carte eligibles-permanente et constantes PERMANENTE_ALT_MIN/MAX_MONTHS supprimées. Résultat : vendre 24, préparer 61, déposer 20, expirées 7. Le front rend les cartes dynamiquement (aucune référence en dur, pas de restart). Snapshot régénéré (backup avant).permanente-deposer ne retient donc que 0 <= delta <= 90 (expiration encore à venir). Les expirés (réels ou estimés) sortent dans une nouvelle carte permanente-expiree (« Temporaires expirées sans permanente, à traiter à part »), triés du plus anciennement expiré au plus récent. L'ancienne carte permanente-verifier (basée sur un garde-fou 6 mois) est remplacée par permanente-expiree (tout expiré, sans seuil). Résultat : permanente-deposer 27 → 20 (à venir sous 90 j), permanente-expiree = 7. EST_STALE_GRACE_DAYS devient inutilisé (laissé en constante, sans effet). Snapshot régénéré.Fecha de expiración temporal n'est rempli qu'à ~10% (306/340 vides), donc l'alerte principale était quasi aveugle. Nouvelle logique dans gen_snapshot.py : expiration EFFECTIVE = date réelle si connue, sinon estimée = Fecha de depósito de residencia temporaria + 27 mois (constantes TEMP_PRINT_DELAY_MONTHS=3 + TEMP_VALIDITY_MONTHS=24 ; règle confirmée par Matt : la carte est imprimée ~3 mois après le dépôt et c'est là que démarre la validité de 2 ans). Garde-fou EST_STALE_GRACE_DAYS=180 : une estimation dépassée de plus de 6 mois (probable permanente déjà faite mais non saisie) ne va pas dans « à déposer » mais dans une nouvelle carte permanente-verifier (« statut à vérifier »). Helper add_months() ajouté (arithmétique de mois sans dateutil). Résultat : permanente-deposer passe de 6 à 27 (4 sur date réelle + 23 nouvellement révélés par estimation), permanente-verifier = 0 (base jeune, aucun dépôt assez ancien pour une estimation très dépassée). Les lignes estimées sont marquées « · estimé » et « ~le » dans l'UI. Aucune écriture Airtable (100% dashboard). Snapshot régénéré (backup snapshot.json.bak-* avant). Le front sert le snapshot tel quel (pas de restart). NB : la carte eligibles-permanente (upsell 12-24 mois, fallback expiration inconnue) est conservée telle quelle et tuile naturellement avec la nouvelle (12-24 mois = « approche », 24-27 mois = « à déposer »). Nouveau script d'audit qualité data : scripts/audit_data_quality.py (lecture seule, sort /root/workspace/RP-dashboard-audit-donnees.md + data/audit_data_quality.json).permanenteInfo() (docs.js) ne se base plus sur l'ancienneté/expiration de la temporaire mais sur Estado de cédula : (a) cédula récupérée (Retirado ou Fecha de retiro de cédula présente) → step: 'permanente' = prochaine étape la résidence permanente, avec checklist des prérequis (PERMANENTE_CHECKLIST : cédula ✓ déjà en main, temporaire > 2 ans, passeport valide 6+ mois, casier d'origine apostillé récent, antécédents judiciaires paraguayens, justificatif de domicile) et whatsapp = Younes (YOUNES_WA = 595972939531) ; (b) cédula prête à retirer (Listo para retiro) → step: 'cedula' = prochaine étape récupérer la cédula + bouton WhatsApp ; (c) permanente déjà déposée/obtenue → step: 'faite' (rien) ; (d) sinon step: 'attente' (rien). Front public/client.html : la bannière cédula-prête porte maintenant le bouton "En discuter avec Younes" (message pré-rempli), et le bloc "Prochaine étape : la résidence permanente" affiche la checklist (pastille ✓/○ + hint par item) + le bouton WhatsApp. CSS ajouté (.ckl, .wa-btn). L'ancienne logique âge/expiration (PERM_ALT_MIN/MAX_MONTHS, helpers _dparse/_daysFromNow) retirée de docs.js (elle ne servait qu'à ce bloc client ; la carte ops de gen_snapshot.py garde sa propre règle, inchangée). Bug corrigé au passage : public/client.html contenait 'Échec de l\\'envoi' (double backslash, lignes ex-239/242) introduit par une édition antérieure → erreur de syntaxe JS de haut niveau qui empêchait TOUT le script du portail client de s'exécuter (page bloquée sur "Chargement…"). Remis en simple backslash. Vérifié : node --check du script OK, API testée sur les 4 états cédula (retiree/ready/attente/faite → payloads corrects), rendu Playwright confirmé sur un client "Retirado" (checklist + bouton) et un client "Listo para retiro" (bannière + bouton). Service rp-dashboard redémarré. NB checklist : basée sur le dossier RP standard (KB Résidence) ; la liste exacte des pièces permanente reste à confirmer avec Matt/Younes si besoin d'ajustement.permanenteInfo() dans docs.js : le bloc client "Prochaine étape : la résidence permanente" (upsell) ne s'affiche plus pour une temporaire déposée depuis ≥ 24 mois (ancien seuil, créneau passé) mais uniquement entre 12 et 24 mois (PERM_ALT_MIN_MONTHS = 12, PERM_ALT_MAX_MONTHS = 24), quand l'expiration temporaire est inconnue. Un client à 30 mois ne voit donc plus un "prochaine étape" trompeur. La règle principale (expiration connue ≤ 90 j) est inchangée. Texte de l'upsell inchangé ("Vous êtes dans la période où elle peut être déposée", déjà cohérent). Front n'utilise que permanente.eligible (pas de rendu à adapter). node --check OK, service redémarré. Miroir de la carte ops (voir entrée précédente).PERMANENTE_ALT_MONTHS = 24 + fourchette 3 mois), donc quasi toujours "fenêtre dépassée, à déposer" : ces clients ont dépassé l'échéance des 2 ans et ont sans doute déjà fait la permanente de leur côté (créneau perdu). Nouvelle règle : temporaire déposée depuis plus de 12 mois mais moins de 24 mois (PERMANENTE_ALT_MIN_MONTHS = 12, PERMANENTE_ALT_MAX_MONTHS = 24), c.-à-d. le client est ENCORE dans le créneau avant l'expiration à 2 ans. Sous-texte par client "encore ~N mois dans le créneau", tri par urgence (les plus proches des 24 mois d'abord). S'applique toujours uniquement quand Fecha de expiración temporal est absente (sinon règle principale). Résultat : eligibles-permanente 23 → 90. Aucune écriture Airtable (logique 100 % dashboard). Snapshot régénéré ; le timer 3h maintient la liste. NB : le bloc upsell CÔTÉ CLIENT (permanenteInfo() dans docs.js) utilise encore le seuil ≥ 24 mois (sémantique "éligibilité", pas "créneau ops") : à aligner sur go de Matt si voulu.scripts/mirror_docs.py : lit le champ Airtable "Enlace de documentos" (280/332 clients ont leur dossier Drive lié), télécharge les pièces via l'API Drive (token OAuth local resident.paraguay, scope drive) dans data/rp-documents/<airtable_id>/ (idempotent, perms 700), puis OCR des passeports/cédulas via Gemini vision (gemini-flash-latest ; NB gemini-2.0-flash renvoie 404 avec la clé GEMINI_TTS_API_KEY, seul *-latest répond) pour extraire les dates d'expiration. N'ÉCRIT PAS dans Airtable : produit data/mirror_report.json (perms 600) pour validation humaine. Résultat pilote : 53 fichiers / 38 Mo pour 10 clients (extrapolation ~1 Go pour 280) ; passeport lu 4/4 là où un fichier passeport lisible existe (noms/numéros/dates cohérents, confiance 0,95-0,99). Limites identifiées : fichiers HEIF (iPhone) non lus par Gemini inline (à convertir), et beaucoup de dossiers sans fichier passeport ou avec seulement des justificatifs de dépôt cédula (pas la carte). Reste : conversion HEIF->JPG, run complet, écriture des dates dans Airtable (Vencimiento del pasaporte) sur go de Matt, branchement des docs au portail gestionnaire.AUDIT-2026-08-07.md). (1) Correctif timeline : dans buildResidence (docs.js), l'étape "en cours" pointe désormais la PROCHAINE étape non franchie, plus la dernière déjà faite (avant, un client dont la cédula était déposée voyait "Cédula déposée, en cours" alors que c'est la permanente qui est en cours). (2) Expiration temporaire affichée au client : nouvelle donnée expiration_temporaire dans l'API /api/c/:token + ligne "Votre résidence temporaire expire le X" (ambre si <90 j, rouge si dépassée) sous la timeline. (3) Éligibilité résidence permanente : permanenteInfo() (miroir doux des règles ops : expiration connue <=90 j, ou temporaire déposée depuis >=24 mois quand l'expiration est inconnue) -> bloc upsell "Prochaine étape : la résidence permanente" quand éligible. (4) Aide par document : <details> "Comment l'obtenir ?" par pièce (passeport, acte de naissance, casier) avec explication de l'apostille et de la traduction assermentée. (5) Bloc contact : nouvelle sortie contact (piloté par .env : RP_CONTACT_NAME / RP_CONTACT_EMAIL / RP_CONTACT_WHATSAPP) + advisor (champ Airtable "Agente inmobiliario asignado", vide sur toute la base pour l'instant) -> carte "Une question ?" avec boutons WhatsApp + email, MASQUÉE tant qu'aucun canal n'est renseigné. Fichiers : docs.js (backend), public/client.html (front), .env (clés contact ajoutées vides). Vérifié end-to-end sur 4 clients réels (éligible par ancienneté, résidence expirée, cédula prête, cas standard) + page publique 200. Service rp-dashboard redémarré. Reste à activer le bloc contact : renseigner le WhatsApp + l'email support RP dans .env.MANAGER_PASSWORD dans .env changé de rp2026 vers un mot de passe temporaire fort à la demande de Matt (accès page /manager = Suivi dossiers clients). Service rp-dashboard redémarré (variable lue au démarrage). Vérifié : login /api/m/login renvoie 200 + token avec le nouveau mot de passe. Note : mot de passe unique partagé pour la page gestionnaire (pas de comptes individuels) ; à faire tourner régulièrement./manager) enrichie + ajoutée au portail. La page gestionnaire liste désormais les 295 clients avec, pour chacun : l'étape de résidence (badge, ex. "Cédula déposée" ; "Cédula prête ✓" en vert si Estado de cédula = Listo para retiro), la progression documents, un bouton "Voir la fiche ↗" qui ouvre la vue exacte du client (/c/:token) dans un nouvel onglet, et "Copier le lien". Ajout d'une recherche (nom/email, filtrage client-side, indispensable à 295 clients). Backend docs.js : /api/m/clients devient async et enrichit chaque client via allClientes() (un seul batch Airtable de toutes les fiches Clientes, cache 5 min) → champs etapa, stage_label, cedula_ready ; map STAGE_LABEL (Etapa → libellé FR). Titres passés à "Suivi dossiers clients". Auth inchangée (mot de passe gestionnaire MANAGER_PASSWORD). Ajouté au portail new-tab : apps/portail/index.html groupe RP → "Suivi clients RP" (icône receipt) → https://ops-resident.panelbay.com/manager. Vérifié : 295 clients, 46 cédulas prêtes, boutons fiche OK, /manager public 200. Frontend statique (recharger la page) ; backend redémarré./c/:token, module docs.js) ne contenait qu'un client démo et était déconnecté d'Airtable. Mise en service : (1) scripts/import_clients.py (idempotent, clé = airtable_id) importe les clients ACTIFS d'Airtable Clientes (Etapa hors Prospecto/Archivado, ou date de dépôt temporaire) → 295 clients créés avec token d'accès + email + checklist de docs ; client démo retiré. (2) docs.js lit le statut de résidence EN DIRECT depuis Airtable (fetchCliente par airtable_id, cache 2 min, clé lue dans /root/aios/.env) et le mappe en timeline via buildResidence : étapes Dossier ouvert → Résidence temporaire → Cédula → RUC (si package Fiscal/RUC/Permanente) → Résidence permanente, "en cours" = dernière étape franchie, dates réelles, + cedula_ready (Estado de cédula = Listo para retiro) et cedula_retiree. /api/c/:token renvoie désormais residence. (3) public/client.html : nouvelle section "Ma résidence" (timeline verticale avec pastilles, bannière verte si cédula prête à retirer) au-dessus de "Mes documents" (upload inchangé). (4) .env : ajout DASH_BASE_URL=https://ops-resident.panelbay.com (sinon les liens portail affichés à l'équipe seraient en localhost). Le statut n'est PAS copié en local (toujours lu live = à jour). Aucun email envoyé aux clients (accès générés seulement). Vérifié end-to-end sur 2 vrais clients (timeline + bannière cédula). Reste pour plus tard : distribution des liens aux clients (Resend), checklist adaptée au package, sync des statuts de documents vers Airtable Documentos.gen_snapshot.py : fetch() accepte désormais un paramètre view= (ajout import urllib.parse) ; nouvelle carte ruc-declaraciones (KPI, domaine Fiscal & Domiciliation) alimentée par cette vue, triée par Vencimiento RUC (jour du mois de l'échéance IVA), affichant nom + échéance calculée (« due le X du mois (dans N j / passée) »). Sécurité : la vue contient des champs sensibles (Contraseña correo electrónico RUC = mots de passe email en clair, Correo electrónico RUC) ; la carte n'expose QUE id/nom/échéance, jamais ces champs. Constat au passage : Estado del RUC est vide sur toute la base (champ non utilisé) ; le vrai signal RUC = Fecha de depósito RUC / Vencimiento RUC / Documentos de las declaraciones. Aucune écriture Airtable.PERMANENTE_ELIGIBLE_MONTHS = 21 (à confirmer) par les 2 règles confirmées par Matt. (1) Règle principale (carte "permanentes à déposer") : fenêtre resserrée de 120 j à 90 j (PERMANENTE_WINDOW_DAYS = 90) = 3 mois avant l'expiration temporaire jusqu'à l'expiration ; ne concerne que les fiches où l'expiration est connue. (2) Règle alternative (carte "éligibles permanente / upsell") : ne s'applique QUE quand Fecha de expiración temporal est absente (cas fréquent, 298/332), fenêtre ouverte à 24 mois après le dépôt de la temporaire (PERMANENTE_ALT_MONTHS = 24, fourchette PERMANENTE_ALT_WINDOW_MONTHS = 3), tag "fenêtre ouverte" vs "dépassée, à déposer". Les deux cartes ne se chevauchent plus (principale = expiration connue, alternative = inconnue). Résultat : permanente-deposer 6 → 3, eligibles-permanente 54 → 23. Aucune écriture Airtable (logique 100 % côté dashboard). Reste du socle dérivé à faire (écrit Airtable, donc plus tard) : table Declaraciones + champ formule Meses validados.gen_snapshot.py fetch désormais la table Pagos (tblztEIKxw1HPxL4c) et reconstruit le lien Clientes → Caso → Pagos pour croiser fiche et paiements réels. Deux nouvelles cartes (domaine "Fiabilité des données") : (1) Impayés suspects (high, 12 fiches) = Saldo pendiente > 0 mais un paiement Pago completo/Saldo marqué Pagado existe ; l'encaissé est affiché en devise native (ex. 14 820 000 Gs), détection indépendante du taux de change ; (2) Fiches en devise non fiable (info, 108 clients) = payé uniquement en guaraníes alors que la fiche est en EUR (+ compteurs 298 sans date d'expiration temporaire, 331 sans RUC). Signalement pur, aucune écriture Airtable. NB structurel : bascule 100 % guaraníes le 15/08/2026 → la devise de référence de la réconciliation évoluera vers le ₲ ; décision d'écriture de champs corrigés reportée à après cette date pour construire Gs-natif une seule fois.rp-dashboard-refresh.timer (toutes les 3h) + bouton "Rafraîchir" (POST /api/refresh) + lien "Se déconnecter". (4) Digest du matin dans les notifications (scripts/digest_notify.py, cron 08:00 Asunción). Constaté au passage : les champs Vencimiento de cédula, Vencimiento del pasaporte sont absents de la base et Estado del RUC quasi vide, donc les alertes cédula/passeport/RUC de l'audit ne sont pas câblables tant que ces champs ne sont pas alimentés côté Airtable.scripts/gen_snapshot.py : régénère snapshot.json depuis Airtable (clé .env), chiffres du jour (46 cédulas à retirer, 6 permanentes, 12 soldes impayés = 16 676 €, 4 onboardings bloqués). (2) Ajout d'un login équipe dans server.js (page /login, cookie HMAC 30 j ; / et /api/dashboard protégés ; /c/:token reste public). (3) Service systemd rp-dashboard créé + activé (port 8324). (4) Exposé en HTTPS sur https://ops-resident.panelbay.com (Cloudflare + Let's Encrypt). Reste à faire : mode live auto-rafraîchi (timer), câbler les ~20 alertes restantes, remplir les champs dates Airtable.