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_assignation → waiting_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_creation → waiting_for_contract_creation (P2P pending attaché ; il est joué ensuite par PendingLemonwayP2P) |
ReservationWaitingForConfirmation |
30 s | Purchase waiting_for_reservation_confirmation → waiting_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_confirmation → declined (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_creation → confirmed (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.