Marketplace — Architecture & Reference
Source de vérité pour la marketplace BoostEcom (frontend, backend, data, SEO, monétisation, vendor flow). Mis à jour : 2026-09 (marketplace/3005).
Source de vérité pour la marketplace BoostEcom (frontend, backend, data, SEO, monétisation, vendor flow). Mis à jour : 2026-09 (
marketplace/3005).
1. Vue d'ensemble
L'enum Prisma MarketplaceType déclare 13 types. 10 sont publics.
Trois ne le sont pas :
CLIest retiré, et sa page de catégorie répond 308 vers/features/mcp;NEWSLETTERest retiré depuismarketplace/3005(décision D8 du 2026-09-25) : il vendait le produit de/sponsor/newsletteret n'avait aucune annonce. Sa cible de redirection est/sponsor/newsletter;JOBest hors du public depuismarketplace/3005(décision D7) : ses seules offres étaient les cinq de BoostEcom en JSON, qui contredisaient/careers, et c'est la page carrières qui fait foi. Il n'est pas retiré (pas de redirection) : une ligne JOB déjà en base garde sa page détail, ennoindex, mais plus aucun vendeur n'en crée.
Le tableau ci-dessous est vérifié : src/test/marketplace-type-registry.test.ts
échoue si une ligne manque, si une ligne cite un type non public, si l'ordre
diffère de l'ordre éditorial, ou si une coche ne correspond plus à
TYPE_CAPABILITIES. Il a annoncé « CLI : achat direct ✅, reviews ✅ »
pendant des mois pour une catégorie que TYPE_COPY libelle « retired »,
et « JOB : hire ✅ apply » pour un type sans entrée de registre.
| Type | Slug pluriel | Achat direct | Hire flow | Apply flow | Reviews |
|---|---|---|---|---|---|
| APP | apps | ✅ | — | — | ✅ |
| THEME | themes | ✅ | — | — | ✅ |
| AGENCY | agencies | — | ✅ | — | ✅ |
| FREELANCER | freelancers | — | ✅ | — | ✅ |
| STORE | stores | — | — | — | — |
| EXTENSION | extensions | ✅ | — | — | ✅ |
| SKILL | skills | ✅ | — | — | ✅ |
| TOOL | tools | ✅ | — | — | ✅ |
| PROMPT | prompts | ✅ | — | — | ✅ |
| MCP | mcp | ✅ | — | — | ✅ |
STORE ne coche rien ci-dessus et c'est normal : son flux est l'offer + escrow + APA, décrit en §3, et il n'a pas de reviews (acquisition one-shot).
Constantes canoniques :
src/types/marketplace-constants.ts.
Ne JAMAIS dupliquer TYPE_TO_PLURAL, PLURAL_TO_TYPE, TYPE_COPY ailleurs —
ni, depuis marketplace/0645, TYPE_CAPABILITIES et les listes qui en
dérivent (TYPE_ORDER, BUYABLE_TYPES, HIRE_TYPES, APPLY_TYPES,
REVIEWABLE_TYPES, VENDOR_CREATABLE_TYPES).
1.1. Surface publique : ce qu'on montre, et pourquoi
Cette section existe parce que la question « qu'est-ce qui est public ? » avait neuf réponses qui ne concordaient pas, et qu'aucune n'était écrite.
Une catégorie est publique quand TYPE_CAPABILITIES[type].publicIndex
est vrai. Elle est alors listée sur /marketplace — mais seulement si elle
a au moins une ligne LIVE : resolveVisibleCounts ne lie jamais vers une
catégorie qui rendrait zéro résultat.
Une catégorie mince n'entre pas dans l'index des moteurs. Deux verrous,
posés ensemble et lisant la MÊME règle, isCategoryIndexable
(counts.ts) :
generateMetadata de /marketplace/[type] rend noindex, follow, et
sitemap.ts n'émet pas son URL, tant que la catégorie n'a pas
MIN_THIRD_PARTY_LISTINGS_TO_INDEX (3) annonces LIVE tierces
(ownedByBoostecom: false). Une catégorie non publique n'est jamais
indexée.
Deux étapes pour en arriver là. Avant, les douze catégories partaient à
priorité 0.8 daily quoi qu'il arrive, donc nous poussions activement vers
Google des pages « No jobs listed yet. ». Le premier correctif exigeait UNE
ligne LIVE, et le catalogue que ce dépôt seed la fournissait tout seul :
un placeholder BoostEcom par catégorie (thème, app, outil, prompt, skill
jamais livrés), cinq offres d'emploi à nous, le fondateur et son agence.
Chacune de ces catégories partait donc à Google portée par une annonce que
nous avions écrite sur nous-mêmes (marketplace/3005). Les pages restent
joignables et liées depuis /marketplace : seuls le noindex et le
sitemap suivent le seuil.
/marketplace/hire suit la même règle par isHireIndexable : il est
indexé dès que l'un de ses deux catalogues (HIRE_TYPES : agences,
freelances) l'est. Tant qu'ils ne contiennent que le fondateur et son
agence, il est en noindex et hors sitemap.
Dans les deux verrous, null (la base n'a pas répondu) n'est pas
zéro : un incident transitoire ne doit pas désindexer le catalogue.
Soumettre une annonce — deux chemins, tous deux réservés aux plans payants.
Décision du 2026-09-25 (marketplace/2993) : vendre est une
capacité des plans payants, acheter reste ouvert à tous. Les deux routes
appellent canSellOnMarketplace
(seller-eligibility.ts) :
au moins une organisation du membre dont l'entitlement est déverrouillé
(resolveEntitlement, donc un plan offert compte, le cache
Organization.plan jamais). Refus = 402 seller-plan-required. La
soumission anonyme du wizard /sell n'existe donc plus : 401.
| Chemin | Qui | Types | Coût |
|---|---|---|---|
/sell → wizard registre → POST /api/marketplace/listings | membre d'une org payante | store uniquement | 3 % au closing |
/sell/dashboard/new → POST /api/vendor/marketplace/listings | membre d'une org payante | les 10 publics (VENDOR_CREATABLE_TYPES) | review admin |
/sponsor | public | tous | payant (placement) |
Le CTA des pages catégorie envoie sur /sponsor pour tout type autre que
STORE : c'est assumé, les placements non-store sont sponsorisés. Le
libellé le dit maintenant (« Sponsor a placement ») ; il annonçait
« Submit a listing » / « Submit your work » en menant sur une page de
tarifs.
Portée réelle du registre de soumission.
listing-types.ts
décrit 11 types, mais le seul composant qui le lit — <SellStoreWizard /> —
est monté à un seul endroit, src/app/(marketing)/sell/page.tsx, avec
type="store" écrit en littéral et sans sélecteur de type. Dix des onze
entrées sont donc de la configuration que rien ne rend aujourd'hui. Ce n'est
pas un bug : c'est la conséquence de la règle ci-dessus. Ne pas lire ces dix
entrées comme une fonctionnalité vivante.
2. Routes & surfaces
Public (marketing)
/marketplace # Index — TYPE_ORDER + trust strip + featured rail + recent rail
/marketplace/[type] # Liste par type + pagination (PAS de filtres, cf. ci-dessous)
/marketplace/[type]/[slug] # Detail listing (privacy-aware, JSON-LD par type, §6).
# 308 vers listingPathFor(card.type, slug) quand le
# type de l'URL n'est pas celui de l'annonce
/marketplace/hire # Funnel agency/freelancer hire
/marketplace/systems # 308 vers /features/systems (+ chaque slug vers /features/systems/<slug>) :
# un System n'est pas une annonce (growth-web/3018, redirections permanentes)
Aucune n'est en ISR, quoi qu'en dise son export const revalidate. Le
layout RACINE lit la requête deux fois — headers() pour le nonce CSP et
cookies() via getLocale() — et la doc Next 16 est formelle : un nonce CSP
désactive l'optimisation statique et l'ISR pour TOUTES les pages de
l'application. Les trois pages rendent donc à chaque requête : zéro hit CDN,
une salve Postgres par visiteur anonyme et par passage de crawler. Les
valeurs revalidate restent déclarées pour le jour où le layout cessera de
lire la requête (backlog/app-shell/0361, arbitrage chiffré dans 0365) ;
la page détail, elle, ne redeviendra pas cacheable — elle lit headers()
elle-même pour le compteur de vues (marketplace/0440).
Corollaire : les revalidatePath() des actions admin purgent un cache qui
n'existe pas. Ils sont inoffensifs et restent en place pour la même raison.
Cette section annonçait « Toutes en ISR » et des « filtres » sur la page
liste. Il n'y a pas de filtres : aucun composant ListingFilters n'existe
dans le dépôt (cf. §11). Le moteur de recherche et de filtrage vit dans le
Hub de la home, pas dans les pages marketing.
API publique
| Endpoint | Méthode | Auth | Rate-limit |
|---|---|---|---|
/api/marketplace/listings | POST | opt | 10/min IP |
/api/marketplace/listings/[id]/save | POST | user | 50/min user |
/api/marketplace/listings/[id]/reviews | POST | user | 60/24h user |
/api/marketplace/listings/[id]/reviews | DELETE | auteur ou admin | 30/h user |
/api/marketplace/deals/[id]/advance | POST | partie du deal | 20/h user |
/api/marketplace/listings/[id]/checkout | POST | user | 10/24h user (ONE_TIME + SUBSCRIPTION) |
/api/marketplace/offers | POST | opt | 20/24h IP |
/api/marketplace/offers/[id]/verify | POST | opt | 10/h IP+offerId |
/api/marketplace/leads | POST | opt | 30/24h IP + dedup 60s |
/api/marketplace/recommendations | GET | — | 30/min IP + cursor |
/api/marketplace/search | GET | — | 60/min IP — full-text tsvector |
/api/marketplace/stats | GET | — | 60/min IP |
/api/marketplace/sponsor-state | GET | — | 60/min IP |
API admin
| Endpoint | Méthode | Rate-limit | Audit log |
|---|---|---|---|
/api/admin/marketplace/listings | POST | 10/min | ✅ |
/api/admin/marketplace/listings/[id] | PATCH | 50/min | ✅ diff |
/api/admin/marketplace/listings/[id] | DELETE | 20/min | ✅ |
Vendor dashboard
Les listings vendeur ne sont plus org-scoped : MarketplaceListing n'a pas
d'orgId, les deux surfaces lisaient les mêmes lignes via
listSellerListings(user.id), et /[orgSlug]/~/listings* a ete fusionne dans
/sell/dashboard* par marketplace/0145 (la redirection est retiree de
next.config.mjs depuis le 2026-09-26 : l'ancienne URL rend 404). Cette
section a décrit les routes org-scoped comme vivantes jusqu'en
septembre 2026.
/sell/dashboard # Mes listings + stats agrégées + bulk actions
/sell/dashboard/new # Create form (rhf+zod, save draft OR submit for review)
/sell/dashboard/analytics # Analytics (totals all-time + KPIs 30j + top 5)
/sell/dashboard/[id] # Detail / offres en attente / deals actifs
/sell/dashboard/[id]/edit # Form complet edit (rhf+zod, markdown preview, image upload)
/sell/dashboard/[id]/analytics # Analytics par listing (fenêtre 7/30/90j)
/sell/deals/[id] # Deal detail vendeur
/[orgSlug]/~/deals # Index deals (seller + buyer side) : toujours org-scoped
/[orgSlug]/~/deals/[id] # Deal pipeline + checklist + messages
Corrige le 2026-09-05. Ce bloc documentait le dashboard vendeur sous
/[orgSlug]/~/listings*. Ce prefixe n'existe plus :marketplace/0145l'a consolide sur/sell/dashboard*parce que les deux surfaces lisaient les MEMES lignes vialistSellerListings(user.id)et queMarketplaceListingn'a pas d'orgId— le[orgSlug]gatait sur l'appartenance a l'org puis ne scopait rien.next.config.mjsporte les deux redirections, etsrc/test/vendor-listings-consolidation.test.tsrefuse que la route revienne.
Vendor API CRUD
| Endpoint | Méthode | Rate-limit |
|---|---|---|
/api/vendor/marketplace/listings | GET | (auth) |
/api/vendor/marketplace/listings | POST | 5/h |
/api/vendor/marketplace/listings/[id] | PATCH | 30/h |
/api/vendor/marketplace/listings/[id] | DELETE | 5/h |
/api/vendor/marketplace/listings/[id]/submit | POST | 10/h (DRAFT → PENDING_REVIEW) |
/api/vendor/marketplace/offers/[id] | POST | 30/h (accept/reject) |
/api/vendor/marketplace/deals/[id]/messages | POST | 60/h |
/api/vendor/marketplace/deals/[id]/advance | POST | 20/h |
/api/vendor/marketplace/deals/[id]/sign | POST | 30/h (nda/loi/apa) |
/api/vendor/marketplace/upload | POST | 20/h (image upload Blob) |
/api/vendor/marketplace/listings/[id]/preview-token | POST | 10/h (rotate ou revoke) |
/api/vendor/marketplace/listings/bulk | POST | 10/h (pause/unpause/delete jusqu'à 50 IDs) |
/api/vendor/marketplace/deals/[id]/cancel | POST | 5/h |
Tous owner-gated via sellerId === user.id ou seller/buyer === user.id
(refuse 403 sinon). Audit log org-scoped sur toutes les mutations.
V4 — Stripe Connect + Disputes
| Endpoint | Méthode | Auth | Rate-limit |
|---|---|---|---|
/api/vendor/stripe/connect/onboard | POST | user | 5/h |
/api/vendor/stripe/connect/status | GET | user | 10/min (refresh=1) |
/api/vendor/stripe/connect/dashboard | POST | user | 20/h |
/api/marketplace/disputes | POST | buyer | 5/h |
/api/marketplace/disputes | GET | buyer | — |
/api/marketplace/disputes/[id] | GET | party (buyer/seller/admin) | — |
/api/marketplace/disputes/[id] | PATCH | buyer | — (withdraw) |
/api/vendor/marketplace/disputes/[id]/respond | POST | seller | 10/h |
/api/admin/marketplace/disputes/[id]/resolve | POST | admin | 50/h + audit log |
Routes UI V4 :
# Buyer (account-scoped, no org needed)
/account/settings/payouts # Stripe Connect onboarding + status + dashboard CTA
/account/disputes # Index disputes raised by me
/account/disputes/[id] # Unified thread (multi-role render via isAdmin/raisedBy/sellerId)
# Vendor (org-scoped)
/[orgSlug]/~/disputes # Disputes against my listings (seller side)
# Admin
/admin/content/marketplace-disputes # Queue admin (priority sort)
Admin
/admin/content/marketplace # Queue modération + decisions
/admin/content/marketplace/new # Create listing direct
/admin/content/marketplace-disputes # V4 — Dispute mediation queue
3. Modèle de données
Source : prisma/schema.prisma
Modèles marketplace
MarketplaceListing— la listing publique. Compteurs dénormalisés (savesCount,viewsCount,offersCount,reviewsAvg,reviewsCount).VerifiedRevenue— MRR / ARR / last30d en cents, token OAuth chiffré.Offer— offre acheteur. Unique constraint sur(listingId, buyerEmail, status)empêche les doublons PENDING. Deux champs de texte libre écrits par le VENDEUR, et ils ne sont pas interchangeables :counterMessageaccompagnecounterAmountsur une contre-offre,rejectionReasonporte le motif d'un refus. Ils ont partagé une seule colonne jusqu'àmarketplace/0442— le motif s'écrivait danscounterMessage, personne ne le relisait, et le seul bouton « refuser » du produit ne l'envoyait pas. Ils se lisent exclusivement viaservices/marketplace/offer-messages.ts: la règle « quel statut possède ce texte » y est écrite une fois, au lieu d'être redérivée par surface.DealThread— pipeline UNDER_OFFER → LOI_SIGNED → DILIGENCE → APA_SIGNED → ESCROW_FUNDED → TRANSFERRING → CLOSED.DealMessage— chat seller × buyer.Order— checkout Stripe (apps/themes/extensions/skills/prompts/MCP/tool/CLI).Review—verifiedBuyer(Order PAID OU DealThread CLOSED). Sur un type achetable (BUYABLE_TYPES: APP, THEME, EXTENSION, SKILL, PROMPT, MCP, TOOL, CLI) ce n'est pas un badge mais une condition : sans achat, 403. Les types de prestation (AGENCY, FREELANCER) restent ouverts, leur travail se contracte hors plateforme et il n'existe aucunOrderà pointer.DELETEretire la review de son auteur, ou n'importe laquelle pour un admin (jamais le vendeur), et recalcule l'agrégat dans la même transaction.SavedListing— favorites.
BuyerProfile(KYC + track record, design V1) a été retiré du schéma en juin 2026 : jamais écrit ni lu par aucun code path, et le volet KYC est couvert parKycVerification(V5.1).
Machine à états du deal (qui avance quoi)
DealThread n'a pas un seul conducteur. Le service
advanceDealStage refuse une
transition dont l'appelant n'est pas la partie désignée, et refuse une
transition dont la preuve n'est pas déjà sur la ligne :
| Depuis | Vers | Qui | Exige |
|---|---|---|---|
| UNDER_OFFER | LOI_SIGNED | vendeur | loiSignedAt |
| LOI_SIGNED | DILIGENCE | vendeur | — |
| DILIGENCE | APA_SIGNED | vendeur | apaSignedAt |
| APA_SIGNED | ESCROW_FUNDED | vendeur OU acheteur | — |
| ESCROW_FUNDED | TRANSFERRING | vendeur | — |
| TRANSFERRING | CLOSED | acheteur | — |
Deux règles derrière ce tableau :
- Une transition lit un fait, elle ne l'écrit pas.
loiSignedAtetapaSignedAtviennent de la signature (markLegalDocSigned,signSignature), pas du clic « étape suivante ».escrowReleasedAtn'est plus écrit nulle part par la FSM : de l'argent qui sort d'un escrow est un évènement de paiement, et aucun n'existe dans ce dépôt. - La partie qui subit la conséquence exécute la transition. La
clôture passe la listing en SOLD et crédite le track record public du
vendeur (
totalDealsClosedsur/sellers/[id]) : elle appartient à l'acheteur, viaPOST /api/marketplace/deals/[id]/advance. Le vendeur qui la demande reçoit 409buyer-confirms-this-stage. Le financement de l'escrow reste ouvert aux deux : il ne fait qu'OUVRIR la fenêtre de dispute de 14 jours (disputes.tstesteescrowFundedAt || closedAt), donc un vendeur qui le déclare ne peut jamais enfermer un acheteur.
Avant marketplace/0323, les six transitions étaient toutes au vendeur et
chacune estampait le fait qu'elle nommait : six clics suffisaient à
produire un LOI signé, un APA signé, un escrow financé, un escrow
libéré, un deal clos et une listing vendue, sans signature, sans
paiement et sans acheteur.
escrowFundedAt reste une déclaration, pas une preuve : aucun
prestataire d'escrow n'est branché (DealThread.escrowId n'est écrit par
aucun chemin). Le jour où il l'est, c'est le requires de cette table qui
gagne un test sur escrowId.
Les deux écrans de deal (/account/deals/[id],
/[orgSlug]/~/deals/[id]) proposent désormais chaque transition à la
partie qui la détient : l'acheteur y confirme la réception et clôture,
sur POST /api/marketplace/deals/[id]/advance. Ils lisent
nextDealStage() côté serveur et passent la réponse au composant client
(deal-stage-policy.ts, sous _components/ de la route org), plutôt
que d'en garder une copie. Le libellé de la clôture est une
confirmation de réception, pas un « suivant » : c'est cette déclaration
qui rend la vente publique, et elle passe par une confirmation
explicite. Entre marketplace/0323 et app-shell/0389 la capacité
serveur existait sans aucune surface pour l'appeler, et les deals
s'arrêtaient en TRANSFERRING.
Le document qu'une transition exige est proposé au stade qu'il faut
QUITTER (LOI à UNDER_OFFER, APA à DILIGENCE), et non au stade déjà
atteint : proposer le LOI une étape trop tard, c'était rendre
impossible depuis cet écran la seule preuve que la transition demande.
Le NDA n'y figure pas — aucune transition ne l'exige — et reste signable
sur la même page via SignaturesPanel, qui est le chemin bilatéral et
tracé (LegalSignature).
La seule condition KYC du pipeline
apaSignedAt est le fait qui fait sortir un deal de DILIGENCE, et deux
chemins l'écrivent : signSignature (V5.1) et markLegalDocSigned (V2,
click-through). Les deux refusent la signature de l'APA par l'ACHETEUR
quand agreedAmount dépasse KYC_REQUIRED_ABOVE_CENTS ($10 000) et que
son KycVerification n'est pas VERIFIED — code kyc-required, rendu
409 par les deux routes.
Et la TRANSITION DILIGENCE → APA_SIGNED pose la même condition, quel
que soit qui a signé. C'est le vrai point d'application depuis
marketplace/0603 : apaSignedAt est UNE colonne partagée par les deux
parties, la signature exempte volontairement le vendeur, et le seul
bouton « signer l'APA » que le produit rende est le sien
(DOC_REQUIRED_TO_LEAVE propose le document à la partie qui pilote
l'étape). Un vendeur qui signait en premier stampait donc la colonne et
faisait avancer un deal à sept chiffres en APA_SIGNED puis
ESCROW_FUNDED sans que l'identité de l'acheteur ait jamais été
vérifiée — exactement le contournement que l'ADR 0011 nommait en écartant
les autres étapes. advanceDealStage lit buyerId + agreedAmount sur
la ligne et interroge isKycVerified(buyerId) dans le requires de
l'étape (devenu asynchrone pour ça) : le deal reste en DILIGENCE, les
deux routes advance rendent kyc-required en 409, et le remède reste
la KycBanner de /account/deals/[id], qui lit le même
dealRequiresBuyerKyc.
Ce n'est pas un revirement de l'ADR 0011 : la ligne qu'il écartait visait
APA_SIGNED → ESCROW_FUNDED, ouverte aux DEUX parties (by: "either"),
où une condition sur le KYC acheteur bloquerait aussi le vendeur.
DILIGENCE → APA_SIGNED est pilotée par le vendeur seul et conditionnée
sur l'acheteur : personne n'y est bloqué par la vérification d'un autre.
Le vendeur n'est pas gaté ici : Stripe Connect vérifie déjà son identité et
cron/marketplace-payouts ne libère aucun escrow vers un compte qui n'est
pas ENABLED. Le NDA et la LOI ne le sont pas non plus. L'acte de signer
l'APA lui reste ouvert sans KYC (legal-doc-click-through.test.ts) ; ce
qui attend l'acheteur, c'est l'état du deal.
Avant marketplace/0441, ce seuil n'existait que comme littéral dans la
condition JSX qui affiche KycBanner sur /account/deals/[id], et
isKycVerified n'avait aucun appelant : la bannière promettait une garantie
que rien ne tenait. Le raisonnement complet, et les six étapes candidates
écartées, sont dans
docs/decisions/0011-la-porte-kyc-du-marketplace.md.
Que deux chemins écrivent la même colonne reste un défaut à part entière
(marketplace/0443) : la porte est posée sur les deux — et sur la
transition qui les lit — plutôt que d'en dépendre.
Gardes : src/services/marketplace/deal-stage-kyc.test.ts (la transition),
legal-signatures.test.ts + legal-doc-click-through.test.ts (les deux
signatures, exemption vendeur comprise).
Modèles V4
StripeConnectAccount— Stripe Connect (Express) account du seller. Mirror des flagschargesEnabled/payoutsEnabled/detailsSubmitted+currentlyDue/pastDuerequirements. Status enum : PENDING → RESTRICTED → ENABLED / REJECTED. Synced via webhookaccount.updated. Lié à User (1:1) + orgId nullable.Dispute— buyer-raised dispute sur un Order (orderId) OR DealThread (dealId), exclusif. REFUND_FULL / REFUND_PARTIAL ne s'appliquent qu'aux disputes d'Order : sur une dispute de deal,adminResolveDisputerépondescrow-refund-manual(409).refundOrderinverse un PaymentIntent Stripe porté par unOrder; un deal n'en a pas, et l'e-mail de résolution rendrefundAmounttel quel. Une décision REFUND_* sur un deal annonçait donc à l'acheteur un remboursement que rien ne versait et que rien ne traçait. L'admin résout NO_ACTION avec le règlement hors plateforme dansnotes, ce qui laisse unAdminAuditLogqui dit ce qui s'est réellement passé. Workflow : OPEN → AWAITING_SELLER → AWAITING_ADMIN → RESOLVED_REFUND / RESOLVED_PARTIAL / RESOLVED_NO_ACTION / WITHDRAWN. Evidence URLs (buyerEvidence / sellerEvidence, max 5 chacun). Admin resolution déclencherefundOrdersi REFUND_FULL/PARTIAL → stripeRefundId capturé. sellerDeadline (createdAt + 7j) drive l'auto-escalation cron.ListingEvent— timeline horodatée fine-grained (VIEW / SAVE / UNSAVE / OFFER_CREATED / OFFER_ACCEPTED / DEAL_STARTED / DEAL_CLOSED / CHECKOUT_STARTED / ORDER_PAID / REVIEW_POSTED / CONTACT_CLICK / EXTERNAL_CLICK). VIEW dedup par visitorHash (sha256(ip + ua + day)). Foundation pour ML recos v2. Cette liste est celle de l'enum, pas celle des lignes écrites :OFFER_ACCEPTED,OFFER_REJECTED,DEAL_STARTEDetDEAL_CLOSEDn'ont toujours aucun producteur (marketplace/0464).VIEWen avait un depuismarketplace/0440seulement, cf. § « Compteur de vues » ci-dessous.
Indexes composites (depuis cette PR)
MarketplaceListing @@index([type, status, publishedAt(sort: Desc)])
MarketplaceListing @@index([sellerId, status, publishedAt(sort: Desc)])
DealThread @@index([sellerId, status, createdAt(sort: Desc)])
DealThread @@index([buyerId, status, createdAt(sort: Desc)])
Review @@index([listingId, createdAt(sort: Desc)])
Review @@index([listingId, verifiedBuyer, createdAt(sort: Desc)])
Order @@index([buyerId, status, createdAt(sort: Desc)])
Order @@index([listingId, status])
Offer @@unique([listingId, buyerEmail, status])
Offer @@index([listingId, status, createdAt(sort: Desc)])
Offer @@index([expiresAt, status])
VerifiedRevenue @@index([source, refreshFailedAt])
# V4 — Stripe Connect / disputes / timeline
StripeConnectAccount @@unique([userId])
StripeConnectAccount @@unique([stripeAccountId])
StripeConnectAccount @@index([orgId])
StripeConnectAccount @@index([status])
Dispute @@unique([orderId])
Dispute @@unique([dealId])
Dispute @@index([raisedById, createdAt(sort: Desc)])
Dispute @@index([status, createdAt(sort: Desc)])
Dispute @@index([sellerDeadline, status])
ListingEvent @@index([listingId, createdAt(sort: Desc)])
ListingEvent @@index([listingId, kind, createdAt(sort: Desc)])
ListingEvent @@index([kind, createdAt(sort: Desc)])
ListingEvent @@index([userId, createdAt(sort: Desc)])
ListingEvent @@index([visitorHash, createdAt(sort: Desc)])
4. Migration JSON → DB
État : en cours (les JSON files sous content/marketplace/ restent
seed pour les listings curated par BoostEcom).
# push le schema (créé/maj les tables marketplace)
pnpm db:push
Le seed des listings depuis content/marketplace/*.json est
100% automatique : aucun script manuel. Le sync tourne au cold-start
Node-lambda (src/services/marketplace/sync-from-content.ts,
piloté par src/instrumentation.ts),
idempotent (upsert par slug + sweep des orphelins).
Supprimer un fichier JSON dépublie son annonce. Le sync écrit
ownedByBoostecom: true sur chaque ligne qu'il crée, et il est le seul à
le faire : c'est le marqueur. Au cold-start suivant, toute ligne
ownedByBoostecom: true, status: LIVE dont le slug n'est plus dans le
dossier passe ARCHIVED. Une ligne d'un vendeur n'est jamais touchée.
marketplace/3005 a retiré ainsi le thème, l'app, l'outil, le prompt et le
skill placeholders, les cinq offres d'emploi, et l'ancien slug
boostecom-extension (l'annonce s'appelle boostecom-spy).
L'ensemble épargné par le sweep est celui des fichiers LUS sur disque,
pas celui des upserts RÉUSSIS. C'était le second jusqu'à marketplace/3005
: un timeout de pool sur un seul upsert faisait de cette annonce un orphelin,
archivé, puis gardé archivé à chaque déploiement par la règle du statut tenu
par un admin ci-dessous.
Le JSON est la source de vérité du contenu, pas du cycle de vie :
l'upsert écrit status à la création, et à la mise à jour seulement si la
ligne n'est pas PAUSED / ARCHIVED / SOLD / REJECTED. Jusqu'à
marketplace/0323 le même objet servait de create ET d'update, donc
chaque cold start (donc chaque déploiement) republiait ce qu'un admin
avait mis en pause ou archivé, en silence : l'AdminAuditLog disait
pause, la ligne disait LIVE. Conséquence assumée : re-déposer un fichier
JSON supprimé ne ressuscite pas une listing archivée, c'est un PATCH admin
qui le fait, délibérément. Un force-sync
admin est dispo via POST /api/admin/marketplace/sync.
Le service src/services/marketplace/unified.ts
fait automatiquement DB-first → JSON-fallback avec cache Redis 5min
pour les hot paths. Log structuré (logger.info / warn) sur chaque
fallback pour monitorer la migration.
Le repli JSON ne sert que ce que la base ne connaît pas. Un slug qui a
une ligne en base, quel que soit son statut (ARCHIVED, SOLD, PAUSED,
DRAFT…), n'est jamais servi depuis le JSON : lookupListingBySlug répond
visible, hidden ou missing, et seul missing (ou une base qui ne
répond pas) déclenche le repli. Avant marketplace/3005, « pas de ligne
LIVE » valait « pas en base » : une annonce archivée par un admin, ou
balayée par le sync, revenait par le JSON avec un 200 et un canonical, et
le sitemap la soumettait. listUnified applique la même règle à ses
grilles.
4.5. Search interne (tsvector)
Postgres native full-text search via to_tsvector + ts_rank avec
pondération :
- A : title
- B : tagline
- C : description
- D : tags (array joined)
Service : src/services/marketplace/search.ts
websearch_to_tsquery parse la query user naturellement (quotes pour
phrases, OR/AND, parenthèses). Cache Redis 5min sur (q, type, limit).
L'index GIN n'est pas créé automatiquement, mais le script existe depuis
la V3 : pnpm db:marketplace:fts-index
(scripts/marketplace-fts-index.mjs)
pose l'index d'expression pondéré A/B/C/D qui matche search.ts, plus un
index partiel WHERE status='LIVE', le tout idempotent
(CREATE INDEX IF NOT EXISTS). À lancer quand le volume dépasse ~10k
listings ; en dessous le seq scan suffit. Cette section a dit « pas
d'index GIN, à ajouter via migration SQL » jusqu'en septembre 2026.
5. Sécurité
Rate limiting
Toutes les routes mutantes ont un rate limit Upstash Redis (avec fallback
in-memory en dev). Voir src/lib/security/rate-limit.ts.
Audit log admin
Toute mutation admin (create/update/delete listing) écrit dans
AdminAuditLog via logAdminAction().
Audit log vendeur / acheteur
Une mutation marketplace non-admin passe par
recordMarketplaceAudit(). Le
helper existe pour une raison précise : treize routes recopiaient les mêmes
huit lignes, dont douze sans orderBy, donc l'org sous laquelle l'historique
d'un vendeur multi-org atterrissait changeait d'un appel à l'autre. La
résolution est maintenant totale (joinedAt puis orgId), la même que
lib/security/session-auth.ts.
Ce n'est pas l'org correcte, seulement une org stable :
MarketplaceListing et DealThread ne portent pas d'orgId (le scope
vendeur est l'utilisateur, cf. §9) et AuditLog.orgId est NOT NULL. Rendre
la colonne nullable, ou ouvrir une table d'audit vendeur, est un changement
de schéma qui appartient à data-platform ; ce fichier est alors le seul
endroit à changer.
Idempotence checkout
Le checkout Stripe réutilise un Order PENDING existant avec
stripeSessionId non-expiré (fenêtre 30 min) au lieu de créer un nouveau
row. Stripe idempotencyKey scoped sur orderId empêche également les
sessions dupliquées côté Stripe.
Devise d'une offre
POST /api/marketplace/offers accepte encore un champ currency, mais ne
le croit jamais : l'offre est persistée dans listing.currency, et un body
qui en nomme une autre reçoit 400. Un Offer est un entier de cents plus
une devise, acceptOffer recopie l'entier dans DealThread.agreedAmount,
et toute la suite (e-mails, pipeline, Dispute.refundAmount, analytics
vendeur) l'affiche dans la devise de la listing : une offre en JPY se
lisait donc comme une offre sérieuse en USD.
Trace d'une lead
POST /api/marketplace/leads écrit un ListingEvent CONTACT_CLICK
avant l'envoi Resend, et répond 502 si l'envoi échoue. Il n'y a pas
de table MarketplaceLead : cette ligne de timeline est la seule trace
durable d'une lead. Avant, un échec d'envoi produisait un { ok: true }
et un console.error contenant l'adresse du prospect, seule copie de la
demande, dans une rétention de logs que rien ici ne contrôle. Le hold de
dédup 60 s est relâché sur le 502, sinon la nouvelle tentative repartait
en { ok: true, deduped: true }.
Dedup offers/leads
- Offers :
@@unique([listingId, buyerEmail, status])côté DB + pre-checkfindFirstretournant 409. - Leads : hash SHA-256 court (16 chars) sur
(listingId, email, message)stocké en Redis 60s → renvoie{ ok: true, deduped: true }si match.
6. SEO
Structured Data JSON-LD (detail page)
-
WebPage+BreadcrumbList(toujours ;ProfilePagepour un freelance) -
PAS d'
Article. Cette ligne disait « +Article(toujours) » bien après son retrait. Une annonce était typéeProductETArticlesur la même URL — même titre, même description, sans auteur, avec undatePublishedqui n'existait que pour les items JSON. Deux types primaires contradictoires pour une page, c'est un tirage au sort sur l'entité qu'un moteur de réponse garde ; celle qui compte est leProduct(prix, disponibilité, note). -
un type schema.org par sorte d'annonce (
marketplace/3005,listing-json-ld.ts, table exhaustiveLISTING_SCHEMA_KIND) :Types Entité APP, THEME, EXTENSION, MCP, TOOL, SKILL SoftwareApplication, le vendeur enpublisherPROMPT CreativeWorkAGENCY OrganizationFREELANCER la page devient ProfilePage,mainEntity: PersonSTORE Productsans brand, seulement avec un prix demandéJOB, NEWSLETTER, CLI aucune (aucun n'est public) Jusque-là, toute annonce était un
Productde marque « BoostEcom » : une app tierce, le profil du fondateur, une agence, des offres d'emploi, des boutiques vendues par d'autres, et la plupart sans offre ni note (un Product invalide pour Google). -
aucun
Productsans offre ni note, et aucun vendeur par défaut : BoostEcom n'est nommé que sur nos annonces (ownedByBoostecom), un vendeur tiers seulement quand la page le nomme aussi (ligne « Seller » de la fiche, son nom public de/sellers/[id]).productJsonLdn'a plus debrandpar défaut ; -
offerssuit le prix que la page affiche (listingOffer) : prix demandé pour une boutique,ONE_TIME/SUBSCRIPTION/FREE(prix 0) sinon, rien pour un prix négociable ; -
aggregateRatingquandreviewsCount > 0; -
mode anonyme : ni image, ni site, ni vendeur, et aucune entité hors boutique (on ne publie pas une
Personnommée « annonce privée »).
Liens sortants
- le bouton « visit » d'une annonce (
listingVisitRel,outbound-rel.ts) :sponsoredsi l'annonce a payé sa visibilité (featuredou un tier au-dessus de STARTER),nofollowpour tout autre tiers, rien de plus pour nos propres annonces.noopener noreferrertoujours ; - les emplacements pub (
AdSlotCard,AdSlotsMobileBanner) portentAD_SLOT_REL=sponsored noopener noreferrer; - le markdown écrit par un membre (
ProseMarkdown: descriptions, reviews, forum) :ugc nofollow noopener noreferrerpour un hôte externe, rien pour nos propres URLs absolues.
Canonical
Une annonce a UNE URL, listingPathFor(card.type, slug) : le canonical, le
sitemap, les ItemList des pages catégorie et vendeur. La recherche en
base se fait par slug seul, donc /marketplace/<autre type>/<slug> rendait
l'annonce avec un canonical sur le mauvais chemin ; il répond maintenant
308 vers le bon, en gardant un éventuel ?preview=.
Sitemap
src/app/sitemap.ts inclut les listings
LIVE des catégories publiques (priorité 0.55-0.75 selon featured /
verified), à leur URL canonique, plus une entrée par catégorie
indexable au sens de isCategoryIndexable (0.8 daily) et
/marketplace/hire quand isHireIndexable. Un fichier JSON n'est soumis
que si aucune ligne de même slug n'existe en base (ou si la base ne répond
pas) : une annonce archivée ou vendue ne revient plus par le catalogue.
Un profil /sellers/[id] n'est soumis que pour un vendeur qui a une
annonce LIVE non anonyme, et la page répond 404 sinon. Les slugs de
catégorie sont dérivés de TYPE_ORDER : ce fichier portait sa propre liste
de douze slugs écrite à la main, neuvième copie d'un mapping dont
marketplace-constants.ts est la seule maison.
Pages hors index
- catégorie mince ou non publique →
noindex, follow(§1.1) ; /marketplace/hiretant que ses deux catalogues sont minces (§1.1) ;- annonce d'une catégorie non publique (JOB) →
noindex, follow; - pages démo du hub (
card.demo) →noindex, nofollow, nocache, hors sitemap, hors listes de catégorie, et sans aucun JSON-LD : publier unProduct+AggregateRatingsur des chiffres de démonstration laisserait un crawler mettre en cache des notes fictives.
Robots
src/app/robots.ts — /marketplace/**
autorisé pour 24 crawlers AI (GPTBot, ClaudeBot, anthropic-ai,
PerplexityBot, Cohere, Mistral, etc.).
LLM crawl
src/app/llms.txt/route.ts liste
la marketplace en featured section.
7. Monétisation
| Mode | Implementation |
|---|---|
| Take fee 3% | BOOSTECOM_MARKETPLACE_FEE_RATE dans types/marketplace |
| Stripe Tax | automatic_tax: { enabled: true } sur Checkout Session |
| ONE_TIME | ✅ supporté (Stripe Checkout mode: payment) |
| SUBSCRIPTION | ✅ supporté (Stripe Checkout mode: subscription, monthly) — chaque mois payé produit un Order, cf. §7.1 |
| Featured slot | Drapeau featured: true + tier GROWTH/SCALE ; expiry quotidienne via cron marketplace-maintenance |
| Sponsor | /sponsor (les trois types HOME_ROTATION/FEATURED/NEWSLETTER, un seul <SponsorWizard> parametre par type, marketplace/0623) + /api/marketplace/sponsor-state + POST /api/sponsor/checkout (un chemin statique, discrimination sur le body) : l'occupation et le rang « founding » se comptent sur SponsorPlacement, via readSponsorMarketState (src/services/sponsor/placements.ts), une seule fois pour les deux surfaces qui affichent un prix — l'endpoint sponsor-state et le wizard lui-meme. L'ancienne page /checkout/sponsor (qui comptait autrefois un modele sponsor qui n'a jamais existe, derriere un as unknown as — corrige par app-shell/0390) est retiree : le wizard poste directement a /api/sponsor/checkout et redirige vers Stripe, et depuis growth-web/3018 ce n'est plus une page mais une regle 308 de next.config.mjs vers /sponsor. src/test/prisma-cast-names-a-real-model.test.ts refuse tout cast de prisma vers un modele que le schema ne declare pas Diffusion ( marketplace/2995) : le paiement RESERVE, la validation PUBLIE. SponsorPlacement.approvedAt est posee par « Valider et publier » dans /admin/marketplace/sponsors (badge sponsorsToReview) ; aucune surface ne rend une ligne sans elle. Rails : GET /api/sponsor/rotation (services/sponsor/live-rotation.ts) alimente AdSlotsProvider. Newsletter : marketplace/3028 ; featured : marketplace/3029 |
| Affiliate | 30% du net sur 12 factures payees (src/config/affiliate.ts) |
| Stripe Connect | ✅ livre (V4) — onboarding Express, payout vendeur en escrow apres la fenetre de dispute 14j via le cron marketplace-payouts |
7.1. Le cycle recurrent d'une listing SUBSCRIPTION
Le checkout d'une listing SUBSCRIPTION cree un abonnement Stripe, donc
Stripe preleve l'acheteur tous les mois. Jusqu'a marketplace/0460, un
seul de ces mois atteignait le vendeur :
checkout.session.completedbasculait l'OrderPENDINGenPAID;cron/marketplace-payoutsselectionne des lignesOrder, donc il payait ce mois-la et rien d'autre ;- les factures de renouvellement ne resolvaient aucune
Subscriptionlocale, ethandleInvoicePaidles laissait « fall through untouched ».
La plateforme encaissait donc 100 % des mois 2..n, sans Order, sans
evenement, et sans que /sell/dashboard puisse le montrer.
La regle aujourd'hui : un mois paye = un Order PAID.
| Fait | Ou |
|---|---|
| La regle (qui, combien, quand) | services/marketplace/subscription-renewals.ts |
| Le cablage | handleInvoicePaid dans services/webhooks.ts — une delegation, retour anticipe |
| Ce qui identifie la facture | invoice.parent.subscription_details.metadata (snapshot fige par Stripe de subscription_data.metadata pose au checkout ; le shape pre-Basil, a la racine, est lu aussi) |
| Idempotence | Order.stripeInvoiceId, unique en Postgres. StripeEvent ne dedupe que par event.id, et Stripe rejoue une livraison pendant trois jours : une seconde ligne ici serait un second virement reel au vendeur |
| Le montant partage | total_excluding_tax plafonne a amount_paid. La TVA collectee pour un Etat n'est pas du revenu et n'est jamais partagee ; subtotal_excluding_tax ignore les remises au niveau facture |
| Mois 1 | billing_reason: "subscription_create" ne cree rien : il estampille sa facture sur l'Order du checkout, pour qu'un rejeu tardif tombe sur l'index unique au lieu de miner un renouvellement |
| L'escrow | Inchange. Chaque Order mensuel ouvre sa propre fenetre de dispute de 14 jours a partir de son paidAt, et le cron libere le net du mois quand elle se ferme |
Aucun cron ni webhook nouveau. invoice.paid etait deja souscrit et
cron/marketplace-payouts selectionne deja sur Order : le compte de crons
de vercel.json (garde par pnpm docs:claims) ne bouge pas.
Ce qui manque encore : l'acheteur n'a aucune surface pour resilier son
abonnement marketplace (le portail Stripe de ~/billing porte le client
Stripe de l'organisation, pas celui que le Checkout cree pour l'acheteur).
Suivi par marketplace/0462.
8. Email notifications
Toutes via Resend (cf. src/services/email/marketplace-*.ts).
| Event | Template | Service |
|---|---|---|
| Offer OTP (buyer) | offer-otp.tsx | marketplace-offers.ts |
| Offer received (seller) | offer-received.tsx | marketplace-offers.ts |
| Lead contact (seller) | marketplace-lead.tsx | marketplace-leads.ts |
| Listing approved (seller) | marketplace-decision.tsx | marketplace-decisions.ts (depuis cette PR) |
| Listing rejected (seller) | marketplace-decision.tsx | marketplace-decisions.ts (depuis cette PR) |
9. Vendor / Seller dashboard
Route : /sell/dashboard
Permissions : un vendeur voit ses propres listings, via
listSellerListings(user.id). Le scope est l'UTILISATEUR, pas l'org :
MarketplaceListing ne porte pas d'orgId, ce qui est precisement
pourquoi la variante /[orgSlug]/~/listings a ete retiree
(marketplace/0145).
Pages :
/sell/dashboard— index + stats agrégées + table + bulk actions/sell/dashboard/new— create (draft ou submit for review)/sell/dashboard/analytics— KPIs 30j + top performers/sell/dashboard/[id]— detail + offres + deals/sell/dashboard/[id]/edit— edition complete (rhf + zod, preview markdown, upload image, editeur de screenshots)/sell/dashboard/[id]/analytics— analytics par listing (7/30/90j)
Corrige le 2026-09-05. Cette section disait « CRUD complet : 🚧 lecture seule pour l'instant ». L'edition complete est livree, avec sa route, son formulaire et son editeur de screenshots — la ligne decrivait l'etat d'avant. Un doc qui annonce comme a faire quelque chose qui est fait n'est pas seulement perime : il fait rouvrir le chantier.
Compteur de vues (marketplace/0440)
trackListingView existait depuis la V4, correcte, sans aucun
appelant : MarketplaceListing.viewsCount valait 0 sur chaque ligne,
ListingEvent ne contenait aucun VIEW, et les deux écrans d'analytics
vendeur affichaient un zéro qui se lit comme une mesure. Le rail « les
acheteurs ont aussi vu » n'avait rien à corréler.
Elle est appelée depuis
src/app/(marketing)/marketplace/[type]/[slug]/page.tsx,
via after(() => …) : le travail part une fois la réponse committée, il
n'entre jamais en concurrence avec le rendu pour une connexion du pool.
Qui compte, et qui ne compte pas
(view-tracking.ts) :
| Refus | Pourquoi |
|---|---|
| user-agent de bot ou vide | robots.txt invite GPTBot, ClaudeBot, PerplexityBot et Google-Extended sur le catalogue exprès. Un balayage de crawler offrirait quelques milliers de « vues » par semaine à une annonce sponsorisée |
sec-fetch-dest ≠ document | une sous-ressource n'est pas une navigation (même règle que proxy.ts) |
item démo, ?preview=, vendeur lui-même | virtuel, brouillon privé, ou ses propres statistiques |
| entrée JSON du catalogue | pas de ligne à incrémenter, pas de listingId pour la FK |
| même visiteur, même annonce, même jour | dédup par visitorHash |
Le visitorHash dérive de l'IP de confiance (dernier hop de
x-forwarded-for, jamais la position 0) : un client qui fait tourner
l'en-tête ne peut pas gonfler le compteur.
viewsCount compte désormais des visiteurs distincts par jour, pas des
chargements de page, et c'est la même quantité que
COUNT(ListingEvent WHERE kind = 'VIEW'). Avant, l'incrément précédait
l'écriture de l'évènement et seul l'évènement était dédupliqué : garder F5
enfoncé laissait une ligne de timeline et N « vues », donc les deux nombres
que la page analytics imprime côte à côte décrivaient des choses
différentes et le gonflable était celui que le vendeur lit comme de la
demande.
Conséquence assumée : cette page lit headers(), donc elle ne
redeviendra pas cacheable le jour où app-shell/0361 libère le layout
racine. Un compteur de vues et une page en cache s'excluent.
10. Cache
| Surface | Cache |
|---|---|
listUnified() | Upstash Redis 5 min anonymement, viewer flags joints |
listLiveCountsByType() | aucun, un groupBy ; dédupliqué par requête via cache() |
/marketplace index | aucun — revalidate = 3600 est inerte (§2) |
/marketplace/[type] | aucun — revalidate = 300 est inerte (§2) |
/marketplace/[type]/[slug] | aucun, et ce ne changera pas : la page lit headers() (compteur de vues) |
/api/marketplace/stats | s-maxage=300, swr=3600 |
/api/marketplace/recommendations | s-maxage=600, swr=3600 + 30/min RL |
/api/marketplace/sponsor-state | s-maxage=300, swr=3600 |
11. Composants partagés
src/components/shared/marketplace/
— 27 composants (ls *.tsx, hors tests) : ListingCard, ListingRail,
BuyListingButton, ContactListingButton, MakeOfferButton, SaveListingButton,
WriteReviewButton, FollowButton, StoreMetricsHero, TrustStrip,
InstallSnippet, ScreenshotsGallery, AdminNotesPanel, SellStoreWizard,
RegistryFields, SignaturesPanel, RaiseDisputeDialog, KycBanner, …
Cette ligne annonçait « 19 composants » et en nommait un, ListingFilters,
qui n'existe pas — aucun fichier, aucun import. C'est de là que venait la
mention de « filtres » sur la page liste en §2 : la doc citait un composant
fantôme, puis décrivait la fonctionnalité qu'il aurait portée.
TrustStrip mérite une note : les logos qu'il affiche sous « Verified by »
sont une affirmation factuelle sur ce que la plateforme peut prouver, pas
un bandeau partenaires. La barre d'admission est écrite dans le fichier — un
vendeur doit pouvoir connecter la source dans revenue-connect ET
VerifiedRevenue.source doit l'accepter, ce qui laisse Shopify et Stripe.
Stack conforme : shadcn/ui, lucide-react, sonner, next-intl. Anti-patterns interdits : framer-motion, react-hot-toast, modal custom.
12. Tests
Il n'y a pas de répertoire tests/ dans ce repo : les tests vivent à côté
du code qu'ils couvrent, en *.test.ts. Cette section a cité trois chemins
inexistants jusqu'en septembre 2026.
- Gardes de dérive (
src/test/) — les deux se complètent et aucun ne couvre l'autre :marketplace-type-routing.test.ts— le MAPPING : enum Prisma ↔ slug pluriel, aller-retour, et aucune copie locale dans les deux routes marketplace (marketplace/0247) ;marketplace-type-registry.test.ts— les CAPACITÉS :TYPE_CAPABILITIEScouvre l'enum, les listes dérivées le sont vraiment, le registre de soumission concorde, aucun appelant ne garde de copie, et le tableau de la §1 de cette page dit ce que le code dit (marketplace/0645).
- Services :
src/services/marketplace/(counts,email-collision,events,legal-signatures,my-desk,offer-buyer,seller-accept,listing-json-ld,sellers,sync-from-content,unified-fallback) - Liens payants et écrits par un membre :
src/test/paid-and-member-links-are-qualified.test.ts(AD_SLOT_REL,listingVisitRel,markdownLinkProps,marketplace/3005) - Cron payouts :
src/app/api/cron/marketplace-payouts/(payout-net,queue-drain) - Webhooks disputes :
src/services/webhooks-disputes.test.ts - Funnel achat :
src/test/checkout-funnel.test.tsetsrc/test/offer-buyer-link.test.ts
13. Roadmap (haut niveau)
- ✅ V1 : DB schema + services + queue admin + emails + audit log
- ✅ V1.1 (cette PR) : indexes composites + cache + vendor dashboard skeleton + decision emails + SEO Product/AggregateRating + idempotence checkout + dedup offers/leads
- ✅ V2 : CRUD vendor complet (forms react-hook-form + zod)
- ✅ V3 : Stripe Connect (payout vendeur, versé en escrow par cron)
- ✅ V4 : disputes + refund automation +
ListingEvent - ✅ V5.1 : KYC pipeline + scaffold e-signature NDA/LOI/APA.
POST /api/marketplace/kyc/startn'accepte queSTRIPE_IDENTITY: c'est le seul provider branché. PERSONA / ONFIDO existent dans l'enum mais n'ont aucun client, etstartVerificationles refuse (kyc-provider-not-wired) au lieu de créer une ligne IN_REVIEW que personne ne peut faire avancer ; MANUAL est une décision admin, pas une demande utilisateur. Un second start sur une vérification déjà ouverte renvoie de nouveau une URL hébergée (retrieve, puis nouvelle session si le lien de 48 h est périmé) au lieu denull. Le verdict provider n'arrive toujours que par un pull admin : le webhookidentity.verification_session.*est un itembilling. Depuismarketplace/0441ce pipeline garde une porte : l'APA signé par l'acheteur au-dessus de $10 000 (§3, « La seule condition KYC du pipeline »), et depuismarketplace/0603la sortie deDILIGENCEelle-même, que l'APA ait été signé par l'acheteur ou par le vendeur. Il a été installé et n'a rien conditionné pendant toute la V5.1. - ✅ V5.2 (
marketplace/0645) : une seule source de vérité pour les types et leurs capacités (TYPE_CAPABILITIES), surface publique explicite (§1.1), CLI retiré pour de bon, catégories vides hors index, pages catégorie traduites dans les six locales, et le tableau de la §1 vérifié par un test. - 🚧 Reste : Meilisearch (le tsvector suffit aujourd'hui), embeddings sémantiques pour les recos, DocuSign/HelloSign live, mobile. Pas de filtres sur les pages catégorie : le besoin est servi par le Hub de la home, et c'est un choix, pas un oubli (cf. §2 et §11).
Voir docs/architecture/marketplace-roadmap.md
pour le détail.