Aller au contenu

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) et banking ({ beneficiary, iban, bic, bankName, address } obligatoire, IBAN via iban_zod / ibanValidator, BIC via bic_zod / bicValidator, pas de _view).
  • Intention d’investissement (company_internal_fundraising_investment_intention) : JSON + _view (même pattern que l’intention déclarative). status jsonb 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 passera confirmed en même temps que receivedAt. Une rétractation (investisseur ≤ J+4, ou admin) passe { status: 'canceled', canceledBy: 'investor' | 'bricks', canceledAt }. XOR : wallet (walletTransactionId) ou peaPme ou bigWire. Achat wallet : walletTransactionId + walletTransactionId_view UNIQUE FK vers le WT. Achat PEA-PME : pas de WT, peaPme: { bankTransferReference, receivedAt? } (BRICKS-PEA- + 6 chars sans 0/O/1/I/L), walletTransactionId_view NULL, index unique JSON peaPme.bankTransferReference, une seule intention PEA-PME waiting ou confirmed par investisseur particulier et par campagne (409 pea-pme-already-exists). Big-wire : pas de WT, bigWire: { bankTransferReference, receivedAt? } (BRICKS-VIR- + 6 chars ; receivedAt absent à la création, posé plus tard au BO à la réception des fonds, en même temps que confirmed), index unique JSON bigWire.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. onBehalfOfCompany est optionnel dans le JSON (absent = investissement perso) et n’est jamais posé sur un PEA-PME ni un big-wire. Compte individual uniquement : un compte business n’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 lit onBehalfOfCompany depuis 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 avant startedAt, sans solde ni WT. Unique actif (campaignId, investorId) (deletedAt null). Soft delete (deletedAt) = tombstone ; un PUT ultérieur insère une nouvelle ligne, jamais de revival (deletedAt = null sur l’ancienne). Montant : multiple de 10 €, min 10 €, max businessRules.companyInternalFundraising.maxDeclarativeIntentionAmount (1 000 000 €). assertTransactionRights(['all']) sans gate identité : freeze / Lemonway status > 6 / blockedTemporarilyAtLemonway refusés. Pas de gate KYC ni onboarding form.
  • Montant levé confirmé (getConfirmedTotalsByCampaignId, sumConfirmedByCampaignIds, GET current / poll / liste admin / clôture) = SUM des intentions confirmed (wallet + PEA-PME + big-wire). waiting hors montant. uniqueInvestorCount = COUNT DISTINCT investorId des mêmes confirmed. Le poll raised-amount n’exécute que l’agrégat du cycle en cours (voir API). Liste admin : même SUM confirmed si non clôturée, sinon ended.raisedAmount (total confirmed figé à la clôture, avant refund vers le legal max). ended.uniqueInvestorCount = investisseurs distincts des mêmes confirmed. legalMax n’est pas un cap d’investissement. endAtTheorical est visuel et ne bloque pas l’invest. À l’écriture, il doit être strictement après startedAt (400 started-at-after-end-at-theorical). documents, presentationVideoUrl et banking sont à la racine du JSON campagne. documents : { name, url, tag?, description? }. Ordre du tableau = ordre d’affichage front (le BO réordonne). tag optionnel (string). Les slots connus vivent dans companyInternalFundraisingCampaignDocumentTagEnum. Un tag hors enum reste lisible (GET investisseur / JSON). description optionnel, max 200. Un seul document par tag et par campagne (400 duplicate-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, titlesAttributed et holdingDocumentIssued (YYYY-MM-DD ou null). Descriptors get|updateAdminShareholdingConfigEndpoint. PUT journalisé COMPANY_INTERNAL_FUNDRAISING_SHAREHOLDING_CONFIG_UPDATE.
  • POST …/campaigns/:id/dev/reset-intentions — ENVIRONMENT local ou development seulement (403 dev-only-endpoint sinon). 409 campaign-closed si ended est posé. 409 dev-reset-requires-pause si newInvestmentPausedAt est 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 WT company_internal_fundraise_purchase et _refund qui 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é guardNewInvestmentAllowed avant 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 le FOR UPDATE de l’intention (l’admin attend aussi le lock campagne) : il finit avant le reset, ou il trouve la ligne déjà supprimée. startedAt redevient modifiable (hasActiveByCampaignId redevient faux) : le gel exige aussi au moins un investissement waiting ou confirmed. 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 intention waiting / confirmed 2026 (nav Actionnariat, y compris PEA et big-wire déclarés). Transitoire jusqu’au company_share_ledger. La colonne DB reste 2022-only.
  • GET …/shareholding — page actionnariat. Toujours sharePrices et capitalGainPercentage. fundraising2026 regroupe titlesAttributed et holdingDocumentIssued (YYYY-MM-DD ou null) et closedAt optionnel (ended.closedAt de la campagne dont startedAt est en 2026, Europe/Paris, une par an, y compris sans position 2026). fundraising2022 optionnel : getCompanyInvestmentAmount + getFirstCompanyInvestmentDate (premier company_investment confirmed) ; sharesCount = montant / sharePriceInCents (10 €). individualFundraising2026 optionnel (absent si montant 0) : campagne 2026, compte normal. Wallet confirmed. Big-wire et PEA confirmed ou waiting : les deux entrent dans value, sharesCount, platformAmount et peaPmeAmount. waitingAmount est la part déjà comprise dans value (big-wire et PEA waiting du particulier). onBehalfOfCompanyFundraising2026 optionnel (absent si aucune société) : groupé par (registrationCountry, registrationNumber), amount inclut le big-wire waiting, waitingAmount est cette part, 3 dernières WT. recentWalletTransactions = les 3 dernières du particulier, hors sociétés. Descriptor getShareholdingEndpoint.
  • 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 et ended === null. isClosed = ended !== null. ended = null ou { closedAt, raisedAmount, uniqueInvestorCount }. isNewInvestmentAllowed = newInvestmentPausedAt === null || pausedAt > now. newInvestmentPausedAt = date de pause (null si jamais pausée). legalMax BO only. Pas d’objet eligibility. Expose presentationVideoUrl, raisedAmount (intentions confirmed live, ou ended.raisedAmount si clôturée — même SUM) + uniqueInvestorCount (mêmes confirmed live, ou ended.* si clôturée), myInvestedAmount (total waiting + confirmed : wallet + PEA + big-wire), banking (RIB campagne, toujours présent, indépendant de peaPme, required y compris bic / bankName / address), peaPme ({ id?, status, amount, bankTransferReference } ⇔ intention PEA-PME waiting ou confirmed de 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 (intentions waiting + confirmed bigWire de cet investisseur sur cette campagne, [] si aucune ; item investorCompanyInternalFundraisingBigWireIntention : id, amount, bankTransferReference, status (même objet que peaPme.status), createdAt — pas d’investorId ; tri id desc), 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 / Lemonway account-is-being-verified, pas de 403 KYC account-not-completed). Écritures seulement si la campagne est scheduled (409 campaign-not-scheduled si ouverte ou clôturée). Gel dès now >= earlyAccess.startedAt (409 early-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 404 declarative-intention-not-found si aucune active. Descriptors upsert|deleteCompanyInternalFundraisingDeclarativeIntentionEndpoint.
  • POST …/big-wire-intentions — même parcours que POST …/purchase/pea-pme (campaignId + amount ≥ 100 k€, onboarding, identité, perso only, guardCanPurchase) : intention waiting bigWire sans WT (receivedAt + confirmed plus tard au BO). Génère bankTransferReference (BRICKS-VIR- + 6 chars). Plusieurs POST du même investisseur = plusieurs intentions. Compte business → 409 bank-transfer-requires-individual. Montant < 100 k€ → 400 bank-transfer-amount-too-low. onBehalfOfCompany → 400 bank-transfer-personal-only. Rétractation → 409 bank-transfer-refund-not-supported. Descriptor createCompanyInternalFundraisingBigWireIntentionEndpoint. Input createCompanyInternalFundraisingBigWireIntentionPayload, output companyInternalFundraisingBigWireIntention.
  • GET …/campaigns/current/raised-amount — poll barre de progression (front 3 s en open ou earlyAccess.hasStarted, 10 s en scheduled hors seuil). Toujours { lifecycleStatus, raisedAmount, uniqueInvestorCount, declarativeIntentionsAmount, declarativeIntentionInvestorCount } (lifecycleStatus: 'closed' et 0 si aucune campagne). Un seul agrégat live selon le cycle : scheduled hors fenêtre → SUM/COUNT déclaratives actives (confirmed = 0) ; scheduled pendant l’avant-première ou open → SUM + COUNT DISTINCT investorId des confirmed (waiting exclus, déclaratif = 0, lifecycleStatus reste scheduled pendant l’avant-première) ; closed → ended.* sans SUM (déclaratif = 0, ended.raisedAmount = confirmed figé à la clôture). Lookup campagne : id_view / startedAt_view / endedAt_view / earlyAccessBeforeStartInHours + extraits ended.* (pas le JSON complet, raisedAmount en bigint). Redis company-internal-fundraising:current-progress TTL 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é par hasAccess). Le front patche selon lifecycleStatus du poll (pas le isOpen du cache GET current) : pendant l’avant-première il applique raisedAmount / uniqueInvestorCount sans passer isOpen à 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, aucun lockCustomer. useBricksTotalBalance: true → remainder amount − cadeau − withdrawable. false → remainder = amount (aucune poche). Remainder > 0 et < businessRules.moneyIn.card.minAmountInCents (1 €) → 400 amount-to-money-in-too-low. Pas de plafond sur le remainder (Checkout). Sinon { amountToMoneyIn, availableBalanceBeforeTopup } (200). Descriptor quoteCompanyInternalFundraisingPurchaseEndpoint.
  • POST …/purchase/draft-bulletin — aperçu pendant l’achat. Body du confirm wallet + isPeaPme?. Mêmes gates d’achat (guardCanPurchase, onboarding, droits). isPeaPme: true reprend les refus de POST …/purchase/pea-pme (perso uniquement, ≥ 100 k€, 409 pea-pme-already-exists). Génère le PDF à la volée (watermark PROJET sur toutes les pages), sans intention ni WT. Pied de page n / total sur 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 pas startedAt / 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). onBehalfOfCompany utilise le même bloc société qu’un compte business. Un bulletin PEA lit campaign.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 compte business imprime Adresse : N/A à la place de l’adresse du profil. Descriptor generateCompanyInternalFundraisingDraftBulletinEndpoint. Réponse binaire application/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. Descriptor generateCompanyInternalFundraisingDraftBulletinUrlEndpoint. Le POST …/purchase/draft-bulletin binaire 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 en Content-Disposition: inline. Jeton inconnu ou expiré → 404 draft-bulletin-not-found. Jeton malformé → 400 validation-params.
  • GET …/campaigns/:campaignId/intentions/:investmentIntentionId/signed-bulletin — bulletin définitif à la volée, PEA-PME et big-wire seulement. JWT + onboarding + droits. Path campaignId + investmentIntentionId. Génère le PDF depuis cette intention (montant, onBehalfOfCompany, createdAt). Wallet (walletTransactionId) → 409 signed-bulletin-wallet-not-supported (autre parcours). Pas de watermark PROJET. Signature : « Fait par acte électronique. » / « Le DD-MM-YYYY HH:mm (Europe/Paris). » (toujours intention.createdAt, pas de lieu) puis Allura prénom + nom de la personne physique. PEA-PME : mode PEA-PME, RIB campaign.banking, pas de rétractation 4 jours. Big-wire : mode Hors 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, ou canceled → 404 investment-intention-not-found (pas de leak). Campagne inconnue → 404 campaign-not-found. Descriptor getCompanyInternalFundraisingSignedBulletinEndpoint. Réponse binaire application/pdf (attachment). Le GET binaire 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éfixe company-internal-fundraising:signed-bulletin). Réponse JSON { url } : BACK_URL + GET …/signed-bulletin/:token. Descriptor generateCompanyInternalFundraisingSignedBulletinUrlEndpoint.
  • 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 en Content-Disposition: inline. Jeton inconnu ou expiré → 404 signed-bulletin-not-found. Jeton malformé → 400 validation-params. Descriptor getCompanyInternalFundraisingSignedBulletinByTokenEndpoint.
  • POST /administration/company-internal-fundraising/campaigns/:id/bulletin-preview — même rendu, sans guardCanPurchase (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 → watermark PROJET. signed: true → pas de watermark, signature électronique (Allura) au prénom + nom de la personne physique (legalRepresentative si on-behalf-of-company), date = maintenant, imprimée « Le DD-MM-YYYY HH:mm (Europe/Paris). ». Signé sans prénom/nom → 400 signed-bulletin-requires-name. pea-pme : RIB campaign.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€ → 400 validation-body. Campagne inconnue → 404 campaign-not-found. Descriptor previewAdminCompanyInternalFundraisingBulletinEndpoint. Réponse binaire application/pdf.
  • POST …/purchase/confirm — body du quote + onBehalfOfCompany?. Wallet uniquement : intention confirmed + WT company_internal_fundraise_purchase WAITING. onBehalfOfCompany optionnel (compte individual investit 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), certification true). Un code inconnu (zz) → 400 validation-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 compte business qui envoie l’objet → 409 on-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 → 409 not-enough-balance. Mêmes asserts onboarding / droits / guardCanPurchase que le quote. Descriptor confirmCompanyInternalFundraisingPurchaseEndpoint.
  • POST …/purchase/pea-pme — achat PEA-PME dédié (campaignId + amount). Perso uniquement, pas de onBehalfOfCompany, pas de useBricksTotalBalance, pas de quote, pas de débit wallet, pas de Checkout, pas de WT. onBehalfOfCompany envoyé → 400 validation-body (pea-pme-personal-only sur onBehalfOfCompany). Compte business → 409 pea-pme-requires-individual. Profil absent → 500 (customer-profile-not-found). Montant ≥ businessRules.companyInternalFundraising.minPeaPmeAmount (Cents(100_000_00)) sinon 400 validation-body (pea-pme-amount-too-low sur amount). Pas de max. JSON peaPme.bankTransferReference = BRICKS-PEA-XXXXXX (nanoid, alphabet 23456789ABCDEFGHJKLMNPQRSTUVWXYZ) ; la réponse exige peaPmeBankTransferReference. Intention waiting (waitingAt) ; confirmed + réception des fonds plus tard au BO. Un 2e PEA-PME du même investisseur sur la même campagne → 409 pea-pme-already-exists (un wallet + un PEA restent possibles ; un PEA sur une autre campagne aussi). Rétractation PEA-PME → 409 pea-pme-refund-not-supported (PR BO). Mêmes asserts onboarding / droits / guardCanPurchase que le quote. Descriptor confirmCompanyInternalFundraisingPeaPmePurchaseEndpoint.
  • 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=success UI ne déclenche pas l’achat.
  • Les routes purchase (quote, confirm wallet, pea-pme) : multiple de 10 €. Pendant l’avant-première (now >= startedAt − Nh et now < startedAt) : intention déclarative active requise, sinon 409 campaign-not-open. Après startedAt : tout investisseur onboardé. 409 campaign-not-open si planifiée hors fenêtre / clôturée, 409 new-investment-not-allowed si pause.
  • POST …/refund — investmentIntentionId dans 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 : intention FOR UPDATE, puis customer, puis WT. Mêmes asserts que le purchase (assertCompletedInvestorOnboarding + assertTransactionRights(['all'])). Descriptor cancelCompanyInternalFundraisingPurchaseEndpoint.
  • POST /administration/company-internal-fundraising/refund — même mécanique, canceledBy: 'bricks', sans fenêtre 4 jours, campagne non clôturée. Lock campagne FOR UPDATE d’abord (même ordre que close : course possible après J+4), puis intention, customer, WT. Descriptor refundAdminCompanyInternalFundraisingPurchaseEndpoint.

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.