1 Commits

Author SHA1 Message Date
bit4bit
1f75ac46f4 Create new branch named "quantity-float"
FossilOrigin-Name: 20c749c4c007bedda6d9fe1218eec2bdfb1c2930de90ad7e1cf685936e4fcde8
2020-11-10 23:15:50 +00:00
15 changed files with 165 additions and 586 deletions

View File

@@ -2,6 +2,8 @@
facho facho
===== =====
!!INESTABLE NO RECOMENDAMOS USO PARA PRODUCION!!
Libreria para facturacion electronica colombia. Libreria para facturacion electronica colombia.
- facho/facho.py: abstracion para manipulacion del XML - facho/facho.py: abstracion para manipulacion del XML
@@ -22,7 +24,7 @@ usando pip::
CLI CLI
=== ===
tambien se provee linea de comandos **facho** para generacion, firmado y envio de documentos:: tambien se provee linea de comandos **facho** para firmado y envio de documentos::
facho --help facho --help
CONTRIBUIR CONTRIBUIR
@@ -30,13 +32,17 @@ CONTRIBUIR
ver **CONTRIBUTING.rst** ver **CONTRIBUTING.rst**
USO
===
ver **USAGE.rst**
DIAN HABILITACION DIAN HABILITACION
================= =================
guia oficial actualizada al 2020-04-20: https://www.dian.gov.co/fizcalizacioncontrol/herramienconsulta/FacturaElectronica/Facturaci%C3%B3n_Gratuita_DIAN/Documents/Guia_usuario_08052019.pdf#search=numeracion guia oficial actualizada al 2020-04-20: https://www.dian.gov.co/fizcalizacioncontrol/herramienconsulta/FacturaElectronica/Facturaci%C3%B3n_Gratuita_DIAN/Documents/Guia_usuario_08052019.pdf#search=numeracion
ERROR X509SerialNumber
======================
lxml.etree.DocumentInvalid: Element '{http://www.w3.org/2000/09/xmldsig#}X509SerialNumber': '632837201711293159666920255411738137494572618415' is not a valid value of the atomic type 'xs:integer'
Actualmente el xmlschema usado por xmlsig para el campo X509SerialNumber es tipo
integer ahi que parchar manualmente a tipo string, en el archivo site-packages/xmlsig/data/xmldsig-core-schema.xsd.

View File

