diff --git a/modules/trade_finance/__init__.py b/modules/trade_finance/__init__.py index 8dae6b1..92b2408 100644 --- a/modules/trade_finance/__init__.py +++ b/modules/trade_finance/__init__.py @@ -12,6 +12,7 @@ from . import ( counterparty, fx, operational, + facility, ) @@ -35,6 +36,18 @@ def register(): fx.FxFeeder, operational.BlockingReason, operational.ChargeType, + facility.Facility, + facility.FacilityCovenant, + facility.FacilityLimit, + facility.FacilityLimitHaircut, + facility.FacilityLimitCurrency, + facility.FacilityLimitCost, + facility.FacilityLimitCostVariation, + facility.FacilityLimitOpStatus, + facility.FacilityLimitBankAccount, + facility.FacilityCap, + facility.FacilityCapHaircut, + facility.FacilityConstraint, module='trade_finance', type_='model') Pool.register( fx.PriceCalendar, diff --git a/modules/trade_finance/facility.py b/modules/trade_finance/facility.py new file mode 100644 index 0000000..95f40de --- /dev/null +++ b/modules/trade_finance/facility.py @@ -0,0 +1,470 @@ +# 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'])}, + } + + @Workflow.transition('active') + def activate(cls, facilities): + pass + + @Workflow.transition('blocked') + def block(cls, facilities): + pass + + @Workflow.transition('active') + def unblock(cls, facilities): + pass + + @Workflow.transition('cancelled') + def cancel(cls, facilities): + pass + + @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) diff --git a/modules/trade_finance/facility.xml b/modules/trade_finance/facility.xml new file mode 100644 index 0000000..4a4afcd --- /dev/null +++ b/modules/trade_finance/facility.xml @@ -0,0 +1,346 @@ + + + + + + + + + + Trade Finance + + + + + + + + + + + + trade_finance.facility + tree + + facility_tree + + + trade_finance.facility + form + facility_form + + + TF Facilities + trade_finance.facility + + + + + + + + + + + + + + + trade_finance.facility + + + + + + + trade_finance.facility + + + + + + + + trade_finance.facility + + + + + + + + + + + + + trade_finance.facility_covenant + tree + + facility_covenant_tree + + + trade_finance.facility_covenant + + + + + + + trade_finance.facility_covenant + + + + + + + + + + + + + trade_finance.facility_limit + tree + + facility_limit_tree + + + trade_finance.facility_limit + form + facility_limit_form + + + trade_finance.facility_limit + + + + + + + trade_finance.facility_limit + + + + + + + + + + + + + trade_finance.facility_limit_haircut + tree + + facility_limit_haircut_tree + + + trade_finance.facility_limit_haircut + + + + + + + trade_finance.facility_limit_haircut + + + + + + + + + trade_finance.facility_limit_currency + tree + + facility_limit_currency_tree + + + trade_finance.facility_limit_currency + + + + + + + trade_finance.facility_limit_currency + + + + + + + + + trade_finance.facility_limit_cost + tree + + facility_limit_cost_tree + + + trade_finance.facility_limit_cost + + + + + + + trade_finance.facility_limit_cost + + + + + + + + + trade_finance.facility_limit_cost_variation + tree + + facility_limit_cost_variation_tree + + + trade_finance.facility_limit_cost_variation + + + + + + + trade_finance.facility_limit_cost_variation + + + + + + + + + trade_finance.facility_limit_op_status + tree + + facility_limit_op_status_tree + + + trade_finance.facility_limit_op_status + + + + + + + trade_finance.facility_limit_op_status + + + + + + + + + trade_finance.facility_limit_bank_account + tree + + facility_limit_bank_account_tree + + + trade_finance.facility_limit_bank_account + + + + + + + trade_finance.facility_limit_bank_account + + + + + + + + + + + + + trade_finance.facility_cap + tree + + facility_cap_tree + + + trade_finance.facility_cap + + + + + + + trade_finance.facility_cap + + + + + + + + + trade_finance.facility_cap_haircut + tree + + facility_cap_haircut_tree + + + trade_finance.facility_cap_haircut + + + + + + + trade_finance.facility_cap_haircut + + + + + + + + + + + + + trade_finance.facility_constraint + tree + + facility_constraint_tree + + + trade_finance.facility_constraint + + + + + + + trade_finance.facility_constraint + + + + + + + + + diff --git a/modules/trade_finance/tryton.cfg b/modules/trade_finance/tryton.cfg index 2183e93..328deab 100644 --- a/modules/trade_finance/tryton.cfg +++ b/modules/trade_finance/tryton.cfg @@ -5,5 +5,7 @@ depends: res stock price + bank xml: reference.xml + facility.xml diff --git a/modules/trade_finance/view/facility_cap_haircut_tree.xml b/modules/trade_finance/view/facility_cap_haircut_tree.xml new file mode 100644 index 0000000..59c3be4 --- /dev/null +++ b/modules/trade_finance/view/facility_cap_haircut_tree.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/modules/trade_finance/view/facility_cap_tree.xml b/modules/trade_finance/view/facility_cap_tree.xml new file mode 100644 index 0000000..2ad96ee --- /dev/null +++ b/modules/trade_finance/view/facility_cap_tree.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/trade_finance/view/facility_constraint_tree.xml b/modules/trade_finance/view/facility_constraint_tree.xml new file mode 100644 index 0000000..8d882ba --- /dev/null +++ b/modules/trade_finance/view/facility_constraint_tree.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/trade_finance/view/facility_covenant_tree.xml b/modules/trade_finance/view/facility_covenant_tree.xml new file mode 100644 index 0000000..e80b477 --- /dev/null +++ b/modules/trade_finance/view/facility_covenant_tree.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/trade_finance/view/facility_form.xml b/modules/trade_finance/view/facility_form.xml new file mode 100644 index 0000000..d659853 --- /dev/null +++ b/modules/trade_finance/view/facility_form.xml @@ -0,0 +1,49 @@ +
+