From 077ebf0ecc3d5758a21135996eb25dd1a503ecc0 Mon Sep 17 00:00:00 2001 From: Nicolas ROCHE Date: Thu, 16 Dec 2021 10:55:05 +0100 Subject: [PATCH] eo_facture: manage invoice cancelation (#36633) --- eo_gestion/eo_facture/admin.py | 5 + eo_gestion/eo_facture/models.py | 6 +- .../admin/eo_facture/facture/change_form.html | 3 + eo_gestion/eo_facture/templates/factur-x.xml | 19 ++++ eo_gestion/eo_facture/views.py | 23 +++++ tests/settings.py | 5 + tests/test_facturx.py | 93 +++++++++++++++++++ 7 files changed, 153 insertions(+), 1 deletion(-) diff --git a/eo_gestion/eo_facture/admin.py b/eo_gestion/eo_facture/admin.py index 67a9779..9a046fa 100644 --- a/eo_gestion/eo_facture/admin.py +++ b/eo_gestion/eo_facture/admin.py @@ -386,6 +386,11 @@ class FactureAdmin(LookupAllowed, admin.ModelAdmin): self.admin_site.admin_view(self.sheet), name="eo_facture_facture_sheet", ), + url( + r'^(.+)/cancel/', + self.admin_site.admin_view(views.cancel), + name='eo_facture_facture_cancel', + ), ] return my_urls + urls diff --git a/eo_gestion/eo_facture/models.py b/eo_gestion/eo_facture/models.py index 58228f0..0f055b8 100644 --- a/eo_gestion/eo_facture/models.py +++ b/eo_gestion/eo_facture/models.py @@ -228,6 +228,9 @@ class Facture(models.Model): contrat = models.ForeignKey( Contrat, related_name='factures', blank=True, null=True, on_delete=models.CASCADE ) + annulation = models.ForeignKey( + 'Facture', related_name='factures_avoir', blank=True, null=True, on_delete=models.CASCADE + ) intitule = models.CharField(max_length=150, verbose_name='Intitulé', blank=True) notes = models.TextField(blank=True) emission = models.DateField(verbose_name="Émission", default=today, db_index=True) @@ -379,7 +382,7 @@ class Facture(models.Model): raise RuntimeError('facturx_pdf() can only be called on non proforma invoices') facturx_ctx = { 'numero_de_facture': self.code(), - 'type_facture': '380', + 'type_facture': '380' if not self.annulation else '381', # 380 => facture, 381 => avoir 'date_emission': self.emission, 'vendeur': settings.VENDOR_NAME, 'vendeur_siret': settings.VENDOR_SIRET, @@ -393,6 +396,7 @@ class Facture(models.Model): 'montant_ttc': str(self.montant_ttc), 'montant_tva': str(self.tva), 'taux_tva': self.taux_tva or (self.contrat and self.contrat.tva) or self.client.tva or 0, + 'annulation_code': self.annulation.code() if self.annulation else None, } return facturx.add_facturx_from_bytes( self.pdf(template_name=template_name, base_uri=base_uri), facturx_ctx diff --git a/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html b/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html index 5a7a455..c1a96c8 100644 --- a/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html +++ b/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html @@ -14,6 +14,9 @@ {% if original.client.chorus_structure and not original.proforma %}
  • Envoyer à Chorus
  • {% endif %} + {% if not original.annulation %} +
  • Annuler
  • + {% endif %} {% endif %}{% endif %} {% endblock %} diff --git a/eo_gestion/eo_facture/templates/factur-x.xml b/eo_gestion/eo_facture/templates/factur-x.xml index 1cd4e6c..7ca59ef 100644 --- a/eo_gestion/eo_facture/templates/factur-x.xml +++ b/eo_gestion/eo_facture/templates/factur-x.xml @@ -78,6 +78,16 @@ CHORUS PRO : pour le secteur public, il s'agit du "Service Executant". Il est ob EUR + {% if annulation_code %} + {% comment %} + Value "Clearing between partners" explained on S2.20, page 171 of + the Chorus specifications: https://dev.entrouvert.org/attachments/60076 + Default value is "virement" for Chorus (and we guess for Factur-X too) + {% endcomment %} + + 97 + + {% endif %} {{ montant_tva }} VAT @@ -92,6 +102,15 @@ CHORUS PRO : pour le secteur public, il s'agit du "Service Executant". Il est ob {{ montant_ttc }} {{ montant_ttc }} + {% if annulation_code %} + {% comment %} + "identifiant de la facture d’origine" explained on G1.05, page 74 and 113 of + the Chorus specifications: https://dev.entrouvert.org/attachments/60076 + {% endcomment %} + + {{ annulation_code }} + + {% endif %} {% endlocalize %} diff --git a/eo_gestion/eo_facture/views.py b/eo_gestion/eo_facture/views.py index d877433..bf2f0b0 100644 --- a/eo_gestion/eo_facture/views.py +++ b/eo_gestion/eo_facture/views.py @@ -115,3 +115,26 @@ def send_to_chorus(request, facture_id): logger.warning('failed to send invoice %s to ChorusPro, response: %s', facture, description) messages.error(request, msg) return redirect('admin:eo_facture_facture_change', facture_id) + + +def cancel(request, facture_id): + facture = get_object_or_404(Facture, id=facture_id) + facture_avoir = get_object_or_404(Facture, id=facture_id) + facture_avoir.pk = None + facture_avoir.ordre = None + facture_avoir.annulation = facture + facture_avoir.proforma = True + facture_avoir.intitule = "AVOIR POUR LA FACTURE " + facture.intitule + facture_avoir.paid = False + facture_avoir.creator = request.user + facture_avoir.account_on_previous_period = False + facture_avoir.save() + for ligne in facture.lignes.all(): + ligne.pk = None + ligne.facture = facture_avoir + ligne.quantite = ligne.quantite * -1 + ligne.save() + for payment in facture.payments.all(): + payment.pk = None + payment.save() + return redirect('admin:eo_facture_facture_change', facture_avoir.id) diff --git a/tests/settings.py b/tests/settings.py index 390c0d3..f81ef66 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -30,3 +30,8 @@ DATABASES = { for key in ('PGPORT', 'PGHOST', 'PGUSER', 'PGPASSWORD'): if key in os.environ: DATABASES['default']['TEST'][key[2:]] = os.environ[key] + + +VENDOR_NAME = "Entr'ouvert" +VENDOR_SIRET = '44317013900036' +VENDOR_TVAI = 'FR09491081899' diff --git a/tests/test_facturx.py b/tests/test_facturx.py index 160d2bd..b55df48 100644 --- a/tests/test_facturx.py +++ b/tests/test_facturx.py @@ -122,3 +122,96 @@ def test_add_facturx_from_bytes(fake_invoice_bytes): ], ], ] + + +def test_add_facturx_from_bytes_facture_avoir(fake_invoice_bytes): + facturx_ctx = { + 'numero_de_facture': 'F20190002', + 'type_facture': '381', + 'date_emission': datetime.date(2019, 1, 1), + 'chorus_service_code': 'service-code', + 'vendeur': 'Entr\'ouvert', + 'vendeur_siret': '44317013900036', + 'vendeur_tvai': 'FR09491081899', + 'client_siret': '1234', + 'client_name': 'RGFIPD', + 'numero_engagement': '5678', + 'numero_marche': 'ABCD', + 'montant_ht': '10', + 'montant_ttc': '12', + 'montant_tva': '2', + 'taux_tva': 20.0, + 'annulation_code': 'F20190001', + } + facturx_bytes = add_facturx_from_bytes(fake_invoice_bytes, facturx_ctx) + _, xml_str = facturx.get_facturx_xml_from_pdf(io.BytesIO(facturx_bytes)) + root = ET.fromstring(xml_str) + + def helper(root): + tag = root.tag.split('}')[-1] + if len(root) == 0: + return [tag, root.text or ''] + else: + return [tag] + [helper(node) for node in root] + + assert helper(root) == [ + 'CrossIndustryInvoice', + [ + 'ExchangedDocumentContext', + ['BusinessProcessSpecifiedDocumentContextParameter', ['ID', 'A1']], + ['GuidelineSpecifiedDocumentContextParameter', ['ID', 'urn:factur-x.eu:1p0:basicwl']], + ], + [ + 'ExchangedDocument', + ['ID', 'F20190002'], + ['TypeCode', '381'], + ['IssueDateTime', ['DateTimeString', '20190101']], + ], + [ + 'SupplyChainTradeTransaction', + [ + 'ApplicableHeaderTradeAgreement', + ['BuyerReference', 'service-code'], + [ + 'SellerTradeParty', + ['Name', "Entr'ouvert"], + ['SpecifiedLegalOrganization', ['ID', '44317013900036']], + ['PostalTradeAddress', ['CountryID', 'FR']], + ['SpecifiedTaxRegistration', ['ID', 'FR09491081899']], + ], + [ + 'BuyerTradeParty', + ['Name', 'RGFIPD'], + [ + 'SpecifiedLegalOrganization', + ['ID', '1234'], + ], + ], + ['BuyerOrderReferencedDocument', ['IssuerAssignedID', '5678']], + ['ContractReferencedDocument', ['IssuerAssignedID', 'ABCD']], + ], + ['ApplicableHeaderTradeDelivery', ''], + [ + 'ApplicableHeaderTradeSettlement', + ['InvoiceCurrencyCode', 'EUR'], + ['SpecifiedTradeSettlementPaymentMeans', ['TypeCode', '97']], + [ + 'ApplicableTradeTax', + ['CalculatedAmount', '2'], + ['TypeCode', 'VAT'], + ['BasisAmount', '10'], + ['CategoryCode', 'S'], + ['RateApplicablePercent', '20.0'], + ], + [ + 'SpecifiedTradeSettlementHeaderMonetarySummation', + ['LineTotalAmount', '10'], + ['TaxBasisTotalAmount', '10'], + ['TaxTotalAmount', '2'], + ['GrandTotalAmount', '12'], + ['DuePayableAmount', '12'], + ], + ['InvoiceReferencedDocument', ['IssuerAssignedID', 'F20190001']], + ], + ], + ]