16 Commits

Author SHA1 Message Date
one
50b1a13c0a DIAN 1.8.-2021: FAD03
FossilOrigin-Name: 0dd43cdbcd489f5433785b3b6281854e528719761b183d1eb498c43c8ae57924
2021-08-05 01:17:16 +00:00
bit4bit
4e68025e48 se adiciona fe.DianXmlExtensionSigner.from_bytes para firmado usando bytes
FossilOrigin-Name: 39ed96abb30b2559e72d4948f2fd65de8f0e5e0f9004f0f1d4e88135b950bd61
2021-06-16 02:35:07 +00:00
bit4bit
ead21bd4f2 se adiciona prueba para confirmar que el zip sea comprimido
FossilOrigin-Name: 5a220c699199ea94baed777c3a9a51be954c44d18630122907dbab817c98d4d1
2021-06-16 02:20:09 +00:00
bit4bit
5e79850686 se corrige prueba de redondeo de Amount segun indicado decreto
FossilOrigin-Name: 5a1555e4c6af4db50a11027974da0a694daef68ede90ce445f0d175e6a0cd6b8
2021-06-16 02:13:36 +00:00
pingara
988d01daf7 Se agrega condicional para el caso de no recibir errores
FossilOrigin-Name: 09314cc648235ca61b8c7b161ef0b7ddcb33fca30467e57bc75a7010b1c6435b
2021-05-04 05:31:21 +00:00
pingara
28a963b76a Política de firma es leída localmente, para mejorar rendimiento
FossilOrigin-Name: 47fd230ff94210ce928b7dd1e7446b98cbc8a13ca3baf8ce126eeedea528e832
2021-05-03 02:23:03 +00:00
pingara
af99a7593a Se agrega método de comprensión
FossilOrigin-Name: e8ef9c8d9ea2ca55fc37a56bd9934628757ad6b9e6de73253d941134491ae2c9
2021-05-01 21:05:00 +00:00
bit4bit
d02c0b13b8 se elimina comentario de inestabilidad, actualmente tenemos usuarios usando esta libreria en produccion
FossilOrigin-Name: 44d1ea9e124620506c65eee06e6be8b5a49ffc1f2437d4ad728abce19ead78e4
2021-04-28 02:20:46 +00:00
pingara
79209964e0 Se actualiza plantilla de factura
FossilOrigin-Name: 2a3cb9db35a149014195962f31d561d41997d1df637adee1cc9317680f2b86cd
2021-03-10 02:12:39 +00:00
bit4bit
38f4c5ae45 se adiciona xml para linea de AllowanceCharge
FossilOrigin-Name: 20a354ef9c6c5aa3fb0436716b43d68cac7bfa1b56be550abc18af79444f1662
2020-12-02 20:30:28 +00:00
bit4bit
1143b26988 Se adiciona xml de AllowanceCharge para documento
FossilOrigin-Name: 93cd9530bb2f63a2803b8a32a5aceeeda015eff2d26c96e95a13dbb9193cad82
2020-12-02 18:46:18 +00:00
bit4bit
e571009945 form.AllowanceChargeAsDiscount nueva clase para descuentos
FossilOrigin-Name: 5b11ff93dff3a301628694c2a6e71940915aea8754a9eab3f35644bc669ddf87
2020-12-02 18:10:29 +00:00
bit4bit
48619106c5 se adiciona documentacion
FossilOrigin-Name: 20a903a2426d4454a9909c78411b3ad7bd7f7d34d576ed5618e73784f77c8d92
2020-11-15 23:21:52 +00:00
bit4bit
bcf5120d82 Amount implementa truncate_as_string para forzar truncado de Decimal
FossilOrigin-Name: e65c80a4d6956aff8cd80f3ee35cad827ffbd5c965a1fb651ab07424e6b22f31
2020-11-11 01:58:01 +00:00
bit4bit
f648188834 nuevo atribute Price.quantity para cantidad base y se separa de InvoiceLine.quantity
FossilOrigin-Name: 4ed8ab2f9ce5505b59f75c9a0ac9f01d7ba5b9512856f4da8ea0b40fe4acfef0
2020-11-11 01:08:48 +00:00
bit4bit
67156ec9a6 usa Amount en Quantity
FossilOrigin-Name: 0f51683a044925969f6f113710ca9456dfa541a80ec3d7a3b9e5235718cbf0ea
2020-11-10 23:17:25 +00:00
15 changed files with 586 additions and 165 deletions

