Audits · juin 2026Audit « ce qu'il reste » — 2026-06-15 (post #303→#306)

Audit « ce qu'il reste » — 2026-06-15 (post #303→#306)

Reconciliation après merge des PRs #298/#303/#305/#306. Trois inspecteurs : (1) dette restante vs main, (2) robustesse du tunnel onboarding→transfert→ activation, (3) scan frais du code récent (#304/#305). Tout vérifié…

Reconciliation après merge des PRs #298/#303/#305/#306. Trois inspecteurs : (1) dette restante vs main, (2) robustesse du tunnel onboarding→transfert→ activation, (3) scan frais du code récent (#304/#305). Tout vérifié par lecture du code (fichier:ligne). Les items des PRs mergées sont exclus.

Verdict

Plus aucun critique de correctness/sécurité « classique » (les 5 du précédent audit sont corrigés). Le risque s'est déplacé vers la couche conversion/monétisation du tunnel : le moteur de provisioning est production-grade, mais les étapes qui transforment un store fini en revenu ont des trous critiques. Plus un vrai bug d'argent sur les payouts escrow.


🔴 CRITIQUE — bloque directement l'objectif d'activation

F1 — Le cockpit ne peut PAS atteindre « Prêt à vendre » (plafond à 79 %)

  • Où : launch-checklist.tsx:198-204,222 + launch/route.ts:308-342 + wizard-skills/registry.ts:40-54
  • Problème : 6 jalons auto (preferences-configurator, consent-native, markets-configurator, tax-configurator, shipping-configurator, search-configurator) n'ont pas d'exécuteur mais sont comptés au dénominateur (readyTotal = auto + human). Run parfait = 23/29 = 79 %. L'entête (pct >= 100 ? "Ready to sell" : "Preparing your store") reste toujours « Preparing… 79 % », avec TVA/consentement RGPD figés en « Planned ». Le CTA « Prêt à vendre → Transférer » exige 100 % → ne s'affiche jamais → étouffe transfert + activation.
  • Correctif (~2 lignes, meilleur ROI du repo) : exclure du dénominateur les auto sans exécuteur (ou reclasser les 6 en checkpoints human guidés avec deep-links Shopify admin). Mirroir dans launch/route.ts counts.

F2 — Les stores « waitlisted » (pool vide) sont perdus à jamais

  • Où : wizard/store/provision/route.ts:79-113 (branche waitlist) + launch-tick/route.ts:197 (if (!connectedSet.has(storeId)) continue)
  • Problème : pool vide → status:"waitlisted", email ops, « le flux continue » : mais le Store n'a pas d'IntegrationConnection active, donc launch-tick le saute. Quand l'ops ajoute de la capacité, le nouveau row est juste AVAILABLE : aucun job ne re-claim pour les stores waitlistés. Le lead le plus chaud (wizard terminé) reste queued à vie. La doc sous-entend un backfill auto : aucun code ne l'implémente.
  • Correctif : passe de backfill dans launch-tick (ou au register pool) : pour chaque Store avec activité launch.provision.waitlisted sans connexion shopify active → tenter claimDevStore, binder la connexion, purger le row.

🟠 ÉLEVÉ

