veza/docs/PLAN_V0_603_IMPLEMENTATION.md

620 lines
20 KiB
Markdown
Raw Normal View History

# Plan d'implémentation v0.603 — Transfer automatique, Commission & Stabilisation
**Date** : 2026-02-23
**Base** : v0.602 taguée
**Durée estimée** : 4 sprints (~20 jours ouvrés)
**Référence** : [V0_603_RELEASE_SCOPE.md](V0_603_RELEASE_SCOPE.md)
---
## Vue d'ensemble
```
Sprint 1 (j1-5) → T1-01..T1-05 : Config, migration, modèle, interface, injection
Sprint 2 (j6-12) → T1-06..T1-10 : Logique transfer, endpoint, frontend, tests
Sprint 3 (j13-17) → DT1 : Triage TODOs, archivage docs, nettoyage
Sprint 4 (j18-20) → QA3 : Tests E2E, smoke test, docs, rétro, tag
```
---
## Diagramme d'architecture cible
```mermaid
flowchart TD
subgraph Webhook["Hyperswitch Webhook (payment.succeeded)"]
PW["ProcessPaymentWebhook"]
end
subgraph Transfer["Transfer Flow (nouveau v0.603)"]
Group["Regrouper items par seller_id"]
Calc["Calculer montant net = prix - commission"]
Check["Vendeur a un compte Connect ?"]
Yes["CreateTransfer (Stripe API)"]
No["Log warning, status = skipped"]
Record["Enregistrer SellerTransfer en DB"]
end
subgraph Existing["Existant (v0.602)"]
License["Créer licences"]
Order["Order status = completed"]
end
PW --> Order
PW --> License
License --> Group
Group --> Calc
Calc --> Check
Check -->|Oui| Yes
Check -->|Non| No
Yes --> Record
No --> Record
```
---
## Sprint 1 — Config, migration, modèle, injection (jours 1-5)
> **Objectif** : Préparer l'infrastructure backend pour les transferts.
### Tâche T1-01 : Config commission plateforme
**Fichier** : `veza-backend-api/internal/config/config.go`
Ajouter dans la struct Config :
```go
PlatformFeeRate float64 // PLATFORM_FEE_RATE, default 0.10 (10%)
```
Chargement dans `LoadConfig()` :
```go
cfg.PlatformFeeRate = getEnvFloat("PLATFORM_FEE_RATE", 0.10)
```
**Fichier** : `.env.example`
```env
# Platform commission on marketplace sales (0.10 = 10%)
PLATFORM_FEE_RATE=0.10
```
**Commit** : `feat(commerce): add PLATFORM_FEE_RATE config (default 10%)`
---
### Tâche T1-02 : Migration seller_transfers
**Fichier** : `veza-backend-api/migrations/115_seller_transfers.sql`
```sql
-- v0.603 T1-02: Seller transfer tracking
CREATE TABLE IF NOT EXISTS seller_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
stripe_transfer_id VARCHAR(255),
amount_cents BIGINT NOT NULL,
platform_fee_cents BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
status VARCHAR(50) NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_seller_transfers_seller ON seller_transfers(seller_id);
CREATE INDEX IF NOT EXISTS idx_seller_transfers_order ON seller_transfers(order_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_seller_transfers_seller_order
ON seller_transfers(seller_id, order_id);
```
**Commit** : `feat(commerce): add 115_seller_transfers migration`
---
### Tâche T1-03 : Modèle SellerTransfer
**Fichier** : `veza-backend-api/internal/core/marketplace/models.go`
Ajouter après le type `ProductReview` :
```go
// SellerTransfer tracks a Stripe Connect transfer for a completed order.
type SellerTransfer struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
SellerID uuid.UUID `gorm:"type:uuid;not null" json:"seller_id"`
OrderID uuid.UUID `gorm:"type:uuid;not null" json:"order_id"`
StripeTransferID string `gorm:"size:255" json:"stripe_transfer_id,omitempty"`
AmountCents int64 `gorm:"not null" json:"amount_cents"`
PlatformFeeCents int64 `gorm:"not null" json:"platform_fee_cents"`
Currency string `gorm:"size:3;default:'EUR'" json:"currency"`
Status string `gorm:"size:50;default:'pending'" json:"status"` // pending, completed, failed, skipped
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (st *SellerTransfer) BeforeCreate(tx *gorm.DB) (err error) {
if st.ID == uuid.Nil {
st.ID = uuid.New()
}
return
}
```
**Commit** : `feat(commerce): add SellerTransfer model`
---
### Tâche T1-04 : Interface TransferService
**Fichier** : `veza-backend-api/internal/core/marketplace/service.go`
Ajouter avant le type `Service struct` :
```go
// TransferService abstracts the payout transfer provider.
type TransferService interface {
CreateTransfer(ctx context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) error
}
```
**Commit** : combiné avec T1-05
---
### Tâche T1-05 : Option WithTransferService
**Fichier** : `veza-backend-api/internal/core/marketplace/service.go`
Ajouter les champs dans `Service struct` :
```go
type Service struct {
db *gorm.DB
logger *zap.Logger
storage StorageService
paymentProvider PaymentProvider
hyperswitchEnabled bool
checkoutSuccessURL string
transferService TransferService // v0.603: optional payout transfer
platformFeeRate float64 // v0.603: platform commission rate (e.g. 0.10)
}
```
Ajouter la `ServiceOption` :
```go
// WithTransferService injects the payout transfer service and platform fee rate.
func WithTransferService(ts TransferService, feeRate float64) ServiceOption {
return func(s *Service) {
s.transferService = ts
s.platformFeeRate = feeRate
}
}
```
**Fichier** : Wiring — là où `marketplace.NewService` est appelé (probablement `cmd/server/main.go` ou `internal/api/setup.go`), ajouter l'option :
```go
marketplace.WithTransferService(stripeConnectService, cfg.PlatformFeeRate)
```
**Commit** : `feat(commerce): add TransferService interface and WithTransferService option`
---
## Sprint 2 — Logique transfer, endpoint, frontend (jours 6-12)
> **Objectif** : Implémenter le flux transfer dans le webhook et exposer l'historique.
### Tâche T1-06 : Logique transfer dans ProcessPaymentWebhook
**Fichier** : `veza-backend-api/internal/core/marketplace/service.go`
Dans la méthode `ProcessPaymentWebhook`, dans le case `"succeeded"`, **après** la boucle de création des licences, ajouter :
```go
// v0.603 T1-06: Transfer to sellers after license creation
if s.transferService != nil {
s.processSellerTransfers(ctx, tx, &order, items)
}
```
Nouvelle méthode privée `processSellerTransfers` :
```go
// processSellerTransfers groups order items by seller and initiates transfers.
// Errors are logged and recorded but do not fail the order.
func (s *Service) processSellerTransfers(ctx context.Context, tx *gorm.DB, order *Order, items []OrderItem) {
// 1. Group items by seller, sum prices
sellerTotals := make(map[uuid.UUID]float64)
for _, item := range items {
var product Product
if err := tx.First(&product, "id = ?", item.ProductID).Error; err != nil {
s.logger.Warn("Transfer: product not found for item",
zap.String("item_id", item.ID.String()))
continue
}
sellerTotals[product.SellerID] += item.Price
}
// 2. For each seller, calculate net amount and transfer
for sellerID, totalPrice := range sellerTotals {
grossCents := int64(totalPrice * 100)
feeCents := int64(float64(grossCents) * s.platformFeeRate)
netCents := grossCents - feeCents
st := SellerTransfer{
SellerID: sellerID,
OrderID: order.ID,
AmountCents: netCents,
PlatformFeeCents: feeCents,
Currency: order.Currency,
Status: "pending",
}
err := s.transferService.CreateTransfer(ctx, sellerID, netCents, order.Currency, order.ID.String())
if err != nil {
st.Status = "failed"
st.ErrorMessage = err.Error()
s.logger.Error("Transfer failed for seller",
zap.String("seller_id", sellerID.String()),
zap.String("order_id", order.ID.String()),
zap.Error(err))
} else {
st.Status = "completed"
s.logger.Info("Transfer completed for seller",
zap.String("seller_id", sellerID.String()),
zap.Int64("amount_cents", netCents))
}
if err := tx.Create(&st).Error; err != nil {
s.logger.Error("Failed to record seller transfer",
zap.String("seller_id", sellerID.String()),
zap.Error(err))
}
}
}
```
**Commit** : `feat(commerce): trigger seller transfers on payment succeeded`
---
### Tâche T1-07 : Endpoint GET /sell/transfers
**Fichier** : `veza-backend-api/internal/handlers/sell_handler.go`
Ajouter le handler `GetSellerTransfers` :
```go
// GetSellerTransfers returns the transfer history for the authenticated seller.
// GET /api/v1/sell/transfers
func (h *SellHandler) GetSellerTransfers(c *gin.Context) {
userID := getUserID(c)
var transfers []marketplace.SellerTransfer
if err := h.db.Where("seller_id = ?", userID).
Order("created_at DESC").
Find(&transfers).Error; err != nil {
c.JSON(500, gin.H{"error": "failed to fetch transfers"})
return
}
c.JSON(200, gin.H{"data": transfers})
}
```
**Fichier** : routes — ajouter `GET /sell/transfers` dans le groupe sell authentifié.
**Commit** : `feat(commerce): add GET /sell/transfers endpoint`
---
### Tâche T1-08 : Frontend — SellerTransfersCard
**Fichier** : `apps/web/src/features/seller/SellerDashboardView.tsx`
Ajouter une carte « Historique des transferts » dans le dashboard vendeur :
- Appel `GET /sell/transfers`
- Liste : date, montant, commission, statut (badge coloré)
- États : Loading (skeleton), Empty (« Aucun transfert »), Error
**Fichier** : `apps/web/src/services/marketplaceService.ts`
```typescript
export const getSellerTransfers = () =>
apiClient.get<{ data: SellerTransfer[] }>('/sell/transfers');
export interface SellerTransfer {
id: string;
order_id: string;
amount_cents: number;
platform_fee_cents: number;
currency: string;
status: 'pending' | 'completed' | 'failed' | 'skipped';
error_message?: string;
created_at: string;
}
```
**Commit** : `feat(seller): add transfers history card to SellerDashboard`
---
### Tâche T1-09 : MSW handlers + Story
**Fichier** : `apps/web/src/mocks/handlers.ts`
```typescript
http.get('/api/v1/sell/transfers', () => {
return HttpResponse.json({
data: [
{
id: '...',
order_id: '...',
amount_cents: 900,
platform_fee_cents: 100,
currency: 'EUR',
status: 'completed',
created_at: '2026-02-23T10:00:00Z',
},
],
});
}),
```
**Story** : `SellerDashboardView.stories.tsx` — ajouter un état « Avec transferts » montrant la carte remplie.
**Commit** : `test(seller): add MSW handler and story for transfers`
---
### Tâche T1-10 : Tests unitaires transfer
**Fichier** : `veza-backend-api/internal/core/marketplace/process_webhook_test.go`
Ajouter 3 cas de test :
1. **TestProcessWebhook_TransferSuccess** — paiement réussi → license créée + transfer `completed` enregistré
2. **TestProcessWebhook_MultiSeller** — 2 items de 2 vendeurs → 2 `SellerTransfer` distincts
3. **TestProcessWebhook_SellerNoConnect** — vendeur sans compte Connect → `CreateTransfer` retourne `ErrNoStripeAccount` → transfer `failed` enregistré, commande quand même `completed`
Mock `TransferService` :
```go
type mockTransferService struct {
callCount int
err error
}
func (m *mockTransferService) CreateTransfer(ctx context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) error {
m.callCount++
return m.err
}
```
**Commit** : `test(commerce): add transfer tests — success, multi-seller, no-connect`
---
## Sprint 3 — Dette technique & Nettoyage (jours 13-17)
> **Objectif** : Triage des TODOs, archivage des docs obsolètes.
### Tâche DT1-01 : Triage TODOs backend
**Procédure** :
```bash
cd veza-backend-api
rg -n "TODO|FIXME" --type go | sort > /tmp/todos.txt
wc -l /tmp/todos.txt # ~210
```
Pour chaque TODO :
- **Obsolète** (code déjà refactoré, feature livrée) → supprimer le commentaire
- **Pertinent** (bug connu, amélioration future) → créer une issue GitHub, garder un `// TODO(#NNN): ...`
- **Objectif** : réduire de 210 à < 100
**Commit** : `chore(backend): triage TODOs — remove 100+ obsolete, convert rest to issues`
---
### Tâche DT1-02 : Archiver docs obsolètes
**Déplacer vers** `docs/archive/` les docs pré-v0.501 qui ne sont plus des références actives :
- `V0_101_*.md`, `V0_102_*.md`, `V0_103_*.md`
- `V0_201_*.md`, `V0_202_*.md`, `V0_203_*.md`
- `V0_301_*.md`, `V0_302_*.md`, `V0_303_*.md`
- `V0_401_*.md`, `V0_402_*.md`, `V0_403_*.md`
- `PLAN_V0_301_*.md`, `PLAN_V0_401_*.md`, `PLAN_V0_402_*.md`, `PLAN_V0_403_*.md`
- Anciens audits : `AUDIT_TEMP_*.md`, `DB_MIGRATIONS_AUDIT_*.md`, `UUID_DB_*.md`, `AUDIT_DB_*.md`
**Ne pas archiver** : `SCOPE_CONTROL.md`, `PROJECT_STATE.md`, `FEATURE_STATUS.md`, `MIGRATIONS.md`, `ENV_CONFIG.md`, `ONBOARDING.md`, `MONITORING_SETUP.md`, `STORYBOOK_CONTRACT.md`, plans v0.601+.
**Commit** : `chore(docs): archive obsolete pre-v0.501 docs`
---
### Tâche DT1-03 : Mettre à jour PAYOUT_MANUAL.md
**Fichier** : `docs/PAYOUT_MANUAL.md`
Remplacer la section « Procédure manuelle » par :
```markdown
## Implémentation v0.603
Le transfert automatique est opérationnel depuis v0.603 :
1. Paiement réussi (webhook Hyperswitch `succeeded`)
2. Licences créées pour l'acheteur
3. Items regroupés par vendeur
4. Commission plateforme déduite (PLATFORM_FEE_RATE)
5. Transfer Stripe Connect exécuté vers le compte du vendeur
6. SellerTransfer enregistré en DB (table seller_transfers)
Voir : V0_603_RELEASE_SCOPE.md § 5 pour le détail technique.
```
**Commit** : `docs(payout): update PAYOUT_MANUAL for v0.603 auto transfer`
---
### Tâche DT1-04 : Nettoyage code mort marketplace
**Fichier** : `veza-backend-api/internal/core/marketplace/`
- Vérifier imports inutilisés
- Vérifier variables déclarées non utilisées
- Lancer `go vet ./internal/core/marketplace/...`
**Commit** : `chore(marketplace): remove dead code and unused imports`
---
## Sprint 4 — Tests E2E, docs, tag (jours 18-20)
> **Objectif** : Validation complète, documentation, release.
### Tâche QA3-01 : Test E2E transfer
**Fichier** : `veza-backend-api/internal/core/marketplace/process_webhook_test.go` ou nouveau fichier d'intégration.
Test complet dans une transaction :
1. Créer un user vendeur avec `seller_stripe_accounts` (PayoutsEnabled = true)
2. Créer un produit de ce vendeur
3. Créer une commande (buyer, 1 item)
4. Simuler webhook `succeeded` avec `ProcessPaymentWebhook`
5. Vérifier : order `completed`, license créée, `SellerTransfer` enregistré avec `status = completed`
**Commit** : `test(commerce): add E2E transfer flow test`
---
### Tâche QA3-02 : Test multi-vendeur
Même setup que QA3-01 mais :
- 2 vendeurs avec comptes Connect
- 1 commande avec 2 items (1 par vendeur)
- Vérifier : 2 `SellerTransfer` distincts, montants corrects
**Commit** : combiné avec QA3-01
---
### Tâche QA3-03 : Test vendeur sans Connect
- 1 vendeur **sans** entrée dans `seller_stripe_accounts`
- 1 commande, webhook `succeeded`
- Vérifier : licences créées, `SellerTransfer` avec `status = failed`, commande `completed`
**Commit** : combiné avec QA3-01
---
### Tâche QA3-04 : Smoke test v0.603
**Fichier** : `docs/SMOKE_TEST_V0603.md` — voir document dédié.
**Commit** : `docs: add SMOKE_TEST_V0603.md`
---
### Tâche QA3-05 : Mise à jour documentation
**Fichiers** :
- `docs/PROJECT_STATE.md` — ajouter section v0.603 livrée
- `docs/FEATURE_STATUS.md` — Marketplace → « Transfer automatique opérationnel (v0.603) »
- `CHANGELOG.md` — section v0.603
**Commit** : `docs: update PROJECT_STATE, FEATURE_STATUS, CHANGELOG for v0.603`
---
### Tâche QA3-06 : Rétrospective, archivage, tag
1. Créer `docs/RETROSPECTIVE_V0603.md`
2. Déplacer `docs/V0_603_RELEASE_SCOPE.md``docs/archive/`
3. Créer placeholder `docs/V0_604_RELEASE_SCOPE.md`
4. Mettre à jour `docs/SCOPE_CONTROL.md` — référence active → V0_604
5. Tag : `git tag -a v0.603 -m "v0.603 — Transfer automatique, Commission & Stabilisation"`
**Commits** :
- `docs: add RETROSPECTIVE_V0603.md`
- `chore(release): archive v0.603 scope, create v0.604 placeholder`
- `chore(release): v0.603 — Transfer automatique, Commission & Stabilisation`
---
## Commits récapitulatifs (ordre d'exécution)
| # | Sprint | Commit |
|---|--------|--------|
| 1 | 1 | `feat(commerce): add PLATFORM_FEE_RATE config (default 10%)` |
| 2 | 1 | `feat(commerce): add 115_seller_transfers migration` |
| 3 | 1 | `feat(commerce): add SellerTransfer model` |
| 4 | 1 | `feat(commerce): add TransferService interface and WithTransferService option` |
| 5 | 2 | `feat(commerce): trigger seller transfers on payment succeeded` |
| 6 | 2 | `feat(commerce): add GET /sell/transfers endpoint` |
| 7 | 2 | `feat(seller): add transfers history card to SellerDashboard` |
| 8 | 2 | `test(seller): add MSW handler and story for transfers` |
| 9 | 2 | `test(commerce): add transfer tests — success, multi-seller, no-connect` |
| 10 | 3 | `chore(backend): triage TODOs — remove 100+ obsolete, convert rest to issues` |
| 11 | 3 | `chore(docs): archive obsolete pre-v0.501 docs` |
| 12 | 3 | `docs(payout): update PAYOUT_MANUAL for v0.603 auto transfer` |
| 13 | 3 | `chore(marketplace): remove dead code and unused imports` |
| 14 | 4 | `test(commerce): add E2E transfer flow test` |
| 15 | 4 | `docs: add SMOKE_TEST_V0603.md` |
| 16 | 4 | `docs: update PROJECT_STATE, FEATURE_STATUS, CHANGELOG for v0.603` |
| 17 | 4 | `docs: add RETROSPECTIVE_V0603.md` |
| 18 | 4 | `chore(release): archive v0.603 scope, create v0.604 placeholder` |
| 19 | 4 | `chore(release): v0.603 — Transfer automatique, Commission & Stabilisation` |
---
## Dépendances entre lots
```
T1-01..T1-05 (Config/Migration/Modèle) → aucune dépendance
T1-06 (Logique transfer) → dépend de T1-01..T1-05
T1-07 (Endpoint) → dépend de T1-03
T1-08..T1-09 (Frontend) → dépend de T1-07
T1-10 (Tests unitaires) → dépend de T1-06
DT1 (Dette technique) → indépendant (peut démarrer en parallèle)
QA3 (Tests E2E, docs) → dépend de T1, DT1
```
---
## Risques et mitigations
| Risque | Mitigation |
|--------|------------|
| Stripe rate limit sur CreateTransfer | 1 transfer par vendeur par commande, pas par item |
| Échec transfer silencieux | Enregistré en DB, log `zap.Error`, alertable via Grafana/Alertmanager |
| Float→int64 precision loss | Conversion `int64(price * 100)` au plus tôt, calculs en centimes |
| TODO triage trop long | Timebox à 2 jours max, focus sur `internal/core/` et `internal/handlers/` |
| Migration 115 conflict | Vérifier dernier numéro avant de commencer |
---
## Validation finale
```bash
# Backend
cd veza-backend-api && go build ./...
cd veza-backend-api && go test ./... -v
cd veza-backend-api && go test ./internal/core/marketplace/... -v -run TestProcessWebhook
# Frontend
cd apps/web && npm run build
cd apps/web && npm test -- --run
# Storybook
cd apps/web && npm run build-storybook
cd apps/web && npx http-server storybook-static -p 6007 &
cd apps/web && npm run test:storybook
```