View File

@@ -2,8 +2,6 @@
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
@@ -24,7 +22,7 @@ usando pip::
CLI CLI
=== ===
tambien se provee linea de comandos **facho** para firmado y envio de documentos:: tambien se provee linea de comandos **facho** para generacion, firmado y envio de documentos::
facho --help facho --help
CONTRIBUIR CONTRIBUIR
@@ -32,17 +30,13 @@ 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.

24
USAGE.rst Normal file
View File

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,117 @@
# 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

@@ -1,74 +0,0 @@
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

114
examples/use-as-lib.py Normal file
View File

@@ -0,0 +1,114 @@
# 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,6 +129,11 @@ 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,10 +138,16 @@ 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'],
data['ErrorMessage']['string']) error_message)
@dataclass @dataclass
class GetStatus(SOAPService): class GetStatus(SOAPService):

View File

@@ -14,6 +14,7 @@ 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']
@@ -25,8 +26,9 @@ SCHEME_AGENCY_ATTRS = {
} }
pwd = Path(__file__).parent
# RESOLUCION 0001: pagina 516 # RESOLUCION 0001: pagina 516
POLICY_ID = 'https://facturaelectronica.dian.gov.co/politicadefirma/v2/politicadefirmav2.pdf' POLICY_ID = 'file://'+str(pwd)+'/data/dian/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.'
@@ -49,8 +51,7 @@ NAMESPACES = {
} }
def fe_from_string(document: str) -> FachoXML: def fe_from_string(document: str) -> FachoXML:
xml = LXMLBuilder.from_string(document) return FeXML.from_string(document)
return FachoXML(xml, nsmap=NAMESPACES)
from contextlib import contextmanager from contextlib import contextmanager
@contextmanager @contextmanager
@@ -69,6 +70,7 @@ 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):
@@ -79,6 +81,10 @@ 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:", "")\
@@ -113,7 +119,8 @@ 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())
fachoxml.set_element('./cbc:ProfileID', 'DIAN 2.1') #DIAN 1.8.-2021: FAD03
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',
@@ -185,14 +192,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']).format('%.02f'), form.Amount(build_vars['ValorBruto']).truncate_as_string(2),
CodImpuesto1, CodImpuesto1,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).format('%.02f'), build_vars['ValorImpuestoPara'].get(CodImpuesto1, form.Amount(0.0)).truncate_as_string(2),
CodImpuesto2, CodImpuesto2,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).format('%.02f'), build_vars['ValorImpuestoPara'].get(CodImpuesto2, form.Amount(0.0)).truncate_as_string(2),
CodImpuesto3, CodImpuesto3,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).format('%.02f'), build_vars['ValorImpuestoPara'].get(CodImpuesto3, form.Amount(0.0)).truncate_as_string(2),
form.Amount(build_vars['ValorTotalPagar']).format('%.02f'), build_vars['ValorTotalPagar'].truncate_as_string(2),
'%s' % build_vars['NitOFE'], '%s' % build_vars['NitOFE'],
'%s' % build_vars['NumAdq'], '%s' % build_vars['NumAdq'],
'%s' % build_vars['ClTec'], '%s' % build_vars['ClTec'],
@@ -222,14 +229,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']).format('%.02f'), form.Amount(build_vars['ValorBruto']).truncate_as_string(2),
CodImpuesto1, CodImpuesto1,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).format('%.02f'), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).truncate_as_string(2),
CodImpuesto2, CodImpuesto2,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).format('%.02f'), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).truncate_as_string(2),
CodImpuesto3, CodImpuesto3,
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).format('%.02f'), form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).truncate_as_string(2),
form.Amount(build_vars['ValorTotalPagar']).format('%.02f'), form.Amount(build_vars['ValorTotalPagar']).truncate_as_string(2),
'%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'],
@@ -277,16 +284,24 @@ 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_path = pkcs12_path self._pkcs12_data = open(pkcs12_path, 'rb').read()
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_pkcs12(self, filepath, password=None): def from_bytes(cls, data, passphrase=None, mockpolicy=False):
p12 = OpenSSL.crypto.load_pkcs12(open(filepath, 'rb').read(), password) self = cls.__new__(cls)
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)
signature = self.sign_xml_element(xml) signature = self.sign_xml_element(xml)
@@ -341,7 +356,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(open(self._pkcs12_path, 'rb').read(), ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(self._pkcs12_data,
self._passphrase)) self._passphrase))
if self._mockpolicy: if self._mockpolicy:
@@ -360,6 +375,7 @@ 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
@@ -429,7 +445,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') self.zipfile = zipfile.ZipFile(file_like, mode='w', compression=zipfile.ZIP_DEFLATED)
self.num_files = 0 self.num_files = 0
def add_invoice_xml(self, name, xml_data): def add_invoice_xml(self, name, xml_data):
@@ -452,8 +468,8 @@ class DianZIP:
class DianXMLExtensionSignerVerifier: class DianXMLExtensionSignerVerifier:
def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False): def __init__(self, pkcs12_path_or_bytes, passphrase=None, mockpolicy=False):
self._pkcs12_path = pkcs12_path self._pkcs12_path_or_bytes = pkcs12_path_or_bytes
self._passphrase = None self._passphrase = None
self._mockpolicy = mockpolicy self._mockpolicy = mockpolicy
if passphrase: if passphrase:
@@ -470,7 +486,12 @@ 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,6 +4,7 @@
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
@@ -53,7 +54,7 @@ class AmountCollection(Collection):
return total return total
class Amount: class Amount:
def __init__(self, amount: int or float or Amount, currency: Currency = Currency('COP')): def __init__(self, amount: int or float or str 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):
@@ -63,7 +64,7 @@ class Amount:
self.amount = amount.amount self.amount = amount.amount
self.currency = amount.currency self.currency = amount.currency
else: else:
if amount < 0: if float(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,
@@ -71,12 +72,17 @@ 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 '%.06f' % self.amount return str(self.float())
def __lt__(self, other): def __lt__(self, other):
if not self.is_same_currency(other): if not self.is_same_currency(other):
@@ -88,17 +94,27 @@ 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 __add__(self, other): def _cast(self, val):
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, other): def __sub__(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 __mul__(self, other): def __mul__(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)
@@ -106,8 +122,9 @@ 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 format(self, formatter): def truncate_as_string(self, prec):
return formatter % self.float() parts = str(self.float()).split('.', 1)
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))
@@ -116,27 +133,25 @@ class Amount:
class Quantity: class Quantity:
def __init__(self, val, code): def __init__(self, val, code):
if not isinstance(val, int): if type(val) not in [float, int]:
raise ValueError('val expected int') raise ValueError('val expected int or float')
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 = val self.value = Amount(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:
@@ -323,11 +338,15 @@ 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:
@@ -378,6 +397,52 @@ 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
@@ -391,9 +456,31 @@ 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):
return self.quantity * self.price.amount charge = AmountCollection(self.allowance_charge)\
.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):
@@ -441,37 +528,6 @@ 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):
@@ -570,7 +626,7 @@ class Invoice:
self.invoice_operation_type = operation self.invoice_operation_type = operation
def add_allownace_charge(self, charge: AllowanceCharge): def add_allowance_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):
@@ -618,11 +674,22 @@ 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,10 +540,31 @@ 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.quantity, invoice_line.price.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
en caso de fallar validacion retorna None""" en caso de fallar validacion retorna None"""
@@ -579,6 +600,7 @@ 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,3 +20,34 @@ 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,3 +110,19 @@ 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,6 +55,13 @@ 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)
@@ -112,7 +119,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, '94'), quantity = form.Quantity(1.00, '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', ''),
@@ -170,6 +177,7 @@ 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,7 +38,10 @@ 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()
inv.add_invoice_line(form.InvoiceLine( inv.add_invoice_line(form.InvoiceLine(
@@ -58,7 +61,7 @@ def test_FAU10():
] ]
) )
)) ))
inv.add_allownace_charge(form.AllowanceCharge(amount=form.Amount(19.0))) inv.add_allowance_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)
@@ -86,7 +89,7 @@ def test_FAU14():
] ]
) )
)) ))
inv.add_allownace_charge(form.AllowanceCharge(amount=form.Amount(19.0))) inv.add_allowance_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,9 +6,14 @@
"""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
@@ -27,3 +32,65 @@ 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'