Levée de fonds communautaire (company_internal_fundraising)¶
2022 (company_customer_investment, kind WT company_investment, GET investor/community-fundraising) est legacy : zéro lecture/écriture nouvelle.
Modèle¶
- Campagne (
company_internal_fundraising_campaign) : document JSON + colonnes_view. Unique partiel : une seule campagne non clôturée (endedAt_view IS NULL). Elle porte une FAQ (questions/réponses en français sur la page de vente ; vide = le bloc n’apparaît pas),presentationVideoUrl(URL obligatoire du play « Découvrir la vision », pas de_view) etbanking({ beneficiary, iban, bic, bankName, address }obligatoire, IBAN viaiban_zod/ibanValidator, BIC viabic_zod/bicValidator, pas de_view). - Intention d’investissement (
company_internal_fundraising_investment_intention) : JSON +_view(même pattern que l’intention déclarative).statusjsonb discriminé dans le document (waiting|confirmed|canceled). Achat wallet : l’invest insère{ status: 'confirmed', confirmedAt }. PEA-PME et big-wire : l’invest insère{ status: 'waiting', waitingAt }; le BO passeraconfirmeden même temps quereceivedAt. Une rétractation (investisseur ≤ J+4, ou admin) passe{ status: 'canceled', canceledBy: 'investor' | 'bricks', canceledAt }. XOR : wallet (walletTransactionId) oupeaPmeoubigWire. Achat wallet :walletTransactionId+walletTransactionId_viewUNIQUE FK vers le WT. Achat PEA-PME : pas de WT,peaPme: { bankTransferReference, receivedAt? }(BRICKS-PEA-+ 6 chars sans 0/O/1/I/L),walletTransactionId_viewNULL, index unique JSONpeaPme.bankTransferReference, une seule intention PEA-PMEwaitingouconfirmedpar investisseur particulier et par campagne (409pea-pme-already-exists). Big-wire : pas de WT,bigWire: { bankTransferReference, receivedAt? }(BRICKS-VIR-+ 6 chars ;receivedAtabsent à la création, posé plus tard au BO à la réception des fonds, en même temps queconfirmed), index unique JSONbigWire.bankTransferReference(retry insert sur 23505), plusieurs par investisseur et campagne. Les intentions wallet du même investisseur restent possibles. Pas de cap max (la banque du PEA contrôle). Annulation admin, rapprochement virement (receivedAt) et email RIB : PR BO.onBehalfOfCompanyest optionnel dans le JSON (absent = investissement perso) et n’est jamais posé sur un PEA-PME ni un big-wire. Compteindividualuniquement : un comptebusinessn’a pas cet objet. S’il est présent, tous ses champs sont requis. Le n° d’immatriculation est stocké sans espaces. Persisté au confirm uniquement. Le bulletin litonBehalfOfCompanydepuis le payload d’achat, pas depuis l’intention persistée. Pas de colonne_view. Le validateur Zod du JSON sert aussi à la lecture (refund, liste) : retirer un code pays ou baisser un max rend les intentions déjà écrites illisibles. - Config actionnariat (
company_internal_fundraising_shareholding_config) : singleton JSON-only (pas de colonnes_view). Unique index((true)).sharePrice2021InCents/sharePrice2022InCents/sharePrice2026InCents/capitalGainPercentage/updatedAt. Seed Flyway = 0,10 € / 1 € / 2,55 € / 155 %. Source de vérité du GET investisseur et du formulaire BO. - Intention déclarative (
company_internal_fundraising_declarative_intention) : JSON +_view. Souhait d’investir avantstartedAt, sans solde ni WT. Unique actif(campaignId, investorId)(deletedAtnull). Soft delete (deletedAt) = tombstone ; un PUT ultérieur insère une nouvelle ligne, jamais de revival (deletedAt = nullsur l’ancienne). Montant : multiple de 10 €, min 10 €, maxbusinessRules.companyInternalFundraising.maxDeclarativeIntentionAmount(1 000 000 €).assertTransactionRights(['all'])sans gate identité : freeze / Lemonwaystatus > 6/blockedTemporarilyAtLemonwayrefusés. Pas de gate KYC ni onboarding form. - Montant levé confirmé (
getConfirmedTotalsByCampaignId,sumConfirmedByCampaignIds, GET current / poll / liste admin / clôture) =SUMdes intentionsconfirmed(wallet + PEA-PME + big-wire).waitinghors montant.uniqueInvestorCount=COUNT DISTINCT investorIddes mêmesconfirmed. Le pollraised-amountn’exécute que l’agrégat du cycle en cours (voir API). Liste admin : même SUMconfirmedsi non clôturée, sinonended.raisedAmount(totalconfirmedfigé à la clôture, avant refund vers le legal max).ended.uniqueInvestorCount= investisseurs distincts des mêmesconfirmed.legalMaxn’est pas un cap d’investissement.endAtTheoricalest visuel et ne bloque pas l’invest. À l’écriture, il doit être strictement aprèsstartedAt(400started-at-after-end-at-theorical).documents,presentationVideoUrletbankingsont à la racine du JSON campagne.documents:{ name, url, tag?, description? }. Ordre du tableau = ordre d’affichage front (le BO réordonne).tagoptionnel (string). Les slots connus vivent danscompanyInternalFundraisingCampaignDocumentTagEnum. Un tag hors enum reste lisible (GET investisseur / JSON).descriptionoptionnel, max 200. Un seul document par tag et par campagne (400duplicate-document-tagà l’upsert). Les documents sans tag restent valides.
Campagne ouverte ⇔ endedAt_view IS NULL et startedAt_view <= now. isOpen HTTP = cette ouverture officielle — jamais true pendant l’avant-première. JSON earlyAccessBeforeStartInHours (PositiveInteger, min 1, défaut 4 à la création / backfill Flyway V202609231138). Writable admin jusqu’à now >= startedAt − Nh ; ensuite gelé (409 early-access-started, même valeur PUT ok). GET investisseur expose toujours earlyAccess: { startedAt: startedAt − Nh, hasAccess, hasStarted } (hasAccess = intention déclarative active, hasStarted ⇔ now >= earlyAccess.startedAt, reste true après l’ouverture officielle et la clôture).
Pendant la levée, newInvestmentPausedAt (null = ouvert, date future = encore ouvert, date ≤ now = pause) bloque ou rouvre les nouveaux achats sans clôturer. Le DTO HTTP dérive isNewInvestmentAllowed (pausedAt === null || pausedAt > now). Les achats déjà commencés avant la pause vont au bout (pas de FOR UPDATE campagne à l’achat). PATCH admin …/campaigns/:id/new-investment-allowed : body { newInvestmentPausedAt }. null efface la pause. Une date strictement future la programme. Une date ≤ now pause tout de suite (l’instant stocké est l’horloge serveur). Poser une date aligne endAtTheorical sur l’instant stocké. Effacer une pause définie (pausedAt → null) remet endAtTheorical à now + 2 semaines. Dans les deux cas endAtTheorical n’est jamais posé avant startedAt. PATCH null si déjà null ne touche pas endAtTheorical, sauf clamp si la date actuelle est avant startedAt. 409 campaign-closed si déjà clôturée. POST …/campaigns/:id/close exige une campagne ouverte (campaign-not-open si planifiée), une pause déjà active (new-investment-still-allowed si null ou date future), puis l’absence d’intention wallet instable (purchases-not-stable) : intention canceled ↔ WT canceled ; intention confirmed ↔ WT waiting (P2P après clôture) ; refundableUntil présent et fenêtre close (refundable-until-missing / refund-window-open). Les PEA et les big-wires n’ont pas de WT : ils n’empêchent pas la clôture (traités à part, rapprochement en PR BO). Le refund admin prend le même FOR UPDATE campagne avant d’annuler (pas de course avec la clôture). Cette fenêtre (4 j) est le cooldown naturel wallet. La clôture fige ended: { closedAt, raisedAmount, uniqueInvestorCount } (SUM et COUNT DISTINCT investorId des intentions confirmed ; waiting exclus).
startedAt (date + heure) n’est plus modifiable si l’instant est passé, l’avant-première a commencé et il existe au moins un investissement waiting ou confirmed (409 started-at-locked ; même timestamp PUT ok). Un changement d’heure le même jour UTC reste possible tant que ces trois conditions ne sont pas réunies, y compris pendant l’avant-première. Une campagne clôturée (ended !== null) n’est plus éditable via PUT (409 campaign-closed). Le contenu de page et la FAQ restent modifiables après clôture. Les autres champs d’une campagne non clôturée restent éditables. Pas de statut draft / published.
Kind WT¶
company_internal_fundraise_purchase (debit, groupe companyInternalFundraise). Le résumé wallet BO compte les WT confirmed, plus les waiting uniquement pour ce kind (P2P à la clôture). Les autres kinds restent confirmed-only. Contexte { type, campaignId, investmentIntentionId, refundableUntil, refund? }. refundableUntil = achat + businessRules.companyInternalFundraising.retractDelayDays (4 jours), fin de journée Europe/Paris, stocké UTC. Pas de P2P à l’achat : WT WAITING sans lemonwayP2P. Le débit pending baisse le solde withdrawable. Le kind 2022 company_investment reste dans le groupe fundraise.
Une rétractation wallet est possible tant que la campagne n’est pas clôturée (P2P d’achat posés à la clôture). Investisseur : dans les 4 jours (refundableUntil). Admin : sans cette limite. Elle annule la WT d’achat (CANCELED + context.refund.at immédiat) et pose une WT company_internal_fundraise_refund CONFIRMED (lemonwayTransactionId: -1), sans P2P inverse. Ce kind de refund est une ligne de crédit du GET wallet, à part de l’achat canceled (qui garde refund nested). Il reste exclu de l’export XLSX. Rétractation PEA-PME → 409 pea-pme-refund-not-supported. Rétractation big-wire → 409 bank-transfer-refund-not-supported. Annulation waiting : plus tard.
Les achats de #6154 n’avaient pas refundableUntil. Aucune levée en prod : le backfill Flyway V202609142146 (depuis createdAt) ne sert qu’aux environnements de développement. Pause : V202609211400 pose newInvestmentPausedAt (null si ended JSON null).
API¶
Admin (AdminAuthGuard) : liste / create / update / pause (newInvestmentPausedAt) / close / devResetIntentions / aperçu bulletin / config actionnariat. Pas de liste BO des intentions big-wire dans cette PR.
GET|PUT /administration/company-internal-fundraising/shareholding-config— prix d’action 2021/2022/2026, plus-value,titlesAttributedetholdingDocumentIssued(YYYY-MM-DDounull). Descriptorsget|updateAdminShareholdingConfigEndpoint. PUT journaliséCOMPANY_INTERNAL_FUNDRAISING_SHAREHOLDING_CONFIG_UPDATE.POST …/campaigns/:id/dev/reset-intentions—ENVIRONMENTlocaloudevelopmentseulement (403dev-only-endpointsinon). 409campaign-closedsiendedest posé. 409dev-reset-requires-pausesinewInvestmentPausedAtest null ou futur. La campagne et ses PDF restent. Supprime les intentions déclaratives (y compris soft-deleted), les intentions d’investissement verrouillées au début du reset, les WTcompany_internal_fundraise_purchaseet_refundqui s’y rattachent, puis recalcule les soldes. La pause bloque tout achat qui lit la campagne après elle. Seul un achat déjà passéguardNewInvestmentAllowedavant le commit de la pause peut encore s’insérer : le reset ne supprime que les ids verrouillés, pour ne pas effacer cette intention sans sa WT. Un refund attend leFOR UPDATEde l’intention (l’admin attend aussi le lock campagne) : il finit avant le reset, ou il trouve la ligne déjà supprimée.startedAtredevient modifiable (hasActiveByCampaignIdredevient faux) : le gel exige aussi au moins un investissementwaitingouconfirmed. Aucun mouvement Lemonway n’est annulé.
Le DTO campagne (liste / create / update / close) expose presentationVideoUrl, banking, newInvestmentPausedAt, ended (null ou { closedAt, raisedAmount, uniqueInvestorCount }), closeableAt (null si la campagne n'est pas ouverte, ou si les nouveaux investissements sont encore autorisés ; si pause d'une campagne ouverte : MAX(refundableUntil) des intentions confirmed, sinon newInvestmentPausedAt) et earlyAccessBeforeStartInHours (champ create/edit BO, gelé à l’UI dès now >= startedAt − Nh). Interdit d’écrire ended via PUT. PUT startedAt → 409 started-at-locked si gelé. PUT earlyAccessBeforeStartInHours → 409 early-access-started si la fenêtre a commencé (même valeur ok). PUT campagne clôturée → 409 campaign-closed. PATCH …/campaigns/:id/new-investment-allowed → { isNewInvestmentAllowed } (409 campaign-closed). POST …/campaigns/:id/close → 409 campaign-not-open / new-investment-still-allowed / purchases-not-stable. PATCH …/campaigns/:id/dynamic-html met à jour dynamicHtml (y compris campagne clôturée) ; le PUT ne transporte pas ce champ. PATCH …/campaigns/:id/banking met à jour banking (y compris campagne clôturée) ; le PUT ne transporte pas ce champ ; le body IBAN/BIC n’est pas persisté dans admin_action. Documents PDF : POST /administration/files/documents (purpose: company-internal-fundraising) → {env}/documents/company-internal-fundraising/ sur le bucket public, puis { name, url, tag? } persistés dans le JSON campagne. Le tag se pose à la création ou sur un document déjà listé. Delete S3 : DELETE /administration/company-internal-fundraising/documents (purpose figé côté serveur).
Investisseur (JWT) :
GET /customers/me:isInvestorCommunityFundraising= colonne 2022 ou intentionwaiting/confirmed2026 (nav Actionnariat, y compris PEA et big-wire déclarés). Transitoire jusqu’aucompany_share_ledger. La colonne DB reste 2022-only.GET …/shareholding— page actionnariat. ToujourssharePricesetcapitalGainPercentage.fundraising2026regroupetitlesAttributedetholdingDocumentIssued(YYYY-MM-DDounull) etclosedAtoptionnel (ended.closedAtde la campagne dontstartedAtest en 2026, Europe/Paris, une par an, y compris sans position 2026).fundraising2022optionnel :getCompanyInvestmentAmount+getFirstCompanyInvestmentDate(premiercompany_investmentconfirmed) ;sharesCount= montant /sharePriceInCents(10 €).individualFundraising2026optionnel (absent si montant 0) : campagne 2026, compte normal. Walletconfirmed. Big-wire et PEAconfirmedouwaiting: les deux entrent dansvalue,sharesCount,platformAmountetpeaPmeAmount.waitingAmountest la part déjà comprise dansvalue(big-wire et PEAwaitingdu particulier).onBehalfOfCompanyFundraising2026optionnel (absent si aucune société) : groupé par (registrationCountry,registrationNumber),amountinclut le big-wirewaiting,waitingAmountest cette part, 3 dernières WT.recentWalletTransactions= les 3 dernières du particulier, hors sociétés. DescriptorgetShareholdingEndpoint.GET …/campaigns/current— lecture ouverte, pas de gate droits.findCurrentOrLast: campagne non clôturée si elle existe, sinon la dernière clôturée.isOpen= commencée etended === null.isClosed=ended !== null.ended= null ou{ closedAt, raisedAmount, uniqueInvestorCount }.isNewInvestmentAllowed=newInvestmentPausedAt === null || pausedAt > now.newInvestmentPausedAt= date de pause (null si jamais pausée).legalMaxBO only. Pas d’objeteligibility. ExposepresentationVideoUrl,raisedAmount(intentionsconfirmedlive, ouended.raisedAmountsi clôturée — même SUM) +uniqueInvestorCount(mêmesconfirmedlive, ouended.*si clôturée),myInvestedAmount(totalwaiting+confirmed: wallet + PEA + big-wire),banking(RIB campagne, toujours présent, indépendant depeaPme, required y comprisbic/bankName/address),peaPme({ id?, status, amount, bankTransferReference }⇔ intention PEA-PMEwaitingouconfirmedde cet investisseur sur cette campagne, sinon absent ;id= l’intention (optionnel côté validator client, Maestro) ;status= l’objet JSON ({ status: 'waiting', waitingAt }|{ status: 'confirmed', confirmedAt })),bigWireIntentions(intentionswaiting+confirmedbigWirede cet investisseur sur cette campagne,[]si aucune ; iteminvestorCompanyInternalFundraisingBigWireIntention:id,amount,bankTransferReference,status(même objet quepeaPme.status),createdAt— pas d’investorId; triiddesc),myDeclarativeIntentionAmount(null si aucune),declarativeIntentionsAmount,declarativeIntentionInvestorCount,earlyAccess: { startedAt, hasAccess, hasStarted }(hasAccess⇔ intention déclarative active,hasStarted⇔now >= earlyAccess.startedAt, pas de borne haute).PUT|DELETE …/campaigns/:campaignId/declarative-intention—assertTransactionRights(['all'], { requireIdentityVerified: false })(403 freeze / Lemonwayaccount-is-being-verified, pas de 403 KYCaccount-not-completed). Écritures seulement si la campagne estscheduled(409campaign-not-scheduledsi ouverte ou clôturée). Gel dèsnow >= earlyAccess.startedAt(409early-access-started). Pas de lock campagne (même raison que le purchase : une row campagne sérialiserait tout le trafic). Lock uniquement la ligne d’intention active de l’investisseur. PUT upsert sur l’active ; si aucune active (jamais créée ou soft-deleted) → insert d’une nouvelle row, pas de revival. DELETE 404declarative-intention-not-foundsi aucune active. Descriptorsupsert|deleteCompanyInternalFundraisingDeclarativeIntentionEndpoint.POST …/big-wire-intentions— même parcours quePOST …/purchase/pea-pme(campaignId+amount≥ 100 k€, onboarding, identité, perso only,guardCanPurchase) : intentionwaitingbigWiresans WT (receivedAt+confirmedplus tard au BO). GénèrebankTransferReference(BRICKS-VIR-+ 6 chars). Plusieurs POST du même investisseur = plusieurs intentions. Comptebusiness→ 409bank-transfer-requires-individual. Montant < 100 k€ → 400bank-transfer-amount-too-low.onBehalfOfCompany→ 400bank-transfer-personal-only. Rétractation → 409bank-transfer-refund-not-supported. DescriptorcreateCompanyInternalFundraisingBigWireIntentionEndpoint. InputcreateCompanyInternalFundraisingBigWireIntentionPayload, outputcompanyInternalFundraisingBigWireIntention.GET …/campaigns/current/raised-amount— poll barre de progression (front 3 s enopenouearlyAccess.hasStarted, 10 s enscheduledhors seuil). Toujours{ lifecycleStatus, raisedAmount, uniqueInvestorCount, declarativeIntentionsAmount, declarativeIntentionInvestorCount }(lifecycleStatus: 'closed'et 0 si aucune campagne). Un seul agrégat live selon le cycle :scheduledhors fenêtre → SUM/COUNT déclaratives actives (confirmed = 0) ;scheduledpendant l’avant-première ouopen→ SUM +COUNT DISTINCT investorIddesconfirmed(waitingexclus, déclaratif = 0,lifecycleStatusrestescheduledpendant l’avant-première) ;closed→ended.*sans SUM (déclaratif = 0,ended.raisedAmount=confirmedfigé à la clôture). Lookup campagne :id_view/startedAt_view/endedAt_view/earlyAccessBeforeStartInHours+ extraitsended.*(pas le JSON complet,raisedAmountenbigint). Rediscompany-internal-fundraising:current-progressTTL 2 s (pas d’invalidation à l’écriture : un pic d’achats viderait le cache en continu). Index covering(campaignId_view, status_view) INCLUDE (amount_view, investorId_view). Auth :InvestorSessionOnlyGuard. Le poll est global (pas filtré parhasAccess). Le front patche selonlifecycleStatusdu poll (pas leisOpendu cache GET current) : pendant l’avant-première il appliqueraisedAmount/uniqueInvestorCountsans passerisOpenà true.POST …/purchase/quote— devis wallet uniquement (campaignId+amount+useBricksTotalBalance). Le front PEA-PME n’appelle pas le quote.guardCanPurchase+ lecture des soldes, aucune intention, aucun WT, aucunlockCustomer.useBricksTotalBalance: true→ remainderamount − cadeau − withdrawable.false→ remainder =amount(aucune poche). Remainder > 0 et <businessRules.moneyIn.card.minAmountInCents(1 €) → 400amount-to-money-in-too-low. Pas de plafond sur le remainder (Checkout). Sinon{ amountToMoneyIn, availableBalanceBeforeTopup }(200). DescriptorquoteCompanyInternalFundraisingPurchaseEndpoint.POST …/purchase/draft-bulletin— aperçu pendant l’achat. Body du confirm wallet +isPeaPme?. Mêmes gates d’achat (guardCanPurchase, onboarding, droits).isPeaPme: truereprend les refus dePOST …/purchase/pea-pme(perso uniquement, ≥ 100 k€, 409pea-pme-already-exists). Génère le PDF à la volée (watermarkPROJETsur toutes les pages), sans intention ni WT. Pied de pagen / totalsur chaque page. À partir de la page 2, en-tête « COMMUNAUTÉ BRICKS 2 · BULLETIN DE SOUSCRIPTION ». L’en-tête du document est fixe (« Bulletin de souscription », « Communauté Bricks 2 ») : il ne reprend pas le nom de campagne. La période de souscription imprimée est fixe (« Du 1er octobre 2026 au 30 septembre 2027 ») : elle ne suit passtartedAt/endAtTheorical, un PEA-PME pouvant arriver hors fenêtre web. Partie souscripteur : personne physique (nom et domicile) ou société (dénomination et siège).onBehalfOfCompanyutilise le même bloc société qu’un comptebusiness. Un bulletin PEA litcampaign.banking(beneficiary,bankName,iban,bic) et ne promet pas la rétractation de 4 jours. Un bulletin wallet (hors PEA) n’imprime pas le RIB et ajoute « sous réserve de mon droit de rétractation de quatre jours » — ce délai n’existe que pour un achat plateforme via wallet. Un comptebusinessimprimeAdresse : N/Aà la place de l’adresse du profil. DescriptorgenerateCompanyInternalFundraisingDraftBulletinEndpoint. Réponse binaireapplication/pdf(attachment).POST …/purchase/draft-bulletin/url— même body, mêmes gates, même PDF. Le PDF est stocké dans Redis 5 min sous un jeton aléatoire (32 octets base64url). Réponse JSON{ url }:BACK_URL+GET …/purchase/draft-bulletin/:token. DescriptorgenerateCompanyInternalFundraisingDraftBulletinUrlEndpoint. LePOST …/purchase/draft-bulletinbinaire reste servi pour les apps déjà publiées.GET …/purchase/draft-bulletin/:token— sans auth : ouvert par l’in-app browser (natif) ou un nouvel onglet (web), qui n’ont pas la session. Le jeton est le seul secret. Réutilisable jusqu’à expiration (rechargement, téléchargement depuis le viewer). Sert le PDF enContent-Disposition: inline. Jeton inconnu ou expiré → 404draft-bulletin-not-found. Jeton malformé → 400validation-params.GET …/campaigns/:campaignId/intentions/:investmentIntentionId/signed-bulletin— bulletin définitif à la volée, PEA-PME et big-wire seulement. JWT + onboarding + droits. PathcampaignId+investmentIntentionId. Génère le PDF depuis cette intention (montant,onBehalfOfCompany,createdAt). Wallet (walletTransactionId) → 409signed-bulletin-wallet-not-supported(autre parcours). Pas de watermarkPROJET. Signature : « Fait par acte électronique. » / « Le DD-MM-YYYY HH:mm (Europe/Paris). » (toujoursintention.createdAt, pas de lieu) puis Allura prénom + nom de la personne physique. PEA-PME : modePEA-PME, RIBcampaign.banking, pas de rétractation 4 jours. Big-wire : modeHors PEA-PME, même RIB, pas de rétractation 4 jours (la rétractation 4 jours n’existe que pour un achat wallet). Intention inconnue, d’un autre investisseur, d’une autre campagne, oucanceled→ 404investment-intention-not-found(pas de leak). Campagne inconnue → 404campaign-not-found. DescriptorgetCompanyInternalFundraisingSignedBulletinEndpoint. Réponse binaireapplication/pdf(attachment). LeGETbinaire reste servi pour un téléchargement authentifié.POST …/campaigns/:campaignId/intentions/:investmentIntentionId/signed-bulletin/url— mêmes gates, même PDF. Le PDF est stocké dans Redis 5 min sous un jeton aléatoire (32 octets base64url, préfixecompany-internal-fundraising:signed-bulletin). Réponse JSON{ url }:BACK_URL+GET …/signed-bulletin/:token. DescriptorgenerateCompanyInternalFundraisingSignedBulletinUrlEndpoint.GET …/signed-bulletin/:token— sans auth : ouvert par l’in-app browser (natif) ou un nouvel onglet (web), qui n’ont pas la session. Le jeton est le seul secret. Réutilisable jusqu’à expiration (rechargement, téléchargement depuis le viewer). Sert le PDF enContent-Disposition: inline. Jeton inconnu ou expiré → 404signed-bulletin-not-found. Jeton malformé → 400validation-params. DescriptorgetCompanyInternalFundraisingSignedBulletinByTokenEndpoint.POST /administration/company-internal-fundraising/campaigns/:id/bulletin-preview— même rendu, sansguardCanPurchase(une campagne planifiée ou clôturée reste prévisualisable), sans intention ni WT. Body : montant (multiple de 10 €),settlement(wallet|pea-pme|big-wire),signed,subscriber(individual|business|on-behalf-of-company).signed: false→ watermarkPROJET.signed: true→ pas de watermark, signature électronique (Allura) au prénom + nom de la personne physique (legalRepresentativesion-behalf-of-company), date = maintenant, imprimée « Le DD-MM-YYYY HH:mm (Europe/Paris). ». Signé sans prénom/nom → 400signed-bulletin-requires-name.pea-pme: RIBcampaign.banking, pas de rétractation 4 jours.big-wire: Hors PEA-PME, même RIB, pas de rétractation.wallet: rétractation 4 jours, pas de RIB. PEA/big-wire + souscripteur non particulier, ou montant < 100 k€ → 400validation-body. Campagne inconnue → 404campaign-not-found. DescriptorpreviewAdminCompanyInternalFundraisingBulletinEndpoint. Réponse binaireapplication/pdf.POST …/purchase/confirm— body du quote +onBehalfOfCompany?. Wallet uniquement : intentionconfirmed+ WTcompany_internal_fundraise_purchaseWAITING.onBehalfOfCompanyoptionnel (compteindividualinvestit pour une société). Si présent, tous les champs internes sont requis (pays d’immatriculation : code ISO 3166-1 alpha-2 de la liste fermée, stocké en minuscules, raison sociale, n° d’immatriculation, adresse du siège,legalRepresentative(firstName,lastName), certificationtrue). Un code inconnu (zz) → 400validation-body. Pays stocké en minuscules, n° d’immatriculation sans espaces. Campagne non achetable → mêmes 409 que le quote, avant le gate société. Puis un comptebusinessqui envoie l’objet → 409on-behalf-of-company-requires-individual. Profil absent → 500 (customer-profile-not-found).useBricksTotalBalance: true→ cadeau au max puis withdrawable.false→ withdrawable seul (cadeau intact). Solde insuffisant → 409not-enough-balance. Mêmes asserts onboarding / droits /guardCanPurchaseque le quote. DescriptorconfirmCompanyInternalFundraisingPurchaseEndpoint.POST …/purchase/pea-pme— achat PEA-PME dédié (campaignId+amount). Perso uniquement, pas deonBehalfOfCompany, pas deuseBricksTotalBalance, pas de quote, pas de débit wallet, pas de Checkout, pas de WT.onBehalfOfCompanyenvoyé → 400validation-body(pea-pme-personal-onlysuronBehalfOfCompany). Comptebusiness→ 409pea-pme-requires-individual. Profil absent → 500 (customer-profile-not-found). Montant ≥businessRules.companyInternalFundraising.minPeaPmeAmount(Cents(100_000_00)) sinon 400validation-body(pea-pme-amount-too-lowsuramount). Pas de max. JSONpeaPme.bankTransferReference=BRICKS-PEA-XXXXXX(nanoid, alphabet23456789ABCDEFGHJKLMNPQRSTUVWXYZ) ; la réponse exigepeaPmeBankTransferReference. Intentionwaiting(waitingAt) ;confirmed+ réception des fonds plus tard au BO. Un 2e PEA-PME du même investisseur sur la même campagne → 409pea-pme-already-exists(un wallet + un PEA restent possibles ; un PEA sur une autre campagne aussi). Rétractation PEA-PME → 409pea-pme-refund-not-supported(PR BO). Mêmes asserts onboarding / droits /guardCanPurchaseque le quote. DescriptorconfirmCompanyInternalFundraisingPeaPmePurchaseEndpoint.- Le mobile envoie le même
useBricksTotalBalance(toggle « solde Bricks ») au quote et au confirm. Après Checkout, le front confirme (pas un second quote).payment_status=successUI ne déclenche pas l’achat. - Les routes purchase (quote, confirm wallet, pea-pme) : multiple de 10 €. Pendant l’avant-première (
now >= startedAt − Nhetnow < startedAt) : intention déclarative active requise, sinon 409campaign-not-open. AprèsstartedAt: tout investisseur onboardé. 409campaign-not-opensi planifiée hors fenêtre / clôturée, 409new-investment-not-allowedsi pause. POST …/refund—investmentIntentionIddans le body. Rétractation investisseur, fenêtre 4 jours (même durée que l’achat primaire), campagne non clôturée. Pas d’intention déclarative exigée. Pas de lock campagne (fenêtre ouverte ⇔ close bloquée). Ordre : intentionFOR UPDATE, puis customer, puis WT. Mêmes asserts que le purchase (assertCompletedInvestorOnboarding+assertTransactionRights(['all'])). DescriptorcancelCompanyInternalFundraisingPurchaseEndpoint.POST /administration/company-internal-fundraising/refund— même mécanique,canceledBy: 'bricks', sans fenêtre 4 jours, campagne non clôturée. Lock campagneFOR UPDATEd’abord (même ordre que close : course possible après J+4), puis intention, customer, WT. DescriptorrefundAdminCompanyInternalFundraisingPurchaseEndpoint.
GET /wallet-transactions a une query dédiée pour company_internal_fundraise_purchase (comme l’achat primaire) : expose refundableUntil et refund depuis le contexte WT. Le kind refund est exclu.
Hors scope¶
Surplus / canceledBy: 'bricks' de masse · P2P Lemonway à l’achat (clôture) · import 2022 · i18n des codes refund · lecture / export BO des intentions déclaratives · conversion intention déclarative → purchase.