ADRADR-0011 · La porte KYC du marketplace est la signature de l'APA par l'acheteur

ADR-0011 — La porte KYC du marketplace est la signature de l'APA par l'acheteur

Le pipeline KYC « enhanced » de la V5.1 existe en entier : la table KycVerification, le chiffrement at-rest du rapport provider, le client Stripe Identity, le verdict manuel admin, POST /api/marketplace/kyc/start, et…

Statut

Accepté · 2026-09-06

Piliers : marketplace

Contexte

Le pipeline KYC « enhanced » de la V5.1 existe en entier : la table KycVerification, le chiffrement at-rest du rapport provider, le client Stripe Identity, le verdict manuel admin, POST /api/marketplace/kyc/start, et une banniere KycBanner sur /account/deals/[id].

Il ne conditionnait rien. grep -rn "isKycVerified" src ne rendait que sa definition ; son commentaire disait « cheap call pour gating » et l'en-tete du module annoncait « Deals > $10k ou on a besoin d'un check enhanced ». Aucun chemin de src/ ne comparait un montant a un seuil pour demander un KYC (marketplace/0441).

L'item d'audit decrivait ca comme « aucune porte, il faut trancher une politique ». Verification faite, la politique existait deja, ecrite dans un endroit ou elle ne pouvait pas s'appliquer :

// src/app/(dashboard)/account/deals/[id]/page.tsx
{!isSeller && (deal.agreedAmount ?? 0) > 1_000_000 ? <KycBanner … /> : null}

Le seuil ($10 000), l'acteur (l'acheteur) et l'etape (le deal) etaient donc tranches depuis la V5.1. Ce qui manquait n'etait pas une decision produit, c'etait un serveur qui l'applique — et la regle du pilier est explicite : une verification de role dans l'UI n'est pas une verification. Un acheteur qui n'a jamais charge cette page, ou qui appelle l'endpoint directement, signait comme un acheteur verifie.

Deux contraintes bornent la reponse :

  1. Stripe Connect verifie deja le vendeur. L'onboarding Express fait une verification d'identite, et cron/marketplace-payouts refuse de liberer l'escrow tant que StripeConnectAccount.status !== "ENABLED". La porte du vendeur existe : elle est a la sortie de l'argent, pas ici.
  2. Une porte posee trop tot est une porte que personne ne franchit. Demander un passeport pour lire un NDA arrete la diligence avant que l'acheteur ait une raison de le donner.

Décision

Une seule porte : la signature de l'APA par l'ACHETEUR, sur un deal dont agreedAmount depasse KYC_REQUIRED_ABOVE_CENTS ($10 000), exige un KycVerification au statut VERIFIED. Sinon les deux chemins qui posent apaSignedAt levent kyc-required, que leur route rend en 409.

Le seuil est exporte depuis services/marketplace/kyc.ts et la page le lit la : la banniere et la porte partagent le meme nombre.

Le vendeur n'est pas gate. Le NDA et la LOI ne sont pas gates.

Pourquoi l'APA et pas ailleurs

Etape candidatePourquoi non
L'offreagreedAmount n'existe pas encore : rien a mesurer. Et gater l'entree du funnel sur un document d'identite tue le funnel.
Le NDAIl ouvre la data room, il ne transfere rien. Trop tot : l'acheteur n'a pas encore de raison de se verifier.
La LOINon contraignante. Meme argument.
advanceDealStage (APA_SIGNED → ESCROW_FUNDED)La transition est ouverte aux deux parties (by: "either"). Gater sur le KYC de l'acheteur y bloquerait aussi le vendeur, et un vendeur qui clique en premier contournerait la porte. Le point est ambigu par construction.
TRANSFERRING → CLOSEDL'argent a deja bouge. Une porte a la fin n'est pas une porte.
Le payout vendeurBloquer l'escrow sur un KYC BoostEcom immobiliserait de l'argent deja encaisse chez des vendeurs qui ont deja passe la verification Stripe. C'est un doublon qui cree un incident, pas un controle.

L'APA est le seul point qui reunit les trois proprietes : le montant est connu, l'acte est contraignant, et la FSM du deal exige apaSignedAt pour sortir de DILIGENCE — donc refuser la signature arrete reellement le pipeline au lieu de le decorer.

