26 KiB
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:
-
Contextual creation actions
- “Start Financing” button available from contract, contract line, shipment, BL, matching, invoice, etc.
-
A polymorphic origin reference
- The financing presentation can reference different Tryton models using a
fields.Reference.
- The financing presentation can reference different Tryton models using a
-
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:
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:
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:
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:
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:
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:
trade.finance = financing file / business case
It groups all presentations and movements related to the same financing lifecycle.
It should contain:
trade.finance
- number
- bank_reference
- bank
- party / supplier / counterparty
- company
- currency
- start_date
- status
- description
- presentations
4.3 trade.finance.presentation
Responsibility:
trade.finance.presentation = chronological business event
It records the stage or trigger of limit usage.
It should contain:
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:
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:
trade.finance.presentation.line = limit movement / exposure journal
It records the signed quantitative and financial impact on a selected bank limit.
It should contain:
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:
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:
TFR-0001
TFR-0002
TFR-0003
Suggested field:
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:
UBS/TF/2026/045
BNP-COF-18723
Suggested field:
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:
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:
PREFI
IN_TRANSIT
POST_SHIPMENT
MATCHED_SALE
INVOICE_FINANCING
Example:
UBS Limit 1
financing_type = PREFI
interest_rate = 10%
haircut = 20%
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:
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:
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.
Bank: UBS
Limit 1: UBS PREFI
Interest rate: 10%
Haircut: 20%
The commodity value is:
1000 MT × 1,000 USD/MT = 1,000,000 USD
The financed amount is:
1,000,000 × (1 - 20%) = 800,000 USD
9.2 Presentation 1 — Initial Prefinancing
Presentation #1
Type: PREFI
Origin: Purchase Contract
Quantity: 1000 MT
Limit: UBS PREFI
Generated movement line:
+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:
Limit 2: UBS IN_TRANSIT
Interest rate: 6%
Haircut: 10%
For 400 MT:
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:
360,000 - 320,000 = 40,000 USD
9.4 Presentation 2 — Transfer to In-Transit
Presentation #2
Type: TRANSFER_TO_IN_TRANSIT
Origin: Shipment / logistics event
Quantity: 400 MT
Previous presentation: Presentation #1
Generated movement lines:
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:
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:
Interest = Exposure × Interest Rate × Days / Day Count Basis
Example day count basis:
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:
- order all movement lines by date,
- calculate running balance per bank limit,
- identify date intervals between movements,
- apply the rate to each interval.
Example:
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:
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:
Financed Amount = Gross Collateral Value × (1 - Haircut %)
When goods move to a lower-risk stage, the haircut may decrease.
Example:
PREFI haircut = 20%
IN_TRANSIT haircut = 10%
Gross value = 400,000 USD
Liquidity gain:
400,000 × (20% - 10%) = 40,000 USD
This cash gain should be traceable through the presentation lines.
Possible calculated fields:
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:
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:
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:
trade.finance.presentation
- origin = fields.Reference(...)
Adding a new start point later requires:
- add the model to the allowed
Referenceselection, - implement eligibility logic,
- add a contextual button/action on the new source object,
- optionally add presentation defaults for stage, quantity, amount, and origin.
14. Suggested Tryton Python Skeleton
14.1 trade.finance
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
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
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:
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:
- 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:
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
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
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:
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:
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:
Interest = Exposure × Rate × Days / Day Count Basis
17. UX Suggestions
17.1 Contextual Actions
Add a button/action:
Start Financing
on:
- purchase contract,
- purchase contract line,
- shipment,
- BL,
- matching,
- invoice.
This button should open a wizard or form with default values:
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
- Do not store one foreign key per possible start point on
trade.finance. - Use
fields.Referenceontrade.finance.presentation.origin. - Treat
presentation.lineas a journal/ledger. - Store signed movement values.
- Never overwrite exposure balances manually.
- Store applied rate and haircut on movement lines for auditability.
- Use reversal movements for cancellations.
- Use snapshots only as derived reporting tables, not as source of truth.
- Filter available bank limits by bank, date, status, currency, and finance stage.
- Keep the architecture open to future financing start points.
20. Suggested Module Files
Possible Tryton module structure:
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:
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:
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.