Aller au contenu

Statuts & transitions

Un primary-purchase est modélisé comme une discriminated union sur le champ status. Chaque statut représente une étape distincte du cycle de vie de l'achat, avec ses propres champs requis. Le code de référence est dans primary-purchase.model.ts.

Tous les chemins d'entrée (achat direct, réservation carte, réservation admin, auto-invest) créent l'achat en waiting_for_assignation ; ce qui les distingue, ce sont les champs attachés (purchaseWtId, reservation, autoInvest) qui déterminent le routage du watcher d'assignation.

State diagram

stateDiagram-v2
    direction TB

    [*] --> waiting_for_assignation: purchaseBricks (achat direct, auto-invest)<br/>ou reserveBricks (réservation carte / admin)

    waiting_for_assignation --> waiting_for_p2p_creation: Bricks assignées<br/>(achat direct)
    waiting_for_assignation --> waiting_for_reservation_confirmation: Bricks assignées<br/>(réservation attachée)
    waiting_for_assignation --> waiting_for_auto_invest_investor_confirmation: Bricks assignées<br/>(auto-invest)
    waiting_for_assignation --> declined: Pas assez de bricks<br/>reason no-bricks-left<br/>+ funding bloqué
    waiting_for_assignation --> refunded: Refund (la WT d'achat<br/>existe déjà)

    waiting_for_reservation_confirmation --> waiting_for_p2p_creation: Carte confirmée ou admin<br/>WT d'achat créée
    waiting_for_reservation_confirmation --> declined: expireAt dépassé (expired)<br/>ou échec WT (could-not-create-purchase-wt)

    waiting_for_auto_invest_investor_confirmation --> waiting_for_p2p_creation: Investor confirme
    waiting_for_auto_invest_investor_confirmation --> refunded: Investor annule

    waiting_for_p2p_creation --> waiting_for_contract_creation: P2P pending attaché à la WT
    waiting_for_p2p_creation --> refunded: Refund

    waiting_for_contract_creation --> confirmed: P2P joué et réussi<br/>contrat PDF généré
    waiting_for_contract_creation --> refunded: P2P échoué

    confirmed --> refunded: Rétractation ≤ J+4 / admin /<br/>propertyNotFinanced

    confirmed --> [*]
    declined --> [*]
    refunded --> [*]

declined et refunded sont terminaux : un achat declined ne peut pas être remboursé (il n'y a rien à rembourser — la WT d'achat, si elle existait, est passée DECLINED). Les conditions d'accès à refunded sont détaillées dans Remboursement.

Statuts du purchase (8)

Champs communs à tous les statuts : id (UUIDv7), propertyId, brickCount, brickPrice, value, investorId, contractType, createdAt, updatedAt. La colonne « Champs distinctifs » liste ce que chaque statut ajoute.

Statut Description Champs distinctifs Déclencheur d'entrée Statuts suivants
waiting_for_assignation Achat créé, attend que le watcher assigne les bricks disponibles purchaseWtId? (achat direct / auto-invest), reservation? (waiting), autoInvest? (non confirmé) purchaseBricks ou reserveBricks waiting_for_p2p_creation, waiting_for_reservation_confirmation, waiting_for_auto_invest_investor_confirmation, declined (no-bricks-left), refunded
waiting_for_reservation_confirmation Bricks assignées, attend le paiement carte (ou la confirmation admin) reservation (waiting), brickIds Watcher assignation (réservation attachée) waiting_for_p2p_creation, declined (expired ou could-not-create-purchase-wt)
waiting_for_auto_invest_investor_confirmation Bricks assignées via auto-invest, attend la confirmation explicite de l'investor brickIds, autoInvest (non confirmé), purchaseWtId Watcher assignation (achat auto-invest) waiting_for_p2p_creation (confirm), refunded (cancel)
waiting_for_p2p_creation WT d'achat débitée, P2P Lemonway à attacher (transfert investor → SPV) purchaseWtId, brickIds, reservation? (confirmed), autoInvest? (confirmé) Watcher assignation, confirmation réservation ou confirmation auto-invest waiting_for_contract_creation, refunded
waiting_for_contract_creation P2P pending attaché, attend son exécution chez Lemonway puis le contrat purchaseWtId, brickIds, reservation? (confirmed), autoInvest? (confirmé) Watcher P2P confirmed (P2P réussi), refunded (P2P échoué)
confirmed ✅ Achat finalisé : contrat PDF généré, referral rewards déclenchés, événement CustomerIO primary_purchase émis contractDocumentId, refereeRewardId?, referrerRewardId? Worker PlayedLemonwayP2P (P2P réussi) → confirmPrimaryPurchase refunded
declined ❌ Achat refusé : la WT d'achat (si elle existe) passe DECLINED, bricks libérées, compteur Redis décrémenté reason: 'expired' \| 'no-bricks-left' \| 'could-not-create-purchase-wt', purchaseWtId?, brickIds?, reservation? Watcher expiration (expired), watcher assignation (no-bricks-left), échec de création de la WT à la confirmation (could-not-create-purchase-wt) (terminal)
refunded 💰 Achat remboursé : WT de refund créée, voir Remboursement refund { wtId, reason, at }, purchaseWtId, brickIds?, contractDocumentId?, reservation? (confirmed) Rétractation investor, refund admin, P2P échoué, annulation auto-invest, script funding-failure (terminal)

legacyRoyalty

Le modèle contient aussi un validateur legacyRoyalty (contractType: 'royalty', statuts confirmed ou refunded uniquement) pour les achats historiques en royalties. Ces lignes ne passent pas par la state machine ci-dessus.

Statuts de la réservation imbriquée (5)

Quand l'achat passe par une réservation (investor sans balance suffisante, ou réservation admin), une PrimaryReservation est attachée au purchase. Cette sous-machine suit le money-in carte ou la confirmation admin. Le code de référence est dans primary-reservation.model.ts.

Champs communs à tous les statuts : expireAt, amountToUseFromGiftBalance?, adminId?.

stateDiagram-v2
    direction LR
    [*] --> waiting_for_card_initiation: reserveBricks (investor)
    [*] --> waiting_for_admin_confirmation: reserveBricks (admin)
    waiting_for_card_initiation --> waiting_for_card_confirmation: WT carte créée<br/>(payment-intent)
    waiting_for_card_confirmation --> confirmed: WT d'achat créée
    waiting_for_admin_confirmation --> confirmed: Confirmation admin
    waiting_for_card_confirmation --> expired: Échec de la WT d'achat
    waiting_for_admin_confirmation --> expired: Échec de la WT d'achat
    confirmed --> [*]
    expired --> [*]
Statut réservation Description Champs distinctifs
waiting_for_card_initiation Réservation créée, WT carte pas encore initialisée
waiting_for_card_confirmation WT carte créée via payment-intent, attend le webhook money-in Lemonway cardWtId
waiting_for_admin_confirmation Réservation créée par un admin, attend sa confirmation manuelle
confirmed WT d'achat créée, l'achat continue (waiting_for_p2p_creation) cardWtId?, purchasedAt
expired Échec de création de la WT d'achat à la confirmation (could-not-create-purchase-wt) cardWtId?, expiredAt

Au timeout, la réservation reste en waiting_*

Quand expireAt est dépassé, le watcher d'expiration fait passer le purchase en declined (reason expired) mais la réservation imbriquée est conservée telle quelle, dans son statut waiting_*. Le sous-statut expired n'est écrit que par le chemin could-not-create-purchase-wt.

Délais d'expiration

Valeurs dans businessRules.primaryObligation.reservation :

Contexte Délai
Réservation investor (carte) expireAt = création + 10 min
Réservation admin expireAt = création + 10 jours
expireAt renvoyé au front par POST /primary-purchase + 5 min (marge volontaire vs le délai back)
Réservations en cours max par investor 3

State-watchers — qui pilote quoi

Les transitions ne sont pas pilotées par les endpoints HTTP : ce sont des workers asynchrones qui les portent (voir la vue d'ensemble pour leur fonctionnement — la valeur indiquée est un timeout d'itération, pas une cadence).

Worker Timeout d'itération Transition portée
PurchaseWaitingForBricksAssignation 10 s waiting_for_assignationwaiting_for_p2p_creation / waiting_for_reservation_confirmation / waiting_for_auto_invest_investor_confirmation (si bricks manquantes : blockPropertyFunding + decline des achats en excès)
PurchaseWaitingForP2PCreation 10 s waiting_for_p2p_creationwaiting_for_contract_creation (P2P pending attaché ; il est joué ensuite par PendingLemonwayP2P)
ReservationWaitingForConfirmation 30 s Purchase waiting_for_reservation_confirmationwaiting_for_p2p_creation et réservation → confirmed quand la WT carte est prête : TOPUP_CARD confirmed (Lemonway) ou TOPUP_CHECKOUT waiting (Checkout approved). La confirmation admin passe par le même service via PATCH /administration/bricks/reservation. Un payment_declined Checkout n'expire pas la réservation (retry session possible jusqu'à expireAt naturel — front 5 min / back 10 min).
ReservationWaitingForExpiration 30 s waiting_for_reservation_confirmationdeclined (reason expired) quand expireAt est dépassé — sauf si une WT carte est déjà prête (TOPUP_CARD confirmed ou TOPUP_CHECKOUT waiting), pour ne pas tuer une réservation payée avant le watcher de confirmation
PlayedLemonwayP2P 60 s waiting_for_contract_creationconfirmed (P2P réussi) ou → refunded (P2P échoué)

Garanties d'atomicité

Toutes les transitions s'exécutent dans un safePgTransaction. Deux stratégies de verrouillage coexistent :

  • Batch verrouillé (assignation, P2P) : jusqu'à 1000 purchases verrouillés d'un coup avec FOR UPDATE … SKIP LOCKED — deux workers concurrents se partagent le travail sans se bloquer.
  • Verrou par purchase (confirmation et expiration de réservation) : les ids sont lus sans verrou, puis chaque purchase est verrouillé individuellement dans sa propre transaction (concurrence pLimit(10) pour la confirmation, séquentiel pour l'expiration). Un purchase déjà traité entre la lecture et le verrou est simplement ignoré.

En cas de crash ou de timeout, Postgres rollback et l'état reste cohérent ; les compteurs Redis (bricks vendues) sont compensés via setOnRollback.

Pas de transition arbitraire

Aucun endpoint ne permet de forcer un statut : les endpoints admin existants (création / confirmation / annulation de réservation, refund) passent par les mêmes services que le flux nominal et préservent les invariants. Pour toute autre modification manuelle, il faut une migration ou un script ponctuel — c'est volontaire.