# Tryton Trade Finance Module — Architecture & Developer Specification ## 1. Objective This document describes the proposed architecture for a Tryton ERP module dedicated to Trade Finance. The goal is to support financing that can start at different operational stages of a purchase contract execution: - purchase contract creation, - purchase contract line, - production or goods availability, - shipment / Bill of Lading, - transit stage, - sale matching, - invoice / receivable stage, - any future business object that becomes eligible for financing. The architecture is designed to be evolutive, auditable, and suitable for calculating: - bank limit usage, - chronological exposure movements, - funding cost, - haircut impact, - liquidity gain or loss when moving between limits, - historical exposure at any date. The core design principle is: > A bank limit is not a static allocation. > It is a balance evolving through chronological movements. --- ## 2. Key Architecture Decision Use the combination of: 1. **Contextual creation actions** - “Start Financing” button available from contract, contract line, shipment, BL, matching, invoice, etc. 2. **A polymorphic origin reference** - The financing presentation can reference different Tryton models using a `fields.Reference`. 3. **A movement ledger** - Each presentation generates one or more movement lines against bank limits. - Current exposure is calculated from the sum of movements, not stored manually. This makes the module evolutive: adding a new financing start point later should not require redesigning the database schema. --- ## 3. Main Concepts ### 3.1 `bank.limit` This already exists or is similar in the current system. It represents the contractual banking facility or available credit line. Examples: - UBS PREFI Limit - UBS In Transit Limit - BNP Post-Shipment Limit - Documentary Credit Limit - Invoice Discounting Facility Typical fields may include: ```text bank.limit - bank - code - name - financing_type - currency - maximum_amount - haircut_percent - interest_rate - day_count_basis - effective_date - expiration_date - active ``` If rates and haircuts can change over time, introduce a child table: ```text bank.limit.condition - bank_limit - effective_date - expiration_date - interest_rate - haircut_percent - margin - base_rate ``` ### 3.2 `trade.finance` This is the Trade Finance file/header. It represents the business financing case. Example: ```text TFR-0001 Bank: UBS Bank Reference: UBS/TF/2026/045 Supplier: ABC Brazil Commodity: Coffee Currency: USD ``` Important distinction: - internal reference: generated by Tryton sequence, for example `TFR-0001` - bank reference: external reference provided by the bank ### 3.3 `trade.finance.presentation` This is the chronological business event within a Trade Finance file. Examples: - initial prefinancing request, - goods produced, - transfer to in-transit, - BL received, - on-board vessel, - matched with sale, - invoice presented, - financing release, - cancellation, - refinancing. A presentation is not only a form entry. It is the operational event that explains why limit exposure changes. ### 3.4 `trade.finance.presentation.line` This is the exposure movement ledger. It behaves like: - a bank account movement, - an accounting journal line, - or a stock movement. A presentation may generate one or several movement lines. Example: transfer 400 MT from Prefi Limit to Transit Limit: ```text Presentation: Transfer 400 MT to in-transit stage Line 1: Limit: UBS PREFI Signed quantity: -400 MT Signed financed amount: -320,000 USD Line 2: Limit: UBS TRANSIT Signed quantity: +400 MT Signed financed amount: +360,000 USD ``` --- ## 4. Model Responsibilities ### 4.1 `bank.limit` Responsibility: ```text bank.limit = contractual banking facility / available line ``` It defines what is theoretically available. It should answer: - Which bank owns the limit? - Which financing type does it support? - What is the maximum authorized amount? - What haircut applies? - What interest rate applies? - Is the limit active? - Is the limit valid on the presentation date? - Which currency applies? ### 4.2 `trade.finance` Responsibility: ```text trade.finance = financing file / business case ``` It groups all presentations and movements related to the same financing lifecycle. It should contain: ```text trade.finance - number - bank_reference - bank - party / supplier / counterparty - company - currency - start_date - status - description - presentations ``` ### 4.3 `trade.finance.presentation` Responsibility: ```text trade.finance.presentation = chronological business event ``` It records the stage or trigger of limit usage. It should contain: ```text trade.finance.presentation - trade_finance - sequence - presentation_date - presentation_type - finance_stage - origin - bank - description - previous_presentation - state - lines ``` The field `origin` should be a Tryton `fields.Reference`. Possible origin models: ```text purchase.contract purchase.contract.line stock.shipment stock.shipment.line bill.of.lading sale.purchase.matching account.invoice ``` New models can be added later to the reference selection. ### 4.4 `trade.finance.presentation.line` Responsibility: ```text trade.finance.presentation.line = limit movement / exposure journal ``` It records the signed quantitative and financial impact on a selected bank limit. It should contain: ```text trade.finance.presentation.line - presentation - bank_limit - movement_date - finance_stage - signed_quantity - uom - commodity_price - gross_collateral_value - haircut_percent_applied - financed_amount - signed_financed_amount - interest_rate_applied - day_count_basis_applied - currency - maturity_date - value_date - description ``` Important design rule: > Store applied haircut and applied interest rate on the movement line. Even if these values come from `bank.limit`, copy them at posting time to preserve auditability. Reason: ```text bank.limit = what is theoretically available now presentation.line = what was actually used at that point in time ``` If the bank changes the haircut or rate later, historical calculations must remain correct. --- ## 5. Internal and Bank References ### 5.1 Internal Trade Finance Reference `trade.finance.number` should be generated by `ir.sequence`. Example sequence: ```text TFR-0001 TFR-0002 TFR-0003 ``` Suggested field: ```python number = fields.Char("Trade Finance Reference", required=True, readonly=True) ``` The sequence should be generated during `create()` if no number has been supplied. ### 5.2 Bank Reference `trade.finance.bank_reference` is manually entered or imported from the bank. Example: ```text UBS/TF/2026/045 BNP-COF-18723 ``` Suggested field: ```python bank_reference = fields.Char("Bank Reference") ``` This value is not controlled by Tryton sequence because it comes from the bank. --- ## 6. Financing Type `financing_type` describes the nature of the bank limit. Examples: ```text PREFI IN_TRANSIT POST_SHIPMENT INVENTORY_FINANCING RECEIVABLE_FINANCING INVOICE_DISCOUNTING LETTER_OF_CREDIT BANK_GUARANTEE DOCUMENTARY_COLLECTION FACTORING ``` For an initial version, use a smaller controlled list: ```text PREFI IN_TRANSIT POST_SHIPMENT MATCHED_SALE INVOICE_FINANCING ``` Example: ```text UBS Limit 1 financing_type = PREFI interest_rate = 10% haircut = 20% ``` ```text UBS Limit 2 financing_type = IN_TRANSIT interest_rate = 6% haircut = 10% ``` The financing type helps filter available limits depending on the stage of contract execution. --- ## 7. Effective Date and Expiration Date on `bank.limit` ### 7.1 `effective_date` The date from which the bank limit becomes available. Example: ```text Limit: UBS PREFI 2026 Effective date: 2026-01-01 Expiration date: 2026-12-31 ``` A presentation dated before 2026-01-01 should not normally be allowed to use this limit. ### 7.2 `expiration_date` The date after which the bank limit is no longer valid. After this date: - no new presentation should normally use the limit, - existing exposure may need to be repaid, renewed, or transferred, - the limit should be hidden or blocked in normal selection lists. These dates describe the validity of the credit facility itself. They are different from: - contract date, - shipment date, - BL date, - maturity date, - repayment date, - value date, - interest period date. --- ## 8. Finance Stage Introduce a normalized `finance_stage`. Examples: ```text PREFI PRODUCED INLAND_TRANSIT PORT_STOCK ON_BOARD OCEAN_TRANSIT DELIVERED MATCHED INVOICED REPAID CANCELLED ``` Purpose: - classify the risk state of the goods, - determine eligible limits, - apply haircut and rate rules, - support reporting, - support future workflow automation. A presentation represents a transition or event affecting the finance stage. --- ## 9. Example Scenario ### 9.1 Initial Situation A purchase contract is signed for 1000 MT. The bank grants a prefinancing limit. ```text Bank: UBS Limit 1: UBS PREFI Interest rate: 10% Haircut: 20% ``` The commodity value is: ```text 1000 MT × 1,000 USD/MT = 1,000,000 USD ``` The financed amount is: ```text 1,000,000 × (1 - 20%) = 800,000 USD ``` ### 9.2 Presentation 1 — Initial Prefinancing ```text Presentation #1 Type: PREFI Origin: Purchase Contract Quantity: 1000 MT Limit: UBS PREFI ``` Generated movement line: ```text +1000 MT on UBS PREFI +800,000 USD financed amount ``` ### 9.3 After 10 Days — 400 MT Move to In-Transit 400 MT have been produced and moved into a lower-risk stage. New limit: ```text Limit 2: UBS IN_TRANSIT Interest rate: 6% Haircut: 10% ``` For 400 MT: ```text Gross collateral value = 400 × 1,000 = 400,000 USD Financed amount under PREFI haircut = 400,000 × 80% = 320,000 USD Financed amount under IN_TRANSIT haircut = 400,000 × 90% = 360,000 USD ``` Liquidity gain: ```text 360,000 - 320,000 = 40,000 USD ``` ### 9.4 Presentation 2 — Transfer to In-Transit ```text Presentation #2 Type: TRANSFER_TO_IN_TRANSIT Origin: Shipment / logistics event Quantity: 400 MT Previous presentation: Presentation #1 ``` Generated movement lines: ```text Line 1: Limit: UBS PREFI Signed quantity: -400 MT Signed financed amount: -320,000 USD Line 2: Limit: UBS IN_TRANSIT Signed quantity: +400 MT Signed financed amount: +360,000 USD ``` After this event: ```text UBS PREFI exposure: 1000 - 400 = 600 MT UBS IN_TRANSIT exposure: 400 MT ``` The system can now calculate: - 1000 MT on Limit 1 for 10 days, - 600 MT on Limit 1 for the following 8 days, - 400 MT on Limit 2 from the transfer date onward. --- ## 10. Funding Cost Calculation Funding cost can be calculated from the movement ledger. Generic formula: ```text Interest = Exposure × Interest Rate × Days / Day Count Basis ``` Example day count basis: ```text 360 365 ACT/360 ACT/365 ``` Because each movement line contains: - movement date, - bank limit, - signed financed amount, - applied rate, - day count basis, the system can reconstruct exposure over time. ### 10.1 Exposure Periods To calculate interest: 1. order all movement lines by date, 2. calculate running balance per bank limit, 3. identify date intervals between movements, 4. apply the rate to each interval. Example: ```text Day 0 to Day 10: Limit 1 exposure = 800,000 USD Day 10 to Day 18: Limit 1 exposure = 480,000 USD Limit 2 exposure = 360,000 USD ``` ### 10.2 Important Rule Do not overwrite current exposure manually. Instead: ```text Current exposure = SUM(signed_financed_amount) GROUP BY bank_limit ``` For reporting performance, a snapshot table can be added later, but it should be derived from ledger lines. --- ## 11. Haircut and Liquidity Gain Haircut represents the percentage of collateral value that the bank does not finance. Formula: ```text Financed Amount = Gross Collateral Value × (1 - Haircut %) ``` When goods move to a lower-risk stage, the haircut may decrease. Example: ```text PREFI haircut = 20% IN_TRANSIT haircut = 10% Gross value = 400,000 USD ``` Liquidity gain: ```text 400,000 × (20% - 10%) = 40,000 USD ``` This cash gain should be traceable through the presentation lines. Possible calculated fields: ```text gross_collateral_value financed_amount haircut_amount liquidity_gain_loss ``` --- ## 12. Filtering Bank Limits When selecting a bank limit in `trade.finance.presentation.line`, the list should be filtered by: - selected bank from `trade.finance`, - active status, - effective date <= presentation date, - expiration date >= presentation date or expiration date is null, - currency compatibility, - financing type / finance stage compatibility. Conceptual domain: ```python domain=[ ('bank', '=', Eval('_parent_presentation', {}).get('bank')), ('active', '=', True), ] ``` Additional filtering may be done with `on_change`, dynamic domains, or validation methods depending on the final Tryton model structure. --- ## 13. Evolutivity The design is evolutive because new start points do not require new foreign keys on `trade.finance`. Avoid this anti-pattern: ```text trade.finance - purchase_contract - purchase_contract_line - shipment - matching - invoice - ... ``` This would require schema changes every time a new starting point is added. Use this instead: ```text trade.finance.presentation - origin = fields.Reference(...) ``` Adding a new start point later requires: 1. add the model to the allowed `Reference` selection, 2. implement eligibility logic, 3. add a contextual button/action on the new source object, 4. optionally add presentation defaults for stage, quantity, amount, and origin. --- ## 14. Suggested Tryton Python Skeleton ### 14.1 `trade.finance` ```python from trytond.model import ModelSQL, ModelView, fields from trytond.pool import Pool from trytond.transaction import Transaction class TradeFinance(ModelSQL, ModelView): "Trade Finance" __name__ = 'trade.finance' number = fields.Char("Trade Finance Reference", required=True, readonly=True) bank_reference = fields.Char("Bank Reference") bank = fields.Many2One('party.party', "Bank", required=True) party = fields.Many2One('party.party', "Counterparty") company = fields.Many2One('company.company', "Company", required=True) currency = fields.Many2One('currency.currency', "Currency", required=True) start_date = fields.Date("Start Date", required=True) state = fields.Selection([ ('draft', "Draft"), ('active', "Active"), ('closed', "Closed"), ('cancelled', "Cancelled"), ], "State", required=True, readonly=True) presentations = fields.One2Many( 'trade.finance.presentation', 'trade_finance', "Presentations") @classmethod def __setup__(cls): super().__setup__() cls._order.insert(0, ('number', 'DESC')) @staticmethod def default_state(): return 'draft' @classmethod def create(cls, vlist): pool = Pool() Sequence = pool.get('ir.sequence') # Replace this with your module configuration sequence. # Recommended: store the sequence on a trade.finance.configuration model. for values in vlist: if not values.get('number'): values['number'] = Sequence.get_id( 'trade_finance.sequence_trade_finance') return super().create(vlist) ``` ### 14.2 `trade.finance.presentation` ```python class TradeFinancePresentation(ModelSQL, ModelView): "Trade Finance Presentation" __name__ = 'trade.finance.presentation' trade_finance = fields.Many2One( 'trade.finance', "Trade Finance", required=True, ondelete='CASCADE') sequence = fields.Integer("Sequence") presentation_date = fields.Date("Presentation Date", required=True) presentation_type = fields.Selection([ ('prefi', "Prefinancing"), ('transfer', "Transfer"), ('shipment', "Shipment Presentation"), ('matching', "Matched Sale"), ('invoice', "Invoice Financing"), ('release', "Release"), ('cancellation', "Cancellation"), ], "Presentation Type", required=True) finance_stage = fields.Selection([ ('prefi', "Prefi"), ('produced', "Produced"), ('inland_transit', "Inland Transit"), ('port_stock', "Port Stock"), ('on_board', "On Board"), ('ocean_transit', "Ocean Transit"), ('delivered', "Delivered"), ('matched', "Matched"), ('invoiced', "Invoiced"), ('repaid', "Repaid"), ('cancelled', "Cancelled"), ], "Finance Stage", required=True) origin = fields.Reference( "Origin", selection='get_origin_models' ) previous_presentation = fields.Many2One( 'trade.finance.presentation', "Previous Presentation") description = fields.Text("Description") lines = fields.One2Many( 'trade.finance.presentation.line', 'presentation', "Movement Lines") state = fields.Selection([ ('draft', "Draft"), ('posted', "Posted"), ('cancelled', "Cancelled"), ], "State", required=True, readonly=True) @staticmethod def default_state(): return 'draft' @classmethod def get_origin_models(cls): return [ ('purchase.contract', "Purchase Contract"), ('purchase.contract.line', "Purchase Contract Line"), ('stock.shipment', "Shipment"), ('sale.purchase.matching', "Sale/Purchase Matching"), ('account.invoice', "Invoice"), ] ``` ### 14.3 `trade.finance.presentation.line` ```python from decimal import Decimal class TradeFinancePresentationLine(ModelSQL, ModelView): "Trade Finance Presentation Line" __name__ = 'trade.finance.presentation.line' presentation = fields.Many2One( 'trade.finance.presentation', "Presentation", required=True, ondelete='CASCADE') bank_limit = fields.Many2One( 'bank.limit', "Bank Limit", required=True) movement_date = fields.Date("Movement Date", required=True) signed_quantity = fields.Numeric("Signed Quantity", digits=(16, 4)) uom = fields.Many2One('product.uom', "UoM") commodity_price = fields.Numeric("Commodity Price", digits=(16, 6)) gross_collateral_value = fields.Numeric( "Gross Collateral Value", digits=(16, 2)) haircut_percent_applied = fields.Numeric( "Haircut % Applied", digits=(5, 2)) financed_amount = fields.Numeric( "Financed Amount", digits=(16, 2)) signed_financed_amount = fields.Numeric( "Signed Financed Amount", digits=(16, 2), required=True) interest_rate_applied = fields.Numeric( "Interest Rate % Applied", digits=(7, 4)) day_count_basis_applied = fields.Selection([ ('360', "360"), ('365', "365"), ('act_360', "ACT/360"), ('act_365', "ACT/365"), ], "Day Count Basis") currency = fields.Many2One('currency.currency', "Currency", required=True) value_date = fields.Date("Value Date") maturity_date = fields.Date("Maturity Date") description = fields.Text("Description") @fields.depends( 'signed_quantity', 'commodity_price', 'haircut_percent_applied', 'gross_collateral_value', 'financed_amount', 'signed_financed_amount') def on_change_with_gross_collateral_value(self, name=None): if self.signed_quantity is None or self.commodity_price is None: return None return abs(self.signed_quantity) * self.commodity_price @fields.depends( 'gross_collateral_value', 'haircut_percent_applied') def on_change_with_financed_amount(self, name=None): if self.gross_collateral_value is None: return None haircut = self.haircut_percent_applied or Decimal('0') return self.gross_collateral_value * (Decimal('1') - haircut / Decimal('100')) ``` --- ## 15. Posting Logic A presentation should probably have a workflow: ```text draft -> posted -> cancelled ``` When a presentation is posted: - validate selected limits, - validate dates, - validate signed amounts, - copy applied values from `bank.limit`, - prevent modification of posted lines, - update no stored balance directly. Pseudo-rules: ```text - Draft presentations can be edited. - Posted presentations are immutable. - Cancellation should create reverse movements or mark the presentation as cancelled depending on audit policy. ``` Recommended approach: > Prefer reversal movements rather than deleting or editing posted movements. Example cancellation: ```text Original: +1000 MT +800,000 USD Cancellation: -1000 MT -800,000 USD ``` This keeps the audit trail intact. --- ## 16. Reporting and Calculation Views ### 16.1 Current Exposure by Limit ```sql SELECT bank_limit, SUM(signed_quantity) AS current_quantity, SUM(signed_financed_amount) AS current_financed_amount FROM trade_finance_presentation_line GROUP BY bank_limit; ``` ### 16.2 Exposure by Trade Finance ```sql SELECT trade_finance, bank_limit, SUM(signed_financed_amount) AS exposure FROM trade_finance_presentation_line l JOIN trade_finance_presentation p ON p.id = l.presentation GROUP BY trade_finance, bank_limit; ``` ### 16.3 Historical Exposure To calculate historical exposure at a date: ```sql SELECT bank_limit, SUM(signed_financed_amount) AS exposure FROM trade_finance_presentation_line WHERE movement_date <= :as_of_date GROUP BY bank_limit; ``` ### 16.4 Funding Cost Funding cost requires interval calculation: ```text For each bank_limit: 1. sort movement lines by movement_date, 2. calculate running exposure, 3. calculate days until next movement, 4. apply interest formula. ``` Formula: ```text Interest = Exposure × Rate × Days / Day Count Basis ``` --- ## 17. UX Suggestions ### 17.1 Contextual Actions Add a button/action: ```text Start Financing ``` on: - purchase contract, - purchase contract line, - shipment, - BL, - matching, - invoice. This button should open a wizard or form with default values: ```text origin = current record presentation_type = inferred from source object finance_stage = inferred from source object quantity = eligible quantity bank = selected or inherited currency = inherited ``` ### 17.2 Central Trade Finance Form The central `trade.finance` form should show: - header information, - bank reference, - internal reference, - bank, - counterparty, - status, - list of presentations in chronological order, - current exposure summary. ### 17.3 Presentation Form The `trade.finance.presentation` form should show: - business event details, - origin object, - previous presentation, - stage, - movement lines, - calculated liquidity impact, - posting button. --- ## 18. Validation Rules Suggested validations: ### On `trade.finance` - number is unique, - bank reference may be unique per bank if required, - bank is required, - currency is required. ### On `trade.finance.presentation` - presentation date is required, - origin is required for operational presentations, - previous presentation is required for transfer events, - cannot modify posted presentations, - sequence is chronological within a Trade Finance file. ### On `trade.finance.presentation.line` - bank limit is required, - bank limit must belong to the selected bank, - bank limit must be active, - bank limit must be valid at movement date, - currency must match the limit or be explicitly converted, - signed amount cannot be zero, - applied rate and haircut must be set before posting, - posted lines are immutable. --- ## 19. Design Rules to Preserve 1. Do not store one foreign key per possible start point on `trade.finance`. 2. Use `fields.Reference` on `trade.finance.presentation.origin`. 3. Treat `presentation.line` as a journal/ledger. 4. Store signed movement values. 5. Never overwrite exposure balances manually. 6. Store applied rate and haircut on movement lines for auditability. 7. Use reversal movements for cancellations. 8. Use snapshots only as derived reporting tables, not as source of truth. 9. Filter available bank limits by bank, date, status, currency, and finance stage. 10. Keep the architecture open to future financing start points. --- ## 20. Suggested Module Files Possible Tryton module structure: ```text trade_finance/ ├── __init__.py ├── __manifest__.py ├── trade_finance.py ├── bank.py ├── configuration.py ├── trade_finance.xml ├── bank.xml ├── configuration.xml ├── message.xml ├── tests/ │ ├── __init__.py │ └── test_trade_finance.py ``` Possible models: ```text trade.finance trade.finance.presentation trade.finance.presentation.line trade.finance.configuration trade.finance.configuration.sequence ``` If `bank.limit` already exists, extend it only if necessary. --- ## 21. Initial Development Roadmap ### Phase 1 — Core Models - Create `trade.finance` - Create `trade.finance.presentation` - Create `trade.finance.presentation.line` - Add sequence for `TFR-0001` - Link presentation lines to existing `bank.limit` ### Phase 2 — Basic UX - Create menu entries - Create list/form views - Add contextual action from purchase contract - Add contextual action from purchase contract line - Add contextual action from shipment ### Phase 3 — Posting and Validation - Add draft/posted/cancelled states - Block edits after posting - Validate bank limit eligibility - Copy applied haircut and rate on posting ### Phase 4 — Exposure Reporting - Current exposure by bank limit - Exposure by Trade Finance - Historical exposure as of date - Presentation chronological view ### Phase 5 — Funding Cost and Haircut Engine - Calculate exposure periods - Calculate funding cost - Calculate haircut effect - Calculate liquidity gain/loss during transfers ### Phase 6 — Advanced Workflow - Add matching with sale - Add invoice financing - Add repayment/release - Add automatic transfer proposals - Add alerts for expired limits or over-limit exposure --- ## 22. Final Conceptual Summary The final architecture is: ```text bank.limit = available contractual bank facility trade.finance = financing file / internal TFR reference trade.finance.presentation = chronological business event in the financing lifecycle trade.finance.presentation.line = signed movement against a bank limit ``` The system behaves like a Trade Finance subledger. It allows the business to answer: - Which limit is currently used? - What is the exposure by limit? - What was the exposure at a past date? - Which financing stage generated the exposure? - How much funding cost has been incurred? - What cash/liquidity gain was generated by moving to a lower-risk limit? - Which operational document triggered each movement? This architecture is evolutive, auditable, and suitable for complex commodity trade finance workflows.