feat: request SIRET/SIREN admin approval from payment page
This commit is contained in:
parent
295874e11f
commit
37c685d73f
@ -12,6 +12,7 @@ import {
|
|||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
@ -41,10 +42,16 @@ import {
|
|||||||
ORGANIZATION_REPOSITORY,
|
ORGANIZATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/organization.repository';
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
|
import {
|
||||||
|
NotificationType,
|
||||||
|
NotificationPriority,
|
||||||
|
} from '@domain/entities/notification.entity';
|
||||||
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,7 +71,9 @@ export class OrganizationsController {
|
|||||||
private readonly logger = new Logger(OrganizationsController.name);
|
private readonly logger = new Logger(OrganizationsController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
|
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
||||||
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -297,6 +306,72 @@ export class OrganizationsController {
|
|||||||
return OrganizationMapper.toDto(updatedOrg);
|
return OrganizationMapper.toDto(updatedOrg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request SIRET/SIREN approval from admins
|
||||||
|
*
|
||||||
|
* Any authenticated user can call this to notify all admins
|
||||||
|
* that their organization's SIRET/SIREN needs approval.
|
||||||
|
*/
|
||||||
|
@Post('request-siret-approval')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Request SIRET/SIREN approval',
|
||||||
|
description: 'Sends a notification to all admins requesting manual SIRET/SIREN verification.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
||||||
|
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
||||||
|
async requestSiretApproval(
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const organization = await this.organizationRepository.findById(user.organizationId);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organization.siren && !organization.siret) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Aucun SIRET ou SIREN renseigné sur votre organisation. Veuillez les ajouter avant de demander la validation.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organization.siretVerified) {
|
||||||
|
throw new BadRequestException('Votre SIRET/SIREN est déjà vérifié.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = await this.userRepository.findByRole('ADMIN');
|
||||||
|
|
||||||
|
const identifier = organization.siret
|
||||||
|
? `SIRET ${organization.siret}`
|
||||||
|
: `SIREN ${organization.siren}`;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
admins.map(admin =>
|
||||||
|
this.notificationService.createNotification({
|
||||||
|
userId: admin.id,
|
||||||
|
organizationId: admin.organizationId,
|
||||||
|
type: NotificationType.ORGANIZATION_UPDATE,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
title: 'Demande de validation SIRET/SIREN',
|
||||||
|
message: `L'organisation "${organization.name}" demande la validation de son ${identifier}.`,
|
||||||
|
metadata: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
siret: organization.siret,
|
||||||
|
siren: organization.siren,
|
||||||
|
requestedBy: user.email,
|
||||||
|
},
|
||||||
|
actionUrl: `/dashboard/admin/organizations`,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[${user.email}] SIRET/SIREN approval requested for org ${organization.name} (${organization.id})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { message: 'Votre demande a été envoyée aux administrateurs.' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List organizations
|
* List organizations
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { OrganizationsController } from '../controllers/organizations.controller';
|
import { OrganizationsController } from '../controllers/organizations.controller';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
// Import domain ports
|
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider
|
TypeOrmModule.forFeature([OrganizationOrmEntity]),
|
||||||
|
UsersModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [
|
providers: [
|
||||||
@ -18,8 +21,6 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/
|
|||||||
useClass: TypeOrmOrganizationRepository,
|
useClass: TypeOrmOrganizationRepository,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [ORGANIZATION_REPOSITORY],
|
||||||
ORGANIZATION_REPOSITORY, // optional, if other modules need it
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class OrganizationsModule {}
|
export class OrganizationsModule {}
|
||||||
|
|||||||
@ -19,8 +19,12 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
Clock,
|
Clock,
|
||||||
|
ShieldCheck,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
||||||
|
import { getOrganization, requestSiretApproval } from '@/lib/api/organizations';
|
||||||
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
|
|
||||||
interface BookingData {
|
interface BookingData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -42,6 +46,13 @@ interface BookingData {
|
|||||||
commissionAmountEur?: number;
|
commissionAmountEur?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OrgData {
|
||||||
|
siren?: string | null;
|
||||||
|
siret?: string | null;
|
||||||
|
siretVerified?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
type PaymentMethod = 'card' | 'transfer' | null;
|
type PaymentMethod = 'card' | 'transfer' | null;
|
||||||
|
|
||||||
const BANK_DETAILS = {
|
const BANK_DETAILS = {
|
||||||
@ -54,21 +65,32 @@ export default function PayCommissionPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const bookingId = params.id as string;
|
const bookingId = params.id as string;
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [booking, setBooking] = useState<BookingData | null>(null);
|
const [booking, setBooking] = useState<BookingData | null>(null);
|
||||||
|
const [org, setOrg] = useState<OrgData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
const [declaring, setDeclaring] = useState(false);
|
const [declaring, setDeclaring] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const [requestingApproval, setRequestingApproval] = useState(false);
|
||||||
|
const [approvalSent, setApprovalSent] = useState(false);
|
||||||
|
const [approvalError, setApprovalError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchBooking() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const data = await getCsvBooking(bookingId);
|
const [bookingData, orgData] = await Promise.all([
|
||||||
setBooking(data as any);
|
getCsvBooking(bookingId),
|
||||||
if (data.status !== 'PENDING_PAYMENT') {
|
user?.organizationId ? getOrganization(user.organizationId) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBooking(bookingData as any);
|
||||||
|
if (orgData) setOrg(orgData);
|
||||||
|
|
||||||
|
if ((bookingData as any).status !== 'PENDING_PAYMENT') {
|
||||||
router.replace('/dashboard/bookings');
|
router.replace('/dashboard/bookings');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -77,8 +99,8 @@ export default function PayCommissionPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bookingId) fetchBooking();
|
if (bookingId) fetchData();
|
||||||
}, [bookingId, router]);
|
}, [bookingId, router, user?.organizationId]);
|
||||||
|
|
||||||
const handlePayByCard = async () => {
|
const handlePayByCard = async () => {
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
@ -104,6 +126,21 @@ export default function PayCommissionPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequestApproval = async () => {
|
||||||
|
setRequestingApproval(true);
|
||||||
|
setApprovalError(null);
|
||||||
|
try {
|
||||||
|
await requestSiretApproval();
|
||||||
|
setApprovalSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setApprovalError(
|
||||||
|
err instanceof Error ? err.message : 'Erreur lors de l\'envoi de la demande'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRequestingApproval(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copyToClipboard = (value: string, key: string) => {
|
const copyToClipboard = (value: string, key: string) => {
|
||||||
navigator.clipboard.writeText(value);
|
navigator.clipboard.writeText(value);
|
||||||
setCopied(key);
|
setCopied(key);
|
||||||
@ -113,6 +150,14 @@ export default function PayCommissionPage() {
|
|||||||
const formatPrice = (price: number, currency: string) =>
|
const formatPrice = (price: number, currency: string) =>
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
||||||
|
|
||||||
|
const hasSiretOrSiren = org && (org.siret || org.siren);
|
||||||
|
const siretNotVerified = hasSiretOrSiren && !org.siretVerified;
|
||||||
|
const identifier = org?.siret
|
||||||
|
? `SIRET ${org.siret}`
|
||||||
|
: org?.siren
|
||||||
|
? `SIREN ${org.siren}`
|
||||||
|
: null;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
@ -164,6 +209,60 @@ export default function PayCommissionPage() {
|
|||||||
Finalisez votre booking en réglant la commission de service
|
Finalisez votre booking en réglant la commission de service
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
||||||
|
{siretNotVerified && (
|
||||||
|
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-amber-900 mb-1">
|
||||||
|
Votre {identifier} n'est pas encore validé
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-700 mb-4">
|
||||||
|
Un administrateur doit vérifier votre numéro d'identification avant de
|
||||||
|
finaliser certaines opérations. Vous pouvez lui envoyer une demande de validation
|
||||||
|
directement depuis cette page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{approvalSent ? (
|
||||||
|
<div className="flex items-center space-x-2 text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm">
|
||||||
|
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
Demande envoyée aux administrateurs. Vous serez notifié dès que votre{' '}
|
||||||
|
{identifier} sera validé.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{approvalError && (
|
||||||
|
<p className="text-sm text-red-600">{approvalError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleRequestApproval}
|
||||||
|
disabled={requestingApproval}
|
||||||
|
className="inline-flex items-center space-x-2 px-4 py-2.5 bg-amber-600 hover:bg-amber-700 disabled:bg-amber-400 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{requestingApproval ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Envoi en cours...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
<span>Demander la validation à un administrateur</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
@ -420,6 +519,29 @@ export default function PayCommissionPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation info */}
|
||||||
|
{org && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-2 text-sm">
|
||||||
|
<p className="font-medium text-gray-700">{org.name}</p>
|
||||||
|
{identifier && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-500">{identifier}</span>
|
||||||
|
{org.siretVerified ? (
|
||||||
|
<span className="flex items-center space-x-1 text-green-600 text-xs font-medium">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
<span>Vérifié</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center space-x-1 text-amber-600 text-xs font-medium">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
<span>Non vérifié</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
|
|||||||
@ -66,3 +66,12 @@ export async function updateOrganization(
|
|||||||
): Promise<OrganizationResponse> {
|
): Promise<OrganizationResponse> {
|
||||||
return patch<OrganizationResponse>(`/api/v1/organizations/${id}`, data);
|
return patch<OrganizationResponse>(`/api/v1/organizations/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request SIRET/SIREN approval from admins
|
||||||
|
* POST /api/v1/organizations/request-siret-approval
|
||||||
|
* Requires: Authenticated user
|
||||||
|
*/
|
||||||
|
export async function requestSiretApproval(): Promise<{ message: string }> {
|
||||||
|
return post<{ message: string }>('/api/v1/organizations/request-siret-approval', {});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user