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']],
+ ],
+ ],
+ ]