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 @@
+
+