Files
tradon/modules/trade_finance/facility.py
AzureAD\SylvainDUVERNAY 13d26ac41b Trade Finance
2026-04-12 16:36:08 +02:00

498 lines
19 KiB
Python

# 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