From 1de784598244fc18d68f32400f7f78157ed08b3e Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 28 May 2020 17:04:48 +0200 Subject: [PATCH] avis_imposition: mimic avis-imposition API particulier endpoint (#43479) Return value is the same as API particulier for the avis-imposition endpoint, except for some data massaging put into alternative keys: * dates are converted to ISO format and put in keys with @_iso@ suffix, * family situation is simplified to ASCII and put in key with @_simple@ suffix, * addresse is returned as oneline joined with newlines as in API particulier, but also with individual lines separated into 3 keys : adresse1, adresse2, adresse3. * fake datas for testing can be configured trough AVIS_IMPOSITION_FAKE_DATA. --- passerelle/apps/avis_imposition/__init__.py | 0 .../migrations/0001_initial.py | 38 ++ .../avis_imposition/migrations/__init__.py | 0 passerelle/apps/avis_imposition/models.py | 478 ++++++++++++++++++ .../avisimposition_detail.html | 40 ++ passerelle/settings.py | 1 + tests/data/avis-imposition.html | 85 ++++ tests/data/avis-imposition.json | 140 +++++ tests/test_avis_imposition.py | 190 +++++++ 9 files changed, 972 insertions(+) create mode 100644 passerelle/apps/avis_imposition/__init__.py create mode 100644 passerelle/apps/avis_imposition/migrations/0001_initial.py create mode 100644 passerelle/apps/avis_imposition/migrations/__init__.py create mode 100644 passerelle/apps/avis_imposition/models.py create mode 100644 passerelle/apps/avis_imposition/templates/avis_imposition/avisimposition_detail.html create mode 100644 tests/data/avis-imposition.html create mode 100644 tests/data/avis-imposition.json create mode 100644 tests/test_avis_imposition.py diff --git a/passerelle/apps/avis_imposition/__init__.py b/passerelle/apps/avis_imposition/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/apps/avis_imposition/migrations/0001_initial.py b/passerelle/apps/avis_imposition/migrations/0001_initial.py new file mode 100644 index 00000000..d709b3dd --- /dev/null +++ b/passerelle/apps/avis_imposition/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 1.11.20 on 2020-05-28 19:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('base', '0020_auto_20200515_1923'), + ] + + operations = [ + migrations.CreateModel( + name='AvisImposition', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('title', models.CharField(max_length=50, verbose_name='Title')), + ('slug', models.SlugField(unique=True, verbose_name='Identifier')), + ('description', models.TextField(verbose_name='Description')), + ( + 'users', + models.ManyToManyField( + blank=True, + related_name='_avisimposition_users_+', + related_query_name='+', + to='base.ApiUser', + ), + ), + ], + options={ + 'verbose_name': 'API Particulier', + }, + ), + ] diff --git a/passerelle/apps/avis_imposition/migrations/__init__.py b/passerelle/apps/avis_imposition/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/apps/avis_imposition/models.py b/passerelle/apps/avis_imposition/models.py new file mode 100644 index 00000000..0f19e2a1 --- /dev/null +++ b/passerelle/apps/avis_imposition/models.py @@ -0,0 +1,478 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +'''Interface with "Service de vérification des avis" of impots.gouv.fr + + See also: + + https://cfsmsp.impots.gouv.fr/secavis/ + https://www.economie.gouv.fr/particuliers/authenticite-avis-impot-svair + + Original code: + https://github.com/betagouv/svair-api + https://github.com/bdauvergne/svair-api (fork if upstream disapear) +''' + +import datetime +import logging +import re +import urllib.parse + +import lxml.html +import requests +from django.conf import settings +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ + +from passerelle.base.models import BaseResource +from passerelle.utils.api import endpoint +from passerelle.utils.json import unflatten +from passerelle.utils.jsonresponse import APIError +from passerelle.utils.xml import text_content + +AVIS_IMPOSITION_GOUV_FR_URL = 'https://cfsmsp.impots.gouv.fr/secavis/' + + +def remove_spaces(value): + return re.sub(r'\s', '', value, flags=re.U) + + +def simplify_spaces(value): + return re.sub(r'\s+', ' ', value, flags=re.U) + + +class NotFound(APIError): + def __init__(self): + super().__init__(_('These fiscal number and tax notice number are unknown.')) + + +VERIFY_SCHEMA_RESPONSE_NUMBERS = [ + "dateEtablissement_year", + "dateRecouvrement_year", + "foyerFiscal__year", + "impotRevenuNetAvantCorrections", + "nombreParts", + "nombrePersonnesCharge", + "revenuBrutGlobal", + "revenuFiscalReference", + "revenuImposable", +] + +ISO_DATE_PATTERN = {'type': 'string', 'pattern': r'\d\d\d\d-\d\d-\d\d'} +FR_DATE_PATTERN = {'type': 'string', 'pattern': r'\d\d/\d\d/\d\d\d\d'} + +VERIFY_SCHEMA_OTHERS = { + "montantImpot": { + 'oneOf': [ + {'type': 'string'}, + {'enum': ['Nonimposable']}, + ] + }, + "situationFamille_simple": { + 'enum': [ + 'pacs/mariage', + 'divorce', + 'celibataire', + ] + }, + "dateEtablissement_iso": ISO_DATE_PATTERN, + "dateRecouvrement_iso": ISO_DATE_PATTERN, + "declarant1__dateNaissance_iso": ISO_DATE_PATTERN, + "declarant2__dateNaissance_iso": ISO_DATE_PATTERN, + "dateEtablissement": FR_DATE_PATTERN, + "dateRecouvrement": FR_DATE_PATTERN, + "declarant1__dateNaissance": FR_DATE_PATTERN, + "declarant2__dateNaissance": FR_DATE_PATTERN, +} + +VERIFY_SCHEMA_RESPONSE_STRINGS = [ + 'numero_fiscal', + 'reference_avis', + "declarant1", + "declarant1__nom", + "declarant1__nomNaissance", + "declarant1__prenom", + "declarant2__nom", + "declarant2__nomNaissance", + "declarant2__prenom", + "foyerFiscal__adresse", + "foyerFiscal__adresse1", + "foyerFiscal__adresse2", + "foyerFiscal__adresse3", + "situationFamille", + "situationPartielle", +] + + +def generate_verify_schema_response(): + schema = { + 'type': 'object', + } + + def set_key(name, value): + parts = name.split('__') + d = schema + for part in parts[:-1]: + d = d.setdefault('properties', {}).setdefault(part, {}) + d['type'] = 'object' + d.setdefault('properties', {})[parts[-1]] = value + + for key in VERIFY_SCHEMA_RESPONSE_NUMBERS: + set_key(key, {'type': 'number'}) + for key in VERIFY_SCHEMA_RESPONSE_STRINGS: + set_key(key, {'type': 'string'}) + for key, value in VERIFY_SCHEMA_OTHERS.items(): + set_key(key, value) + return schema + + +VERIFY_RESPONSE_SCHEMA = generate_verify_schema_response() + +# Map fields by tr/td indexes +FIELD_INDEXES = { + 'declarant1/nom': (1, 1), + 'declarant2/nom': (1, 2), + 'declarant1/nomNaissance': (2, 1), + 'declarant2/nomNaissance': (2, 2), + 'declarant1/prenom': (3, 1), + 'declarant2/prenom': (3, 2), + 'declarant1/dateNaissance': (4, 1), + 'declarant2/dateNaissance': (4, 2), + 'declarant1/dateNaissance_iso': (4, 1), + 'declarant2/dateNaissance_iso': (4, 2), + 'foyerFiscal/adresse1': (5, 1), + 'foyerFiscal/adresse2': (6, 1), + 'foyerFiscal/adresse3': (7, 1), + 'dateRecouvrement': (9, 1), + 'dateEtablissement': (10, 1), + 'dateRecouvrement_iso': (9, 1), + 'dateEtablissement_iso': (10, 1), + 'dateRecouvrement_year': (9, 1), + 'dateEtablissement_year': (10, 1), + 'nombreParts': (11, 1), + 'situationFamille': (12, 1), + 'situationFamille_simple': (12, 1), + 'nombrePersonnesCharge': (13, 1), + 'revenuBrutGlobal': (14, 1), + 'revenuImposable': (15, 1), + 'impotRevenuNetAvantCorrections': (16, 1), + 'montantImpot': (17, 1), + 'revenuFiscalReference': (18, 1), +} + + +def get_field(name, td_contents, tr_length): + # adjust tr/td indexes based on the number of address lines deduced from + # the number of tr elements + i, j = FIELD_INDEXES[name] + # address has 3 lines + if tr_length == 19: + return td_contents[(i, j)] + # address has 2 lines + if tr_length == 18: + if i < 5: + return td_contents[(i, j)] + if i == 5: + return '' + if i > 5: + return td_contents[(i - 1, j)] + # address has 1 line + if tr_length == 17: + if i < 5: + return td_contents[(i, j)] + if i in (5, 6): + return '' + if i > 6: + return td_contents[(i - 2, j)] + raise NotImplementedError + + +def nombre_de_parts(value): + try: + return float(value) + except (ValueError, TypeError): + return None + + +def euros(value): + if not value: + return value + value = remove_spaces(value) + value = value.rstrip('€') + try: + return int(value) + except ValueError: + return value + + +def situation_familiale(value): + if value == 'Pacs\xe9(e)s': + return 'pacs/mariage' + if value == 'Mari\xe9(e)s': + return 'pacs/mariage' + if value == 'Divorc\xe9(e)': + return 'divorce' + if value == 'C\xe9libataire': + return 'celibataire' + raise NotImplementedError(value) + + +def date(value): + if not value: + return '' + try: + return datetime.datetime.strptime(value, '%d/%m/%Y').date() + except (ValueError, TypeError): + return '' + + +def year(value): + if not value: + return '' + try: + return datetime.datetime.strptime(value, '%d/%m/%Y').date().year + except (ValueError, TypeError): + return '' + + +ADAPTERS = { + 'nombreParts': nombre_de_parts, + 'revenuBrutGlobal': euros, + 'revenuImposable': euros, + 'impotRevenuNetAvantCorrections': euros, + 'revenuFiscalReference': euros, + 'montantImpot': euros, + 'situationFamille_simple': situation_familiale, + 'dateEtablissement_iso': date, + 'dateRecouvrement_iso': date, + 'dateEtablissement_year': year, + 'dateRecouvrement_year': year, + 'declarant1/dateNaissance_iso': date, + 'declarant2/dateNaissance_iso': date, + 'nombrePersonnesCharge': nombre_de_parts, +} + + +def get_form(session, logger=None): + cache_key = 'avis-imposition-form' + try: + action_url, data = cache.get(cache_key) + except TypeError: + pass + else: + return action_url, data + + logger = logger or logging + try: + response = session.get(AVIS_IMPOSITION_GOUV_FR_URL, timeout=15) + response.raise_for_status() + except requests.RequestException as e: + raise APIError('service-is-down', data=str(e)) + + if 'Saisissez les identifiants' not in response.text: + logger.error(_('https://cfsmsp.impots.gouv.fr/secavis/: service has changed, %s'), response.text) + raise RuntimeError('service has changed') + + html = lxml.html.fromstring(response.content) + data = {} + for form_elt in html.xpath('//form'): + if 'action' not in form_elt.attrib: + continue + action_url = form_elt.attrib['action'] + break + else: + logger.error( + _('https://cfsmsp.impots.gouv.fr/secavis/: service has changed, form[action] not found, %s'), + response.text, + ) + raise RuntimeError('service has changed') + logger.debug('using found action_url %s', action_url) + + fields = ['j_id_7:spi', 'j_id_7:num_facture'] + for input_elt in html.xpath('//input'): + if 'value' in input_elt.attrib or input_elt.attrib['name'] in fields: + data[input_elt.attrib['name']] = input_elt.attrib.get('value', '') + for field in fields: + if field not in data: + logger.error( + _('https://cfsmsp.impots.gouv.fr/secavis/: service has changed, field %s not found, %s'), + field, + response.text, + ) + raise RuntimeError('service has changed') + cache.set(cache_key, (action_url, data), 180) + return action_url, data + + +def get_avis_imposition(session, numero_fiscal, reference_avis, logger=None): + logger = logger or logging + + action_url, data = get_form(session, logger=logger) + + data['j_id_7:spi'] = numero_fiscal + data['j_id_7:num_facture'] = reference_avis + + url = urllib.parse.urljoin(AVIS_IMPOSITION_GOUV_FR_URL, action_url) + try: + response = session.post(url, params=data) + response.raise_for_status() + except requests.RequestException as e: + raise APIError('service-is-down', data=str(e)) + + if 'Saisissez les identifiants' in response.text: + raise NotFound + + response_html = lxml.html.fromstring(response.content) + td_contents = {} + tr_elements = list(response_html.xpath('//tr')) + for i, tr_element in enumerate(tr_elements): + for j, td_element in enumerate(tr_element.xpath('td')): + td_contents[(i, j)] = simplify_spaces(text_content(td_element).strip()) + + logger.debug('got td_contents %s', td_contents) + + data = { + # https://github.com/betagouv/svair-api/blob/master/utils/year.js + 'foyerFiscal/year': int('20' + reference_avis[:2]), + } + for field, coordinates in FIELD_INDEXES.items(): + try: + data[field] = get_field(field, td_contents, len(tr_elements)) + except IndexError: + logger.error( + _( + 'https://cfsmsp.impots.gouv.fr/secavis/: service has changed, data field %s(%s) not found, %s' + ), + field, + coordinates, + response.text, + ) + raise RuntimeError('service has changed') + if field in ADAPTERS: + data[field] = ADAPTERS[field](data[field]) + for situation_partielle_elt in response_html.xpath('//*[@id="situationPartielle"]'): + data['situationPartielle'] = text_content(situation_partielle_elt).strip() + break + + # add reference number to result + data['numero_fiscal'] = numero_fiscal + data['reference_avis'] = reference_avis + + # restore structure to the data + unflattened = unflatten(data) + + # flatten address for simple use case + foyer_fiscal = unflattened['foyerFiscal'] + foyer_fiscal['adresse'] = ' '.join( + value for key, value in sorted(foyer_fiscal.items()) if key.startswith('adresse') and value + ) + return unflattened + + +def get_fake_data(numero_fiscal, reference_avis): + fake_data = getattr(settings, 'AVIS_IMPOSITION_FAKE_DATA', []) + for data in fake_data or []: + if data['numero_fiscal'] == numero_fiscal and data['reference_avis'] == reference_avis: + return data + + +class AvisImposition(BaseResource): + log_requests_errors = True + requests_timeout = 10 + requests_max_retries = { + 'total': 5, + 'backoff_factor': 0.5, + 'allowed_methods': ['GET', 'POST'], + # retry after: 0.5, 1.5 and 3.5 seconds + 'status_forcelist': [413, 429, 503, 504], + } + + category = _('Business Process Connectors') + + class Meta: + verbose_name = _('French tax notice validator on impots.gouv.fr') + + @endpoint( + perm='can_access', + description=_('Get citizen\'s fiscal informations'), + parameters={ + 'numero_fiscal': { + 'description': _('fiscal identifier'), + 'example_value': '1562456789521', + }, + 'reference_avis': { + 'description': _('tax notice number'), + 'example_value': '1512456789521', + }, + }, + json_schema_response=VERIFY_RESPONSE_SCHEMA, + ) + def verify(self, request, numero_fiscal, reference_avis): + numero_fiscal = remove_spaces(numero_fiscal) + reference_avis = remove_spaces(reference_avis) + + if not ( + # See. + # https://fr.wikipedia.org/wiki/Num%C3%A9ro_d'immatriculation_fiscale#France + # a 13 digits decimal number + len(numero_fiscal) == 13 + and numero_fiscal.isascii() + and numero_fiscal.isdigit() + # No clear information on the format, it seems to be a 13 digits + # decimal number too from examples. As we aren´t sure we will ask + # for a more than 10 digits long alphanumeric string. + and len(reference_avis) >= 10 + and reference_avis.isascii() + and reference_avis.isalnum() + ): + raise APIError(_('Fiscal identifier or tax notice number are invalid.')) + + cache_key = f'avis-imposition-{numero_fiscal}-{reference_avis}' + data = cache.get(cache_key) + + if data == 'not-found': + raise NotFound + + if not data: + # use test vectors... + data = get_fake_data(numero_fiscal=numero_fiscal, reference_avis=reference_avis) + # or the real thing. + try: + data = data or get_avis_imposition( + self.requests, numero_fiscal=numero_fiscal, reference_avis=reference_avis + ) + except NotFound: + cache.set(cache_key, 'not-found', 60) + raise + # service is slow, cache successful response for 12 hours + cache.set(cache_key, data, 3600 * 12) + return {'data': data} + + def check_status(self): + get_form(self.requests) + + def get_test_data(self): + fake_data = getattr(settings, 'AVIS_IMPOSITION_FAKE_DATA', []) + result = [] + for data in fake_data or []: + try: + numero_fiscal = data['numero_fiscal'] + reference_avis = data['reference_avis'] + revenu_fiscal_de_reference = data['revenuFiscalReference'] + except Exception: + pass + result.append((numero_fiscal, reference_avis, revenu_fiscal_de_reference)) + return result diff --git a/passerelle/apps/avis_imposition/templates/avis_imposition/avisimposition_detail.html b/passerelle/apps/avis_imposition/templates/avis_imposition/avisimposition_detail.html new file mode 100644 index 00000000..7b31f55a --- /dev/null +++ b/passerelle/apps/avis_imposition/templates/avis_imposition/avisimposition_detail.html @@ -0,0 +1,40 @@ +{% extends "passerelle/manage/service_view.html" %} +{% load i18n passerelle %} + +{% block description %} +

{% blocktrans trimmed %} + This connector verify fiscal number and tax notice number on + https://cfsmsp.impots.gouv.fr/secavis/ + and returns the scrapped data. + {% endblocktrans %} +

+

{% blocktrans trimmed %} + Documentation on the secavis service. + {% endblocktrans %} +

+ {% with test_data=object.get_test_data %} + {% if test_data %} +

{% trans "Test datas" %}

+ + + + + + + + + + + {% for numero_fiscal, reference_avis, revenu_fiscal_de_reference in test_data %} + + + + + + + {% endfor %} + +
{% trans "Fiscal number" %}{% trans "Tax notice number" %}{% trans "Reference income" %}{% trans "Test URL" %}
{{ numero_fiscal }}{{ reference_avis }}{{ revenu_fiscal_de_reference}} €Test
+ {% endif %} + {% endwith %} +{% endblock %} diff --git a/passerelle/settings.py b/passerelle/settings.py index f4750b91..c1efa51d 100644 --- a/passerelle/settings.py +++ b/passerelle/settings.py @@ -136,6 +136,7 @@ INSTALLED_APPS = ( 'passerelle.apps.astre_rest', 'passerelle.apps.atal', 'passerelle.apps.atos_genesys', + 'passerelle.apps.avis_imposition', 'passerelle.apps.base_adresse', 'passerelle.apps.bbb', 'passerelle.apps.bdp', diff --git a/tests/data/avis-imposition.html b/tests/data/avis-imposition.html new file mode 100644 index 00000000..2a87731e --- /dev/null +++ b/tests/data/avis-imposition.html @@ -0,0 +1,85 @@ + + + + + Impots.gouv.fr - Service de vérification en ligne des avis + + + + + + + +
+
+
+
+
+
+ + +

Le service permet de vérifier l'authenticité des avis (Impôt sur le revenu) présentés par un usager

+
+
Accès au service de vérification
+
+
Saisissez les identifiants
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+
+ aideSPI +
+
+ aideReferenceAvis +
+
+
+ +
+
+ +
+
+
+
* données obligatoires
+
+
© Ministère de l'Économie et des Finances
+
+ +
\ No newline at end of file diff --git a/tests/data/avis-imposition.json b/tests/data/avis-imposition.json new file mode 100644 index 00000000..ee8acf9d --- /dev/null +++ b/tests/data/avis-imposition.json @@ -0,0 +1,140 @@ +[ + { + "numero_fiscal": "1234567890123", + "reference_avis": "0987654321", + "response": "\n\n\t\n\n\n\nImpots.gouv.fr - Service de v\u00e9rification en ligne des avis\n\n\n\n\n\n
\n
\n\t\t\t
\"\"
\n\t\t\t
\"\"
\n
\n
\n
\nL'administration fiscale certifie l'authenticit\u00e9 du document pr\u00e9sent\u00e9 pour les donn\u00e9es suivantes :\n
\n\n
\nImp\u00f4t 2019 sur les revenus de l'ann\u00e9e 2018 \n\n
\t\t\n\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\n\t\t\t
\n\t\t\t\t\tD\u00e9clarant 1\n\t\t\t\t\tD\u00e9clarant 2\n\t\t\t\t\t
Nom\n\t\t\t\t\tDOE\n\t\t\t\t\tDOE\n\t\t\t\t\t
Nom de naissance\n\t\t\t\t\tDOE\n\t\t\t\t\tDOE\n\t\t\t\t\t
Pr\u00e9nom(s)\n\t\t\t\t\tJOHN\n\t\t\t\t\tJANE\n\t\t\t\t\t
Date de naissance\n\t\t\t\t\t01/01/1970\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\tAdresse d\u00e9clar\u00e9e au 1er janvier 2019\n\t\t\t\t\tR\u00c9SIDENCE DU CALVAIRE\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\tRUE VICTOR HUGO\n\t\t\t\t\t
\n\t\t\t\t\t75014 PARIS\n\t\t\t\t\t
Date de mise en recouvrement de l'avis d'imp\u00f4t\n\t\t\t\t\t31/12/2019\n\t\t\t\t\t
Date d'\u00e9tablissement\n\t\t\t\t\t09/12/2019\n\t\t\t\t\t
Nombre de part(s)\n\t\t\t\t\t4.00\n\t\t\t\t\t
Situation de famille\n\t\t\t\t\tPacs\u00e9(e)s\n\t\t\t\t\t
Nombre de personne(s) \u00e0 charge\n\t\t\t\t\t4\n\t\t\t\t\t
Revenu brut global\n\t\t\t\t\t48\u00a0473 \u20ac\n\t\t\t\t\t
Revenu imposable\n\t\t\t\t\t48\u00a0473 \u20ac\n\t\t\t\t\t
Imp\u00f4t sur le revenu net avant corrections\n\t\t\t\t\t112 \u20ac\n\t\t\t\t\t
Montant de l'imp\u00f4t\n\t\t\t\t\tNon imposable\n\t\t\t\t\t
Revenu fiscal de r\u00e9f\u00e9rence\n\t\t\t\t\t48\u00a0473 \u20ac\n\t\t\t\t\t
\n\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\n\n\t\t
\n\t\t
\u00a9 Minist\u00e8re de l'\u00c9conomie et des Finances
\"\"\n\t
\n\n", + "result": { + "numero_fiscal": "1234567890123", + "reference_avis": "0987654321", + "dateEtablissement": "09/12/2019", + "dateEtablissement_iso": "2019-12-09", + "dateRecouvrement": "31/12/2019", + "dateRecouvrement_iso": "2019-12-31", + "dateEtablissement_year": 2019, + "dateRecouvrement_year": 2019, + "declarant1": { + "dateNaissance": "01/01/1970", + "dateNaissance_iso": "1970-01-01", + "nom": "DOE", + "nomNaissance": "DOE", + "prenom": "JOHN" + }, + "declarant2": { + "dateNaissance": "", + "dateNaissance_iso": "", + "nom": "DOE", + "nomNaissance": "DOE", + "prenom": "JANE" + }, + "foyerFiscal": { + "adresse": "R\u00c9SIDENCE DU CALVAIRE RUE VICTOR HUGO 75014 PARIS", + "adresse1": "R\u00c9SIDENCE DU CALVAIRE", + "adresse2": "RUE VICTOR HUGO", + "adresse3": "75014 PARIS", + "year": 2009 + }, + "impotRevenuNetAvantCorrections": 112, + "montantImpot": "Nonimposable", + "nombreParts": 4.0, + "nombrePersonnesCharge": 4.0, + "revenuBrutGlobal": 48473, + "revenuFiscalReference": 48473, + "revenuImposable": 48473, + "situationFamille": "Pacs\u00e9(e)s", + "situationFamille_simple": "pacs/mariage", + "situationPartielle": "" + } + }, + { + "numero_fiscal": "1234567890123", + "reference_avis": "0987654321", + "response": "\n\n\t\n\n\n\nImpots.gouv.fr - Service de v\u00e9rification en ligne des avis\n\n\n\n\n\n
\n
\n\t\t\t
\"\"
\n\t\t\t
\"\"
\n
\n
\n
\nL'administration fiscale certifie l'authenticit\u00e9 du document pr\u00e9sent\u00e9 pour les donn\u00e9es suivantes :\n
\n\n
\nImp\u00f4t 2019 sur les revenus de l'ann\u00e9e 2018 \n\n
\t\t\n\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\n\t\t\t
\n\t\t\t\t\tD\u00e9clarant 1\n\t\t\t\t\tD\u00e9clarant 2\n\t\t\t\t\t
Nom\n\t\t\t\t\tDOE\n\t\t\t\t\t\n\t\t\t\t\t
Nom de naissance\n\t\t\t\t\tDOE\n\t\t\t\t\t\n\t\t\t\t\t
Pr\u00e9nom(s)\n\t\t\t\t\tJACK\n\t\t\t\t\t\n\t\t\t\t\t
Date de naissance\n\t\t\t\t\t01/01/1970\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\tAdresse d\u00e9clar\u00e9e au 1er janvier 2019\n\t\t\t\t\tR\u00c9SIDENCE DU CALVAIRE\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\tRUE VICTOR HUGO\n\t\t\t\t\t
\n\t\t\t\t\t75014 PARIS\n\t\t\t\t\t
Date de mise en recouvrement de l'avis d'imp\u00f4t\n\t\t\t\t\t31/07/2019\n\t\t\t\t\t
Date d'\u00e9tablissement\n\t\t\t\t\t09/07/2019\n\t\t\t\t\t
Nombre de part(s)\n\t\t\t\t\t2.00\n\t\t\t\t\t
Situation de famille\n\t\t\t\t\tDivorc\u00e9(e)\n\t\t\t\t\t
Nombre de personne(s) \u00e0 charge\n\t\t\t\t\t2\n\t\t\t\t\t
Revenu brut global\n\t\t\t\t\t48\u00a0473 \u20ac\n\t\t\t\t\t
Revenu imposable\n\t\t\t\t\t48\u00a0473 \u20ac\n\t\t\t\t\t
Imp\u00f4t sur le revenu net avant corrections\n\t\t\t\t\t5\u00a0084 \u20ac\n\t\t\t\t\t
Montant de l'imp\u00f4t\n\t\t\t\t\tNon imposable\n\t\t\t\t\t
Revenu fiscal de r\u00e9f\u00e9rence\n\t\t\t\t\t48\u00a0473 \u20ac\n\t\t\t\t\t
\n\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\n\n\t\t
\n\t\t
\u00a9 Minist\u00e8re de l'\u00c9conomie et des Finances
\"\"\n\t
\n\n", + "result": { + "numero_fiscal": "1234567890123", + "reference_avis": "0987654321", + "dateEtablissement": "09/07/2019", + "dateEtablissement_iso": "2019-07-09", + "dateRecouvrement": "31/07/2019", + "dateRecouvrement_iso": "2019-07-31", + "dateEtablissement_year": 2019, + "dateRecouvrement_year": 2019, + "declarant1": { + "dateNaissance": "01/01/1970", + "dateNaissance_iso": "1970-01-01", + "nom": "DOE", + "nomNaissance": "DOE", + "prenom": "JACK" + }, + "declarant2": { + "dateNaissance": "", + "dateNaissance_iso": "", + "nom": "", + "nomNaissance": "", + "prenom": "" + }, + "foyerFiscal": { + "adresse": "R\u00c9SIDENCE DU CALVAIRE RUE VICTOR HUGO 75014 PARIS", + "adresse1": "R\u00c9SIDENCE DU CALVAIRE", + "adresse2": "RUE VICTOR HUGO", + "adresse3": "75014 PARIS", + "year": 2009 + }, + "impotRevenuNetAvantCorrections": 5084, + "montantImpot": "Nonimposable", + "nombreParts": 2.0, + "nombrePersonnesCharge": 2.0, + "revenuBrutGlobal": 48473, + "revenuFiscalReference": 48473, + "revenuImposable": 48473, + "situationFamille": "Divorc\u00e9(e)", + "situationFamille_simple": "divorce", + "situationPartielle": "" + } + }, + { + "numero_fiscal": "1234567890123", + "reference_avis": "0987654321", + "response": "\n\n\t\n\n\n\nImpots.gouv.fr - Service de v\u00e9rification en ligne des avis\n\n\n\n\n\n
\n
\n\t\t\t
\"\"
\n\t\t\t
\"\"
\n
\n
\n
\nL'administration fiscale certifie l'authenticit\u00e9 du document pr\u00e9sent\u00e9 pour les donn\u00e9es suivantes :\n
\n\n
\nImp\u00f4t 2018 sur les revenus de l'ann\u00e9e 2017 \n\n
\t\t\n\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\n\t\t\t
\n\t\t\t\t\tD\u00e9clarant 1\n\t\t\t\t\tD\u00e9clarant 2\n\t\t\t\t\t
Nom\n\t\t\t\t\tJOHNSON\n\t\t\t\t\t\n\t\t\t\t\t
Nom de naissance\n\t\t\t\t\tJOHNSON\n\t\t\t\t\t\n\t\t\t\t\t
Pr\u00e9nom(s)\n\t\t\t\t\tROBERT\n\t\t\t\t\t\n\t\t\t\t\t
Date de naissance\n\t\t\t\t\t11/05/1978\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\tAdresse d\u00e9clar\u00e9e au 1er janvier 2018\n\t\t\t\t\t13 RUE ALEXANDRE FLEMING\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t99999 SAINT-HENRI-LES-COQUILLAGES\n\t\t\t\t\t
Date de mise en recouvrement de l'avis d'imp\u00f4t\n\t\t\t\t\t31/07/2018\n\t\t\t\t\t
Date d'\u00e9tablissement\n\t\t\t\t\t02/08/2018\n\t\t\t\t\t
Nombre de part(s)\n\t\t\t\t\t1.00\n\t\t\t\t\t
Situation de famille\n\t\t\t\t\tC\u00e9libataire\n\t\t\t\t\t
Nombre de personne(s) \u00e0 charge\n\t\t\t\t\t0\n\t\t\t\t\t
Revenu brut global\n\t\t\t\t\t30\u00a0317 \u20ac\n\t\t\t\t\t
Revenu imposable\n\t\t\t\t\t30\u00a0317 \u20ac\n\t\t\t\t\t
Imp\u00f4t sur le revenu net avant corrections\n\t\t\t\t\t3\u00a0388 \u20ac\n\t\t\t\t\t
Montant de l'imp\u00f4t\n\t\t\t\t\t3\u00a0388 \u20ac\n\t\t\t\t\t
Revenu fiscal de r\u00e9f\u00e9rence\n\t\t\t\t\t30\u00a0317 \u20ac\n\t\t\t\t\t
\n\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\n\n\t\t
\n\t\t
\u00a9 Minist\u00e8re de l'\u00c9conomie et des Finances
\"\"\n\t
\n\n", + "result": { + "numero_fiscal": "1234567890123", + "reference_avis": "0987654321", + "dateEtablissement": "02/08/2018", + "dateEtablissement_iso": "2018-08-02", + "dateRecouvrement": "31/07/2018", + "dateRecouvrement_iso": "2018-07-31", + "dateEtablissement_year": 2018, + "dateRecouvrement_year": 2018, + "declarant1": { + "dateNaissance": "11/05/1978", + "dateNaissance_iso": "1978-05-11", + "nom": "JOHNSON", + "nomNaissance": "JOHNSON", + "prenom": "ROBERT" + }, + "declarant2": { + "dateNaissance": "", + "dateNaissance_iso": "", + "nom": "", + "nomNaissance": "", + "prenom": "" + }, + "foyerFiscal": { + "adresse": "13 RUE ALEXANDRE FLEMING 99999 SAINT-HENRI-LES-COQUILLAGES", + "adresse1": "", + "adresse2": "13 RUE ALEXANDRE FLEMING", + "adresse3": "99999 SAINT-HENRI-LES-COQUILLAGES", + "year": 2009 + }, + "impotRevenuNetAvantCorrections": 3388, + "montantImpot": 3388, + "nombreParts": 1.0, + "nombrePersonnesCharge": 0.0, + "revenuBrutGlobal": 30317, + "revenuFiscalReference": 30317, + "revenuImposable": 30317, + "situationFamille": "C\u00e9libataire", + "situationFamille_simple": "celibataire", + "situationPartielle": "" + } + } +] diff --git a/tests/test_avis_imposition.py b/tests/test_avis_imposition.py new file mode 100644 index 00000000..dd1bd092 --- /dev/null +++ b/tests/test_avis_imposition.py @@ -0,0 +1,190 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2016 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import re + +import pytest +import requests +import responses + +from passerelle.apps.avis_imposition import models + +from . import utils +from .test_manager import login + +# Content from the form page +with open('tests/data/avis-imposition.html') as fd: + html = fd.read() + +# Contents from the submit result page +with open('tests/data/avis-imposition.json') as fd: + cases = json.load(fd) + + +def responses_add(*args, url=re.compile('https://cfsmsp.impots.gouv.fr/secavis/.*'), **kwargs): + responses.upsert(*args, url=url, **kwargs) + + +@pytest.fixture +def avis_imposition(db, settings): + from django.core.cache import close_caches + + settings.CACHES['default'] = settings.CACHES['dummy'] + close_caches() + with responses.mock: + responses_add(responses.GET, body=html) + yield utils.make_resource(models.AvisImposition, slug='test') + + +@responses.activate +@pytest.mark.parametrize( + 'data', + cases, + ids=[ + ''.join([data['result']['declarant1']['prenom'], data['result']['declarant1']['nom']]) + for data in cases + ], +) +def test_ok(avis_imposition, data, app): + responses_add(responses.POST, body=data['response']) + response = utils.endpoint_get( + expected_url='/avis-imposition/test/verify', + app=app, + resource=avis_imposition, + endpoint='verify', + params={ + 'numero_fiscal': data['numero_fiscal'], + 'reference_avis': data['reference_avis'], + }, + ) + assert response.json['err'] == 0 + assert response.json['data'] == data['result'] + + +@responses.activate +def test_not_found(avis_imposition, app): + responses_add(responses.GET, body=html) + responses_add(responses.POST, body=html) + + response = utils.endpoint_get( + expected_url='/avis-imposition/test/verify', + app=app, + resource=avis_imposition, + endpoint='verify', + params={ + 'numero_fiscal': '1' * 13, + 'reference_avis': '2' * 13, + }, + ) + assert response.json['err'] == 1 + assert response.json['err_desc'] == 'These fiscal number and tax notice number are unknown.' + + +@responses.activate +@pytest.mark.parametrize( + 'response_kwargs', + [ + {'method_or_response': responses.GET, 'body': requests.RequestException('boom!')}, + {'method_or_response': responses.GET, 'status': 500}, + {'method_or_response': responses.POST, 'body': requests.RequestException('boom!')}, + {'method_or_response': responses.POST, 'status': 500}, + ], +) +def test_request_error(avis_imposition, app, response_kwargs): + responses_add(**response_kwargs) + + response = utils.endpoint_get( + expected_url='/avis-imposition/test/verify', + app=app, + resource=avis_imposition, + endpoint='verify', + params={ + 'numero_fiscal': '1234567890123', + 'reference_avis': '0987654321', + }, + ) + assert response.json['err'] == 1 + assert response.json['err_desc'] == 'service-is-down' + + +@responses.activate +def test_fake_data(avis_imposition, app, settings, admin_user): + settings.AVIS_IMPOSITION_FAKE_DATA = [ + { + 'numero_fiscal': '1' * 13, + 'reference_avis': '2' * 13, + "dateEtablissement": "09/12/2019", + "dateEtablissement_iso": "2019-12-09", + "dateRecouvrement": "31/12/2019", + "dateRecouvrement_iso": "2019-12-31", + "dateEtablissement_year": 2019, + "dateRecouvrement_year": 2019, + "declarant1": { + "dateNaissance": "01/01/1970", + "dateNaissance_iso": "1970-01-01", + "nom": "DOE", + "nomNaissance": "DOE", + "prenom": "JOHN", + }, + "declarant2": { + "dateNaissance": "", + "dateNaissance_iso": "", + "nom": "DOE", + "nomNaissance": "DOE", + "prenom": "JANE", + }, + "foyerFiscal": { + "adresse": "R\u00c9SIDENCE DU CALVAIRE RUE VICTOR HUGO 75014 PARIS", + "adresse1": "R\u00c9SIDENCE DU CALVAIRE", + "adresse2": "RUE VICTOR HUGO", + "adresse3": "75014 PARIS", + "year": 2022, + }, + "impotRevenuNetAvantCorrections": 112, + "montantImpot": "Nonimposable", + "nombreParts": 4.0, + "nombrePersonnesCharge": 4.0, + "revenuBrutGlobal": 48473, + "revenuFiscalReference": 48473, + "revenuImposable": 48473, + "situationFamille": "Pacs\u00e9(e)s", + "situationFamille_simple": "pacs/mariage", + "situationPartielle": "", + } + ] + + response = utils.endpoint_get( + expected_url='/avis-imposition/test/verify', + app=app, + resource=avis_imposition, + endpoint='verify', + params={ + 'numero_fiscal': '1' * 13, + 'reference_avis': '2' * 13, + }, + ) + assert response.json['err'] == 0 + assert response.json['data'] == settings.AVIS_IMPOSITION_FAKE_DATA[0] + + # Check that test data are displayed on the backoffice page + app = login(app) + response = app.get('/avis-imposition/test/') + assert 'Test datas' in response + assert [ + [td.text() for td in tr.find('td').items()] + for tr in response.pyquery('table').eq(0).find('tbody tr').items() + ] == [['1111111111111', '2222222222222', '48473\xa0€', 'Test']]