# This file is part of Tradon. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from trytond.model import ModelSQL, ModelView, fields from trytond.model import sequence_ordered from trytond.pool import Pool from trytond.pyson import Eval, Bool from trytond.exceptions import UserError __all__ = [ 'FacilityStatus', 'Facility', 'FacilityCurrency', 'FacilityCovenant', 'FacilityLimit', 'FacilityLimitHaircut', 'FacilityLimitCurrency', 'FacilityLimitCost', 'FacilityLimitCostVariation', 'FacilityLimitOpStatus', 'FacilityLimitBankAccount', 'FacilityCap', 'FacilityCapHaircut', 'FacilityConstraint', ] # --------------------------------------------------------------------------- # Shared Selection lists # --------------------------------------------------------------------------- ATTRIBUTE_TYPES = [ ('commodity', 'Commodity'), ('article', 'Article'), ('origin', 'Origin'), ('destination', 'Destination'), ('loading_place', 'Loading Place'), ('supplier', 'Supplier'), ('customer', 'Customer'), ('payment_condition_purchase', 'Payment Condition (Purchase)'), ('payment_condition_sale', 'Payment Condition (Sale)'), ('purchase_currency', 'Purchase Currency'), ('warehouse', 'Warehouse'), ('incoterm', 'Incoterm'), ('counterparty_rating', 'Counterparty Rating'), ('receivable_category', 'Receivable Category'), ] # --------------------------------------------------------------------------- # Facility Status (configurable reference) # --------------------------------------------------------------------------- class FacilityStatus(ModelSQL, ModelView): 'Facility Status' __name__ = 'trade_finance.facility_status' _rec_name = 'name' code = fields.Char('Code', required=True) name = fields.Char('Name', required=True) active = fields.Boolean('Active') @staticmethod def default_active(): return True # --------------------------------------------------------------------------- # Facility Header # --------------------------------------------------------------------------- class Facility(ModelSQL, ModelView): 'TF Facility' __name__ = 'trade_finance.facility' _rec_name = 'name' name = fields.Char('Name', required=True) tfe = fields.Many2One('bank', 'Trade Finance Entity', required=True, ondelete='RESTRICT', help='Bank or fund providing this facility') description = fields.Text('Description') status = fields.Many2One('trade_finance.facility_status', 'Status', ondelete='RESTRICT') commitment_status = fields.Selection([ ('uncommitted', 'Uncommitted'), ('committed', 'Committed'), ], 'Commitment Status', required=True) date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') currency = fields.Many2One('currency.currency', 'Facility Currency', required=True, ondelete='RESTRICT') fx_feeder = fields.Many2One('trade_finance.fx_feeder', 'FX Rate Feeder', ondelete='RESTRICT') fx_haircut_pct = fields.Numeric('FX Haircut (%)', digits=(16, 2)) is_tpa = fields.Boolean('TPA', help='Third Party Account — broker managed structure') broker = fields.Many2One('party.party', 'Broker', states={'invisible': ~Bool(Eval('is_tpa'))}, depends=['is_tpa'], ondelete='RESTRICT') broker_account = fields.Char('Broker Account', states={'invisible': ~Bool(Eval('is_tpa'))}, depends=['is_tpa']) limits = fields.One2Many('trade_finance.facility_limit', 'facility', 'Limits') currencies = fields.One2Many('trade_finance.facility_currency', 'facility', 'Accepted Currencies') caps = fields.One2Many('trade_finance.facility_cap', 'facility', 'Caps') covenants = fields.One2Many('trade_finance.facility_covenant', 'facility', 'Covenants') constraints = fields.One2Many('trade_finance.facility_constraint', 'facility', 'Facility Constraints', domain=[('limit', '=', None)]) @classmethod def default_status(cls): pool = Pool() FacilityStatus = pool.get('trade_finance.facility_status') statuses = FacilityStatus.search([('code', '=', 'DRAFT')], limit=1) if statuses: return statuses[0].id return None @staticmethod def default_commitment_status(): return 'uncommitted' @staticmethod def default_is_tpa(): return False # --------------------------------------------------------------------------- # Facility Accepted Currency # --------------------------------------------------------------------------- class FacilityCurrency(ModelSQL, ModelView): 'Facility Currency' __name__ = 'trade_finance.facility_currency' facility = fields.Many2One('trade_finance.facility', 'Facility', required=True, ondelete='CASCADE') currency = fields.Many2One('currency.currency', 'Currency', required=True, ondelete='RESTRICT') fx_haircut_formula = fields.Many2One('trade_finance.haircut_formula', 'FX Haircut Formula', ondelete='RESTRICT') fx_feeder = fields.Many2One('trade_finance.fx_feeder', 'FX Rate Feeder', ondelete='RESTRICT') valuation_method = fields.Many2One('trade_finance.valuation_method', 'Valuation Method', ondelete='RESTRICT') date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') # --------------------------------------------------------------------------- # Covenant # --------------------------------------------------------------------------- class FacilityCovenant(ModelSQL, ModelView): 'Facility Covenant' __name__ = 'trade_finance.facility_covenant' _rec_name = 'name' facility = fields.Many2One('trade_finance.facility', 'Facility', required=True, ondelete='CASCADE') name = fields.Char('Covenant', required=True) ratio_type = fields.Selection([ ('leverage_ratio', 'Leverage Ratio'), ('dso', 'DSO'), ('interest_coverage', 'Interest Coverage Ratio'), ('current_ratio', 'Current Ratio'), ('other', 'Other'), ], 'Ratio Type', required=True) threshold = fields.Numeric('Threshold', digits=(16, 4)) currency = fields.Many2One('currency.currency', 'Currency', ondelete='RESTRICT') notes = fields.Text('Notes') # --------------------------------------------------------------------------- # Facility Limit (Global Limit + Sub-Limits in one model) # --------------------------------------------------------------------------- class FacilityLimit(ModelSQL, ModelView): 'Facility Limit' __name__ = 'trade_finance.facility_limit' _rec_name = 'name' facility = fields.Many2One('trade_finance.facility', 'Facility', required=True, ondelete='CASCADE', states={'readonly': Bool(Eval('parent'))}, depends=['parent']) parent = fields.Many2One('trade_finance.facility_limit', 'Parent Limit', ondelete='RESTRICT', domain=[('facility', '=', Eval('facility'))], depends=['facility'], help='Leave empty for Global Limit (root node)') children = fields.One2Many('trade_finance.facility_limit', 'parent', 'Sub-Limits') name = fields.Char('Name', required=True) alternative_name = fields.Char('Limit Alternative Name') financing_type = fields.Many2One('trade_finance.financing_type', 'Financing Type', ondelete='RESTRICT') amount = fields.Numeric('Amount', digits=(16, 2), required=True) tenor = fields.Integer('Tenor (days)', help='Maximum duration of financing from drawdown to repayment') sequence = fields.Integer('Sequence') _order = [('sequence', 'ASC'), ('id', 'ASC')] haircuts = fields.One2Many('trade_finance.facility_limit_haircut', 'limit', 'Haircuts') currencies = fields.One2Many('trade_finance.facility_limit_currency', 'limit', 'Accepted Currencies') costs = fields.One2Many('trade_finance.facility_limit_cost', 'limit', 'Costs') op_statuses = fields.One2Many('trade_finance.facility_limit_op_status', 'limit', 'Expected Operational Statuses') bank_accounts = fields.One2Many('trade_finance.facility_limit_bank_account', 'limit', 'Bank Accounts') constraints = fields.One2Many('trade_finance.facility_constraint', 'limit', 'Limit Constraints', domain=[('facility', '=', None)]) @staticmethod def default_sequence(): return 10 @classmethod def create(cls, vlist): vlist = [v.copy() for v in vlist] for values in vlist: if values.get('parent') and not values.get('facility'): parent = cls(values['parent']) values['facility'] = parent.facility.id return super().create(vlist) @classmethod def validate(cls, limits): super().validate(limits) for limit in limits: limit.check_single_root() limit.check_amount_vs_parent() limit.check_children_amounts() def check_single_root(self): if self.parent is None: roots = self.__class__.search([ ('facility', '=', self.facility.id), ('parent', '=', None), ('id', '!=', self.id), ]) if roots: raise UserError( f"Facility '{self.facility.name}' already has a Global " f"Limit ('{roots[0].name}'). Only one root limit is " f"allowed per facility.") def check_amount_vs_parent(self): if self.parent and self.amount > self.parent.amount: raise UserError( f"Sub-limit '{self.name}' amount ({self.amount}) " f"cannot exceed parent limit '{self.parent.name}' " f"amount ({self.parent.amount}).") def check_children_amounts(self): for child in self.children: if child.amount > self.amount: raise UserError( f"Sub-limit '{child.name}' amount ({child.amount}) " f"cannot exceed parent limit '{self.name}' " f"amount ({self.amount}).") # --------------------------------------------------------------------------- # Limit Haircut # --------------------------------------------------------------------------- class FacilityLimitHaircut(ModelSQL, ModelView): 'Facility Limit Haircut' __name__ = 'trade_finance.facility_limit_haircut' limit = fields.Many2One('trade_finance.facility_limit', 'Limit', required=True, ondelete='CASCADE') attribute = fields.Selection(ATTRIBUTE_TYPES, 'Attribute Type', required=True) value = fields.Char('Value', help='The specific attribute value this haircut applies to ' '(e.g. "Brazil", "Coffee", "USD")') haircut_pct = fields.Numeric('Haircut (%)', digits=(16, 2), required=True) date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') # --------------------------------------------------------------------------- # Limit Accepted Currency # --------------------------------------------------------------------------- class FacilityLimitCurrency(ModelSQL, ModelView): 'Facility Limit Currency' __name__ = 'trade_finance.facility_limit_currency' limit = fields.Many2One('trade_finance.facility_limit', 'Limit', required=True, ondelete='CASCADE') currency = fields.Many2One('currency.currency', 'Currency', required=True, ondelete='RESTRICT') haircut_pct = fields.Numeric('FX Haircut (%)', digits=(16, 2)) fx_feeder = fields.Many2One('trade_finance.fx_feeder', 'FX Rate Feeder', ondelete='RESTRICT') valuation_method = fields.Many2One('trade_finance.valuation_method', 'Valuation Method', ondelete='RESTRICT') date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') # --------------------------------------------------------------------------- # Limit Cost # --------------------------------------------------------------------------- class FacilityLimitCost(ModelSQL, ModelView): 'Facility Limit Cost' __name__ = 'trade_finance.facility_limit_cost' limit = fields.Many2One('trade_finance.facility_limit', 'Limit', required=True, ondelete='CASCADE') cost_type = fields.Selection([ ('interest', 'Interest-Based'), ('flat', 'Flat / Minimal Amount'), ], 'Cost Type', required=True) # Interest-based fields spread = fields.Numeric('Spread (%)', digits=(16, 4), states={'invisible': Eval('cost_type') != 'interest'}, depends=['cost_type']) index = fields.Many2One('trade_finance.market_index', 'Market Index', ondelete='RESTRICT', states={'invisible': Eval('cost_type') != 'interest'}, depends=['cost_type']) index_term = fields.Many2One('trade_finance.index_term', 'Index Term', ondelete='RESTRICT', states={'invisible': Eval('cost_type') != 'interest'}, depends=['cost_type']) interest_formula = fields.Many2One('trade_finance.interest_formula', 'Interest Formula', ondelete='RESTRICT', states={'invisible': Eval('cost_type') != 'interest'}, depends=['cost_type']) # Flat fields flat_amount = fields.Numeric('Flat Amount', digits=(16, 2), states={'invisible': Eval('cost_type') != 'flat'}, depends=['cost_type']) flat_currency = fields.Many2One('currency.currency', 'Currency', ondelete='RESTRICT', states={'invisible': Eval('cost_type') != 'flat'}, depends=['cost_type']) date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') variations = fields.One2Many('trade_finance.facility_limit_cost_variation', 'cost', 'Cost Variations') # --------------------------------------------------------------------------- # Limit Cost Variation # --------------------------------------------------------------------------- class FacilityLimitCostVariation(ModelSQL, ModelView): 'Facility Limit Cost Variation' __name__ = 'trade_finance.facility_limit_cost_variation' cost = fields.Many2One('trade_finance.facility_limit_cost', 'Cost', required=True, ondelete='CASCADE') attribute = fields.Selection(ATTRIBUTE_TYPES, 'Attribute Type', required=True) value = fields.Char('Value') variation_type = fields.Selection([ ('pct', 'Percentage (+/-)'), ('flat', 'Flat Amount (+/-)'), ], 'Variation Type', required=True) variation_value = fields.Numeric('Variation Value', digits=(16, 4), required=True) variation_currency = fields.Many2One('currency.currency', 'Currency', ondelete='RESTRICT', states={'invisible': Eval('variation_type') != 'flat'}, depends=['variation_type']) # --------------------------------------------------------------------------- # Limit Expected Operational Status # --------------------------------------------------------------------------- class FacilityLimitOpStatus(ModelSQL, ModelView): 'Facility Limit Operational Status' __name__ = 'trade_finance.facility_limit_op_status' limit = fields.Many2One('trade_finance.facility_limit', 'Limit', required=True, ondelete='CASCADE') operational_status = fields.Many2One('trade_finance.operational_status', 'Operational Status', required=True, ondelete='RESTRICT') evidence_type = fields.Many2One('trade_finance.evidence_type', 'Required Evidence', ondelete='RESTRICT', help='Evidence required when this operational status is reached') # --------------------------------------------------------------------------- # Limit Bank Account # --------------------------------------------------------------------------- class FacilityLimitBankAccount(ModelSQL, ModelView): 'Facility Limit Bank Account' __name__ = 'trade_finance.facility_limit_bank_account' limit = fields.Many2One('trade_finance.facility_limit', 'Limit', required=True, ondelete='CASCADE') bank_account = fields.Many2One('bank.account', 'Bank Account', required=True, ondelete='RESTRICT') currency = fields.Many2One('currency.currency', 'Currency', required=True, ondelete='RESTRICT') is_default = fields.Boolean('Default') date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') @staticmethod def default_is_default(): return False # --------------------------------------------------------------------------- # Cap # --------------------------------------------------------------------------- class FacilityCap(ModelSQL, ModelView): 'Facility Cap' __name__ = 'trade_finance.facility_cap' _rec_name = 'name' facility = fields.Many2One('trade_finance.facility', 'Facility', required=True, ondelete='CASCADE') name = fields.Char('Name', required=True) amount = fields.Numeric('Cap Amount', digits=(16, 2), required=True) date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') haircuts = fields.One2Many('trade_finance.facility_cap_haircut', 'cap', 'Haircuts') constraints = fields.One2Many('trade_finance.facility_constraint', 'cap', 'Cap Constraints') # --------------------------------------------------------------------------- # Cap Haircut # --------------------------------------------------------------------------- class FacilityCapHaircut(ModelSQL, ModelView): 'Facility Cap Haircut' __name__ = 'trade_finance.facility_cap_haircut' cap = fields.Many2One('trade_finance.facility_cap', 'Cap', required=True, ondelete='CASCADE') attribute = fields.Selection(ATTRIBUTE_TYPES, 'Attribute Type', required=True) value = fields.Char('Value') haircut_pct = fields.Numeric('Haircut (%)', digits=(16, 2), required=True) date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') # --------------------------------------------------------------------------- # Constraint (shared by Facility, Limit, Cap) # --------------------------------------------------------------------------- class FacilityConstraint(ModelSQL, ModelView): 'Facility Constraint' __name__ = 'trade_finance.facility_constraint' facility = fields.Many2One('trade_finance.facility', 'Facility', ondelete='CASCADE') limit = fields.Many2One('trade_finance.facility_limit', 'Limit', ondelete='CASCADE') cap = fields.Many2One('trade_finance.facility_cap', 'Cap', ondelete='CASCADE') constraint_type = fields.Many2One('trade_finance.constraint_type', 'Constraint Type', required=True, ondelete='RESTRICT') is_exclusion = fields.Boolean('Exclusion', help='Checked = Exclusion constraint, unchecked = Inclusion constraint') date_from = fields.Date('Valid From') date_to = fields.Date('Valid To') @staticmethod def default_is_exclusion(): return False