oc-facho/facho/fe/form/__init__.py
sinergia 7e51726a0d SupportDocumentCreditNote
FossilOrigin-Name: 7ac7f5bbd22e5ac381c21adf358b021182eac84ebfe38624a59355c79075d70b
2022-08-31 05:51:56 +00:00

790 lines
23 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
import dataclasses
from dataclasses import dataclass
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: int or float or str or 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 = City('05001')
country: Country = Country('CO')
countrysubentity: CountrySubentity = CountrySubentity('05')
postalzone: PostalZone = 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 = TaxScheme('01')
phone: str = ''
address: Address = 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 = 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 = Amount(0.0)
taxable_amount: Amount = 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 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 = 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 = 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
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]
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
def calculate(self):
self.tax.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()
@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)
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