feat(v0.701): AdminTransfers page/route, MSW, stories, Deep Health, API ref, docs, scope v0.702
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s

- Step 13: AdminTransfersPage, LazyAdminTransfers, route /admin/transfers
- Step 14: MSW handlers admin transfers
- Step 15: AdminTransfersView stories (Default, Empty, WithFailedTransfers, Error, Loading)
- Step 16-17: DeepHealth handler (disk, config), GET /health/deep
- Step 19: health_deep_test.go (4 tests)
- Step 20: docs/API_REFERENCE.md
- Step 21: Archive V0_604, MIGRATIONS.md migration 116
- Step 22: CHANGELOG, PROJECT_STATE, FEATURE_STATUS v0.701
- Step 23: RETROSPECTIVE_V0701, V0_702 placeholder, SCOPE_CONTROL, .cursorrules
- Step 24: Archive V0_701_RELEASE_SCOPE
- Fix: AdminTransfersView Select component (use options API)
This commit is contained in:
senke 2026-02-23 23:42:02 +01:00
parent 36e7bfc355
commit c785e61e69
22 changed files with 1232 additions and 30 deletions

View file

@ -1,10 +1,10 @@
# Règles de Développement UI - Projet SaaS
## 0. Scope v0.701 (priorité absolue)
## 0. Scope v0.702 (priorité absolue)
- **Référence** : `docs/V0_701_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
- Avant toute modification : vérifier si le changement est **dans le scope v0.701**
- **Autorisé v0.701** : Retry transfers, Admin transfers dashboard, Deep health checks, API documentation (voir V0_701_RELEASE_SCOPE.md)
- **Référence** : `docs/V0_702_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
- Avant toute modification : vérifier si le changement est **dans le scope v0.702**
- **Autorisé v0.702** : À définir (voir V0_702_RELEASE_SCOPE.md)
- **Interdit** : nouvelles routes/pages hors scope, nouvelles dépendances (sauf correctif sécurité)
- En cas de doute : ne pas ajouter. Créer une issue pour une version ultérieure.

View file

@ -1,5 +1,25 @@
# Changelog - Veza
## [v0.701] - 2026-02-23
### Added
- Transfer Retry Worker: automatic retry of failed Stripe Connect transfers (exponential backoff, max 3 retries)
- Migration 116: retry_count, next_retry_at columns on seller_transfers
- GET /admin/transfers: paginated admin view of all platform transfers (filters: status, seller, date)
- POST /admin/transfers/:id/retry: manual retry of failed transfers (admin only)
- AdminTransfersView frontend component with status badges, retry button
- GET /health/deep: deep health check (DB, Redis, S3, disk, config summary)
- Startup config validation: PlatformFeeRate range, Stripe Connect coherence, retry config
- Prometheus metrics: transfer retry (total, success, failures, permanent)
- docs/API_REFERENCE.md: API documentation with curl examples
### Changed
- SellerTransfer model: added retry_count, next_retry_at fields
- Config: added TRANSFER_RETRY_ENABLED, TRANSFER_RETRY_MAX, TRANSFER_RETRY_INTERVAL
- Health handler: added DeepHealth method with disk space and config
---
## [v0.603] - 2026-02-23
### Added

View file

@ -0,0 +1,122 @@
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { AdminTransfersView } from './AdminTransfersView';
const meta = {
title: 'Components/Admin/AdminTransfersView',
component: AdminTransfersView,
tags: ['autodocs'],
} satisfies Meta<typeof AdminTransfersView>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Default state with mocked transfers (MSW handlers-admin) */
export const Default: Story = {};
/** Empty state — no transfers */
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/transfers', () =>
HttpResponse.json({
success: true,
data: { transfers: [], total: 0 },
})
),
],
},
},
};
/** Only failed transfers (Retry button visible) */
export const WithFailedTransfers: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/transfers', () =>
HttpResponse.json({
success: true,
data: {
transfers: [
{
id: 'tr-fail-1',
seller_id: 'seller-1',
order_id: 'ord-1',
amount_cents: 2699,
platform_fee_cents: 300,
currency: 'EUR',
status: 'failed',
error_message: 'Stripe API error',
retry_count: 1,
next_retry_at: new Date(Date.now() + 600000).toISOString(),
created_at: new Date(Date.now() - 3600000).toISOString(),
},
{
id: 'tr-fail-2',
seller_id: 'seller-2',
order_id: 'ord-2',
amount_cents: 4500,
platform_fee_cents: 500,
currency: 'EUR',
status: 'failed',
error_message: 'Insufficient funds',
retry_count: 2,
next_retry_at: new Date(Date.now() + 1200000).toISOString(),
created_at: new Date(Date.now() - 86400000).toISOString(),
},
],
total: 2,
},
})
),
],
},
},
};
/** Error state — API returns error */
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/transfers', () =>
HttpResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
),
],
},
},
};
/** Loading state — delayed response shows skeleton */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('*/api/v1/admin/transfers', async () => {
await new Promise((r) => setTimeout(r, 2000));
return HttpResponse.json({
success: true,
data: {
transfers: [
{
id: 'tr-admin-1',
seller_id: 'seller-1',
order_id: 'ord-1',
amount_cents: 2699,
platform_fee_cents: 300,
currency: 'EUR',
status: 'completed',
retry_count: 0,
created_at: new Date().toISOString(),
},
],
total: 1,
},
});
}),
],
},
},
};

