Files
tradon/modules/trade_finance/Documentation/tryton_trade_finance_architecture.md
2026-06-02 14:53:07 +02:00

26 KiB
Raw Permalink Blame History

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:

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:

  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:

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:

  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

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

  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:

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.