Alternatives écartées

OptionPourquoi non
Gater l'acheteur ET le vendeurCasse les vendeurs existants sans remede : la KycBanner n'est rendue que cote acheteur, donc un vendeur bloque lit kyc-required dans un toast et n'a aucune surface pour s'y conformer. La verification du payee est par ailleurs deja faite par Connect. A rouvrir le jour ou une banniere vendeur existe (marketplace/0461, ouvert par cette PR).
Option 2 de l'item : « c'est un outil manuel d'operateur, on le dit dans la doc »Ce serait aligner la doc sur le moins-disant alors que le produit affiche deja une garantie a l'acheteur (« required over $10k »). On ne retire pas une promesse affichee, on la tient.
Option 3 de l'item : retirer la surfaceLa chaine est complete et fonctionnelle ; la jeter pour la reconstruire au premier deal a sept chiffres serait du gaspillage pur.
Un flag d'environnement pour un deploiement progressifUn flag qui vaut false par defaut, c'est la porte ouverte avec une ligne de config en plus. Et il n'y a personne a menager : la population gatee est exactement celle a qui la banniere s'affiche deja, sur l'ecran meme ou le bouton « Start verification » se trouve.
Mettre la porte dans la route plutot que dans le serviceLa route est une porte, pas la serrure — meme regle que advanceDealStage. Une seconde entree (webhook provider, action admin) contournerait un controle pose dans le handler HTTP.

Conséquences

  • Un acheteur non verifie sur un deal a plus de $10 000 est bloque a la signature de l'APA. Le deal reste en DILIGENCE. Le remede est sur le meme ecran ; ce n'est pas un cul-de-sac.
  • IN_REVIEW ne passe pas. « On a demande » n'est pas « on sait » : accepter IN_REVIEW ferait de la porte une formalite qu'on franchit en cliquant une fois.
  • Le code d'erreur kyc-required remonte tel quel dans le toast du SignaturesPanel, comme forbidden et signature-not-pending-* avant lui. Un libelle traduit est une amelioration, pas un prealable.
  • Un deal a exactement $10 000 n'est pas gate : le comparateur est >, comme la condition de la banniere. Un acheteur a la frontiere n'a jamais ete averti, donc il ne peut pas etre bloque.
  • Dette assumee : le vendeur n'a pas de KYC enhanced. Le signal qui doit faire revisiter ce choix est un litige ou l'identite du vendeur est en cause, ou une obligation AML qui vise le cedant.

Comment c'est appliqué

Deux chemins ecrivent apaSignedAt, donc la porte est posee sur les deux. C'est le point qui distingue un controle d'une decoration : signSignature (V5.1) et markLegalDocSigned (V2, le click-through cable a POST /api/vendor/marketplace/deals/[id]/sign et sell/deals/[id]/[doc]) posent la meme colonne, et c'est cette colonne que la FSM du deal lit pour sortir de DILIGENCE. Gater le premier seul aurait livre une porte avec un bouton a cote.

Que ces deux chemins existent est un defaut a part entiere, suivi par marketplace/0443 ; cet ADR ne le resout pas, il refuse seulement d'en dependre.

  • src/services/marketplace/kyc.ts — KYC_REQUIRED_ABOVE_CENTS et dealRequiresBuyerKyc, la seule definition du seuil.
  • src/services/marketplace/legal-signatures.ts — la porte dans signSignature, avant toute ecriture.
  • src/services/marketplace/seller.ts — la meme porte dans markLegalDocSigned, apres les controles de partie et de statut.
  • src/services/marketplace/legal-signatures.test.ts (9 tests) et src/services/marketplace/legal-doc-click-through.test.ts (8 tests) — refus sans KYC / IN_REVIEW / REJECTED, passage VERIFIED, et les non-regressions qui rendent bruyant un elargissement de la porte (vendeur, NDA, LOI, montant sous le seuil).
  • src/app/(dashboard)/account/deals/[id]/page.tsx — la banniere lit dealRequiresBuyerKyc, plus un litteral local.
  • Les deux routes rendent kyc-required en 409.