# 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, Workflow, fields from trytond.model import sequence_ordered from trytond.pool import Pool from trytond.pyson import Eval, If, Bool from trytond.exceptions import UserError __all__ = [ 'Facility', '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_STATES = [ ('draft', 'Draft'), ('active', 'Active'), ('blocked', 'Blocked'), ('cancelled', 'Cancelled'), ('closed', 'Closed'), ] # --------------------------------------------------------------------------- # Facility Header # --------------------------------------------------------------------------- class Facility(Workflow, 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.Selection(FACILITY_STATES, 'Status', required=True, readonly=True) 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', domain=[('parent', '=', None)], help='Global limits (root nodes). Sub-limits are nested inside each.') 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)]) @staticmethod def default_status(): return 'draft' @staticmethod def default_commitment_status(): return 'uncommitted' @staticmethod def default_is_tpa(): return False # Workflow transitions _transitions = { ('draft', 'active'), ('active', 'blocked'), ('blocked', 'active'), ('active', 'cancelled'), ('active', 'closed'), ('blocked', 'cancelled'), ('blocked', 'closed'), } _buttons = { 'activate': {'invisible': Eval('status') != 'draft'}, 'block': {'invisible': Eval('status') != 'active'}, 'unblock': {'invisible': Eval('status') != 'blocked'}, 'cancel': {'invisible': ~Eval('status').in_(['active', 'blocked'])}, 'close': {'invisible': ~Eval('status').in_(['active', 'blocked'])}, } @classmethod @Workflow.transition('active') def activate(cls, facilities): pass @classmethod @Workflow.transition('blocked') def block(cls, facilities): pass @classmethod @Workflow.transition('active') def unblock(cls, facilities): pass @classmethod @Workflow.transition('cancelled') def cancel(cls, facilities): pass @classmethod @Workflow.transition('closed') def close(cls, facilities): pass # --------------------------------------------------------------------------- # 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') 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) 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') is_global = fields.Function(fields.Boolean('Global Limit'), 'get_is_global') 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 def get_is_global(self, name): return self.parent is None @classmethod def validate(cls, limits): super().validate(limits) for limit in limits: limit.check_amount_vs_parent() limit.check_children_amounts() 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.Selection([ ('inclusion', 'Inclusion'), ('exclusion', 'Exclusion'), ], 'Type', required=True) attribute = fields.Selection(ATTRIBUTE_TYPES, 'Attribute', required=True) value = fields.Char('Value', required=True)