View file

@ -0,0 +1,249 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { RefreshCcw, ChevronLeft, ChevronRight } from 'lucide-react';
import { commerceService, type SellerTransfer } from '@/services/commerceService';
import { useToast } from '@/components/feedback/ToastProvider';
const LIMIT = 20;
function statusBadgeClass(status: string): string {
switch (status) {
case 'completed':
return 'bg-success/20 text-success';
case 'failed':
return 'bg-destructive/20 text-destructive';
case 'permanently_failed':
return 'bg-muted text-muted-foreground';
case 'pending':
return 'bg-warning/20 text-warning';
case 'skipped':
return 'bg-primary/20 text-primary';
default:
return 'bg-muted text-muted-foreground';
}
}
function truncateId(id: string): string {
if (!id || id.length < 12) return id;
return `${id.slice(0, 8)}`;
}
export function AdminTransfersView() {
const { addToast } = useToast();
const [transfers, setTransfers] = useState<SellerTransfer[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sellerIdFilter, setSellerIdFilter] = useState('');
const [offset, setOffset] = useState(0);
const [retrying, setRetrying] = useState<string | null>(null);
useEffect(() => {
setOffset(0);
}, [statusFilter, sellerIdFilter]);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string | number | undefined> = {
limit: LIMIT,
offset,
};
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
if (sellerIdFilter.trim()) params.seller_id = sellerIdFilter.trim();
const res = await commerceService.getAdminTransfers(params);
setTransfers(res.transfers);
setTotal(res.total);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
setTransfers([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [offset, statusFilter, sellerIdFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleRetry = async (transferId: string) => {
setRetrying(transferId);
try {
await commerceService.retryAdminTransfer(transferId);
addToast({ message: 'Transfer retried successfully', type: 'success' });
fetchData();
} catch (e) {
addToast({
message: e instanceof Error ? e.message : 'Retry failed',
type: 'error',
});
} finally {
setRetrying(null);
}
};
const hasNext = offset + LIMIT < total;
const hasPrev = offset > 0;
if (error) {
return (
<div className="container mx-auto px-4 py-8 max-w-layout-content">
<ErrorDisplay
error={error}
onRetry={fetchData}
title="Failed to load transfers"
context={{ action: 'loading', resource: 'admin transfers' }}
variant="card"
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-layout-content space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-foreground">Platform Transfers</h1>
<div className="flex flex-wrap items-center gap-2">
<Select
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'permanently_failed', label: 'Permanent fail' },
{ value: 'pending', label: 'Pending' },
{ value: 'skipped', label: 'Skipped' },
]}
value={statusFilter}
onChange={(v) => setStatusFilter(Array.isArray(v) ? v[0] ?? 'all' : v ?? 'all')}
placeholder="Status"
className="w-40"
/>
<Input
placeholder="Seller ID"
value={sellerIdFilter}
onChange={(e) => setSellerIdFilter(e.target.value)}
className="w-48"
/>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCcw className="w-4 h-4 mr-1" />
Refresh
</Button>
</div>
</div>
<Card variant="default" className="overflow-hidden">
{loading ? (
<div className="p-6 space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : transfers.length === 0 ? (
<div className="p-12 text-center text-muted-foreground">
No transfers found
</div>
) : (
<>
<Table aria-label="Platform transfers">
<TableHeader>
<TableRow>
<TableHead>Seller</TableHead>
<TableHead>Order</TableHead>
<TableHead>Net (EUR)</TableHead>
<TableHead>Fee (EUR)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Retries</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfers.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-mono text-xs">
{truncateId(t.seller_id ?? t.id)}
</TableCell>
<TableCell className="font-mono text-xs">
{truncateId(t.order_id)}
</TableCell>
<TableCell>
{(t.amount_cents / 100).toFixed(2)}
</TableCell>
<TableCell>
{(t.platform_fee_cents / 100).toFixed(2)}
</TableCell>
<TableCell>
<span
className={`text-xs px-2 py-0.5 rounded ${statusBadgeClass(
t.status
)}`}
>
{t.status}
</span>
</TableCell>
<TableCell>{t.retry_count ?? 0}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{new Date(t.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
{t.status === 'failed' && (
<Button
variant="outline"
size="sm"
onClick={() => handleRetry(t.id)}
disabled={retrying === t.id}
>
{retrying === t.id ? '…' : 'Retry'}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-sm text-muted-foreground">
{offset + 1}{Math.min(offset + LIMIT, total)} of {total}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setOffset((o) => Math.max(0, o - LIMIT))}
disabled={!hasPrev}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setOffset((o) => o + LIMIT)}
disabled={!hasNext}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</Card>
</div>
);
}

View file

@ -20,6 +20,7 @@ export {
LazyTrackDetail,
LazyPlaylistRoutes,
LazyAdminDashboard,
LazyAdminTransfers,
LazyAnalytics,
LazyWebhooks,
LazyDesignSystemDemo,

View file

@ -23,6 +23,7 @@ export {
LazyTrackDetail,
LazyPlaylistRoutes,
LazyAdminDashboard,
LazyAdminTransfers,
LazyAnalytics,
LazyWebhooks,
LazyDesignSystemDemo,

View file

@ -115,6 +115,14 @@ export const LazyAdminDashboard = createLazyComponent(
undefined,
'Admin Dashboard',
);
export const LazyAdminTransfers = createLazyComponent(
() =>
import('@/features/admin/pages/AdminTransfersPage').then((m) => ({
default: m.AdminTransfersPage,
})),
undefined,
'Admin Transfers',
);
export const LazyAnalytics = createLazyComponent(
() =>
import('@/features/analytics/pages/AnalyticsPage').then((m) => ({

View file

@ -0,0 +1,11 @@
/**
* Admin transfers page v0.701
*/
import { AdminTransfersView } from '@/components/admin/AdminTransfersView';
export function AdminTransfersPage() {
return <AdminTransfersView />;
}
export default AdminTransfersPage;

View file

@ -184,4 +184,62 @@ export const handlersAdmin = [
],
});
}),
// v0.701: Admin transfers
http.get('*/api/v1/admin/transfers', () => {
return HttpResponse.json({
success: true,
data: {
transfers: [
{
id: 'tr-admin-1',
seller_id: 'seller-1',
order_id: 'ord-1',
amount_cents: 2699,
platform_fee_cents: 300,
currency: 'EUR',
status: 'completed',
retry_count: 0,
created_at: new Date().toISOString(),
},
{
id: 'tr-admin-2',
seller_id: 'seller-2',
order_id: 'ord-2',
amount_cents: 4500,
platform_fee_cents: 500,
currency: 'EUR',
status: 'failed',
error_message: 'Stripe error',
retry_count: 1,
next_retry_at: new Date(Date.now() + 600000).toISOString(),
created_at: new Date(Date.now() - 86400000).toISOString(),
},
{
id: 'tr-admin-3',
seller_id: 'seller-1',
order_id: 'ord-3',
amount_cents: 1800,
platform_fee_cents: 200,
currency: 'EUR',
status: 'permanently_failed',
retry_count: 3,
created_at: new Date(Date.now() - 172800000).toISOString(),
},
],
total: 3,
},
});
}),
http.post('*/api/v1/admin/transfers/:id/retry', () => {
return HttpResponse.json({
success: true,
data: {
id: 'tr-admin-2',
status: 'completed',
retry_count: 2,
},
});
}),
];

View file

@ -25,6 +25,7 @@ import {
LazyAnalytics,
LazyWebhooks,
LazyAdminDashboard,
LazyAdminTransfers,
LazyDesignSystemDemo,
LazySocial,
LazySellerDashboard,
@ -97,6 +98,7 @@ export function getProtectedRoutes(): RouteEntry[] {
{ path: '/analytics', element: wrapProtected(<LazyAnalytics />) },
{ path: '/webhooks', element: wrapProtected(<LazyWebhooks />) },
{ path: '/admin', element: wrapProtected(<LazyAdminDashboard />) },
{ path: '/admin/transfers', element: wrapProtected(<LazyAdminTransfers />) },
{ path: '/social', element: wrapProtected(<LazySocial />) },
{ path: '/queue', element: wrapProtected(<LazyQueue />) },
{ path: '/developer', element: wrapProtected(<LazyDeveloper />) },

412
docs/API_REFERENCE.md Normal file
View file

@ -0,0 +1,412 @@
# API Reference — Veza Backend
Base URL: `http://localhost:8080/api/v1` (or your `APP_DOMAIN`)
All responses use the format: `{ "success": true|false, "data": {...} }` or `{ "success": false, "error": "..." }`.
---
## Authentication
### POST /auth/register
Register a new user.
**Auth:** None
**Body:**
```json
{
"username": "johndoe",
"email": "john@example.com",
"password": "SecureP@ssw0rd"
}
```
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"johndoe","email":"john@example.com","password":"SecureP@ssw0rd"}'
```
**Response (201):**
```json
{
"success": true,
"data": {
"user": { "id": "uuid", "username": "johndoe", "email": "john@example.com" },
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"expires_in": 3600
}
}
```
---
### POST /auth/login
Login with email and password.
**Auth:** None
**Body:**
```json
{
"email": "john@example.com",
"password": "SecureP@ssw0rd"
}
```
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"SecureP@ssw0rd"}'
```
**Response (200):**
```json
{
"success": true,
"data": {
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"expires_in": 3600,
"user": { "id": "uuid", "username": "johndoe", "email": "john@example.com" }
}
}
```
---
### POST /auth/refresh
Refresh access token using refresh token.
**Auth:** None (refresh token in body or cookie)
**Body:**
```json
{
"refresh_token": "eyJ..."
}
```
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"eyJ..."}'
```
---
### POST /auth/logout
Logout and invalidate refresh token.
**Auth:** Bearer token (optional)
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/auth/logout \
-H "Authorization: Bearer eyJ..."
```
---
## Marketplace
### GET /marketplace/products
List marketplace products (paginated).
**Auth:** None (public)
**Query params:** `limit`, `offset`, `search`, `sort`
**Example:**
```bash
curl "http://localhost:8080/api/v1/marketplace/products?limit=20&offset=0"
```
**Response (200):**
```json
{
"success": true,
"data": {
"products": [...],
"total": 42
}
}
```
---
### GET /marketplace/products/:id
Get a single product by ID.
**Auth:** None (public)
**Example:**
```bash
curl http://localhost:8080/api/v1/marketplace/products/uuid-here
```
---
### POST /marketplace/orders
Create an order (checkout).
**Auth:** Bearer token (required)
**Body:** Cart items, payment method, etc.
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/marketplace/orders \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"items":[{"product_id":"uuid","quantity":1}],"payment_method":"card"}'
```
---
## Seller
### GET /sell/balance
Get seller balance (Stripe Connect).
**Auth:** Bearer token (required)
**Example:**
```bash
curl http://localhost:8080/api/v1/sell/balance \
-H "Authorization: Bearer eyJ..."
```
**Response (200):**
```json
{
"success": true,
"data": {
"connected": true,
"available": 12500,
"pending": 3200
}
}
```
---
### GET /sell/transfers
List seller transfers (payout history).
**Auth:** Bearer token (required)
**Example:**
```bash
curl http://localhost:8080/api/v1/sell/transfers \
-H "Authorization: Bearer eyJ..."
```
---
### POST /sell/connect/onboard
Get Stripe Connect onboarding URL.
**Auth:** Bearer token (required)
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/sell/connect/onboard \
-H "Authorization: Bearer eyJ..."
```
**Response (200):**
```json
{
"success": true,
"data": {
"onboarding_url": "https://connect.stripe.com/..."
}
}
```
---
## Admin
### GET /admin/transfers
List all platform transfers (admin only, paginated).
**Auth:** Bearer token (admin role required)
**Query params:** `status`, `seller_id`, `from`, `to`, `limit`, `offset`
**Example:**
```bash
curl "http://localhost:8080/api/v1/admin/transfers?status=failed&limit=50&offset=0" \
-H "Authorization: Bearer eyJ..."
```
**Response (200):**
```json
{
"success": true,
"data": {
"transfers": [
{
"id": "uuid",
"seller_id": "uuid",
"order_id": "uuid",
"amount_cents": 2699,
"platform_fee_cents": 300,
"currency": "EUR",
"status": "failed",
"retry_count": 1,
"next_retry_at": "2026-02-24T10:00:00Z",
"created_at": "2026-02-23T12:00:00Z"
}
],
"total": 5
}
}
```
---
### POST /admin/transfers/:id/retry
Manually retry a failed transfer (admin only).
**Auth:** Bearer token (admin role required)
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/admin/transfers/uuid-here/retry \
-H "Authorization: Bearer eyJ..."
```
**Response (200):**
```json
{
"success": true,
"data": {
"id": "uuid",
"status": "completed",
"retry_count": 2
}
}
```
---
## Health
### GET /health
Simple health check (always returns OK).
**Auth:** None
**Example:**
```bash
curl http://localhost:8080/api/v1/health
```
**Response (200):**
```json
{
"success": true,
"data": { "status": "ok" }
}
```
---
### GET /health/deep
Deep health check (DB, Redis, S3, disk, config summary). v0.701
**Auth:** None
**Example:**
```bash
curl http://localhost:8080/api/v1/health/deep
```
**Response (200 healthy/degraded, 503 unhealthy):**
```json
{
"success": true,
"data": {
"status": "healthy",
"timestamp": "2026-02-23T12:00:00Z",
"checks": {
"database": { "status": "ok" },
"redis": { "status": "ok" },
"disk": { "status": "ok", "details": { "free_gb": 42.5 } }
},
"config": {
"jwt_secret_set": true,
"stripe_connect_enabled": true,
"platform_fee_rate": 0.10,
"transfer_retry_enabled": true
}
}
}
```
---
### GET /healthz
Liveness probe (Kubernetes).
**Auth:** None
---
### GET /readyz
Readiness probe (Kubernetes). Returns `ready`, `degraded`, or `not_ready`.
**Auth:** None
---
## Webhooks
### POST /webhooks/hyperswitch
Hyperswitch payment webhook (signature verification required).
**Auth:** Webhook secret (signature header)
**Example:**
```bash
curl -X POST http://localhost:8080/api/v1/webhooks/hyperswitch \
-H "Content-Type: application/json" \
-H "X-Hyperswitch-Signature: ..." \
-d '{"event":"payment.succeeded",...}'
```
---
## Legacy routes (deprecated)
The following routes are also available at the root (without `/api/v1` prefix) but are deprecated:
- `GET /health`, `GET /health/deep`, `GET /healthz`, `GET /readyz`
- `GET /metrics`
Use `/api/v1/...` for new integrations.

View file

@ -1,6 +1,6 @@
# Statut des fonctionnalités — Veza
**Dernière mise à jour** : février 2026 — v0.603 livrée (Transfer automatique Stripe Connect, commission plateforme)
**Dernière mise à jour** : février 2026 — v0.701 livrée (Retry transferts, Admin Dashboard, Deep Health)
Ce document décrit le statut réel des fonctionnalités par rapport au code.
@ -20,7 +20,7 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code.
| Recherche | Oui | Oui | GET /search unifié, GET /tracks/search. v0.202 : musical_key, tri pertinence. v0.203 : pg_trgm fuzzy, AND/OR/NOT, tooltip aide |
| Social (feed, posts, groups, follows, blocks, trending) | Oui | Oui | v0.301 : feed API, explore. v0.302 : groupes avancés (request join, invite, rôles, mes groupes) |
| Administration | Oui | Oui | Complet |
| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.603 : Transfer automatique Stripe Connect après vente, commission plateforme, GET /sell/transfers |
| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.603 : Transfer auto Stripe Connect. v0.701 : Retry auto failed transfers, Admin Dashboard GET/POST /admin/transfers |
| Webhooks | Oui | Oui | Complet |
| Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear |
| Live Streaming (métadonnées) | Oui | Oui | GET /api/v1/live/streams — stream vidéo via Stream Server |
@ -169,6 +169,19 @@ Voir [V0_602_RELEASE_SCOPE.md](archive/V0_602_RELEASE_SCOPE.md) pour le détail.
Voir [V0_603_RELEASE_SCOPE.md](archive/V0_603_RELEASE_SCOPE.md) pour le détail.
## Livré en v0.701 (Phase 7 — Retry, Admin Dashboard, Deep Health)
| Lot | Feature |
|-----|---------|
| R1 | Transfer Retry Worker : retry automatique des transferts failed (backoff exponentiel, max 3) |
| R1 | Migration 116 : retry_count, next_retry_at sur seller_transfers |
| A1 | Admin Transfers Dashboard : GET /admin/transfers, POST /admin/transfers/:id/retry |
| A1 | AdminTransfersView : tableau avec filtres, pagination, bouton Retry |
| H1 | Deep Health : GET /health/deep (DB, Redis, S3, disk, config summary) |
| D1 | docs/API_REFERENCE.md : documentation API avec exemples curl |
Voir [V0_701_RELEASE_SCOPE.md](archive/V0_701_RELEASE_SCOPE.md) pour le détail.
## Prévu en v0.403 (Phase 4 Commerce — suite)
| Lot | Feature |

View file

@ -34,6 +34,12 @@ Output: `veza-backend-api/migrations/baseline_v0601.sql`
3. Update the migration range comment (e.g. `001-113`) to reflect the latest migration number
4. The baseline file is auto-generated; do not edit it manually
## Recent Migrations
| # | File | Description |
|---|------|-------------|
| 116 | `116_seller_transfers_retry.sql` | v0.701: Add `retry_count`, `next_retry_at` to `seller_transfers`; index for failed retries |
## Adding New Migrations
1. Create a new file: `veza-backend-api/migrations/NNN_description.sql`

View file

@ -8,10 +8,10 @@
| Élément | Valeur |
|---------|--------|
| **Dernier tag** | v0.603 |
| **Dernier tag** | v0.701 |
| **Branche courante** | `main` |
| **Phase** | Phase 7 — Production Readiness |
| **Prochaine version** | v0.701 |
| **Prochaine version** | v0.702 |
---
@ -73,6 +73,14 @@
- Infra : MinIO S3-compatible (dev, staging, prod), 6 migrations (103108)
- Sécurité : Trivy container scanning CI
### v0.701 (Phase 7 — Retry, Admin Dashboard, Deep Health)
- Transfer Retry Worker : retry automatique des transferts failed (backoff exponentiel, max 3)
- Migration 116 : retry_count, next_retry_at sur seller_transfers
- GET /admin/transfers, POST /admin/transfers/:id/retry
- AdminTransfersView : tableau admin avec filtres, pagination, bouton Retry
- GET /health/deep : DB, Redis, S3, disk, config summary
- docs/API_REFERENCE.md
### v0.603 (Phase 6+ Transfer automatique, Commission & Stabilisation)
- T1 : Transfer automatique Stripe Connect après paiement réussi (webhook Hyperswitch)
- Commission plateforme configurable (PLATFORM_FEE_RATE, défaut 10 %)

View file

@ -0,0 +1,19 @@
# Rétrospective v0.701 — Retry Transfers, Admin Dashboard & Deep Health
## Ce qui a bien fonctionné
- **Transfer Retry Worker** : Goroutine avec ticker, backoff exponentiel, métriques Prometheus dédiées
- **Admin Transfers Dashboard** : Handler paginé avec filtres (status, seller_id), retry manuel, frontend AdminTransfersView avec badges et pagination
- **Deep Health** : GET /health/deep avec disk space (syscall.Statfs), config summary (jwt_secret_set, stripe_connect_enabled, etc.), distinction unhealthy (DB) vs degraded (Redis/S3)
- **API Reference** : docs/API_REFERENCE.md avec exemples curl pour auth, marketplace, seller, admin, health, webhooks
- **Tests** : transfer_retry_test.go (5 cas), admin_transfer_handler_test.go (6 cas), health_deep_test.go (4 cas)
## Points d'attention
- **Select component** : AdminTransfersView utilise le Select avec options/value/onChange (pas Radix SelectTrigger/SelectContent)
- **Stripe Connect désactivé** : Si Stripe Connect non configuré, RetryTransfer retourne 503 ; le worker ne démarre pas
## Prochaines étapes (v0.702)
- À définir selon V0_702_RELEASE_SCOPE.md
- Pistes : reviews produits, factures PDF, remboursements

View file

@ -1,23 +1,23 @@
# Contrôle du scope — Anti-scope-creep
**Objectif** : Éviter toute dérive de scope. Chaque modification doit être intentionnelle et traçable.
**Référence active** : [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md)
**Version précédente** : [V0_603_RELEASE_SCOPE.md](archive/V0_603_RELEASE_SCOPE.md)
**Référence active** : [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md)
**Version précédente** : [V0_701_RELEASE_SCOPE.md](archive/V0_701_RELEASE_SCOPE.md)
---
## 1. Règle d'or
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.701.**
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.702.**
> Si non → ne pas ajouter. Créer un ticket pour une version ultérieure.
---
## 2. Pendant la phase v0.701 (jusqu'au tag)
## 2. Pendant la phase v0.702 (jusqu'au tag)
### 2.1 Autorisé
- **Corrections de bugs** sur les features IN SCOPE v0.701
- **Corrections de bugs** sur les features IN SCOPE v0.702
- **Stabilisation** : tests, refactoring sans changement de comportement
- **Nettoyage** : suppression de code mort, consolidation
- **Documentation** : mise à jour des docs existantes
@ -26,20 +26,20 @@
### 2.2 Interdit
- **Nouvelles features** hors scope v0.701
- **Nouvelles features** hors scope v0.702
- **Nouvelles routes** ou pages hors scope
- **Nouvelles dépendances** (sauf correctif sécurité)
- **Changements de comportement** sur les features HORS SCOPE
- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.701
- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.702
### 2.3 Cas limite
| Situation | Action |
|-----------|--------|
| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.701. Sinon : ticket pour plus tard. |
| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.702. Sinon : ticket pour plus tard. |
| Dépendance obsolète/vulnérable | Mettre à jour. Documenter dans la PR. |
| Refactoring qui change une API interne | Autorisé si 0 impact sur le contrat public et tests passent. |
| "Petite amélioration UX" | **Non.** Créer un ticket pour v0.702+. |
| "Petite amélioration UX" | **Non.** Créer un ticket pour v0.703+. |
---
@ -47,13 +47,13 @@
### 3.1 Checklist pré-commit (dans la tête)
1. **Mon changement modifie-t-il une feature IN SCOPE v0.701 ?**
1. **Mon changement modifie-t-il une feature IN SCOPE v0.702 ?**
- Oui → Continuer. S'assurer qu'il n'y a pas de régression.
- Non → **STOP.** Est-ce une correction de bug ? Si oui, la feature est-elle IN SCOPE ?
2. **Mon changement ajoute-t-il du code ?**
- Nouvelle route, nouveau composant, nouveau service → Vérifier V0_701_RELEASE_SCOPE. Si hors scope → **STOP.**
- Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.701.
- Nouvelle route, nouveau composant, nouveau service → Vérifier V0_702_RELEASE_SCOPE. Si hors scope → **STOP.**
- Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.702.
3. **Mes tests passent-ils ?**
- `npm test -- --run` (frontend)
@ -81,7 +81,7 @@ Format : `type(scope): description`
Dans chaque PR, le relecteur doit valider :
- [ ] Le changement est dans le scope v0.701 (voir [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md))
- [ ] Le changement est dans le scope v0.702 (voir [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md))
- [ ] Aucune nouvelle feature ajoutée
- [ ] Aucune régression sur les flows critiques
- [ ] Les tests passent
@ -92,19 +92,19 @@ Dans chaque PR, le relecteur doit valider :
Une PR sera rejetée si :
- Elle ajoute une nouvelle route, page ou feature
- Elle modifie le comportement d'une feature HORS SCOPE v0.701 (sauf correctif bug critique)
- Elle modifie le comportement d'une feature HORS SCOPE v0.702 (sauf correctif bug critique)
- Les tests échouent
- Elle introduit une dépendance non justifiée
---
## 5. Proposer une feature pour APRÈS v0.603
## 5. Proposer une feature pour APRÈS v0.701
### 5.1 Template
Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec :
- **Alignement scope** : cocher "Hors scope v0.701 — pour v0.702+"
- **Alignement scope** : cocher "Hors scope v0.702 — pour v0.703+"
- **Justification** : pourquoi cette feature est nécessaire
- **Effort estimé** : S / M / L / XL
- **Dépendances** : quelles features v0.701 doivent être stables avant
@ -112,8 +112,8 @@ Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md
### 5.2 Workflow
1. Créer une issue avec le template
2. **Ne pas implémenter** tant que v0.701 n'est pas taguée
3. Une fois v0.701 stable, prioriser les issues post-v0.701 dans le scope suivant
2. **Ne pas implémenter** tant que v0.702 n'est pas taguée
3. Une fois v0.702 stable, prioriser les issues post-v0.702 dans le scope suivant
---
@ -132,7 +132,7 @@ Si une vulnérabilité critique est identifiée :
Si un bug bloque un déploiement ou un flow critique :
- Correctif autorisé
- La feature concernée doit être IN SCOPE v0.701 ou dépendance directe d'une feature IN SCOPE
- La feature concernée doit être IN SCOPE v0.702 ou dépendance directe d'une feature IN SCOPE
### 6.3 Décision collégiale
@ -140,7 +140,7 @@ Pour tout cas ambigu :
- Ouvrir une issue "Scope clarification"
- Décision documentée dans l'issue
- Mise à jour de V0_701_RELEASE_SCOPE.md si le scope est étendu (exception rare)
- Mise à jour de V0_702_RELEASE_SCOPE.md si le scope est étendu (exception rare)
---
@ -173,12 +173,12 @@ Pour tout cas ambigu :
- v0.602 : Phase 6+ — Payout, Dette Technique & Tests E2E — taguée
- v0.603 : Phase 6+ — Transfer automatique Stripe Connect, Commission plateforme, Dette technique — taguée
- v0.604 : Phase 6+ — absorbé par v0.701
- v0.701 : Phase 7 — Retry Transfers, Admin Dashboard, Deep Health — en cours
- v0.701 : Phase 7 — Retry Transfers, Admin Dashboard, Deep Health — taguée
---
## 8. Rappel pour les contributeurs
- **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification.
- **Humains** : Lire [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) avant de coder.
- **Humains** : Lire [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md) avant de coder.
- **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder.

View file

@ -0,0 +1,18 @@
# Scope v0.702 — Placeholder
**Statut** : Placeholder — à définir
**Version précédente** : [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) (archivé après tag)
---
## Lots prévus (à valider)
- À définir selon priorisation produit
- Pistes : reviews produits (R1), factures PDF (F1), remboursements (R2)
---
## Référence
Voir [SCOPE_CONTROL.md](SCOPE_CONTROL.md) pour les règles de scope.

View file

@ -90,6 +90,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
var healthCheckHandler gin.HandlerFunc
var livenessHandler gin.HandlerFunc
var readinessHandler gin.HandlerFunc
var deepHealthHandler gin.HandlerFunc
if r.db != nil && r.db.GormDB != nil {
var redisClient interface{}
@ -122,19 +123,35 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
jobWorker,
emailSender,
)
if r.config != nil {
healthHandler.SetDeepHealthConfig(&handlers.DeepHealthConfig{
JWTSecretSet: len(r.config.JWTSecret) >= 32,
StripeConnectEnabled: r.config.StripeConnectEnabled,
PlatformFeeRate: r.config.PlatformFeeRate,
TransferRetryEnabled: r.config.TransferRetryEnabled,
})
}
healthCheckHandler = healthHandler.Check
livenessHandler = healthHandler.Liveness
readinessHandler = healthHandler.Readiness
deepHealthHandler = healthHandler.DeepHealth
} else {
healthCheckHandler = handlers.SimpleHealthCheck
livenessHandler = handlers.SimpleHealthCheck
readinessHandler = handlers.SimpleHealthCheck
deepHealthHandler = func(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": true,
"data": gin.H{"status": "unhealthy", "message": "Database not configured"},
})
}
}
deprecationMW := middleware.DeprecationWarning(r.logger)
healthMonitoringMW := middleware.HealthCheckMonitoring(r.logger, r.monitoringService)
router.GET("/health", deprecationMW, healthMonitoringMW, healthCheckHandler)
router.GET("/health/deep", deprecationMW, healthMonitoringMW, deepHealthHandler)
router.GET("/healthz", deprecationMW, healthMonitoringMW, livenessHandler)
router.GET("/readyz", deprecationMW, healthMonitoringMW, readinessHandler)
router.GET("/metrics", deprecationMW, handlers.PrometheusMetrics())
@ -146,6 +163,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
v1Public := router.Group("/api/v1")
{
v1Public.GET("/health", healthCheckHandler)
v1Public.GET("/health/deep", deepHealthHandler)
v1Public.GET("/healthz", livenessHandler)
v1Public.GET("/readyz", readinessHandler)

View file

@ -3,6 +3,7 @@ package handlers
import (
"context"
"net/http"
"syscall"
"time"
"github.com/gin-gonic/gin"
@ -14,6 +15,14 @@ import (
"veza-backend-api/internal/eventbus"
)
// DeepHealthConfig holds config summary for /health/deep (v0.701)
type DeepHealthConfig struct {
JWTSecretSet bool
StripeConnectEnabled bool
PlatformFeeRate float64
TransferRetryEnabled bool
}
// HealthResponse représente la réponse du health check
type HealthResponse struct {
Status string `json:"status"`
@ -41,6 +50,7 @@ type HealthHandler struct {
s3Service interface{} // BE-SVC-016: S3 storage service (optional)
jobWorker interface{} // BE-SVC-016: Job worker (optional)
emailSender interface{} // BE-SVC-016: Email sender (optional)
deepHealthConfig *DeepHealthConfig // v0.701: config summary for /health/deep
}
// NewHealthHandler crée un nouveau handler de health
@ -82,6 +92,11 @@ func NewHealthHandlerWithServices(
return h
}
// SetDeepHealthConfig sets the config summary for DeepHealth (v0.701)
func (h *HealthHandler) SetDeepHealthConfig(cfg *DeepHealthConfig) {
h.deepHealthConfig = cfg
}
// NewHealthHandlerSimple crée un nouveau handler de health simple (sans logger/redis)
// Pour compatibilité avec la spécification T0012
func NewHealthHandlerSimple(db *gorm.DB) *HealthHandler {
@ -248,6 +263,96 @@ func (h *HealthHandler) Liveness(c *gin.Context) {
})
}
// DeepHealth check endpoint (/health/deep) — v0.701
// Returns healthy, degraded (non-critical services down), or unhealthy (DB down).
// HTTP: 200 for healthy/degraded, 503 for unhealthy.
func (h *HealthHandler) DeepHealth(c *gin.Context) {
response := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Checks: make(map[string]HealthCheck),
}
// Critical: DB
dbCheck := h.checkDatabase()
response.Checks["database"] = dbCheck
if dbCheck.Status == "error" {
response.Status = "unhealthy"
response.Message = "Database is down"
c.JSON(http.StatusServiceUnavailable, gin.H{"success": true, "data": response})
return
}
// Non-critical: Redis, RabbitMQ, S3, JobWorker, Email
response.Checks["redis"] = h.checkRedis()
response.Checks["rabbitmq"] = h.checkRabbitMQ()
if h.s3Service != nil {
response.Checks["s3_storage"] = h.checkS3()
}
if h.jobWorker != nil {
response.Checks["job_worker"] = h.checkJobWorker()
}
if h.emailSender != nil {
response.Checks["email_sender"] = h.checkEmailSender()
}
// Disk space
diskCheck := h.checkDiskSpace()
response.Checks["disk"] = diskCheck
// Config summary (v0.701)
configSummary := make(map[string]interface{})
if h.deepHealthConfig != nil {
configSummary["jwt_secret_set"] = h.deepHealthConfig.JWTSecretSet
configSummary["stripe_connect_enabled"] = h.deepHealthConfig.StripeConnectEnabled
configSummary["platform_fee_rate"] = h.deepHealthConfig.PlatformFeeRate
configSummary["transfer_retry_enabled"] = h.deepHealthConfig.TransferRetryEnabled
}
// Determine overall status
for key, check := range response.Checks {
if key == "database" {
continue
}
if check.Status == "error" {
response.Status = "degraded"
break
}
}
// Build response with config field
resp := gin.H{
"status": response.Status,
"timestamp": response.Timestamp,
"checks": response.Checks,
"config": configSummary,
}
if response.Message != "" {
resp["message"] = response.Message
}
RespondSuccess(c, http.StatusOK, resp)
}
// checkDiskSpace returns free disk space in GB (v0.701)
func (h *HealthHandler) checkDiskSpace() HealthCheck {
var stat syscall.Statfs_t
if err := syscall.Statfs("/", &stat); err != nil {
return HealthCheck{
Status: "error",
Message: err.Error(),
}
}
// Block size * free blocks / (1024^3) = GB
freeGB := float64(stat.Bfree) * float64(stat.Bsize) / (1024 * 1024 * 1024)
return HealthCheck{
Status: "ok",
Message: "disk space available",
Details: map[string]interface{}{
"free_gb": freeGB,
},
}
}
// SimpleHealthCheck est une fonction simple pour le health check endpoint public
func SimpleHealthCheck(c *gin.Context) {
RespondSuccess(c, http.StatusOK, gin.H{

View file

@ -0,0 +1,131 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestDeepHealth_AllHealthy(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
logger := zaptest.NewLogger(t)
handler := NewHealthHandler(db, logger, nil, nil, "test")
handler.SetDeepHealthConfig(&DeepHealthConfig{
JWTSecretSet: true,
StripeConnectEnabled: false,
PlatformFeeRate: 0.10,
TransferRetryEnabled: true,
})
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health/deep", handler.DeepHealth)
req, _ := http.NewRequest("GET", "/health/deep", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var wrapper map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
data, ok := wrapper["data"].(map[string]interface{})
require.True(t, ok)
assert.Contains(t, []string{"healthy", "degraded"}, data["status"])
assert.Contains(t, data["checks"], "database")
assert.Contains(t, data["checks"], "disk")
assert.Contains(t, data, "config")
}
func TestDeepHealth_DBDown(t *testing.T) {
logger := zaptest.NewLogger(t)
handler := NewHealthHandler(nil, logger, nil, nil, "test")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health/deep", handler.DeepHealth)
req, _ := http.NewRequest("GET", "/health/deep", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
var wrapper map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
data, ok := wrapper["data"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "unhealthy", data["status"])
}
func TestDeepHealth_RedisDegraded(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
logger := zaptest.NewLogger(t)
// Redis nil = "error" from checkRedis
handler := NewHealthHandler(db, logger, nil, nil, "test")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health/deep", handler.DeepHealth)
req, _ := http.NewRequest("GET", "/health/deep", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Degraded = 200 OK (load balancer continues)
assert.Equal(t, http.StatusOK, w.Code)
var wrapper map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
data, ok := wrapper["data"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "degraded", data["status"])
}
func TestDeepHealth_ConfigSummary(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
logger := zaptest.NewLogger(t)
handler := NewHealthHandler(db, logger, nil, nil, "test")
handler.SetDeepHealthConfig(&DeepHealthConfig{
JWTSecretSet: true,
StripeConnectEnabled: true,
PlatformFeeRate: 0.15,
TransferRetryEnabled: false,
})
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health/deep", handler.DeepHealth)
req, _ := http.NewRequest("GET", "/health/deep", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var wrapper map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
data, ok := wrapper["data"].(map[string]interface{})
require.True(t, ok)
config, ok := data["config"].(map[string]interface{})
require.True(t, ok, "Response should have 'config' field")
assert.Equal(t, true, config["jwt_secret_set"])
assert.Equal(t, true, config["stripe_connect_enabled"])
assert.Equal(t, 0.15, config["platform_fee_rate"])
assert.Equal(t, false, config["transfer_retry_enabled"])
}