facho/fe/form_xml(DIANInvoiceXML): se extrae de form.py para resolver ciclo en dependencias. facho/fe/form.py(Amount): clase para gestion de moneda. test/: se actualizan para hacer uso de Amount. FossilOrigin-Name: 714687a7a825715d272392d361de5e42d7c25d6078ec68a81df653f8843c37a3
457 lines
12 KiB
Python
457 lines
12 KiB
Python
# This file is part of facho. The COPYRIGHT file at the top level of
|
|
# this repository contains the full copyright notices and license terms.
|
|
|
|
import hashlib
|
|
from functools import reduce
|
|
import copy
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
import decimal
|
|
from decimal import Decimal
|
|
|
|
|
|
from .data.dian import codelist
|
|
|
|
DECIMAL_PRECISION = 6
|
|
|
|
class AmountCurrencyError(TypeError):
|
|
pass
|
|
|
|
@dataclass
|
|
class Currency:
|
|
code: str
|
|
|
|
def __eq__(self, other):
|
|
return self.code == other.code
|
|
|
|
def __str__(self):
|
|
return self.code
|
|
|
|
class Collection:
|
|
|
|
def __init__(self, array):
|
|
self.array = array
|
|
|
|
def filter(self, filterer):
|
|
new_array = filter(filterer, self.array)
|
|
return self.__class__(new_array)
|
|
|
|
def map(self, mapper):
|
|
new_array = map(mapper, self.array)
|
|
return self.__class__(new_array)
|
|
|
|
def sum(self):
|
|
return sum(self.array)
|
|
|
|
class AmountCollection(Collection):
|
|
|
|
def sum(self):
|
|
total = Amount(0)
|
|
for v in self.array:
|
|
total += v
|
|
return total
|
|
|
|
class Amount:
|
|
def __init__(self, amount: int or float or Amount, currency: Currency = Currency('COP')):
|
|
|
|
if isinstance(amount, Amount):
|
|
self.amount = amount.amount
|
|
self.currency = amount.currency
|
|
else:
|
|
self.amount = Decimal(amount, decimal.Context(prec=DECIMAL_PRECISION, rounding=decimal.ROUND_HALF_DOWN ))
|
|
self.currency = currency
|
|
|
|
def __round__(self, prec):
|
|
return round(self.amount, prec)
|
|
|
|
def __str__(self):
|
|
return '%.06f' % self.amount
|
|
|
|
def __eq__(self, other):
|
|
if not self.is_same_currency(other):
|
|
raise AmountCurrencyError()
|
|
return round(self.amount, DECIMAL_PRECISION) == round(other.amount, DECIMAL_PRECISION)
|
|
|
|
def __add__(self, other):
|
|
if not self.is_same_currency(other):
|
|
raise AmountCurrencyError()
|
|
return Amount(self.amount + other.amount, self.currency)
|
|
|
|
def __sub__(self, other):
|
|
if not self.is_same_currency(other):
|
|
raise AmountCurrencyError()
|
|
return Amount(self.amount - other.amount, self.currency)
|
|
|
|
def __mul__(self, other):
|
|
if not self.is_same_currency(other):
|
|
raise AmountCurrencyError()
|
|
return Amount(self.amount * other.amount, self.currency)
|
|
|
|
def is_same_currency(self, other):
|
|
return self.currency == other.currency
|
|
|
|
@dataclass
|
|
class Item:
|
|
description: str
|
|
id: str
|
|
|
|
|
|
@dataclass
|
|
class StandardItem(Item):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class Country:
|
|
code: str
|
|
name: str
|
|
|
|
@dataclass
|
|
class CountrySubentity:
|
|
code: str
|
|
name: str
|
|
|
|
@dataclass
|
|
class City:
|
|
code: str
|
|
name: str
|
|
|
|
@dataclass
|
|
class Address:
|
|
name: str
|
|
street: str = ''
|
|
city: City = City('', '')
|
|
country: Country = Country('CO', 'Colombia')
|
|
countrysubentity: CountrySubentity = CountrySubentity('', '')
|
|
|
|
@dataclass
|
|
class PartyIdentification:
|
|
number: str
|
|
dv: str
|
|
type_fiscal: str
|
|
|
|
def __str__(self):
|
|
return self.number
|
|
|
|
def __eq__(self, other):
|
|
return str(self) == str(other)
|
|
|
|
def full(self):
|
|
return "%s%s" % [self.number, self.dv]
|
|
|
|
@dataclass
|
|
class Responsability:
|
|
codes: list
|
|
|
|
def __str__(self):
|
|
return ';'.join(self.codes)
|
|
|
|
def __eq__(self, other):
|
|
return str(self) == str(other)
|
|
|
|
def __iter__(self):
|
|
return iter(self.codes)
|
|
|
|
|
|
@dataclass
|
|
class Party:
|
|
name: str
|
|
ident: str
|
|
responsability_code: str
|
|
responsability_regime_code: str
|
|
organization_code: str
|
|
|
|
phone: str = ''
|
|
address: Address = Address('')
|
|
email: str = ''
|
|
legal_name: str = ''
|
|
legal_company_ident: str = ''
|
|
legal_address: str = ''
|
|
|
|
|
|
@dataclass
|
|
class TaxSubTotal:
|
|
percent: float
|
|
tax_scheme_ident: str = '01'
|
|
tax_scheme_name: str = 'IVA'
|
|
|
|
tax_amount: Amount = Amount(0.0)
|
|
taxable_amount: Amount = Amount(0.0)
|
|
|
|
def calculate(self, invline):
|
|
self.tax_amount = invline.total_amount * Amount(self.percent / 100)
|
|
self.taxable_amount = invline.total_amount
|
|
|
|
|
|
@dataclass
|
|
class TaxTotal:
|
|
subtotals: list
|
|
tax_amount: Amount = Amount(0.0)
|
|
taxable_amount: Amount = Amount(0.0)
|
|
|
|
def calculate(self, invline):
|
|
for subtax in self.subtotals:
|
|
subtax.calculate(invline)
|
|
self.tax_amount += subtax.tax_amount
|
|
self.taxable_amount += subtax.taxable_amount
|
|
|
|
|
|
@dataclass
|
|
class Price:
|
|
amount: Amount
|
|
type_code: str
|
|
type: str
|
|
|
|
|
|
@dataclass
|
|
class PaymentMean:
|
|
DEBIT = '01'
|
|
CREDIT = '02'
|
|
|
|
def __init__(self, id: str, code: str, due_at: datetime, payment_id: str):
|
|
self.id = id
|
|
self.code = code
|
|
self.due_at = due_at
|
|
self.payment_id = payment_id
|
|
|
|
|
|
@dataclass
|
|
class PrePaidPayment:
|
|
#DIAN 1.7.-2020: FBD03
|
|
paid_amount: Amount = Amount(0.0)
|
|
|
|
|
|
@dataclass
|
|
class InvoiceLine:
|
|
# RESOLUCION 0004: pagina 155
|
|
quantity: int
|
|
description: str
|
|
item: Item
|
|
price: Price
|
|
# TODO mover a Invoice
|
|
# ya que al reportar los totales es sobre
|
|
# la factura y el percent es unico por type_code
|
|
# de subtotal
|
|
tax: TaxTotal
|
|
|
|
@property
|
|
def total_amount(self):
|
|
return Amount(self.quantity) * self.price.amount
|
|
|
|
@property
|
|
def total_tax_inclusive_amount(self):
|
|
return self.tax.taxable_amount + self.tax.tax_amount
|
|
|
|
@property
|
|
def total_tax_exclusive_amount(self):
|
|
return self.tax.taxable_amount
|
|
|
|
@property
|
|
def tax_amount(self):
|
|
return self.tax.tax_amount
|
|
|
|
@property
|
|
def taxable_amount(self):
|
|
return self.tax.taxable_amount
|
|
|
|
def calculate(self):
|
|
self.tax.calculate(self)
|
|
|
|
|
|
@dataclass
|
|
class LegalMonetaryTotal:
|
|
line_extension_amount: Amount = Amount(0.0)
|
|
tax_exclusive_amount: Amount = Amount(0.0)
|
|
tax_inclusive_amount: Amount = Amount(0.0)
|
|
charge_total_amount: Amount = Amount(0.0)
|
|
allowance_total_amount: Amount = Amount(0.0)
|
|
payable_amount: Amount = Amount(0.0)
|
|
prepaid_amount: Amount = Amount(0.0)
|
|
|
|
@dataclass
|
|
class AllowanceCharge:
|
|
#DIAN 1.7.-2020: FAQ03
|
|
charge_indicator: bool = True
|
|
amount: Amount = Amount(0.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
|
|
|
|
|
|
|
|
class Invoice:
|
|
def __init__(self):
|
|
self.invoice_period_start = None
|
|
self.invoice_period_end = None
|
|
self.invoice_issue = None
|
|
self.invoice_ident = None
|
|
self.invoice_operation_type = None
|
|
self.invoice_legal_monetary_total = LegalMonetaryTotal()
|
|
self.invoice_customer = None
|
|
self.invoice_supplier = None
|
|
self.invoice_payment_mean = None
|
|
self.invoice_payments = []
|
|
self.invoice_lines = []
|
|
self.invoice_allowance_charge = []
|
|
self.invoice_prepaid_payment = []
|
|
|
|
def set_period(self, startdate, enddate):
|
|
self.invoice_period_start = startdate
|
|
self.invoice_period_end = enddate
|
|
|
|
def set_issue(self, dtime: datetime):
|
|
self.invoice_issue = dtime
|
|
|
|
def set_ident(self, ident: str):
|
|
self.invoice_ident = ident
|
|
|
|
def set_supplier(self, party: Party):
|
|
self.invoice_supplier = party
|
|
|
|
def set_customer(self, party: Party):
|
|
self.invoice_customer = party
|
|
|
|
def set_payment_mean(self, payment_mean: PaymentMean):
|
|
self.invoice_payment_mean = payment_mean
|
|
|
|
def set_operation_type(self, operation):
|
|
self.invoice_operation_type = operation
|
|
|
|
def add_allownace_charge(self, charge: AllowanceCharge):
|
|
self.invoice_allowance_charge.append(charge)
|
|
|
|
def add_invoice_line(self, line: InvoiceLine):
|
|
self.invoice_lines.append(line)
|
|
|
|
def add_prepaid_payment(self, paid: PrePaidPayment):
|
|
self.invoice_prepaid_payment.append(paid)
|
|
|
|
def accept(self, visitor):
|
|
visitor.visit_payment_mean(self.invoice_payment_mean)
|
|
visitor.visit_customer(self.invoice_customer)
|
|
visitor.visit_supplier(self.invoice_supplier)
|
|
for payment in self.invoice_payments:
|
|
visitor.visit_payment(payment)
|
|
for invline in self.invoice_lines:
|
|
visitor.visit_invoice_line(invline)
|
|
|
|
def _calculate_legal_monetary_total(self):
|
|
for invline in self.invoice_lines:
|
|
self.invoice_legal_monetary_total.line_extension_amount += invline.total_amount
|
|
self.invoice_legal_monetary_total.tax_exclusive_amount += invline.total_tax_exclusive_amount
|
|
#DIAN 1.7.-2020: FAU6
|
|
self.invoice_legal_monetary_total.tax_inclusive_amount += invline.total_tax_inclusive_amount
|
|
|
|
#DIAN 1.7.-2020: FAU08
|
|
self.invoice_legal_monetary_total.allowance_total_amount = AmountCollection(self.invoice_allowance_charge)\
|
|
.filter(lambda charge: charge.isDiscount())\
|
|
.map(lambda charge: charge.amount)\
|
|
.sum()
|
|
|
|
#DIAN 1.7.-2020: FAU10
|
|
self.invoice_legal_monetary_total.charge_total_amount = AmountCollection(self.invoice_allowance_charge)\
|
|
.filter(lambda charge: charge.isCharge())\
|
|
.map(lambda charge: charge.amount)\
|
|
.sum()
|
|
|
|
#DIAN 1.7.-2020: FAU12
|
|
self.invoice_legal_monetary_total.prepaid_amount = AmountCollection(self.invoice_prepaid_payment)\
|
|
.map(lambda paid: paid.paid_amount)\
|
|
.sum()
|
|
|
|
#DIAN 1.7.-2020: FAU14
|
|
self.invoice_legal_monetary_total.payable_amount = \
|
|
self.invoice_legal_monetary_total.tax_inclusive_amount \
|
|
+ self.invoice_legal_monetary_total.allowance_total_amount \
|
|
+ self.invoice_legal_monetary_total.charge_total_amount \
|
|
- self.invoice_legal_monetary_total.prepaid_amount
|
|
|
|
|
|
def calculate(self):
|
|
for invline in self.invoice_lines:
|
|
invline.calculate()
|
|
self._calculate_legal_monetary_total()
|
|
|
|
|
|
class DianResolucion0001Validator:
|
|
|
|
def __init__(self):
|
|
self.errors = []
|
|
|
|
def _validate_party(self, model, party):
|
|
for code in party.responsability_code:
|
|
if code not in codelist.TipoResponsabilidad:
|
|
self.errors.append((model,
|
|
'responsability_code',
|
|
'not found %s' % (code)))
|
|
|
|
try:
|
|
codelist.TipoOrganizacion[party.organization_code]
|
|
except KeyError:
|
|
self.errors.append((model, 'organization_code' ,
|
|
'not found %s' % (party.organization_code)))
|
|
try:
|
|
codelist.Departamento[party.address.countrysubentity.code]
|
|
except KeyError:
|
|
self.errors.append((model, 'countrysubentity_code',
|
|
'not found %s' % (party.address.countrysubentity.code)))
|
|
try:
|
|
codelist.Municipio[party.address.city.code]
|
|
except KeyError:
|
|
self.errors.append((model, 'city_code',
|
|
'not found %s' % (party.address.city.code)))
|
|
|
|
def _validate_invoice(self, invoice):
|
|
try:
|
|
codelist.TipoOperacionF[invoice.invoice_operation_type]
|
|
except KeyError:
|
|
self.errors.append(('invoice', 'operation_type',
|
|
'not found %s' % (invoice.invoice_operation_type)))
|
|
|
|
# MACHETE se espera en zona horario colombia
|
|
if invoice.invoice_issue.tzname() not in ['UTC-05:00', '-05', None]:
|
|
self.errors.append(('invoice', 'invoice_issue',
|
|
'expected timezone UTC-05:00 or -05 or empty got %s' % (invoice.invoice_issue.tzname())))
|
|
|
|
def validate(self, invoice):
|
|
invoice.accept(self)
|
|
self._validate_invoice(invoice)
|
|
|
|
return not self.errors
|
|
|
|
def visit_payment_mean(self, mean):
|
|
try:
|
|
codelist.MediosPago[mean.code]
|
|
except KeyError:
|
|
self.errors.append(('payment_mean', 'code',
|
|
'not found %s' % (mean.code)))
|
|
|
|
def visit_customer(self, customer):
|
|
self._validate_party('customer', customer)
|
|
|
|
def visit_supplier(self, supplier):
|
|
self._validate_party('supplier', supplier)
|
|
|
|
def visit_payment(self, payment):
|
|
pass
|
|
|
|
def visit_invoice_line(self, line):
|
|
try:
|
|
codelist.CodigoPrecioReferencia[line.price.type_code]
|
|
except KeyError:
|
|
self.errors.append(('invoice_line', 'line.price',
|
|
'not found %s' % (line.price.type_code)))
|
|
|
|
def valid(self):
|
|
return not self.errors
|