@@ -1,24 +0,0 @@
uso de la libreria
==================
**facho** es tanto una libreria para modelar y generar los documentos xml requeridos para la facturacion,
asi como una herramienta de **consola** para facilitar algunas actividades como: generaciones de xml
apartir de una especificacion en python, comprimir y enviar archivos según el SOAP vigente.
**facho** es diseñado para ser usado en conjunto con el documento **docs/DIAN/Anexo_Tecnico_Factura_Electronica_Vr1_7_2020.pdf**, ya que en gran medida sigue la terminologia presente en este.
Para ejemplos ver **examples/** .
En terminos generales seria modelar la factura usando **facho/fe/form.py**, instanciar las extensiones requeridas ver **facho/fe/fe.py** y
una vez generado el objeto invoice y las extensiones requeridas se procede a crear el XML, ejemplo:
~~~python
....
xml = form_xml.DIANInvoiceXML(invoice)
extensions = module.extensions(invoice)
for extension in extensions:
xml.add_extension(extension)
form_xml.DIANWriteSigned(xml, "factura.xml", "llave privada", "frase")
~~~

View File

@@ -1,117 +0,0 @@
# este archivo es un ejemplo para le generacion
# una factura de venta nacional usando el comando **facho**.
#
# ejemplo: facho generate-invoice generate-invoice-from-cli.py
#
# importar libreria de modelos
from facho.fe import form
from facho.fe import form_xml
# importar libreria extensiones xml para cumplir decreto
from facho.fe import fe
# importar otras necesarias
from datetime import datetime, date
# Datos del fomulario del SET de pruebas
INVOICE_AUTHORIZATION = '181360000001' #Número suministrado por la Dian en el momento de la creación del SET de Pruebas
ID_SOFTWARE = '57bcb6d1-c591-5a90-b80a-cb030ec91440' #Id suministrado por la Dian en el momento de la creación del SET de Pruebas
PIN = '19642' #Número creado por la empresa para poder crear el SET de pruebas
CLAVE_TECNICA = 'fc9eac422eba16e21ffd8c5f94b3f30a6e38162d' ##Id suministrado por la Dian en el momento de la creación del SET de Pruebas
# callback que retonar las extensiones XML necesarias
# para que el documento final XML cumpla el decreto.
#
# muchos de los valores usados son obtenidos
# del servicio web de la DIAN.
def extensions(inv):
security_code = fe.DianXMLExtensionSoftwareSecurityCode(ID_SOFTWARE, PIN, inv.invoice_ident)
authorization_provider = fe.DianXMLExtensionAuthorizationProvider()
cufe = fe.DianXMLExtensionCUFE(inv, CLAVE_TECNICA, fe.AMBIENTE_PRUEBAS)
software_provider = fe.DianXMLExtensionSoftwareProvider('nit_empresa', 'dígito_verificación', ID_SOFTWARE)
inv_authorization = fe.DianXMLExtensionInvoiceAuthorization(INVOICE_AUTHORIZATION,
datetime(2019, 1, 19),#Datos toamdos de
datetime(2030, 1, 19),#la configuración
'SETP', 990000000, 995000000)#del SET de pruebas
return [security_code, authorization_provider, cufe, software_provider, inv_authorization]
def invoice():
# factura de venta nacional
inv = form.Invoice('01')
# asignar periodo de facturacion
inv.set_period(datetime.now(), datetime.now())
# asignar fecha de emision de la factura
inv.set_issue(datetime.now())
# asignar prefijo y numero del documento
inv.set_ident('SETP990000008')
# asignar tipo de operacion ver DIAN:6.1.5
inv.set_operation_type('10')
inv.set_supplier(form.Party(
legal_name = 'Nombre registrado de la empresa',
name = 'Nombre comercial o él mismo nombre registrado',
ident = form.PartyIdentification('nit_empresa', 'digito_verificación', '31'),
# obligaciones del contribuyente ver DIAN:FAK26
responsability_code = form.Responsability(['O-07', 'O-14', 'O-48']),
# ver DIAN:FAJ28
responsability_regime_code = '48',
# tipo de organizacion juridica ver DIAN:6.2.3
organization_code = '1',
email = "correoempresa@correoempresa.correo",
address = form.Address(
'', '', form.City('05001', 'Medellín'),
form.Country('CO', 'Colombia'),
form.CountrySubentity('05', 'Antioquia')),
))
#Tercero a quien se le factura
inv.set_customer(form.Party(
legal_name = 'consumidor final',
name = 'consumidor final',
ident = form.PartyIdentification('222222222222', '', '13'),
responsability_code = form.Responsability(['R-99-PN']),
responsability_regime_code = '49',
organization_code = '2',
email = "consumidor_final0final.final",
address = form.Address(
'', '', form.City('05001', 'Medellín'),
form.Country('CO', 'Colombia'),
form.CountrySubentity('05', 'Antioquia')),
#tax_scheme = form.TaxScheme('01', 'IVA')
))
# asignar metodo de pago
inv.set_payment_mean(form.PaymentMean(
# metodo de pago ver DIAN:3.4.1
id = '1',
# codigo correspondiente al medio de pago ver DIAN:3.4.2
code = '20',
# fecha de vencimiento de la factura
due_at = datetime.now(),
# identificador numerico
payment_id = '2'
))
# adicionar una linea al documento
inv.add_invoice_line(form.InvoiceLine(
quantity = form.Quantity(int(20.5), '94'),
# item general de codigo 999
description = 'productO3',
item = form.StandardItem('test', 9999),
price = form.Price(
# precio base del item (sin iva)
amount = form.Amount(200.00),
# ver DIAN:6.3.5.1
type_code = '01',
type = 'x'
),
tax = form.TaxTotal(
subtotals = [
form.TaxSubTotal(
percent = 19.00,
scheme=form.TaxScheme('01')
)
]
)
))
return inv
def document_xml():
return form_xml.DIANInvoiceXML

View File

@@ -0,0 +1,74 @@
import facho.fe.form as form
from facho.fe import fe
from datetime import datetime
def extensions(inv):
nit = form.PartyIdentification('nit', '5', '31')
security_code = fe.DianXMLExtensionSoftwareSecurityCode('id software', 'pin', inv.invoice_ident)
authorization_provider = fe.DianXMLExtensionAuthorizationProvider()
cufe = fe.DianXMLExtensionCUFE(inv, fe.DianXMLExtensionCUFE.AMBIENTE_PRUEBAS,
'clave tecnica')
software_provider = fe.DianXMLExtensionSoftwareProvider(nit, nit.dv, 'id software')
inv_authorization = fe.DianXMLExtensionInvoiceAuthorization('invoice autorization',
datetime(2019, 1, 19),
datetime(2030, 1, 19),
'SETP', 990000001, 995000000)
return [security_code, authorization_provider, cufe, software_provider, inv_authorization]
def invoice():
inv = form.Invoice()
inv.set_period(datetime.now(), datetime.now())
inv.set_issue(datetime.now())
inv.set_ident('SETP990003033')
inv.set_operation_type('10')
inv.set_supplier(form.Party(
legal_name = 'FACHO SOS',
name = 'FACHO SOS',
ident = form.PartyIdentification('900579212', '5', '31'),
responsability_code = form.Responsability(['O-07', 'O-09', 'O-14', 'O-48']),
responsability_regime_code = '48',
organization_code = '1',
email = "sdds@sd.com",
address = form.Address(
'', '', form.City('05001', 'Medellín'),
form.Country('CO', 'Colombia'),
form.CountrySubentity('05', 'Antioquia'))
))
inv.set_customer(form.Party(
legal_name = 'facho-customer',
name = 'facho-customer',
ident = form.PartyIdentification('999999999', '', '13'),
responsability_code = form.Responsability(['R-99-PN']),
responsability_regime_code = '49',
organization_code = '2',
email = "sdds@sd.com",
address = form.Address(
'', '', form.City('05001', 'Medellín'),
form.Country('CO', 'Colombia'),
form.CountrySubentity('05', 'Antioquia'))
))
inv.set_payment_mean(form.PaymentMean(
id = '1',
code = '10',
due_at = datetime.now(),
payment_id = '1'
))
inv.add_invoice_line(form.InvoiceLine(
quantity = form.Quantity(1, '94'),
description = 'producto facho',
item = form.StandardItem('test', 9999),
price = form.Price(
amount = form.Amount(100.00),
type_code = '01',
type = 'x'
),
tax = form.TaxTotal(
subtotals = [
form.TaxSubTotal(
percent = 19.00,
)
]
)
))
return inv

View File

@@ -1,114 +0,0 @@
# importar libreria de modelos
import facho.fe.form as form
import facho.fe.form_xml
PRIVATE_KEY_PATH='ruta a mi llave privada'
PRIVATE_PASSPHRASE='clave de la llave privada'
# consultar las extensiones necesarias
def extensions(inv):
security_code = fe.DianXMLExtensionSoftwareSecurityCode('id software', 'pin', inv.invoice_ident)
authorization_provider = fe.DianXMLExtensionAuthorizationProvider()
cufe = fe.DianXMLExtensionCUFE(inv, fe.DianXMLExtensionCUFE.AMBIENTE_PRUEBAS,
'clave tecnica')
nit = form.PartyIdentification('nit', '5', '31')
software_provider = fe.DianXMLExtensionSoftwareProvider(nit, nit.dv, 'id software')
inv_authorization = fe.DianXMLExtensionInvoiceAuthorization('invoice autorization',
datetime(2019, 1, 19),
datetime(2030, 1, 19),
'SETP', 990000001, 995000000)
return [security_code, authorization_provider, cufe, software_provider, inv_authorization]
# generar documento desde modelo a ruta indicada
def generate_document(invoice, filepath):
xml = form_xml.DIANInvoiceXML(invoice)
for extension in extensions(invoice):
xml.add_extension(extension)
form_xml.utils.DIANWriteSigned(xml, filepath, PRIVATE_KEY_PATH, PRIVATE_PASSPHRASE, True)
# Modelars las facturas
# ...
# factura de venta nacional
inv = form.NationalSalesInvoice()
# asignar periodo de facturacion
inv.set_period(datetime.now(), datetime.now())
# asignar fecha de emision de la factura
inv.set_issue(datetime.now())
# asignar prefijo y numero del documento
inv.set_ident('SETP990003033')
# asignar tipo de operacion ver DIAN:6.1.5
inv.set_operation_type('10')
# asignar proveedor
inv.set_supplier(form.Party(
legal_name = 'FACHO SOS',
name = 'FACHO SOS',
ident = form.PartyIdentification('900579212', '5', '31'),
# obligaciones del contribuyente ver DIAN:FAK26
responsability_code = form.Responsability(['O-07', 'O-09', 'O-14', 'O-48']),
# ver DIAN:FAJ28
responsability_regime_code = '48',
# tipo de organizacion juridica ver DIAN:6.2.3
organization_code = '1',
email = "sdds@sd.com",
address = form.Address(
name = '',
street = '',
city = form.City('05001', 'Medellín'),
country = form.Country('CO', 'Colombia'),
countrysubentity = form.CountrySubentity('05', 'Antioquia'))
))
inv.set_customer(form.Party(
legal_name = 'facho-customer',
name = 'facho-customer',
ident = form.PartyIdentification('999999999', '', '13'),
responsability_code = form.Responsability(['R-99-PN']),
responsability_regime_code = '49',
organization_code = '2',
email = "sdds@sd.com",
address = form.Address(
name = '',
street = '',
city = form.City('05001', 'Medellín'),
country = form.Country('CO', 'Colombia'),
countrysubentity = form.CountrySubentity('05', 'Antioquia'))
))
# asignar metodo de pago
inv.set_payment_mean(form.PaymentMean(
# metodo de pago ver DIAN:3.4.1
id = '1',
# codigo correspondiente al medio de pago ver DIAN:3.4.2
code = '10',
# fecha de vencimiento de la factura
due_at = datetime.now(),
# identificador numerico
payment_id = '1'
))
# adicionar una linea al documento
inv.add_invoice_line(form.InvoiceLine(
quantity = form.Quantity(1, '94'),
description = 'producto facho',
# item general de codigo 999
item = form.StandardItem('test', 9999),
price = form.Price(
# precio base del tiem
amount = form.Amount(100.00),
# ver DIAN:6.3.5.1
type_code = '01',
type = 'x'
),
tax = form.TaxTotal(
subtotals = [
form.TaxSubTotal(
percent = 19.00,
)
]
)
))
# refrescar valores de la factura
inv.calculate()
# generar el documento xml en la ruta indicada
generate_document(inv, 'ruta a donde guardar el .xml')

View File

@@ -129,11 +129,6 @@ class FachoXML:
self.xpath_for = {} self.xpath_for = {}
self.extensions = [] self.extensions = []
@classmethod
def from_string(cls, document: str, namespaces: dict() = []) -> 'FachoXML':
xml = LXMLBuilder.from_string(document)
return FachoXML(xml, nsmap=namespaces)
def append_element(self, elem, new_elem): def append_element(self, elem, new_elem):
#elem = self.find_or_create_element(xpath, append=append) #elem = self.find_or_create_element(xpath, append=append)
#self.builder.append(elem, new_elem) #self.builder.append(elem, new_elem)

View File

@@ -138,16 +138,10 @@ class GetStatusResponse:
@classmethod @classmethod
def fromdict(cls, data): def fromdict(cls, data):
if data['ErrorMessage']:
error_message = data['ErrorMessage']['string']
else:
error_message = None
return cls(data['IsValid'], return cls(data['IsValid'],
data['StatusDescription'], data['StatusDescription'],
data['StatusCode'], data['StatusCode'],
error_message) data['ErrorMessage']['string'])
@dataclass @dataclass
class GetStatus(SOAPService): class GetStatus(SOAPService):

View File

@@ -14,7 +14,6 @@ from contextlib import contextmanager
from .data.dian import codelist from .data.dian import codelist
from . import form from . import form
from collections import defaultdict from collections import defaultdict
from pathlib import Path
AMBIENTE_PRUEBAS = codelist.TipoAmbiente.by_name('Pruebas')['code'] AMBIENTE_PRUEBAS = codelist.TipoAmbiente.by_name('Pruebas')['code']
AMBIENTE_PRODUCCION = codelist.TipoAmbiente.by_name('Producción')['code'] AMBIENTE_PRODUCCION = codelist.TipoAmbiente.by_name('Producción')['code']
@@ -26,9 +25,8 @@ SCHEME_AGENCY_ATTRS = {
} }
pwd = Path(__file__).parent
# RESOLUCION 0001: pagina 516 # RESOLUCION 0001: pagina 516
POLICY_ID = 'file://'+str(pwd)+'/data/dian/politicadefirmav2.pdf' POLICY_ID = 'https://facturaelectronica.dian.gov.co/politicadefirma/v2/politicadefirmav2.pdf'
POLICY_NAME = u'Política de firma para facturas electrónicas de la República de Colombia.' POLICY_NAME = u'Política de firma para facturas electrónicas de la República de Colombia.'
@@ -51,7 +49,8 @@ NAMESPACES = {
} }
def fe_from_string(document: str) -> FachoXML: def fe_from_string(document: str) -> FachoXML:
return FeXML.from_string(document) xml = LXMLBuilder.from_string(document)
return FachoXML(xml, nsmap=NAMESPACES)
from contextlib import contextmanager from contextlib import contextmanager
@contextmanager @contextmanager
@@ -70,7 +69,6 @@ def mock_xades_policy():
mock.return_value = UrllibPolicyMock() mock.return_value = UrllibPolicyMock()
yield yield
class FeXML(FachoXML): class FeXML(FachoXML):
def __init__(self, root, namespace): def __init__(self, root, namespace):
@@ -81,10 +79,6 @@ class FeXML(FachoXML):
self._cn = root.rstrip('/') self._cn = root.rstrip('/')
#self.find_or_create_element(self._cn) #self.find_or_create_element(self._cn)
@classmethod
def from_string(cls, document: str) -> 'FeXML':
return super().from_string(document, namespaces=NAMESPACES)
def tostring(self, **kw): def tostring(self, **kw):
return super().tostring(**kw)\ return super().tostring(**kw)\
.replace("fe:", "")\ .replace("fe:", "")\
@@ -119,8 +113,7 @@ class DianXMLExtensionCUDFE(FachoXMLExtension):
fachoxml.set_element('./cbc:UUID', cufe, fachoxml.set_element('./cbc:UUID', cufe,
schemeID=self.tipo_ambiente, schemeID=self.tipo_ambiente,
schemeName=self.schemeName()) schemeName=self.schemeName())
#DIAN 1.8.-2021: FAD03 fachoxml.set_element('./cbc:ProfileID', 'DIAN 2.1')
fachoxml.set_element('./cbc:ProfileID', 'DIAN 2.1: Factura Electrónica de Venta')
fachoxml.set_element('./cbc:ProfileExecutionID', self._tipo_ambiente_int()) fachoxml.set_element('./cbc:ProfileExecutionID', self._tipo_ambiente_int())
#DIAN 1.7.-2020: FAB36 #DIAN 1.7.-2020: FAB36
fachoxml.set_element('./ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent/sts:DianExtensions/sts:QRCode', fachoxml.set_element('./ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent/sts:DianExtensions/sts:QRCode',
@@ -192,14 +185,14 @@ class DianXMLExtensionCUFE(DianXMLExtensionCUDFE):
'%s' % build_vars['NumFac'], '%s' % build_vars['NumFac'],
'%s' % build_vars['FecFac'], '%s' % build_vars['FecFac'],
'%s' % build_vars['HoraFac'], '%s' % build_vars['HoraFac'],
form.Amount(build_vars['ValorBruto']).truncate_as_string(2), form.Amount(build_vars['ValorBruto']).format('%.02f'),
CodImpuesto1, CodImpuesto1,
build_vars['ValorImpuestoPara'].get(CodImpuesto1, form.Amount(0.0)).truncate_as_string(2), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).format('%.02f'),
CodImpuesto2, CodImpuesto2,
build_vars['ValorImpuestoPara'].get(CodImpuesto2, form.Amount(0.0)).truncate_as_string(2), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).format('%.02f'),
CodImpuesto3, CodImpuesto3,
build_vars['ValorImpuestoPara'].get(CodImpuesto3, form.Amount(0.0)).truncate_as_string(2), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).format('%.02f'),
build_vars['ValorTotalPagar'].truncate_as_string(2), form.Amount(build_vars['ValorTotalPagar']).format('%.02f'),
'%s' % build_vars['NitOFE'], '%s' % build_vars['NitOFE'],
'%s' % build_vars['NumAdq'], '%s' % build_vars['NumAdq'],
'%s' % build_vars['ClTec'], '%s' % build_vars['ClTec'],
@@ -229,14 +222,14 @@ class DianXMLExtensionCUDE(DianXMLExtensionCUDFE):
'%s' % build_vars['NumFac'], '%s' % build_vars['NumFac'],
'%s' % build_vars['FecFac'], '%s' % build_vars['FecFac'],
'%s' % build_vars['HoraFac'], '%s' % build_vars['HoraFac'],
form.Amount(build_vars['ValorBruto']).truncate_as_string(2), form.Amount(build_vars['ValorBruto']).format('%.02f'),
CodImpuesto1, CodImpuesto1,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).truncate_as_string(2), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).format('%.02f'),
CodImpuesto2, CodImpuesto2,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).truncate_as_string(2), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).format('%.02f'),
CodImpuesto3, CodImpuesto3,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).truncate_as_string(2), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).format('%.02f'),
form.Amount(build_vars['ValorTotalPagar']).truncate_as_string(2), form.Amount(build_vars['ValorTotalPagar']).format('%.02f'),
'%s' % build_vars['NitOFE'], '%s' % build_vars['NitOFE'],
'%s' % build_vars['NumAdq'], '%s' % build_vars['NumAdq'],
'%s' % build_vars['Software-PIN'], '%s' % build_vars['Software-PIN'],
@@ -284,23 +277,15 @@ class DianXMLExtensionSoftwareSecurityCode(FachoXMLExtension):
class DianXMLExtensionSigner: class DianXMLExtensionSigner:
def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False): def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False):
self._pkcs12_data = open(pkcs12_path, 'rb').read() self._pkcs12_path = pkcs12_path
self._passphrase = None self._passphrase = None
self._mockpolicy = mockpolicy self._mockpolicy = mockpolicy
if passphrase: if passphrase:
self._passphrase = passphrase.encode('utf-8') self._passphrase = passphrase.encode('utf-8')
@classmethod @classmethod
def from_bytes(cls, data, passphrase=None, mockpolicy=False): def from_pkcs12(self, filepath, password=None):
self = cls.__new__(cls) p12 = OpenSSL.crypto.load_pkcs12(open(filepath, 'rb').read(), password)
self._pkcs12_data = data
self._passphrase = None
self._mockpolicy = mockpolicy
if passphrase:
self._passphrase = passphrase.encode('utf-8')
return self
def sign_xml_string(self, document): def sign_xml_string(self, document):
xml = LXMLBuilder.from_string(document) xml = LXMLBuilder.from_string(document)
@@ -356,7 +341,7 @@ class DianXMLExtensionSigner:
POLICY_NAME, POLICY_NAME,
xmlsig.constants.TransformSha256) xmlsig.constants.TransformSha256)
ctx = xades.XAdESContext(policy) ctx = xades.XAdESContext(policy)
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(self._pkcs12_data, ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(open(self._pkcs12_path, 'rb').read(),
self._passphrase)) self._passphrase))
if self._mockpolicy: if self._mockpolicy:
@@ -375,7 +360,6 @@ class DianXMLExtensionSigner:
extcontent = fachoxml.builder.xpath(fachoxml.root, './ext:UBLExtensions/ext:UBLExtension[2]/ext:ExtensionContent') extcontent = fachoxml.builder.xpath(fachoxml.root, './ext:UBLExtensions/ext:UBLExtension[2]/ext:ExtensionContent')
fachoxml.append_element(extcontent, signature) fachoxml.append_element(extcontent, signature)
class DianXMLExtensionAuthorizationProvider(FachoXMLExtension): class DianXMLExtensionAuthorizationProvider(FachoXMLExtension):
# RESOLUCION 0004: pagina 176 # RESOLUCION 0004: pagina 176
@@ -445,7 +429,7 @@ class DianZIP:
MAX_FILES = 50 MAX_FILES = 50
def __init__(self, file_like): def __init__(self, file_like):
self.zipfile = zipfile.ZipFile(file_like, mode='w', compression=zipfile.ZIP_DEFLATED) self.zipfile = zipfile.ZipFile(file_like, mode='w')
self.num_files = 0 self.num_files = 0
def add_invoice_xml(self, name, xml_data): def add_invoice_xml(self, name, xml_data):
@@ -468,8 +452,8 @@ class DianZIP:
class DianXMLExtensionSignerVerifier: class DianXMLExtensionSignerVerifier:
def __init__(self, pkcs12_path_or_bytes, passphrase=None, mockpolicy=False): def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False):
self._pkcs12_path_or_bytes = pkcs12_path_or_bytes self._pkcs12_path = pkcs12_path
self._passphrase = None self._passphrase = None
self._mockpolicy = mockpolicy self._mockpolicy = mockpolicy
if passphrase: if passphrase:
@@ -486,12 +470,7 @@ class DianXMLExtensionSignerVerifier:
fachoxml.root.append(signature) fachoxml.root.append(signature)
ctx = xades.XAdESContext() ctx = xades.XAdESContext()
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(open(self._pkcs12_path, 'rb').read(),
pkcs12_data = self._pkcs12_path_or_bytes
if isinstance(self._pkcs12_path_or_bytes, str):
pkcs12_data = open(self._pkcs12_path_or_bytes, 'rb').read()
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(pkcs12_data,
self._passphrase)) self._passphrase))
try: try:

View File

@@ -4,7 +4,6 @@
import hashlib import hashlib
from functools import reduce from functools import reduce
import copy import copy
import dataclasses
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, date from datetime import datetime, date
from collections import defaultdict from collections import defaultdict
@@ -54,7 +53,7 @@ class AmountCollection(Collection):
return total return total
class Amount: class Amount:
def __init__(self, amount: int or float or str or Amount, currency: Currency = Currency('COP')): def __init__(self, amount: int or float or Amount, currency: Currency = Currency('COP')):
#DIAN 1.7.-2020: 1.2.3.1 #DIAN 1.7.-2020: 1.2.3.1
if isinstance(amount, Amount): if isinstance(amount, Amount):
@@ -64,7 +63,7 @@ class Amount:
self.amount = amount.amount self.amount = amount.amount
self.currency = amount.currency self.currency = amount.currency
else: else:
if float(amount) < 0: if amount < 0:
raise ValueError('amount must be positive >= 0') raise ValueError('amount must be positive >= 0')
self.amount = Decimal(amount, decimal.Context(prec=DECIMAL_PRECISION, self.amount = Decimal(amount, decimal.Context(prec=DECIMAL_PRECISION,
@@ -72,17 +71,12 @@ class Amount:
rounding=decimal.ROUND_HALF_EVEN )) rounding=decimal.ROUND_HALF_EVEN ))
self.currency = currency self.currency = currency
def fromNumber(self, val):
return Amount(val, currency=self.currency)
def round(self, prec):
return Amount(round(self.amount, prec), currency=self.currency)
def __round__(self, prec): def __round__(self, prec):
return round(self.amount, prec) return round(self.amount, prec)
def __str__(self): def __str__(self):
return str(self.float()) return '%.06f' % self.amount
def __lt__(self, other): def __lt__(self, other):
if not self.is_same_currency(other): if not self.is_same_currency(other):
@@ -94,27 +88,17 @@ class Amount:
raise AmountCurrencyError() raise AmountCurrencyError()
return round(self.amount, DECIMAL_PRECISION) == round(other.amount, DECIMAL_PRECISION) return round(self.amount, DECIMAL_PRECISION) == round(other.amount, DECIMAL_PRECISION)
def _cast(self, val): def __add__(self, other):
if type(val) in [int, float]:
return self.fromNumber(val)
if isinstance(val, Amount):
return val
raise TypeError("cant cast to amount")
def __add__(self, rother):
other = self._cast(rother)
if not self.is_same_currency(other): if not self.is_same_currency(other):
raise AmountCurrencyError() raise AmountCurrencyError()
return Amount(self.amount + other.amount, self.currency) return Amount(self.amount + other.amount, self.currency)
def __sub__(self, rother): def __sub__(self, other):
other = self._cast(rother)
if not self.is_same_currency(other): if not self.is_same_currency(other):
raise AmountCurrencyError() raise AmountCurrencyError()
return Amount(self.amount - other.amount, self.currency) return Amount(self.amount - other.amount, self.currency)
def __mul__(self, rother): def __mul__(self, other):
other = self._cast(rother)
if not self.is_same_currency(other): if not self.is_same_currency(other):
raise AmountCurrencyError() raise AmountCurrencyError()
return Amount(self.amount * other.amount, self.currency) return Amount(self.amount * other.amount, self.currency)
@@ -122,9 +106,8 @@ class Amount:
def is_same_currency(self, other): def is_same_currency(self, other):
return self.currency == other.currency return self.currency == other.currency
def truncate_as_string(self, prec): def format(self, formatter):
parts = str(self.float()).split('.', 1) return formatter % self.float()
return '%s.%s' % (parts[0], parts[1][0:prec].ljust(prec,'0'))
def float(self): def float(self):
return float(round(self.amount, DECIMAL_PRECISION)) return float(round(self.amount, DECIMAL_PRECISION))
@@ -133,25 +116,27 @@ class Amount:
class Quantity: class Quantity:
def __init__(self, val, code): def __init__(self, val, code):
if type(val) not in [float, int]: if not isinstance(val, int):
raise ValueError('val expected int or float') raise ValueError('val expected int')
if code not in codelist.UnidadesMedida: if code not in codelist.UnidadesMedida:
raise ValueError("code [%s] not found" % (code)) raise ValueError("code [%s] not found" % (code))
self.value = Amount(val) self.value = val
self.code = code self.code = code
def __mul__(self, other): def __mul__(self, other):
if isinstance(other, Amount):
return Amount(self.value) * other
return self.value * other return self.value * other
def __lt__(self, other): def __lt__(self, other):
if isinstance(other, Amount):
return Amount(self.value) < other
return self.value < other return self.value < other
def __str__(self): def __str__(self):
return str(self.value) return str(self.value)
def __repr__(self):
return str(self)
@dataclass @dataclass
class Item: class Item:
@@ -338,15 +323,11 @@ class Price:
amount: Amount amount: Amount
type_code: str type_code: str
type: str type: str
quantity: int = 1
def __post_init__(self): def __post_init__(self):
if self.type_code not in codelist.CodigoPrecioReferencia: if self.type_code not in codelist.CodigoPrecioReferencia:
raise ValueError("type_code [%s] not found" % (self.type_code)) raise ValueError("type_code [%s] not found" % (self.type_code))
if not isinstance(self.quantity, int):
raise ValueError("quantity must be int")
self.amount *= self.quantity
@dataclass @dataclass
class PaymentMean: class PaymentMean:
@@ -397,52 +378,6 @@ class InvoiceDocumentReference(BillingReference):
date: fecha de emision de la nota credito relacionada date: fecha de emision de la nota credito relacionada
""" """
@dataclass
class AllowanceChargeReason:
code: str
reason: str
def __post_init__(self):
if self.code not in codelist.CodigoDescuento:
raise ValueError("code [%s] not found" % (self.code))
@dataclass
class AllowanceCharge:
#DIAN 1.7.-2020: FAQ03
charge_indicator: bool = True
amount: Amount = Amount(0.0)
reason: AllowanceChargeReason = None
#Valor Base para calcular el descuento o el cargo
base_amount: typing.Optional[Amount] = Amount(0.0)
# Porcentaje: Porcentaje que aplicar.
multiplier_factor_numeric: Amount = Amount(1.0)
def isCharge(self):
return self.charge_indicator == True
def isDiscount(self):
return self.charge_indicator == False
def asCharge(self):
self.charge_indicator = True
def asDiscount(self):
self.charge_indicator = False
def hasReason(self):
return self.reason is not None
def set_base_amount(self, amount):
self.base_amount = amount
class AllowanceChargeAsDiscount(AllowanceCharge):
def __init__(self, amount: Amount = Amount(0.0)):
self.charge_indicator = False
self.amount = amount
@dataclass @dataclass
class InvoiceLine: class InvoiceLine:
# RESOLUCION 0004: pagina 155 # RESOLUCION 0004: pagina 155
@@ -456,31 +391,9 @@ class InvoiceLine:
# de subtotal # de subtotal
tax: typing.Optional[TaxTotal] tax: typing.Optional[TaxTotal]
allowance_charge: typing.List[AllowanceCharge] = dataclasses.field(default_factory=list)
def add_allowance_charge(self, charge):
if not isinstance(charge, AllowanceCharge):
raise TypeError('charge invalid type expected AllowanceCharge')
charge.set_base_amount(self.total_amount_without_charge)
self.allowance_charge.add(charge)
@property
def total_amount_without_charge(self):
return (self.quantity * self.price.amount)
@property @property
def total_amount(self): def total_amount(self):
charge = AmountCollection(self.allowance_charge)\ return self.quantity * self.price.amount
.filter(lambda charge: charge.isCharge())\
.map(lambda charge: charge.amount)\
.sum()
discount = AmountCollection(self.allowance_charge)\
.filter(lambda charge: charge.isDiscount())\
.map(lambda charge: charge.amount)\
.sum()
return self.total_amount_without_charge + charge - discount
@property @property
def total_tax_inclusive_amount(self): def total_tax_inclusive_amount(self):
@@ -528,6 +441,37 @@ class LegalMonetaryTotal:
- self.prepaid_amount - self.prepaid_amount
@dataclass
class AllowanceChargeReason:
code: str
reason: str
def __post_init__(self):
if self.code not in codelist.CodigoDescuento:
raise ValueError("code [%s] not found" % (self.code))
@dataclass
class AllowanceCharge:
#DIAN 1.7.-2020: FAQ03
charge_indicator: bool = True
amount: Amount = Amount(0.0)
reason: AllowanceChargeReason = None
def isCharge(self):
return self.charge_indicator == True
def isDiscount(self):
return self.charge_indicator == False
def asCharge(self):
self.charge_indicator = True
def asDiscount(self):
self.charge_indicator = False
def hasReason(self):
return self.reason is not None
class NationalSalesInvoiceDocumentType(str): class NationalSalesInvoiceDocumentType(str):
def __str__(self): def __str__(self):
@@ -626,7 +570,7 @@ class Invoice:
self.invoice_operation_type = operation self.invoice_operation_type = operation
def add_allowance_charge(self, charge: AllowanceCharge): def add_allownace_charge(self, charge: AllowanceCharge):
self.invoice_allowance_charge.append(charge) self.invoice_allowance_charge.append(charge)
def add_invoice_line(self, line: InvoiceLine): def add_invoice_line(self, line: InvoiceLine):
@@ -674,22 +618,11 @@ class Invoice:
#DIAN 1.7.-2020: FAU14 #DIAN 1.7.-2020: FAU14
self.invoice_legal_monetary_total.calculate() self.invoice_legal_monetary_total.calculate()
def _refresh_charges_base_amount(self):
if self.invoice_allowance_charge:
for invline in self.invoice_lines:
if invline.allowance_charge:
# TODO actualmente solo uno de los cargos es permitido
raise ValueError('allowance charge in invoice exclude invoice line')
# cargos a nivel de factura
for charge in self.invoice_allowance_charge:
charge.set_base_amount(self.invoice_legal_monetary_total.line_extension_amount)
def calculate(self): def calculate(self):
for invline in self.invoice_lines: for invline in self.invoice_lines:
invline.calculate() invline.calculate()
self._calculate_legal_monetary_total() self._calculate_legal_monetary_total()
self._refresh_charges_base_amount()
class NationalSalesInvoice(Invoice): class NationalSalesInvoice(Invoice):
def __init__(self): def __init__(self):

View File

@@ -540,30 +540,9 @@ class DIANInvoiceXML(fe.FeXML):
line.set_element('./cac:Price/cbc:PriceAmount', invoice_line.price.amount, currencyID=invoice_line.price.amount.currency.code) line.set_element('./cac:Price/cbc:PriceAmount', invoice_line.price.amount, currencyID=invoice_line.price.amount.currency.code)
#DIAN 1.7.-2020: FBB04 #DIAN 1.7.-2020: FBB04
line.set_element('./cac:Price/cbc:BaseQuantity', line.set_element('./cac:Price/cbc:BaseQuantity',
invoice_line.price.quantity, invoice_line.quantity,
unitCode=invoice_line.quantity.code) unitCode=invoice_line.quantity.code)
for idx, charge in enumerate(invoice_line.allowance_charge):
next_append_charge = idx > 0
fexml.append_allowance_charge(line, index + 1, charge, append=next_append_charge)
def set_allowance_charge(fexml, invoice):
for idx, charge in enumerate(invoice.invoice_allowance_charge):
next_append = idx > 0
fexml.append_allowance_charge(fexml, idx + 1, charge, append=next_append)
def append_allowance_charge(fexml, parent, idx, charge, append=False):
line = parent.fragment('./cac:AllowanceCharge', append=append)
#DIAN 1.7.-2020: FAQ02
line.set_element('./cbc:ID', idx)
#DIAN 1.7.-2020: FAQ03
line.set_element('./cbc:ChargeIndicator', str(charge.charge_indicator).lower())
if charge.reason:
line.set_element('./cbc:AllowanceChargeReasonCode', charge.reason.code)
line.set_element('./cbc:allowanceChargeReason', charge.reason.reason)
line.set_element('./cbc:MultiplierFactorNumeric', str(round(charge.multiplier_factor_numeric, 2)))
fexml.set_element_amount_for(line, './cbc:Amount', charge.amount)
fexml.set_element_amount_for(line, './cbc:BaseAmount', charge.base_amount)
def attach_invoice(fexml, invoice): def attach_invoice(fexml, invoice):
"""adiciona etiquetas a FEXML y retorna FEXML """adiciona etiquetas a FEXML y retorna FEXML
@@ -600,7 +579,6 @@ class DIANInvoiceXML(fe.FeXML):
fexml.set_invoice_totals(invoice) fexml.set_invoice_totals(invoice)
fexml.set_invoice_lines(invoice) fexml.set_invoice_lines(invoice)
fexml.set_payment_mean(invoice) fexml.set_payment_mean(invoice)
fexml.set_allowance_charge(invoice)
fexml.set_billing_reference(invoice) fexml.set_billing_reference(invoice)
return fexml return fexml

View File

@@ -20,34 +20,3 @@ def test_amount_equals():
assert price1 == price2 assert price1 == price2
assert price1 == form.Amount(100) + form.Amount(10) assert price1 == form.Amount(100) + form.Amount(10)
assert price1 == form.Amount(10) * form.Amount(10) + form.Amount(10) assert price1 == form.Amount(10) * form.Amount(10) + form.Amount(10)
assert form.Amount(110) == (form.Amount(1.10) * form.Amount(100))
def test_round_half_even():
# https://www.w3.org/TR/xpath-functions-31/#func-round-half-to-even
assert form.Amount(0.5).round(0).float() == 0.0
assert form.Amount(1.5).round(0).float() == 2.0
assert form.Amount(2.5).round(0).float() == 2.0
assert form.Amount(3.567812e+3).round(2).float() == 3567.81e0
assert form.Amount(4.7564e-3).round(2).float() == 0.0e0
def test_round():
# Entre 0 y 5 Mantener el dígito menos significativo
assert form.Amount(1.133).round(2) == form.Amount(1.13)
# Entre 6 y 9 Incrementar el dígito menos significativo
assert form.Amount(1.166).round(2) == form.Amount(1.17)
# 5, y el segundo dígito siguiente al dígito menos significativo es cero o par Mantener el dígito menos significativo
assert str(form.Amount(1.1542).round(2)) == str(form.Amount(1.15))
# 5, y el segundo dígito siguiente al dígito menos significativo es impar Incrementar el dígito menos significativo
assert str(form.Amount(1.1563).round(2)) == str(form.Amount(1.16))
def test_amount_truncate():
assert form.Amount(1.1569).truncate_as_string(2) == '1.15'
assert form.Amount(587.0700).truncate_as_string(2) == '587.07'
assert form.Amount(14705.8800).truncate_as_string(2) == '14705.88'
assert form.Amount(9423.7000).truncate_as_string(2) == '9423.70'
assert form.Amount(10084.03).truncate_as_string(2) == '10084.03'
assert form.Amount(10000.02245).truncate_as_string(2) == '10000.02'
assert form.Amount(10000.02357).truncate_as_string(2) == '10000.02'
def test_amount_format():
assert str(round(form.Amount(1.1569),2)) == '1.16'

View File

@@ -110,19 +110,3 @@ def test_xml_sign_dian(monkeypatch):
helpers.mock_urlopen(m) helpers.mock_urlopen(m)
xmlsigned = signer.sign_xml_string(xmlstring) xmlsigned = signer.sign_xml_string(xmlstring)
assert "Signature" in xmlsigned assert "Signature" in xmlsigned
def test_xml_sign_dian_using_bytes(monkeypatch):
xml = fe.FeXML('Invoice',
'http://www.dian.gov.co/contratos/facturaelectronica/v1')
xml.find_or_create_element('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent')
ublextension = xml.fragment('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension', append=True)
extcontent = ublextension.find_or_create_element('/ext:UBLExtension/ext:ExtensionContent')
xmlstring = xml.tostring()
p12_data = open('./tests/example.p12', 'rb').read()
signer = fe.DianXMLExtensionSigner.from_bytes(p12_data)
with monkeypatch.context() as m:
helpers.mock_urlopen(m)
xmlsigned = signer.sign_xml_string(xmlstring)
assert "Signature" in xmlsigned

View File

@@ -55,13 +55,6 @@ def test_invoicesimple_zip(simple_invoice):
with fe.DianZIP(zipdata) as dianzip: with fe.DianZIP(zipdata) as dianzip:
name_invoice = dianzip.add_invoice_xml(simple_invoice.invoice_ident, str(xml_invoice)) name_invoice = dianzip.add_invoice_xml(simple_invoice.invoice_ident, str(xml_invoice))
# el zip ademas de archivar debe comprimir los archivos
# de lo contrario la DIAN lo rechaza
with zipfile.ZipFile(zipdata) as dianzip:
dianzip.testzip()
for zipinfo in dianzip.infolist():
assert zipinfo.compress_type == zipfile.ZIP_DEFLATED, "se espera el zip comprimido"
with zipfile.ZipFile(zipdata) as dianzip: with zipfile.ZipFile(zipdata) as dianzip:
xml_data = dianzip.open(name_invoice).read().decode('utf-8') xml_data = dianzip.open(name_invoice).read().decode('utf-8')
assert xml_data == str(xml_invoice) assert xml_data == str(xml_invoice)
@@ -119,7 +112,7 @@ def test_invoice_cufe(simple_invoice_without_lines):
simple_invoice.invoice_supplier.ident = form.PartyIdentification('700085371', '5', '31') simple_invoice.invoice_supplier.ident = form.PartyIdentification('700085371', '5', '31')
simple_invoice.invoice_customer.ident = form.PartyIdentification('800199436', '5', '31') simple_invoice.invoice_customer.ident = form.PartyIdentification('800199436', '5', '31')
simple_invoice.add_invoice_line(form.InvoiceLine( simple_invoice.add_invoice_line(form.InvoiceLine(
quantity = form.Quantity(1.00, '94'), quantity = form.Quantity(1, '94'),
description = 'producto', description = 'producto',
item = form.StandardItem(111), item = form.StandardItem(111),
price = form.Price(form.Amount(1_500_000), '01', ''), price = form.Price(form.Amount(1_500_000), '01', ''),
@@ -177,7 +170,6 @@ def test_invoice_cufe(simple_invoice_without_lines):
assert cufe == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4' assert cufe == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4'
def test_credit_note_cude(simple_credit_note_without_lines): def test_credit_note_cude(simple_credit_note_without_lines):
simple_invoice = simple_credit_note_without_lines simple_invoice = simple_credit_note_without_lines
simple_invoice.invoice_ident = '8110007871' simple_invoice.invoice_ident = '8110007871'

View File

@@ -38,9 +38,6 @@ def test_invoice_legalmonetary():
assert inv.invoice_legal_monetary_total.tax_inclusive_amount == form.Amount(119.0) assert inv.invoice_legal_monetary_total.tax_inclusive_amount == form.Amount(119.0)
assert inv.invoice_legal_monetary_total.charge_total_amount == form.Amount(0.0) assert inv.invoice_legal_monetary_total.charge_total_amount == form.Amount(0.0)
def test_allowancecharge_as_discount():
discount = form.AllowanceChargeAsDiscount(amount=form.Amount(1000.0))
assert discount.isDiscount() == True
def test_FAU10(): def test_FAU10():
inv = form.NationalSalesInvoice() inv = form.NationalSalesInvoice()
@@ -61,7 +58,7 @@ def test_FAU10():
] ]
) )
)) ))
inv.add_allowance_charge(form.AllowanceCharge(amount=form.Amount(19.0))) inv.add_allownace_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
inv.calculate() inv.calculate()
assert inv.invoice_legal_monetary_total.line_extension_amount == form.Amount(100.0) assert inv.invoice_legal_monetary_total.line_extension_amount == form.Amount(100.0)
@@ -89,7 +86,7 @@ def test_FAU14():
] ]
) )
)) ))
inv.add_allowance_charge(form.AllowanceCharge(amount=form.Amount(19.0))) inv.add_allownace_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
inv.add_prepaid_payment(form.PrePaidPayment(paid_amount = form.Amount(50.0))) inv.add_prepaid_payment(form.PrePaidPayment(paid_amount = form.Amount(50.0)))
inv.calculate() inv.calculate()

View File

@@ -6,14 +6,9 @@
"""Tests for `facho` package.""" """Tests for `facho` package."""
import pytest import pytest
from datetime import datetime
import copy
from facho.fe import form
from facho.fe import form_xml from facho.fe import form_xml
from fixtures import *
def test_import_DIANInvoiceXML(): def test_import_DIANInvoiceXML():
try: try:
form_xml.DIANInvoiceXML form_xml.DIANInvoiceXML
@@ -32,65 +27,3 @@ def test_import_DIANCreditNoteXML():
form_xml.DIANCreditNoteXML form_xml.DIANCreditNoteXML
except AttributeError: except AttributeError:
pytest.fail("unexpected not found") pytest.fail("unexpected not found")
def test_allowance_charge_in_invoice(simple_invoice_without_lines):
inv = copy.copy(simple_invoice_without_lines)
inv.add_invoice_line(form.InvoiceLine(
quantity = form.Quantity(1, '94'),
description = 'producto facho',
item = form.StandardItem(9999),
price = form.Price(
amount = form.Amount(100.0),
type_code = '01',
type = 'x'
),
tax = form.TaxTotal(
subtotals = [
form.TaxSubTotal(
percent = 19.0,
)
]
)
))
inv.add_allowance_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
inv.calculate()
xml = form_xml.DIANInvoiceXML(inv)
assert xml.get_element_text('./cac:AllowanceCharge/cbc:ID') == '1'
assert xml.get_element_text('./cac:AllowanceCharge/cbc:ChargeIndicator') == 'true'
assert xml.get_element_text('./cac:AllowanceCharge/cbc:Amount') == '19.0'
assert xml.get_element_text('./cac:AllowanceCharge/cbc:BaseAmount') == '100.0'
def test_allowance_charge_in_invoice_line(simple_invoice_without_lines):
inv = copy.copy(simple_invoice_without_lines)
inv.add_invoice_line(form.InvoiceLine(
quantity = form.Quantity(1, '94'),
description = 'producto facho',
item = form.StandardItem(9999),
price = form.Price(
amount = form.Amount(100.0),
type_code = '01',
type = 'x'
),
tax = form.TaxTotal(
subtotals = [
form.TaxSubTotal(
percent = 19.0,
)
]
),
allowance_charge = [
form.AllowanceChargeAsDiscount(amount=form.Amount(10.0))
]
))
inv.calculate()
# se aplico descuento
assert inv.invoice_legal_monetary_total.line_extension_amount == form.Amount(90.0)
xml = form_xml.DIANInvoiceXML(inv)
with pytest.raises(AttributeError):
assert xml.get_element_text('/fe:Invoice/cac:AllowanceCharge/cbc:ID') == '1'
xml.get_element_text('/fe:Invoice/cac:InvoiceLine/cac:AllowanceCharge/cbc:ID') == '1'
xml.get_element_text('/fe:Invoice/cac:InvoiceLine/cac:AllowanceCharge/cbc:BaseAmount') == '100.0'