B1 — Double-paiement escrow possible (fenêtre d'idempotency Stripe 24h)

  • Où : cron/marketplace-payouts/route.ts:83-93 + services/stripe/connect.ts:193-206
  • Problème : le transfer Stripe est créé AVANT le stamp DB (order.updateMany(stripeTransferId)). Non atomique. Si le transfer réussit mais le stamp échoue (blip DB / timeout après le retour Stripe), l'order reste PAID, stripeTransferId=null et est re-sélectionné. Seul garde-fou = clé d'idempotency Stripe transfer:${orderId}, qui expire après 24h, or le cron tourne quotidiennement. Re-sélection au tick suivant (~24h) = hors fenêtre → second payout réel au vendeur. Perte d'argent réelle.
  • Correctif : claim AVANT Stripe (updateMany gardé : stripeTransferId='pending:<uuid>' ou payoutClaimedAt), et/ou clé d'idempotency stable persistée réutilisée.

F3 — ✅ FAIT : relances auto J+2/5/6 pendant la fenêtre de transfert

  • Où : shopify-partners.ts:161-217,303-360 + modules/email/index.ts:148-155 + chat-banners.tsx
  • Problème : 2 emails seulement (…TransferRequested → ops, …TransferAccepted → user après acceptation). Entre les deux (fenêtre où commission + plan se gagnent) : aucune relance client. pollTransferAcceptance passe à expired puis retombe en CLAIMED silencieusement. Lead refroidi → perdu.
  • Correctif : dans le poll launch-tick, pour les TRANSFER_REQUESTED pendants à J+2/J+5/J+6 → email de relance (idempotent via dedupeKey transfer:reminder:{day})
    • bannière invite_pending avec compte à rebours transferExpiresAt. La data existe.

H1 — Route vocale EVI : français codé en dur (contourne next-intl)

  • Où : evi/chat/completions/route.ts:353 (refus parlé), :496-507 (overlay), :514-524 (fallback)
  • Correctif : résoudre la locale (store/org déjà chargés dans resolveStoreVoiceContext) + ressources par-locale.

H2 — ✅ FAIT (dédup) : une seule bannière crédits s'affiche (i18n par-bannière = reste)

  • Où : credits-limit-banner.tsx:45,73,102 (FR, monté ai-chat.tsx:983) + chat-banners.tsx:141 (EN, monté ai-chat.tsx:1261)
  • Correctif : router les deux via useTranslations, n'afficher qu'une bannière.

🟡 MOYEN

F4 — ✅ PARTIEL : marqueur d'attribution commission posé (réconciliation = Partner API externe)

  • Où : shopify-partners.ts (tout le fichier, aucun enregistrement de commission)
  • Problème : la thèse « le coût est couvert par la commission » est non mesurée : aucune attribution (storeId/orgId/planName/acceptedAt), pas de réconciliation contre le payout Shopify, pas de détection de downgrade post-transfert (TRANSFERRED est terminal).
  • Correctif : sur accepted, écrire un row d'attribution + cron de réconciliation mensuelle.

M1 — ✅ FAIT : dismiss en state React (plus de getElementById/remove)

  • Où : chat-banners.tsx:94,114-121 (getElementById("error-banner") + el.remove() + setTimeout sans cleanup ; id codé en dur collisionne)
  • Correctif : dismiss en state React + rendu conditionnel.

M2 — ⏸️ REPORTÉ (sciemment) : Revenu KPI brut, un chiffre FAUX vaut pire qu'un stub

  • Où : services/jobs/kpi-snapshot.ts:105-107 (grossCents=0, creditCents=0)
  • Correctif : sommer depuis StripeEvent/payment-events depuis since (MRR/ARR déjà câblés).

M3 — Runner de workflow durable = stub

  • Où : api/workflow/[id]/run/route.ts:137-147 (queue le row, 202, n'exécute jamais)
  • Correctif : câbler Vercel Workflow DevKit (graph.nodes → DurableAgent steps + SSE).

🟢 FAIBLE

  • B2 — wizard/store/launch gate sur accès brut, pas store.update (launch/route.ts:87-90) → un viewer matérialise 29 rows + écrit StoreContext. Correctif : ajouter le gate store.update (comme skill/run/checkpoint).
  • B3 — launch-tick plan-selected auto-approve = deny-list → un plan Shopify inconnu auto-flippe en « payé » (launch-tick/route.ts:93-96,117-128). Correctif : allow-list de plans payants connus.
  • F5 — ✅ NON-PROBLÈME : maxDuration 300s (5min) < STALE_RUNNING_MS 15min → Vercel tue un milestone avant qu'il puisse être faussement reclaimé. Pas de correctif nécessaire.
  • L1 — EVI : floor mais pas de réservation (race dépense concurrente), evi/.../route.ts:350-351. Correctif : reserveCreditsForChannel("voice").
  • L2 — domain-verification.tsx:31 setTimeout sans cleanup.
  • L3 — connectors-popover.tsx:79 erreur en couleur seule (le trigger est désormais étiqueté ✅).

♿ A11Y — ~5 boutons restants

  • shared/credits/dropdown.tsx:95,166 (2× ArrowLeft retour), file-manager.tsx:503 (MoreVertical), web-preview/buttons.tsx:200-214 (boutons device sans icône). (Le gros des ~30 est fait.)

🧹 Ménage

  • DC1 — Roster fictif 7 personas (execution-planner/agent-avatar.tsx:8-44, consommé par step-card.tsx/message-plan-part.ts) → remplacer par Atlas+Maya/Marco/Otis/Faye/Sam, ou marquer décoratif.
  • DC2 — web-preview/buttons.tsx:85,173,223,240,277 : 5× (globalThis as any).browserTab alors que lib/electron-bridge.ts existe pour ça.
  • DC3 — ~/billing/page.tsx:145 : (authClient as any).stripe à typer.

🧪 Tests manquants

  • Crypto token round-trip (lib/security/crypto.ts), trackUsage hold→usage swap, pré-stream cost gate / cost-guard, clés API (lib/security/api-keys.ts).
  • ✅ RBAC tenant-auth+permissions : couvert (permissions.test.ts).

Ordre recommandé

  1. ✅ F1 (déblocage 100 % cockpit), FAIT : les 6 jalons orphelins reclassés en étapes guidées humaines (deep-links Shopify admin), le cockpit atteint 100 %.
  2. ✅ F2 (backfill waitlist), FAIT : passe 0.5 dans launch-tick claim+bind les stores waitlistés dès que le pool a de la capacité (helper bindClaimedDevStore).
  3. ✅ B1 (double-payout escrow), FAIT : claim-before-pay (marqueur pending: gardé) avant le transfer Stripe, revert sur échec.
  4. F3 + F4 (relances transfert + tracking commission), la moitié monétisation.
  5. H1/H2 (i18n EVI + double bannière).
  6. M1-M3, puis B2/B3/F5/L*, a11y, ménage, tests.