# 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 import dataclasses from dataclasses import dataclass, field from datetime import datetime, date # from collections import defaultdict import decimal from decimal import Decimal import typing 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: typing.Union[int, float, str, "Amount"], currency: Currency = Currency('COP')): # DIAN 1.7.-2020: 1.2.3.1 if isinstance(amount, Amount): if amount < Amount(0.0): raise ValueError('amount must be positive >= 0') self.amount = amount.amount self.currency = amount.currency else: if float(amount) < 0: raise ValueError('amount must be positive >= 0') self.amount = Decimal( amount, decimal.Context( prec=DECIMAL_PRECISION, # DIAN 1.7.-2020: 1.2.1.1 rounding=decimal.ROUND_HALF_EVEN)) 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): return round(self.amount, prec) def __str__(self): return str(self.float()) def __lt__(self, other): if not self.is_same_currency(other): raise AmountCurrencyError() return round(self.amount, DECIMAL_PRECISION) < round(other, 2) 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 _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): raise AmountCurrencyError() return Amount(self.amount + other.amount, self.currency) def __sub__(self, rother): other = self._cast(rother) if not self.is_same_currency(other): raise AmountCurrencyError() return Amount(self.amount - other.amount, self.currency) def __mul__(self, rother): other = self._cast(rother) 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 def truncate_as_string(self, prec): parts = str(self.float()).split('.', 1) return '%s.%s' % (parts[0], parts[1][0:prec].ljust(prec, '0')) def float(self): return float(round(self.amount, DECIMAL_PRECISION)) class Quantity: def __init__(self, val, code): if type(val) not in [float, int]: raise ValueError('val expected int or float') if code not in codelist.UnidadesMedida: raise ValueError("code [%s] not found" % (code)) self.value = Amount(val) self.code = code def __mul__(self, other): return self.value * other def __lt__(self, other): return self.value < other def __str__(self): return str(self.value) def __repr__(self): return str(self) @dataclass class Item: scheme_name: str scheme_agency_id: str scheme_id: str description: str id: str class StandardItem(Item): def __init__(self, id_: str, description: str = '', name: str = ''): super().__init__(id=id_, description=description, scheme_name=name, scheme_id='999', scheme_agency_id='') class UNSPSCItem(Item): def __init__(self, id_: str, description: str = ''): super().__init__(id=id_, description=description, scheme_name='UNSPSC', scheme_id='001', scheme_agency_id='10') @dataclass class Country: code: str name: str = '' def __post_init__(self): if self.code not in codelist.Paises: raise ValueError("code [%s] not found" % (self.code)) self.name = codelist.Paises[self.code]['name'] @dataclass class CountrySubentity: code: str name: str = '' def __post_init__(self): if self.code not in codelist.Departamento: raise ValueError("code [%s] not found" % (self.code)) self.name = codelist.Departamento[self.code]['name'] @dataclass class City: code: str name: str = '' def __post_init__(self): if self.code not in codelist.Municipio: raise ValueError("code [%s] not found" % (self.code)) self.name = codelist.Municipio[self.code]['name'] @dataclass class PostalZone: code: str = '' @dataclass class Address: name: str street: str = '' city: City = field(default_factory=lambda: City('05001')) country: Country = field(default_factory=lambda: Country('CO')) countrysubentity: CountrySubentity = field( default_factory=lambda: CountrySubentity('05')) postalzone: PostalZone = field(default_factory=lambda: PostalZone('')) @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] def __post_init__(self): if self.type_fiscal not in codelist.TipoIdFiscal: raise ValueError("type_fiscal [%s] not found" % (self.type_fiscal)) @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) def __post_init__(self): for code in self.codes: if code not in codelist.TipoResponsabilidad: raise ValueError("code %s not found" % (code)) @dataclass class TaxScheme: code: str name: str = '' def __post_init__(self): if self.code not in codelist.TipoImpuesto: raise ValueError("code not found") self.name = codelist.TipoImpuesto[self.code]['name'] @dataclass class Party: name: str ident: str responsability_code: typing.List[Responsability] responsability_regime_code: str organization_code: str tax_scheme: TaxScheme = field(default_factory=lambda: TaxScheme('01')) phone: str = '' address: Address = field(default_factory=lambda: Address('')) email: str = '' legal_name: str = '' legal_company_ident: str = '' legal_address: str = '' def __post_init__(self): if self.organization_code not in codelist.TipoOrganizacion: raise ValueError("organization_code not found") @dataclass class TaxScheme: code: str = '01' @property def name(self): return codelist.TipoImpuesto[self.code]['name'] def __post_init__(self): if self.code not in codelist.TipoImpuesto: raise ValueError("code [%s] not found" % (self.code)) @dataclass class TaxSubTotal: percent: float scheme: typing.Optional[TaxScheme] = None tax_amount: Amount = field(default_factory=lambda: Amount(0.0)) def calculate(self, invline): if self.percent is not None: self.tax_amount = invline.total_amount * Amount(self.percent / 100) @dataclass class TaxTotal: subtotals: list tax_amount: Amount = field(default_factory=lambda: Amount(0.0)) taxable_amount: Amount = field(default_factory=lambda: Amount(0.0)) def calculate(self, invline): self.taxable_amount = invline.total_amount for subtax in self.subtotals: subtax.calculate(invline) self.tax_amount += subtax.tax_amount class TaxTotalOmit(TaxTotal): def __init__(self): super().__init__([]) def calculate(self, invline): pass @dataclass class WithholdingTaxSubTotal: percent: float scheme: typing.Optional[TaxScheme] = None tax_amount: Amount = field(default_factory=lambda: Amount(0.0)) def calculate(self, invline): if self.percent is not None: self.tax_amount = invline.total_amount * Amount(self.percent / 100) @dataclass class WithholdingTaxTotal: subtotals: list tax_amount: Amount = field(default_factory=lambda: Amount(0.0)) taxable_amount: Amount = field(default_factory=lambda: Amount(0.0)) def calculate(self, invline): self.taxable_amount = invline.total_amount for subtax in self.subtotals: subtax.calculate(invline) self.tax_amount += subtax.tax_amount class WithholdingTaxTotalOmit(WithholdingTaxTotal): def __init__(self): super().__init__([]) def calculate(self, invline): pass @dataclass class Price: amount: Amount type_code: str type: str quantity: int = 1 def __post_init__(self): if self.type_code not in codelist.CodigoPrecioReferencia: 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 class PaymentMean: DEBIT = '01' CREDIT = '02' def __init__(self, id: str, code: str, due_at: datetime, payment_id: str): if code not in codelist.MediosPago: raise ValueError("code not found") 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 = field(default_factory=lambda: Amount(0.0)) @dataclass class BillingResponse: id: str code: str description: str class SupportDocumentCreditNoteResponse(BillingResponse): """ ReferenceID: Identifica la sección del Documento Soporte original a la cual se aplica la corrección. ResponseCode: Código de descripción de la corrección. Description: Descripción de la naturaleza de la corrección. """ @dataclass class BillingReference: ident: str uuid: str date: date class CreditNoteDocumentReference(BillingReference): """ ident: Prefijo + Numero de la factura relacionada uuid: CUFE de la factura electronica date: fecha de emision de la factura relacionada """ class DebitNoteDocumentReference(BillingReference): """ ident: Prefijo + Numero de la factura relacionada uuid: CUFE de la factura electronica date: fecha de emision de la factura relacionada """ class InvoiceDocumentReference(BillingReference): """ ident: Prefijo + Numero de la nota credito relacionada uuid: CUDE 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 = field(default_factory=lambda: Amount(0.0)) reason: AllowanceChargeReason = None # Valor Base para calcular el descuento o el cargo base_amount: typing.Optional[Amount] = field( default_factory=lambda: Amount(0.0)) # Porcentaje: Porcentaje que aplicar. multiplier_factor_numeric: Amount = field( default_factory=lambda: Amount(1.0)) def isCharge(self): charge_indicator = self.charge_indicator is True return charge_indicator def isDiscount(self): charge_indicator = self.charge_indicator is False return charge_indicator 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 class InvoiceLine: # RESOLUCION 0004: pagina 155 quantity: Quantity 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: typing.Optional[TaxTotal] withholding: typing.Optional[WithholdingTaxTotal] 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 def total_amount(self): 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 def total_tax_inclusive_amount(self): # FAU06 return self.total_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 @property def withholding_amount(self): return self.withholding.tax_amount @property def withholding_taxable_amount(self): return self.withholding.taxable_amount def calculate(self): self.tax.calculate(self) self.withholding.calculate(self) def __post_init__(self): if not isinstance(self.quantity, Quantity): raise ValueError("quantity must be Amount") if self.tax is None: self.tax = TaxTotalOmit() if self.withholding is None: self.withholding = WithholdingTaxTotalOmit() @dataclass class LegalMonetaryTotal: line_extension_amount: Amount = field(default_factory=lambda: Amount(0.0)) tax_exclusive_amount: Amount = field(default_factory=lambda: Amount(0.0)) tax_inclusive_amount: Amount = field(default_factory=lambda: Amount(0.0)) charge_total_amount: Amount = field(default_factory=lambda: Amount(0.0)) allowance_total_amount: Amount = field(default_factory=lambda: Amount(0.0)) payable_amount: Amount = field(default_factory=lambda: Amount(0.0)) prepaid_amount: Amount = field(default_factory=lambda: Amount(0.0)) def calculate(self): # DIAN 1.7.-2020: FAU14 self.payable_amount = \ self.tax_inclusive_amount \ + self.allowance_total_amount \ + self.charge_total_amount \ - self.prepaid_amount class NationalSalesInvoiceDocumentType(str): def __str__(self): # 6.1.3 return '01' class CreditNoteDocumentType(str): def __str__(self): # 6.1.3 return '91' class DebitNoteDocumentType(str): def __str__(self): # 6.1.3 return '92' class CreditNoteSupportDocumentType(str): def __str__(self): return '95' class Invoice: def __init__(self, type_code: str): if str(type_code) not in codelist.TipoDocumento: raise ValueError("type_code [%s] not found") 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 = [] self.invoice_billing_reference = None self.invoice_discrepancy_response = None self.invoice_type_code = str(type_code) self.invoice_ident_prefix = None def set_period(self, startdate, enddate): self.invoice_period_start = startdate self.invoice_period_end = enddate def set_issue(self, dtime: datetime): if dtime.tzname() not in ['UTC-05:00', '-05', None]: raise ValueError("dtime must be UTC-05:00") self.invoice_issue = dtime def _set_ident_prefix_automatic(self): if not self.invoice_ident_prefix: prefix = '' for idx, val in enumerate(self.invoice_ident): if val.isalpha(): prefix += val else: break if len(prefix) <= 4: self.invoice_ident_prefix = prefix else: raise ValueError( 'ident prefix failed to get, expected 0 to 4 chars') def set_ident(self, ident: str): """ identificador de factura; prefijo + consecutivo """ self.invoice_ident = ident self._set_ident_prefix_automatic() def _check_ident_prefix(self, prefix): if len(prefix) != 4: raise ValueError('prefix must be 4 length') def set_ident_prefix(self, prefix: str): """ prefijo de facturacion: ejemplo SETP """ self._check_ident_prefix(prefix) self.invoice_ident_prefix = prefix 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 _get_codelist_tipo_operacion(self): return codelist.TipoOperacionF def set_operation_type(self, operation): if operation not in self._get_codelist_tipo_operacion(): raise ValueError("operation not found") self.invoice_operation_type = operation def add_allowance_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 set_billing_reference(self, billing_reference: BillingReference): self.invoice_billing_reference = billing_reference def set_discrepancy_response(self, billing_response: BillingResponse): self.invoice_discrepancy_response = billing_response 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.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): for invline in self.invoice_lines: invline.calculate() self._calculate_legal_monetary_total() self._refresh_charges_base_amount() class NationalSalesInvoice(Invoice): def __init__(self): super().__init__(NationalSalesInvoiceDocumentType()) class CreditNote(Invoice): def __init__(self, invoice_document_reference: BillingReference): super().__init__(CreditNoteDocumentType()) if not isinstance(invoice_document_reference, BillingReference): raise TypeError('invoice_document_reference invalid type') self.invoice_billing_reference = invoice_document_reference def _get_codelist_tipo_operacion(self): return codelist.TipoOperacionNC def _check_ident_prefix(self, prefix): if len(prefix) != 6: raise ValueError('prefix must be 6 length') def _set_ident_prefix_automatic(self): if not self.invoice_ident_prefix: self.invoice_ident_prefix = self.invoice_ident[0:6] class DebitNote(Invoice): def __init__(self, invoice_document_reference: BillingReference): super().__init__(DebitNoteDocumentType()) if not isinstance(invoice_document_reference, BillingReference): raise TypeError('invoice_document_reference invalid type') self.invoice_billing_reference = invoice_document_reference def _get_codelist_tipo_operacion(self): return codelist.TipoOperacionND def _check_ident_prefix(self, prefix): if len(prefix) != 6: raise ValueError('prefix must be 6 length') def _set_ident_prefix_automatic(self): if not self.invoice_ident_prefix: self.invoice_ident_prefix = self.invoice_ident[0:6] class SupportDocument(Invoice): pass class SupportDocumentCreditNote(SupportDocument): def __init__( self, invoice_document_reference: BillingReference, invoice_discrepancy_response: BillingResponse): super().__init__(CreditNoteSupportDocumentType()) if not isinstance(invoice_document_reference, BillingReference): raise TypeError('invoice_document_reference invalid type') self.invoice_billing_reference = invoice_document_reference self.invoice_discrepancy_response = invoice_discrepancy_response def _get_codelist_tipo_operacion(self): return codelist.TipoOperacionNCDS def _check_ident_prefix(self, prefix): if len(prefix) != 6: raise ValueError('prefix must be 6 length') def _set_ident_prefix_automatic(self): if not self.invoice_ident_prefix: self.invoice_ident_prefix = self.invoice_ident[0:6] pass