eo_facture: manage invoice cancelation (#36633)

This commit is contained in:
Nicolas Roche 2021-12-16 10:55:05 +01:00
parent 53a671300c
commit 077ebf0ecc
7 changed files with 153 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -14,6 +14,9 @@
{% if original.client.chorus_structure and not original.proforma %}
<li><a href="{% url "admin:eo_facture_facture_send_to_chorus" original.id %}">Envoyer à Chorus</a></li>
{% endif %}
{% if not original.annulation %}
<li><a href="{% url "admin:eo_facture_facture_cancel" original.id %}">Annuler</a></li>
{% endif %}
</ul>
{% endif %}{% endif %}
{% endblock %}

View File

@ -78,6 +78,16 @@ CHORUS PRO : pour le secteur public, il s'agit du "Service Executant". Il est ob
<ram:ApplicableHeaderTradeDelivery/>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
{% 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 %}
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>97</ram:TypeCode>
</ram:SpecifiedTradeSettlementPaymentMeans>
{% endif %}
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>{{ montant_tva }}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
@ -92,6 +102,15 @@ CHORUS PRO : pour le secteur public, il s'agit du "Service Executant". Il est ob
<ram:GrandTotalAmount>{{ montant_ttc }}</ram:GrandTotalAmount>
<ram:DuePayableAmount>{{ montant_ttc }}</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
{% if annulation_code %}
{% comment %}
"identifiant de la facture dorigine" explained on G1.05, page 74 and 113 of
the Chorus specifications: https://dev.entrouvert.org/attachments/60076
{% endcomment %}
<ram:InvoiceReferencedDocument>
<ram:IssuerAssignedID>{{ annulation_code }}</ram:IssuerAssignedID>
</ram:InvoiceReferencedDocument>
{% endif %}
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>{% endlocalize %}

View File

@ -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)

View File

@ -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'

View File

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