diff --git a/.env.dev b/.env.dev index 2959b3217..d98479ba4 100644 --- a/.env.dev +++ b/.env.dev @@ -13,10 +13,7 @@ SENTRY_AUTH_TOKEN= MATOMO_ENABLED=disabled MATOMO_SITE_ID= MATOMO_API_SITE_ID= -UPTIME_ROBOT_API_KEY= ALTERNATIVE_SEARCH_ROUTE= -INSEE_DIRIGEANT_CLIENT_ID= -INSEE_DIRIGEANT_CLIENT_SECRET= PROXY_API_KEY= REDIS_URL=redis://127.0.0.1:6379 REDIS_ENABLED=false @@ -39,4 +36,5 @@ FRANCECONNECT_REDIRECT_URI=http://localhost:3000/api/auth/france-connect/callbac FRANCECONNECT_POST_LOGOUT_REDIRECT_URI=http://localhost:3000/api/auth/france-connect/logout-callback CRISP_TOKEN_ID= CRISP_TOKEN_KEY= -CRISP_WEBSITE_ID="064fca1b-bdd6-4a81-af56-9f38e40953ad" \ No newline at end of file +CRISP_WEBSITE_ID="064fca1b-bdd6-4a81-af56-9f38e40953ad" +DATA_SUBVENTION_API_KEY= \ No newline at end of file diff --git a/app/(header-default)/donnees-financieres/[slug]/page.tsx b/app/(header-default)/donnees-financieres/[slug]/page.tsx index c4f781d19..d28983d04 100644 --- a/app/(header-default)/donnees-financieres/[slug]/page.tsx +++ b/app/(header-default)/donnees-financieres/[slug]/page.tsx @@ -1,7 +1,7 @@ -import { Metadata } from 'next'; import { HorizontalSeparator } from '#components-ui/horizontal-separator'; import { FinancesAssociationSection } from '#components/finances-section/association'; import { FinancesSocieteSection } from '#components/finances-section/societe'; +import { SubventionsAssociationSection } from '#components/subventions-association-section'; import Title from '#components/title-section'; import { FICHE } from '#components/title-section/tabs'; import { isAssociation } from '#models/core/types'; @@ -13,6 +13,7 @@ import extractParamsAppRouter, { import getSession from '#utils/server-side-helper/app/get-session'; import ComptesBodacc from 'app/(header-default)/donnees-financieres/[slug]/_components/bodacc'; import { ComptesAssociationSection } from 'app/(header-default)/donnees-financieres/[slug]/_components/dca'; +import { Metadata } from 'next'; import BilansSection from './_components'; export const generateMetadata = async ( @@ -45,10 +46,16 @@ const FinancePage = async (props: AppRouterProps) => { session={session} /> {isAssociation(uniteLegale) ? ( - + <> + + + ) : ( <> diff --git a/app/api/data-fetching/routes-handlers.ts b/app/api/data-fetching/routes-handlers.ts index 434acf6d3..7d1bfd7cb 100644 --- a/app/api/data-fetching/routes-handlers.ts +++ b/app/api/data-fetching/routes-handlers.ts @@ -10,6 +10,7 @@ import { getMandatairesRCS } from '#models/espace-agent/mandataires-rcs'; import { getDocumentsRNEProtected } from '#models/espace-agent/rne-protected/documents'; import { getDirigeantsRNE } from '#models/rne/dirigeants'; import { getRNEObservations } from '#models/rne/observations'; +import { getSubventionsAssociationFromSlug } from '#models/subventions/association'; import { buildAndVerifyTVA } from '#models/tva/verify'; import { UnwrapPromise } from 'types'; import getBeneficiairesController, { @@ -31,6 +32,7 @@ export const APIRoutesHandlers = { association: getAssociationFromSlug, 'verify-tva': buildAndVerifyTVA, 'eori-validation': getEORIValidation, + 'subventions-association': getSubventionsAssociationFromSlug, } as const; export type APIPath = keyof typeof APIRoutesHandlers; diff --git a/app/api/data-fetching/routes-scopes.ts b/app/api/data-fetching/routes-scopes.ts index 8bc8bf086..38205ab76 100644 --- a/app/api/data-fetching/routes-scopes.ts +++ b/app/api/data-fetching/routes-scopes.ts @@ -17,4 +17,5 @@ export const APIRoutesScopes: Record = { association: AppScope.none, 'verify-tva': AppScope.none, 'eori-validation': AppScope.none, + 'subventions-association': AppScope.subventionsAssociation, }; diff --git a/clients/api-data-subvention/index.ts b/clients/api-data-subvention/index.ts new file mode 100644 index 000000000..782bf4d1b --- /dev/null +++ b/clients/api-data-subvention/index.ts @@ -0,0 +1,56 @@ +import { HttpNotFound } from '#clients/exceptions'; +import routes from '#clients/routes'; +import constants from '#models/constants'; +import { ISubvention, ISubventions } from '#models/subventions/association'; +import { Siren } from '#utils/helpers'; +import { httpGet } from '#utils/network'; + +/** + * Data Subvention + * https://api.datasubvention.beta.gouv.fr/ + */ +export const clientDataSubvention = async ( + siren: Siren +): Promise => { + const route = routes.apiDataSubvention.grants.replace('{identifier}', siren); + const data = await httpGet(route, { + headers: { 'x-access-token': process.env.DATA_SUBVENTION_API_KEY }, + timeout: constants.timeout.XXXL, + }); + + const msgNotFound = `No subvention data found for : ${siren}`; + + if (!data.subventions || data.subventions.length === 0) { + throw new HttpNotFound(msgNotFound); + } + + const subventions = mapToDomainObject(data.subventions); + + if (subventions.length === 0) { + throw new HttpNotFound(msgNotFound); + } + return subventions; +}; + +const mapToDomainObject = (grantItems: IGrantItem[]): ISubvention[] => { + return grantItems + .filter((grantItem) => Boolean(grantItem.application)) + .reduce((subventions: ISubvention[], grantItem) => { + const year = grantItem.application.annee_demande?.value; + const label = grantItem.application.statut_label?.value; + const status = grantItem.application.status?.value; + const description = grantItem.application.dispositif?.value; + const amount = grantItem.application.montants?.accorde?.value; + + const newSubvention: ISubvention = { + year, + label, + status, + description, + amount, + }; + + return [...subventions, newSubvention]; + }, []) + .sort((a, b) => b.year - a.year); +}; diff --git a/clients/api-data-subvention/interface.ts b/clients/api-data-subvention/interface.ts new file mode 100644 index 000000000..477302449 --- /dev/null +++ b/clients/api-data-subvention/interface.ts @@ -0,0 +1,96 @@ +type ApplicationField = { + value: T; + provider: string; + last_update: string; + type: string; +}; + +type SubventionStatus = 'Accordé' | 'Refusé' | 'Prise en charge' | 'Recevable'; +type SubventionLabel = 'Accordé' | 'Refusé' | 'En instruction'; + +type Contact = { + email: ApplicationField; + telephone: ApplicationField; +}; + +type Montants = { + total: ApplicationField; + demande: ApplicationField; + propose: ApplicationField; + accorde: ApplicationField; +}; + +type Versement = { + acompte: ApplicationField; + solde: ApplicationField; + realise: ApplicationField; + compensation: { + 'n-1': ApplicationField; + reversement: ApplicationField; + }; +}; + +type Payment = { + activitee: ApplicationField; + amount: ApplicationField; + bop: ApplicationField; + branche: ApplicationField; + centreFinancier: ApplicationField; + codeBranche: ApplicationField; + dateOperation: ApplicationField; + domaineFonctionnel: ApplicationField; + ej: ApplicationField; + libelleProgramme: ApplicationField; + numeroDemandePayment: ApplicationField; + numeroTier: ApplicationField; + programme: ApplicationField; + siret: ApplicationField; + versementKey: ApplicationField; +}; + +type ActionProposeeType = { + ej: ApplicationField; + rang: ApplicationField; + intitule: ApplicationField; + objectifs: ApplicationField; + objectifs_operationnels: { + provider: string; + last_update: string; + type: string; + }; + description: ApplicationField; +}; + +type TerritoireType = { + status: { + provider: string; + last_update: string; + type: string; + }; + commentaire: ApplicationField; +}; + +type Application = { + actions_proposee: ActionProposeeType[]; + annee_demande: ApplicationField; + contact: Contact; + // This is the name of the subvention + dispositif: ApplicationField; + ej: ApplicationField; + financeur_principal: ApplicationField; + montants: Montants; + pluriannualite: ApplicationField; + service_instructeur: ApplicationField; + siret: ApplicationField; + sous_dispositif: ApplicationField; + status: ApplicationField; + statut_label: ApplicationField; + territoires: TerritoireType[]; + versement: Versement; + versementKey: ApplicationField; +}; + +type IGrantItem = { + application: Application; + payments: Payment[]; +}; diff --git a/clients/routes.ts b/clients/routes.ts index 925cbf5db..85522c10b 100644 --- a/clients/routes.ts +++ b/clients/routes.ts @@ -40,6 +40,11 @@ const routes = { datagouv: { ess: 'https://tabular-api.data.gouv.fr/api/resources/57bc99ca-0432-4b46-8fcc-e76a35c9efaf/data/', }, + apiDataSubvention: { + documentation: 'https://api.datasubvention.beta.gouv.fr/docs', + grants: + 'https://api.datasubvention.beta.gouv.fr/association/{identifier}/grants', + }, conventionsCollectives: { site: 'https://code.travail.gouv.fr/outils/convention-collective', details: 'https://code.travail.gouv.fr/convention-collective/', diff --git a/components/administrations/index.tsx b/components/administrations/index.tsx index c8f375d91..71dca216d 100644 --- a/components/administrations/index.tsx +++ b/components/administrations/index.tsx @@ -67,6 +67,15 @@ export const DJEPVA = ({ queryString = '' }) => ( ); +export const DataSubvention = ({ queryString = '' }) => ( + + Data Subvention + +); + export const MEF = ({ queryString = '' }) => ( ( + + Data.subvention est un outil développé par la . Il recense les + subventions demandées et reçues par une association. +
+ Les données sont issues de Chorus et du Fonjep (Fonds de + coopération de la jeunesse et de l’éducation populaire). +
+); + +const SubventionDetails: React.FC<{ subventions: ISubventions }> = ({ + subventions, +}) => { + const subventionStats = useMemo(() => { + const totalSubventions = subventions.length; + const mostRecentYear = subventions[totalSubventions - 1]?.year; + const approvedSubventions = subventions.filter( + (subvention) => subvention.label === 'Accordé' + ); + const totalApproved = approvedSubventions.length; + const totalAmount = approvedSubventions.reduce( + (acc, subvention) => acc + subvention.amount, + 0 + ); + + return { + totalSubventions, + mostRecentYear, + totalApproved, + totalAmount, + }; + }, [subventions]); + + return ( + <> + Depuis {subventionStats.mostRecentYear}, cette association compte{' '} + {subventionStats.totalSubventions} demandes de subventions référencées + dans . +

+ Parmi ces subventions :{' '} + {subventionStats.totalApproved} ont été accordées pour un total + de {formatCurrency(subventionStats.totalAmount)}. Le reste a été + refusé, est en cours d’instruction ou se situe dans un état inconnu. +

+ + ); +}; + +const SubventionsAssociation: React.FC<{ + uniteLegale: IAssociation; + session: ISession | null; +}> = ({ uniteLegale, session }) => { + const subventions = useAPIRouteData( + 'subventions-association', + uniteLegale.siren, + session + ); + + return ( + + {(subventions) => + !subventions || subventions?.length === 0 ? ( + <> + Aucune demande de subvention n’a été trouvée pour cette association + dans . + + ) : ( + <> + + [ + {subvention.year}, + subvention.description ? ( + {subvention.description} + ) : ( + + ), + formatCurrency(subvention.amount), + subvention.label ? ( + + {subvention.label} + + ) : ( + Inconnu + ), + ])} + /> + + ) + } + + ); +}; + +export const SubventionsAssociationSection: React.FC<{ + uniteLegale: IAssociation; + session: ISession | null; +}> = ({ uniteLegale, session }) => { + if (!hasRights(session, AppScope.subventionsAssociation)) { + // for a start lets hide it first before Data subvention validation + return null; + // return ( + // + // ); + } + return ; +}; diff --git a/components/title-section/tabs/index.tsx b/components/title-section/tabs/index.tsx index 01dc3a44d..cb3e91cde 100644 --- a/components/title-section/tabs/index.tsx +++ b/components/title-section/tabs/index.tsx @@ -1,4 +1,3 @@ -import Link from 'next/link'; import { PrintNever } from '#components-ui/print-visibility'; import { checkHasLabelsAndCertificates, @@ -11,6 +10,7 @@ import { } from '#models/core/types'; import { AppScope, hasRights } from '#models/user/rights'; import { ISession } from '#models/user/session'; +import Link from 'next/link'; import styles from './styles.module.css'; import TabLink from './tab-link'; @@ -87,7 +87,7 @@ export const Tabs: React.FC<{ pathPrefix: '/annonces/', noFollow: false, shouldDisplay: true, - width: '130px', + width: uniteLegale.dateMiseAJourInpi ? '130px' : '90px', }, { ficheType: FICHE.CERTIFICATS, diff --git a/data/administrations/djepva.yml b/data/administrations/djepva.yml index 393060985..0c94d1e21 100644 --- a/data/administrations/djepva.yml +++ b/data/administrations/djepva.yml @@ -8,10 +8,18 @@ apiMonitors: apiSlug: rna updownIoId: phbs apiDocumentationLink: https://www.associations.gouv.fr/les-api-et-autres-outils.html + - label: API Data.Subvention + apiSlug: data-subvention + isProtected: true + apiDocumentationLink: https://datasubvention.beta.gouv.fr/api/ dataSources: - label: Documents complémentaires des associations apiSlug: api-entreprise data: - label: documents administratifs complémentaires + - label: Subventions des associations + apiSlug: data-subvention + data: + - label: subventions (sources Chorus et Fonjep) description: | Au sein du ministère de l'Éducation nationale et de la Jeunesse, la DJEPVA élabore et pilote les politiques en faveur des jeunes, de l'engagement, de l'éducation populaire et de la vie associative. diff --git a/models/subventions/association/index.ts b/models/subventions/association/index.ts new file mode 100644 index 000000000..b0990ff63 --- /dev/null +++ b/models/subventions/association/index.ts @@ -0,0 +1,49 @@ +import { clientDataSubvention } from '#clients/api-data-subvention'; +import { HttpNotFound } from '#clients/exceptions'; +import { EAdministration } from '#models/administrations/EAdministration'; +import { + APINotRespondingFactory, + IAPINotRespondingError, +} from '#models/api-not-responding'; +import { getUniteLegaleFromSlug } from '#models/core/unite-legale'; +import { FetchRessourceException } from '#models/exceptions'; +import logErrorInSentry from '#utils/sentry'; + +export type ISubventions = ISubvention[]; + +export interface ISubvention { + year: number; + label: string; + status: string; + description: string; + amount: number; +} + +export const getSubventionsAssociationFromSlug = async ( + slug: string +): Promise => { + const uniteLegale = await getUniteLegaleFromSlug(slug, { + isBot: false, + }); + + const { siren } = uniteLegale; + + try { + return await clientDataSubvention(siren); + } catch (e: any) { + if (e instanceof HttpNotFound) { + return APINotRespondingFactory(EAdministration.DJEPVA, 404); + } + logErrorInSentry( + new FetchRessourceException({ + ressource: 'DataSubvention', + cause: e, + context: { + siren, + }, + administration: EAdministration.DJEPVA, + }) + ); + return APINotRespondingFactory(EAdministration.DJEPVA, 500); + } +}; diff --git a/models/user/rights.ts b/models/user/rights.ts index 2a2ac9989..6d479a179 100644 --- a/models/user/rights.ts +++ b/models/user/rights.ts @@ -17,6 +17,7 @@ export enum AppScope { carteProfessionnelleTravauxPublics = 'opendata', nonDiffusible = 'nonDiffusible', isAgent = 'isAgent', + subventionsAssociation = 'subventionsAssociation', } /** @@ -40,6 +41,8 @@ export function hasRights(session: ISession | null, rightScope: AppScope) { return userScopes.includes('opendata'); case AppScope.beneficiaires: return userScopes.includes('beneficiaires'); + case AppScope.subventionsAssociation: + return userScopes.includes('subventions_association'); case AppScope.nonDiffusible: return userScopes.includes('nonDiffusible'); case AppScope.isAgent: diff --git a/models/user/scopes.ts b/models/user/scopes.ts index 2d6bbb458..cacf330ea 100644 --- a/models/user/scopes.ts +++ b/models/user/scopes.ts @@ -5,6 +5,7 @@ export type IAgentScope = | 'nonDiffusible' | 'conformite' | 'beneficiaires' + | 'subventions_association' | 'agent' | 'opendata'; @@ -15,6 +16,7 @@ export const isAgentScope = (str: string): str is IAgentScope => { 'nonDiffusible', 'conformite', 'beneficiaires', + 'subventions_association', 'agent', 'opendata', ].indexOf(str) > 0 @@ -24,7 +26,7 @@ export const isAgentScope = (str: string): str is IAgentScope => { return false; }; -const agentScope = [ +const defaultAgentScopes = [ 'agent', 'nonDiffusible', 'rne', @@ -47,7 +49,12 @@ export const getAgentScopes = async ( if (isTestAccount) { return { - scopes: [...agentScope, 'conformite', 'beneficiaires'], + scopes: [ + ...defaultAgentScopes, + 'conformite', + 'beneficiaires', + 'subventions_association', + ], userType: 'Super-agent connecté', }; } @@ -55,7 +62,7 @@ export const getAgentScopes = async ( const additionnalScopes = await getAdditionnalIAgentScope(userEmail); return { - scopes: [...agentScope, ...additionnalScopes], + scopes: [...defaultAgentScopes, ...additionnalScopes], userType: additionnalScopes.length > 0 ? 'Super-agent connecté' : 'Agent connecté', };