diff --git a/modules/trade_finance/Documentation/backlog.md b/modules/trade_finance/Documentation/backlog.md
new file mode 100644
index 0000000..03345e1
--- /dev/null
+++ b/modules/trade_finance/Documentation/backlog.md
@@ -0,0 +1,414 @@
+# Trade Finance Backlog
+
+This backlog tracks the next development phases for the `trade_finance`
+module.
+
+Status values:
+
+```text
+todo | in-progress | blocked | done
+```
+
+Priority values:
+
+```text
+low | medium | high
+```
+
+## Phase 1 - Core Ledger Foundation
+
+Goal: stabilize the Trade Finance file, presentation, and movement ledger.
+
+### TF-001 - Validate core model installation
+
+Status: todo
+Area: test
+Priority: high
+
+Goal:
+- Install/update the module in a real Tryton database with dependencies.
+- Confirm the new models, views, sequence, menus, and access rules load.
+
+Implementation notes:
+- Run the targeted module test suite in an environment with `python-sql` and
+ the full Tryton dependency stack.
+- Fix any XML/view/model registration issue found during module activation.
+
+Validation:
+- `python -m unittest modules.trade_finance.tests.test_module`
+- Manual module activation/update in a development database.
+
+### TF-002 - Harden posted movement immutability
+
+Status: todo
+Area: workflow
+Priority: high
+
+Goal:
+- Ensure posted presentations and posted movement lines cannot be edited or
+ deleted through direct writes, nested One2Many writes, or UI actions.
+
+Implementation notes:
+- Review `write`, `delete`, and nested child update behavior.
+- Keep reversal movements as the only correction path for posted exposure.
+
+Validation:
+- Add tests for direct line write/delete after posting.
+- Add tests for nested line modification through presentation write.
+
+### TF-003 - Add robust exposure helper methods
+
+Status: todo
+Area: model
+Priority: high
+
+Goal:
+- Provide reusable helpers for exposure by facility limit and by Trade Finance
+ file.
+
+Implementation notes:
+- Keep ledger lines as the source of truth.
+- Start with Python helpers; SQL/materialized reporting can come later.
+- Include `as_of_date` support for historical exposure.
+
+Validation:
+- Tests for current exposure by limit.
+- Tests for historical exposure before/after transfer movements.
+
+### TF-004 - Improve limit eligibility validation
+
+Status: todo
+Area: model
+Priority: high
+
+Goal:
+- Validate selected `trade_finance.facility_limit` against bank, currency,
+ validity dates, financing type, and expected operational status.
+
+Implementation notes:
+- Reuse `trade_finance.facility_limit_op_status`.
+- Do not create a separate `bank.limit` model.
+- Consider whether root global limits should be selectable or whether only
+ leaf sub-limits should receive movements.
+
+Validation:
+- Tests for rejected bank mismatch.
+- Tests for rejected currency mismatch.
+- Tests for rejected financing type mismatch.
+- Tests for rejected operational status mismatch once connected.
+
+## Phase 2 - Basic UX and Operational Entry Points
+
+Goal: make the core usable from operational documents.
+
+### TF-005 - Add contextual Start Financing wizard
+
+Status: todo
+Area: workflow
+Priority: high
+
+Goal:
+- Let users start a Trade Finance presentation from an operational document.
+
+Implementation notes:
+- Start with stock shipment and stock move because `stock` is already a module
+ dependency.
+- Use `fields.Reference` on presentation origin.
+- Pre-fill date, origin, stage, company, currency, and suggested quantity where
+ available.
+
+Validation:
+- Wizard test from `stock.shipment.in`.
+- Manual UI check from shipment form.
+
+### TF-006 - Add purchase contract entry points
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Start financing from purchase contract and purchase contract line when the
+ relevant purchase contract module is available.
+
+Implementation notes:
+- First identify the actual local model names and module dependency impact.
+- Avoid adding a hard dependency until confirmed necessary.
+- Prefer optional extension module logic if purchase contract is not part of
+ the base dependency graph.
+
+Validation:
+- Tests only if the dependency is activated in the module test environment.
+
+### TF-007 - Add invoice financing entry point
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Start or continue financing from receivable/invoice stage.
+
+Implementation notes:
+- Confirm whether dependency on `account_invoice` is acceptable.
+- Origin should point to `account.invoice`.
+- Presentation type should default to invoice financing.
+
+Validation:
+- Test default origin and stage from invoice when dependency is enabled.
+
+### TF-008 - Add central exposure summary in the UI
+
+Status: todo
+Area: view
+Priority: medium
+
+Goal:
+- Show current exposure by facility limit on the Trade Finance file.
+
+Implementation notes:
+- Use a read-only derived view/model or function fields first.
+- Do not store manual exposure balances.
+
+Validation:
+- Manual UI check with multiple posted movements.
+- Tests for summary values if implemented as model logic.
+
+## Phase 3 - Transfer and Lifecycle Workflow
+
+Goal: support movement between financing stages and limits.
+
+### TF-009 - Add transfer proposal helper
+
+Status: todo
+Area: workflow
+Priority: high
+
+Goal:
+- Generate two movement lines for transfers between facility limits.
+
+Implementation notes:
+- Negative line on the source facility limit.
+- Positive line on the target facility limit.
+- Preserve different haircuts/rates on each line.
+
+Validation:
+- Test PREFI to IN_TRANSIT transfer with liquidity gain.
+
+### TF-010 - Track liquidity gain/loss
+
+Status: todo
+Area: model
+Priority: medium
+
+Goal:
+- Calculate liquidity gain/loss when moving collateral between limits with
+ different haircuts.
+
+Implementation notes:
+- Keep this derived from movement lines.
+- Store applied haircut on each line for audit.
+
+Validation:
+- Test gain when target haircut is lower.
+- Test loss when target haircut is higher.
+
+### TF-011 - Add repayment/release presentation support
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Allow users to release financed exposure partially or fully.
+
+Implementation notes:
+- Repayment/release should create negative movement lines.
+- Avoid editing original posted movements.
+
+Validation:
+- Test partial repayment.
+- Test full repayment reduces exposure to zero.
+
+## Phase 4 - Funding Cost Engine
+
+Goal: calculate funding costs from chronological exposure movements.
+
+### TF-012 - Build exposure period calculation
+
+Status: todo
+Area: model
+Priority: high
+
+Goal:
+- Convert dated movement lines into running exposure periods per facility
+ limit.
+
+Implementation notes:
+- Sort by movement date.
+- Calculate running balance.
+- Build intervals until the next movement date or an explicit end date.
+
+Validation:
+- Test one movement.
+- Test multiple movements on different dates.
+- Test same-day movements.
+
+### TF-013 - Calculate interest/funding cost
+
+Status: todo
+Area: model
+Priority: high
+
+Goal:
+- Calculate funding cost using exposure, applied rate, days, and day-count
+ basis.
+
+Implementation notes:
+- Use values copied onto movement lines at posting time.
+- Support `360`, `365`, `ACT/360`, and `ACT/365`.
+
+Validation:
+- Tests for 360 and 365 bases.
+- Test changing facility cost after posting does not alter historical cost.
+
+### TF-014 - Add funding cost report screen
+
+Status: todo
+Area: report
+Priority: medium
+
+Goal:
+- Provide a user-facing report of funding cost by Trade Finance file and by
+ facility limit.
+
+Implementation notes:
+- Start with a Tryton model/report view.
+- Add export later only if needed.
+
+Validation:
+- Manual report check.
+- Tests for report backing model if applicable.
+
+## Phase 5 - Reporting and Controls
+
+Goal: make exposure auditable and operationally controllable.
+
+### TF-015 - Add current exposure by facility limit report
+
+Status: todo
+Area: report
+Priority: high
+
+Goal:
+- Show exposure grouped by `trade_finance.facility_limit`.
+
+Implementation notes:
+- Source data must be posted movement lines.
+- Include quantity and signed financed amount.
+
+Validation:
+- Test aggregation across multiple Trade Finance files.
+
+### TF-016 - Add historical exposure as-of report
+
+Status: todo
+Area: report
+Priority: medium
+
+Goal:
+- Show exposure at a selected historical date.
+
+Implementation notes:
+- Filter movement lines where `movement_date <= as_of_date`.
+- Group by facility limit and optionally by Trade Finance file.
+
+Validation:
+- Tests around before/after transfer dates.
+
+### TF-017 - Add over-limit checks and alerts
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Detect when posted exposure exceeds facility limit amount.
+
+Implementation notes:
+- Decide whether to block posting or warn first.
+- Consider parent/global limit and child sub-limit behavior.
+
+Validation:
+- Test over child limit.
+- Test over global limit if hierarchical aggregation is implemented.
+
+### TF-018 - Add expired limit controls
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Highlight or block new exposure on expired limits.
+
+Implementation notes:
+- Posting already checks movement date validity.
+- Add UI filtering and operational alerts.
+
+Validation:
+- Manual UI domain check.
+- Tests for posting behavior already covered by core validation.
+
+## Phase 6 - Advanced Operational Integration
+
+Goal: connect financing to the broader commodity workflow.
+
+### TF-019 - Add sale matching support
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Continue financing lifecycle when goods are matched with sales.
+
+Implementation notes:
+- Identify the actual local matching model.
+- Add it to origin selection only after confirming dependency/model names.
+
+Validation:
+- Scenario test once model dependency is clear.
+
+### TF-020 - Add automatic stage suggestions
+
+Status: todo
+Area: workflow
+Priority: medium
+
+Goal:
+- Suggest finance stage from origin document status.
+
+Implementation notes:
+- Keep suggestions editable.
+- Implement stage inference per origin model.
+
+Validation:
+- Tests per supported origin type.
+
+### TF-021 - Add derived snapshot table if performance requires it
+
+Status: todo
+Area: report
+Priority: low
+
+Goal:
+- Improve reporting performance for large ledgers.
+
+Implementation notes:
+- Snapshot must be derived from ledger lines.
+- Ledger remains the source of truth.
+- Do not implement until performance data justifies it.
+
+Validation:
+- Reconciliation test comparing snapshot totals with ledger totals.
diff --git a/modules/trade_finance/Documentation/development_guidance.md b/modules/trade_finance/Documentation/development_guidance.md
new file mode 100644
index 0000000..3bebfa4
--- /dev/null
+++ b/modules/trade_finance/Documentation/development_guidance.md
@@ -0,0 +1,175 @@
+# Trade Finance Development Guidance
+
+This file captures implementation guidance for the next development steps of
+the `trade_finance` module.
+
+It complements `tryton_trade_finance_architecture.md` and should be used as the
+practical working note before creating or changing code.
+
+## 1. Important Existing Model Mapping
+
+The architecture document uses the conceptual name `bank.limit`.
+
+In this codebase, do not create a new `bank.limit` model for the Trade Finance
+ledger unless there is a later explicit design decision to do so.
+
+Use the existing facility structure instead:
+
+```text
+Concept in architecture doc Existing model in this module
+--------------------------- -----------------------------
+bank.limit trade_finance.facility_limit
+bank / banking facility trade_finance.facility
+bank reference / provider trade_finance.facility.tfe
+limit amount trade_finance.facility_limit.amount
+limit currency trade_finance.facility_limit.currency
+limit validity trade_finance.facility_limit.date_from/date_to
+limit financing type trade_finance.facility_limit.financing_type
+limit haircut rules trade_finance.facility_limit_haircut
+limit accepted currencies trade_finance.facility_limit_currency
+limit cost/rate rules trade_finance.facility_limit_cost
+limit operational eligibility trade_finance.facility_limit_op_status
+```
+
+Any future presentation ledger line should therefore reference
+`trade_finance.facility_limit`, not a hypothetical `bank.limit`.
+
+## 2. Development Principles
+
+- Keep the existing facility/limit model as the contractual source of truth.
+- Treat presentation lines as an exposure movement ledger.
+- Store signed amounts and signed quantities on movement lines.
+- Do not store or manually update current exposure balances as source data.
+- Copy applied haircut/rate/cost values onto posted movement lines for audit.
+- Use reversal movements for cancellations instead of editing posted movements.
+- Keep contextual origin links polymorphic with `fields.Reference`.
+- Add new operational start points by extending the reference selection and
+ action logic, not by adding many foreign keys to a header model.
+
+## 3. Suggested Documentation Structure
+
+Use a small set of focused markdown files under `modules/trade_finance/documentation/`.
+
+Recommended structure:
+
+```text
+documentation/
+ tryton_trade_finance_architecture.md
+ development_guidance.md
+ roadmap.md
+ backlog.md
+ bugs.md
+ decisions.md
+ test_plan.md
+```
+
+Purpose of each file:
+
+```text
+tryton_trade_finance_architecture.md
+ Long-form target architecture and conceptual model.
+
+development_guidance.md
+ Practical implementation rules, mappings to existing models, and local
+ conventions for developers.
+
+roadmap.md
+ Ordered development phases. Stable enough to show where the module is going.
+
+backlog.md
+ Concrete features/tasks not yet scheduled. One item per small deliverable.
+
+bugs.md
+ Known defects, reproduction notes, expected behavior, and current status.
+
+decisions.md
+ Architecture decision records. Use for choices that should not be rediscovered
+ every time, such as using facility_limit instead of bank.limit.
+
+test_plan.md
+ Targeted validation strategy, scenarios to cover, and commands to run.
+```
+
+Do not create every file upfront if it would remain empty. Create them when
+there is useful content to record.
+
+## 4. Suggested Item Formats
+
+### Backlog Item
+
+```text
+## TF-001 - Short title
+
+Status: todo | in-progress | blocked | done
+Area: model | workflow | view | report | test | data
+Priority: low | medium | high
+
+Goal:
+- What business behavior should exist?
+
+Implementation notes:
+- Smallest expected code change.
+- Existing model(s) to reuse.
+
+Validation:
+- Target test file or scenario.
+- Manual check if needed.
+```
+
+### Bug Item
+
+```text
+## BUG-001 - Short title
+
+Status: open | reproduced | fixed | closed
+Severity: low | medium | high | critical
+Area: model | workflow | view | report | test | data
+
+Observed:
+- What happens now?
+
+Expected:
+- What should happen?
+
+Reproduction:
+- Minimal steps or test data.
+
+Fix notes:
+- Linked commit/test once fixed.
+```
+
+### Decision Record
+
+```text
+## ADR-001 - Short title
+
+Date: YYYY-MM-DD
+Status: proposed | accepted | superseded
+
+Context:
+- Why a decision is needed.
+
+Decision:
+- What we decided.
+
+Consequences:
+- What this enables.
+- What tradeoffs remain.
+```
+
+## 5. Immediate Next Development Focus
+
+Recommended next implementation sequence:
+
+1. Create the Trade Finance file/header model.
+2. Create the presentation/event model.
+3. Create the presentation line ledger model linked to
+ `trade_finance.facility_limit`.
+4. Add sequence/configuration for internal Trade Finance references.
+5. Add minimal list/form views and access rules.
+6. Add targeted tests around creation, references, signed movements, and limit
+ validity checks.
+
+The first implementation should stay narrow: no funding-cost engine, no
+snapshot table, and no automatic exposure summary as source of truth until the
+ledger model is stable.
diff --git a/modules/trade_finance/Documentation/tryton_trade_finance_architecture.md b/modules/trade_finance/Documentation/tryton_trade_finance_architecture.md
new file mode 100644
index 0000000..39d834b
--- /dev/null
+++ b/modules/trade_finance/Documentation/tryton_trade_finance_architecture.md
@@ -0,0 +1,1268 @@
+# 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.
diff --git a/modules/trade_finance/__init__.py b/modules/trade_finance/__init__.py
index f8db5d4..0e1c857 100644
--- a/modules/trade_finance/__init__.py
+++ b/modules/trade_finance/__init__.py
@@ -14,6 +14,7 @@ from . import (
operational,
facility,
constraint_type,
+ trade,
)
@@ -52,7 +53,14 @@ def register():
facility.FacilityCapHaircut,
facility.FacilityConstraint,
constraint_type.ConstraintType,
+ trade.TradeFinance,
+ trade.TradeFinancePresentation,
+ trade.TradeFinancePresentationLine,
+ trade.TradeFinanceCreateStart,
module='trade_finance', type_='model')
Pool.register(
fx.PriceCalendar,
module='price', type_='model')
+ Pool.register(
+ trade.TradeFinanceCreate,
+ module='trade_finance', type_='wizard')
diff --git a/modules/trade_finance/tests/test_module.py b/modules/trade_finance/tests/test_module.py
index 8fb1945..7e687d6 100644
--- a/modules/trade_finance/tests/test_module.py
+++ b/modules/trade_finance/tests/test_module.py
@@ -5,6 +5,7 @@ from datetime import date
from decimal import Decimal
from trytond.exceptions import UserError
+from trytond.modules.company.tests import create_company, set_company
from trytond.modules.currency.tests import create_currency
from trytond.pool import Pool
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
@@ -35,6 +36,46 @@ class TradeFinanceTestCase(ModuleTestCase):
facility.save()
return facility, currency
+ def create_trade_finance_case(self):
+ pool = Pool()
+ Cost = pool.get('trade_finance.facility_limit_cost')
+ Haircut = pool.get('trade_finance.facility_limit_haircut')
+ Limit = pool.get('trade_finance.facility_limit')
+ TradeFinance = pool.get('trade.finance')
+
+ facility, currency = self.create_facility()
+ company = create_company(currency=currency)
+ limit, = Limit.create([{
+ 'facility': facility.id,
+ 'name': 'Prefi limit',
+ 'currency': currency.id,
+ 'amount': Decimal('1000000.00'),
+ 'date_from': date(2026, 1, 1),
+ 'date_to': date(2026, 12, 31),
+ }])
+ Haircut.create([{
+ 'limit': limit.id,
+ 'attribute': 'commodity',
+ 'haircut_pct': Decimal('20.00'),
+ 'date_from': date(2026, 1, 1),
+ 'date_to': date(2026, 12, 31),
+ }])
+ Cost.create([{
+ 'limit': limit.id,
+ 'cost_type': 'interest',
+ 'spread': Decimal('10.0000'),
+ 'date_from': date(2026, 1, 1),
+ 'date_to': date(2026, 12, 31),
+ }])
+ with set_company(company):
+ trade_finance, = TradeFinance.create([{
+ 'bank': facility.tfe.id,
+ 'company': company.id,
+ 'currency': currency.id,
+ 'start_date': date(2026, 1, 1),
+ }])
+ return trade_finance, limit, currency
+
@with_transaction()
def test_sublimit_inherits_currency_and_dates(self):
'Test sublimit inherits currency and dates from parent'
@@ -141,5 +182,147 @@ class TradeFinanceTestCase(ModuleTestCase):
self.assertEqual(child.currency, other_currency)
+ @with_transaction()
+ def test_trade_finance_number_sequence(self):
+ 'Test trade finance number sequence'
+ trade_finance, _, _ = self.create_trade_finance_case()
+
+ self.assertTrue(trade_finance.number.startswith('TFR-'))
+
+ @with_transaction()
+ def test_post_presentation_uses_facility_limit_ledger(self):
+ 'Test posting creates an audited facility limit movement'
+ pool = Pool()
+ Presentation = pool.get('trade.finance.presentation')
+ Line = pool.get('trade.finance.presentation.line')
+
+ trade_finance, limit, currency = self.create_trade_finance_case()
+ presentation, = Presentation.create([{
+ 'trade_finance': trade_finance.id,
+ 'presentation_date': date(2026, 1, 10),
+ 'presentation_type': 'prefi',
+ 'finance_stage': 'prefi',
+ }])
+ line, = Line.create([{
+ 'presentation': presentation.id,
+ 'facility_limit': limit.id,
+ 'signed_quantity': Decimal('1000.0000'),
+ 'commodity_price': Decimal('100.000000'),
+ }])
+
+ self.assertEqual(line.currency, currency)
+ self.assertEqual(line.gross_collateral_value, Decimal('100000.0000000000'))
+ self.assertEqual(line.financed_amount, Decimal('100000.0000000000'))
+ Presentation.post([presentation])
+
+ presentation = Presentation(presentation.id)
+ line, = presentation.lines
+ self.assertEqual(presentation.state, 'posted')
+ self.assertEqual(line.haircut_percent_applied, Decimal('20.00'))
+ self.assertEqual(line.interest_rate_applied, Decimal('10.0000'))
+ self.assertEqual(line.day_count_basis_applied, '360')
+ self.assertEqual(line.financed_amount, Decimal('80000.000000000000'))
+ self.assertEqual(
+ line.signed_financed_amount, Decimal('80000.000000000000'))
+
+ @with_transaction()
+ def test_post_presentation_rejects_limit_outside_validity(self):
+ 'Test posting validates facility limit validity dates'
+ pool = Pool()
+ Presentation = pool.get('trade.finance.presentation')
+ Line = pool.get('trade.finance.presentation.line')
+
+ trade_finance, limit, _ = self.create_trade_finance_case()
+ presentation, = Presentation.create([{
+ 'trade_finance': trade_finance.id,
+ 'presentation_date': date(2025, 12, 31),
+ 'presentation_type': 'prefi',
+ 'finance_stage': 'prefi',
+ }])
+ Line.create([{
+ 'presentation': presentation.id,
+ 'facility_limit': limit.id,
+ 'movement_date': date(2025, 12, 31),
+ 'finance_stage': 'prefi',
+ 'currency': trade_finance.currency.id,
+ 'signed_financed_amount': Decimal('100.00'),
+ }])
+
+ with self.assertRaises(UserError):
+ Presentation.post([presentation])
+
+ @with_transaction()
+ def test_cancel_presentation_creates_reversal(self):
+ 'Test cancellation creates a posted reversal movement'
+ pool = Pool()
+ Presentation = pool.get('trade.finance.presentation')
+ Line = pool.get('trade.finance.presentation.line')
+
+ trade_finance, limit, _ = self.create_trade_finance_case()
+ presentation, = Presentation.create([{
+ 'trade_finance': trade_finance.id,
+ 'presentation_date': date(2026, 1, 10),
+ 'presentation_type': 'prefi',
+ 'finance_stage': 'prefi',
+ }])
+ Line.create([{
+ 'presentation': presentation.id,
+ 'facility_limit': limit.id,
+ 'movement_date': date(2026, 1, 10),
+ 'finance_stage': 'prefi',
+ 'currency': trade_finance.currency.id,
+ 'signed_financed_amount': Decimal('80000.00'),
+ }])
+ Presentation.post([presentation])
+ Presentation.cancel([presentation])
+
+ presentations = Presentation.search([
+ ('trade_finance', '=', trade_finance.id),
+ ], order=[('sequence', 'ASC')])
+ self.assertEqual(len(presentations), 2)
+ self.assertEqual(presentations[0].state, 'cancelled')
+ self.assertEqual(presentations[1].state, 'posted')
+ reversal_line, = presentations[1].lines
+ self.assertEqual(reversal_line.signed_financed_amount, Decimal('-80000.00'))
+
+ trade_finance = pool.get('trade.finance')(trade_finance.id)
+ self.assertEqual(trade_finance.exposure_amount, Decimal('0.00'))
+
+ @with_transaction()
+ def test_create_financing_wizard(self):
+ 'Test single input financing creation wizard'
+ pool = Pool()
+ TradeFinance = pool.get('trade.finance')
+ Wizard = pool.get('trade.finance.create', type='wizard')
+
+ _, limit, _ = self.create_trade_finance_case()
+ company = create_company(currency=limit.currency)
+ session_id, start_state, _ = Wizard.create()
+ Wizard.execute(session_id, {
+ start_state: {
+ 'facility_limit': limit.id,
+ 'company': company.id,
+ 'bank_reference': 'BANK/TEST/001',
+ 'start_date': date(2026, 1, 10),
+ 'presentation_date': date(2026, 1, 10),
+ 'presentation_type': 'prefi',
+ 'finance_stage': 'prefi',
+ 'signed_quantity': Decimal('1000.0000'),
+ 'commodity_price': Decimal('100.000000'),
+ 'signed_financed_amount': Decimal('80000.00'),
+ 'post': True,
+ },
+ }, 'create_')
+
+ trade_finance, = TradeFinance.search([
+ ('bank_reference', '=', 'BANK/TEST/001'),
+ ])
+ presentation, = trade_finance.presentations
+ line, = presentation.lines
+ self.assertEqual(trade_finance.bank, limit.facility.tfe)
+ self.assertEqual(trade_finance.currency, limit.currency)
+ self.assertEqual(presentation.state, 'posted')
+ self.assertEqual(line.facility_limit, limit)
+
del ModuleTestCase
diff --git a/modules/trade_finance/trade.py b/modules/trade_finance/trade.py
new file mode 100644
index 0000000..8443c16
--- /dev/null
+++ b/modules/trade_finance/trade.py
@@ -0,0 +1,854 @@
+# 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 decimal import Decimal
+
+from trytond.exceptions import UserError
+from trytond.model import ModelSQL, ModelView, Workflow, fields
+from trytond.pool import Pool
+from trytond.pyson import Eval
+from trytond.transaction import Transaction
+from trytond.wizard import Button, StateAction, StateView, Wizard
+
+__all__ = [
+ 'TradeFinance',
+ 'TradeFinancePresentation',
+ 'TradeFinancePresentationLine',
+ 'TradeFinanceCreateStart',
+ 'TradeFinanceCreate',
+]
+
+
+_DRAFT_STATES = {
+ 'readonly': Eval('state') != 'draft',
+}
+_DRAFT_DEPENDS = ['state']
+
+
+class TradeFinance(Workflow, ModelSQL, ModelView):
+ 'Trade Finance'
+ __name__ = 'trade.finance'
+ _rec_name = 'number'
+
+ number = fields.Char(
+ 'Trade Finance Reference', required=True, readonly=True)
+ bank_reference = fields.Char('Bank Reference')
+ bank = fields.Many2One(
+ 'bank', 'Trade Finance Entity', required=True, ondelete='RESTRICT',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ party = fields.Many2One(
+ 'party.party', 'Counterparty', ondelete='RESTRICT',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ company = fields.Many2One(
+ 'company.company', 'Company', required=True, ondelete='RESTRICT',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ currency = fields.Many2One(
+ 'currency.currency', 'Currency', required=True, ondelete='RESTRICT',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ start_date = fields.Date(
+ 'Start Date', required=True,
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('active', 'Active'),
+ ('closed', 'Closed'),
+ ('cancelled', 'Cancelled'),
+ ], 'State', required=True, readonly=True)
+ description = fields.Text(
+ 'Description', states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ presentations = fields.One2Many(
+ 'trade.finance.presentation', 'trade_finance', 'Presentations',
+ states={'readonly': Eval('state').in_(['closed', 'cancelled'])},
+ depends=['state'])
+ exposure_amount = fields.Function(fields.Numeric(
+ 'Exposure Amount', digits=(16, 2)), 'get_exposure_amount')
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order.insert(0, ('number', 'DESC'))
+ cls._transitions |= set((
+ ('draft', 'active'),
+ ('active', 'closed'),
+ ('draft', 'cancelled'),
+ ('active', 'cancelled'),
+ ('cancelled', 'draft'),
+ ))
+ cls._buttons.update({
+ 'activate': {
+ 'invisible': Eval('state') != 'draft',
+ 'depends': ['state'],
+ },
+ 'close': {
+ 'invisible': Eval('state') != 'active',
+ 'depends': ['state'],
+ },
+ 'cancel': {
+ 'invisible': ~Eval('state').in_(['draft', 'active']),
+ 'depends': ['state'],
+ },
+ 'draft': {
+ 'invisible': Eval('state') != 'cancelled',
+ 'depends': ['state'],
+ },
+ })
+
+ @staticmethod
+ def default_state():
+ return 'draft'
+
+ @staticmethod
+ def default_start_date():
+ Date = Pool().get('ir.date')
+ return Date.today()
+
+ @staticmethod
+ def default_company():
+ return Transaction().context.get('company')
+
+ @classmethod
+ def _new_number(cls):
+ pool = Pool()
+ ModelData = pool.get('ir.model.data')
+ Sequence = pool.get('ir.sequence')
+ try:
+ sequence_id = ModelData.get_id(
+ 'trade_finance', 'sequence_trade_finance')
+ except KeyError:
+ return '/'
+ return Sequence(sequence_id).get()
+
+ @classmethod
+ def create(cls, vlist):
+ vlist = [v.copy() for v in vlist]
+ for values in vlist:
+ values.setdefault('number', cls._new_number())
+ return super().create(vlist)
+
+ @classmethod
+ def write(cls, *args):
+ actions = iter(args)
+ for records, values in zip(actions, actions):
+ if set(values) - {'state'}:
+ for record in records:
+ if record.state in {'closed', 'cancelled'}:
+ raise UserError(
+ 'Closed or cancelled trade finance files cannot '
+ 'be modified.')
+ return super().write(*args)
+
+ def get_rec_name(self, name):
+ parts = [self.number]
+ if self.bank_reference:
+ parts.append('[%s]' % self.bank_reference)
+ return ' '.join(parts)
+
+ @classmethod
+ def search_rec_name(cls, name, clause):
+ _, operator, value = clause
+ if operator.startswith('!') or operator.startswith('not '):
+ bool_op = 'AND'
+ else:
+ bool_op = 'OR'
+ return [bool_op,
+ ('number', operator, value),
+ ('bank_reference', operator, value),
+ ]
+
+ @classmethod
+ def get_exposure_amount(cls, records, name):
+ amounts = {r.id: Decimal('0.00') for r in records}
+ for record in records:
+ for presentation in record.presentations:
+ if presentation.state == 'draft':
+ continue
+ for line in presentation.lines:
+ amounts[record.id] += line.signed_financed_amount or 0
+ return amounts
+
+ @classmethod
+ @ModelView.button
+ @Workflow.transition('active')
+ def activate(cls, records):
+ pass
+
+ @classmethod
+ @ModelView.button
+ @Workflow.transition('closed')
+ def close(cls, records):
+ pass
+
+ @classmethod
+ @ModelView.button
+ @Workflow.transition('cancelled')
+ def cancel(cls, records):
+ pass
+
+ @classmethod
+ @ModelView.button
+ @Workflow.transition('draft')
+ def draft(cls, records):
+ pass
+
+
+class TradeFinancePresentation(Workflow, ModelSQL, ModelView):
+ 'Trade Finance Presentation'
+ __name__ = 'trade.finance.presentation'
+
+ trade_finance = fields.Many2One(
+ 'trade.finance', 'Trade Finance', required=True, ondelete='CASCADE',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ sequence = fields.Integer(
+ 'Sequence', states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ presentation_date = fields.Date(
+ 'Presentation Date', required=True,
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ 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,
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ 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,
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ financing_type = fields.Many2One(
+ 'trade_finance.financing_type', 'Financing Type',
+ ondelete='RESTRICT', states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ origin = fields.Reference(
+ 'Origin', selection='get_origin',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ previous_presentation = fields.Many2One(
+ 'trade.finance.presentation', 'Previous Presentation',
+ ondelete='RESTRICT', states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ description = fields.Text(
+ 'Description', states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ lines = fields.One2Many(
+ 'trade.finance.presentation.line', 'presentation', 'Movement Lines',
+ states=_DRAFT_STATES, depends=_DRAFT_DEPENDS)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('posted', 'Posted'),
+ ('cancelled', 'Cancelled'),
+ ], 'State', required=True, readonly=True)
+ currency = fields.Function(fields.Many2One(
+ 'currency.currency', 'Currency'), 'on_change_with_currency')
+ bank = fields.Function(fields.Many2One(
+ 'bank', 'Trade Finance Entity'), 'on_change_with_bank')
+ exposure_amount = fields.Function(fields.Numeric(
+ 'Exposure Amount', digits=(16, 2)), 'get_exposure_amount')
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order.insert(0, ('presentation_date', 'DESC'))
+ cls._order.insert(1, ('sequence', 'DESC'))
+ cls._transitions |= set((
+ ('draft', 'posted'),
+ ('posted', 'cancelled'),
+ ))
+ cls._buttons.update({
+ 'post': {
+ 'invisible': Eval('state') != 'draft',
+ 'depends': ['state'],
+ },
+ 'cancel': {
+ 'invisible': Eval('state') != 'posted',
+ 'depends': ['state'],
+ },
+ })
+
+ @staticmethod
+ def default_state():
+ return 'draft'
+
+ @staticmethod
+ def default_presentation_date():
+ Date = Pool().get('ir.date')
+ return Date.today()
+
+ @staticmethod
+ def default_presentation_type():
+ return 'prefi'
+
+ @staticmethod
+ def default_finance_stage():
+ return 'prefi'
+
+ @classmethod
+ def _get_origin(cls):
+ 'Return list of Model names for origin Reference.'
+ return ['stock.shipment.in', 'stock.shipment.out', 'stock.move']
+
+ @classmethod
+ def get_origin(cls):
+ Model = Pool().get('ir.model')
+ get_name = Model.get_name
+ return [(None, '')] + [
+ (model, get_name(model)) for model in cls._get_origin()]
+
+ @classmethod
+ def create(cls, vlist):
+ vlist = [v.copy() for v in vlist]
+ for values in vlist:
+ if values.get('trade_finance') and not values.get('sequence'):
+ values['sequence'] = cls._next_sequence(
+ values['trade_finance'])
+ return super().create(vlist)
+
+ @classmethod
+ def write(cls, *args):
+ actions = iter(args)
+ for records, values in zip(actions, actions):
+ if set(values) - {'state'}:
+ for record in records:
+ if record.state != 'draft':
+ raise UserError(
+ 'Posted or cancelled presentations cannot be '
+ 'modified.')
+ return super().write(*args)
+
+ @classmethod
+ def delete(cls, records):
+ for record in records:
+ if record.state != 'draft':
+ raise UserError(
+ 'Posted or cancelled presentations cannot be deleted.')
+ return super().delete(records)
+
+ @classmethod
+ def _next_sequence(cls, trade_finance):
+ presentations = cls.search([
+ ('trade_finance', '=', trade_finance),
+ ], order=[('sequence', 'DESC')], limit=1)
+ if presentations and presentations[0].sequence:
+ return presentations[0].sequence + 1
+ return 1
+
+ @fields.depends('trade_finance', '_parent_trade_finance.currency')
+ def on_change_with_currency(self, name=None):
+ return self.trade_finance.currency if self.trade_finance else None
+
+ @fields.depends('trade_finance', '_parent_trade_finance.bank')
+ def on_change_with_bank(self, name=None):
+ return self.trade_finance.bank if self.trade_finance else None
+
+ def get_rec_name(self, name):
+ items = []
+ if self.trade_finance:
+ items.append(self.trade_finance.number)
+ if self.sequence:
+ items.append('#%s' % self.sequence)
+ items.append(self.presentation_type_string)
+ return ' '.join(items)
+
+ @classmethod
+ def get_exposure_amount(cls, records, name):
+ amounts = {r.id: Decimal('0.00') for r in records}
+ for record in records:
+ for line in record.lines:
+ amounts[record.id] += line.signed_financed_amount or 0
+ return amounts
+
+ @classmethod
+ @ModelView.button
+ @Workflow.transition('posted')
+ def post(cls, presentations):
+ Line = Pool().get('trade.finance.presentation.line')
+ lines = []
+ for presentation in presentations:
+ presentation.check_for_posting()
+ for line in presentation.lines:
+ line.apply_limit_values()
+ line.check_for_posting()
+ lines.append(line)
+ if lines:
+ Line.save(lines)
+
+ @classmethod
+ @ModelView.button
+ @Workflow.transition('cancelled')
+ def cancel(cls, presentations):
+ for presentation in presentations:
+ presentation.create_reversal()
+
+ def check_for_posting(self):
+ if not self.lines:
+ raise UserError(
+ 'A presentation must contain at least one movement line before '
+ 'posting.')
+ if self.presentation_type == 'transfer' and not self.previous_presentation:
+ raise UserError(
+ 'Transfer presentations must reference a previous '
+ 'presentation.')
+
+ def create_reversal(self):
+ Date = Pool().get('ir.date')
+ reversal_lines = []
+ for line in self.lines:
+ reversal_lines.append(('create', [line.get_reverse_values()]))
+ self.__class__.create([{
+ 'trade_finance': self.trade_finance.id,
+ 'sequence': self.__class__._next_sequence(self.trade_finance.id),
+ 'presentation_date': Date.today(),
+ 'presentation_type': 'cancellation',
+ 'finance_stage': 'cancelled',
+ 'financing_type': (
+ self.financing_type.id if self.financing_type else None),
+ 'previous_presentation': self.id,
+ 'description': 'Reversal of %s' % self.rec_name,
+ 'state': 'posted',
+ 'lines': reversal_lines,
+ }])
+
+
+class TradeFinancePresentationLine(ModelSQL, ModelView):
+ 'Trade Finance Presentation Line'
+ __name__ = 'trade.finance.presentation.line'
+
+ presentation = fields.Many2One(
+ 'trade.finance.presentation', 'Presentation',
+ required=True, ondelete='CASCADE')
+ facility_limit = fields.Many2One(
+ 'trade_finance.facility_limit', 'Facility Limit', required=True,
+ ondelete='RESTRICT')
+ movement_date = fields.Date('Movement Date', 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)
+ signed_quantity = fields.Numeric('Signed Quantity', digits=(16, 4))
+ uom = fields.Many2One('product.uom', 'UoM', ondelete='RESTRICT')
+ 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=(16, 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=(16, 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, ondelete='RESTRICT')
+ value_date = fields.Date('Value Date')
+ maturity_date = fields.Date('Maturity Date')
+ description = fields.Text('Description')
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order.insert(0, ('movement_date', 'DESC'))
+
+ @classmethod
+ def create(cls, vlist):
+ vlist = [v.copy() for v in vlist]
+ for values in vlist:
+ cls._set_defaults_from_presentation(values)
+ cls._set_amounts(values)
+ return super().create(vlist)
+
+ @classmethod
+ def write(cls, *args):
+ actions = iter(args)
+ for lines, values in zip(actions, actions):
+ if values:
+ for line in lines:
+ if line.presentation.state != 'draft':
+ raise UserError(
+ 'Posted or cancelled movement lines cannot be '
+ 'modified.')
+ args = list(args)
+ actions = iter(args)
+ new_args = []
+ for lines, values in zip(actions, actions):
+ values = values.copy()
+ cls._set_defaults_from_presentation(values)
+ cls._set_amounts(values)
+ new_args.extend((lines, values))
+ return super().write(*new_args)
+
+ @classmethod
+ def delete(cls, lines):
+ for line in lines:
+ if line.presentation.state != 'draft':
+ raise UserError(
+ 'Posted or cancelled movement lines cannot be deleted.')
+ return super().delete(lines)
+
+ @classmethod
+ def _set_defaults_from_presentation(cls, values):
+ if not values.get('presentation'):
+ return
+ presentation = Pool().get('trade.finance.presentation')(
+ values['presentation'])
+ values.setdefault('movement_date', presentation.presentation_date)
+ values.setdefault('finance_stage', presentation.finance_stage)
+ if presentation.trade_finance:
+ values.setdefault('currency', presentation.trade_finance.currency.id)
+
+ @classmethod
+ def _set_amounts(cls, values):
+ quantity = values.get('signed_quantity')
+ price = values.get('commodity_price')
+ haircut = values.get('haircut_percent_applied')
+ gross = values.get('gross_collateral_value')
+ financed = values.get('financed_amount')
+
+ if gross is None and quantity is not None and price is not None:
+ gross = abs(quantity) * price
+ values['gross_collateral_value'] = gross
+ if financed is None and gross is not None:
+ haircut = haircut or Decimal('0')
+ financed = gross * (Decimal('1') - haircut / Decimal('100'))
+ values['financed_amount'] = financed
+ if (values.get('signed_financed_amount') is None
+ and financed is not None):
+ sign = Decimal('1')
+ if quantity is not None and quantity < 0:
+ sign = Decimal('-1')
+ values['signed_financed_amount'] = financed * sign
+
+ @fields.depends('presentation', '_parent_presentation.presentation_date',
+ '_parent_presentation.finance_stage',
+ '_parent_presentation.trade_finance',
+ '_parent_presentation.trade_finance.currency')
+ def on_change_presentation(self):
+ if not self.presentation:
+ return
+ self.movement_date = self.presentation.presentation_date
+ self.finance_stage = self.presentation.finance_stage
+ if self.presentation.trade_finance:
+ self.currency = self.presentation.trade_finance.currency
+
+ @fields.depends('signed_quantity', 'commodity_price')
+ 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'))
+
+ @fields.depends('financed_amount', 'signed_quantity')
+ def on_change_with_signed_financed_amount(self, name=None):
+ if self.financed_amount is None:
+ return None
+ sign = Decimal('1')
+ if self.signed_quantity is not None and self.signed_quantity < 0:
+ sign = Decimal('-1')
+ return self.financed_amount * sign
+
+ def apply_limit_values(self):
+ haircut_copied = False
+ if self.haircut_percent_applied is None:
+ self.haircut_percent_applied = (
+ self._get_applicable_haircut() or Decimal('0'))
+ haircut_copied = True
+ if self.interest_rate_applied is None:
+ self.interest_rate_applied = (
+ self._get_applicable_interest_rate() or Decimal('0'))
+ if not self.day_count_basis_applied:
+ self.day_count_basis_applied = '360'
+ if self.gross_collateral_value is None:
+ self.gross_collateral_value = (
+ self.on_change_with_gross_collateral_value())
+ if self.financed_amount is None or haircut_copied:
+ self.financed_amount = self.on_change_with_financed_amount()
+ if self.signed_financed_amount is None or (
+ haircut_copied and self.signed_quantity is not None):
+ self.signed_financed_amount = (
+ self.on_change_with_signed_financed_amount())
+
+ def check_for_posting(self):
+ presentation = self.presentation
+ trade_finance = presentation.trade_finance
+ limit = self.facility_limit
+ movement_date = self.movement_date or presentation.presentation_date
+
+ if self.signed_financed_amount == 0:
+ raise UserError('Signed financed amount cannot be zero.')
+ if limit.facility.tfe != trade_finance.bank:
+ raise UserError(
+ "The movement limit must belong to the presentation's Trade "
+ 'Finance Entity.')
+ if limit.currency != self.currency:
+ raise UserError(
+ 'The movement currency must match the facility limit currency.')
+ if movement_date < limit.date_from or movement_date > limit.date_to:
+ raise UserError(
+ 'The movement date must be within the facility limit validity '
+ 'period.')
+ if (presentation.financing_type and limit.financing_type
+ and presentation.financing_type != limit.financing_type):
+ raise UserError(
+ 'The movement financing type must match the facility limit '
+ 'financing type.')
+
+ def _get_applicable_haircut(self):
+ for haircut in self.facility_limit.haircuts:
+ if self._date_matches(haircut.date_from, haircut.date_to):
+ return haircut.haircut_pct
+ return None
+
+ def _get_applicable_interest_rate(self):
+ for cost in self.facility_limit.costs:
+ if (cost.cost_type == 'interest'
+ and self._date_matches(cost.date_from, cost.date_to)):
+ return cost.spread
+ return None
+
+ def _date_matches(self, date_from, date_to):
+ date = self.movement_date or self.presentation.presentation_date
+ if date_from and date < date_from:
+ return False
+ if date_to and date > date_to:
+ return False
+ return True
+
+ def get_reverse_values(self):
+ return {
+ 'facility_limit': self.facility_limit.id,
+ 'movement_date': Pool().get('ir.date').today(),
+ 'finance_stage': 'cancelled',
+ 'signed_quantity': (
+ -self.signed_quantity if self.signed_quantity is not None
+ else None),
+ 'uom': self.uom.id if self.uom else None,
+ 'commodity_price': self.commodity_price,
+ 'gross_collateral_value': self.gross_collateral_value,
+ 'haircut_percent_applied': self.haircut_percent_applied,
+ 'financed_amount': self.financed_amount,
+ 'signed_financed_amount': -self.signed_financed_amount,
+ 'interest_rate_applied': self.interest_rate_applied,
+ 'day_count_basis_applied': self.day_count_basis_applied,
+ 'currency': self.currency.id,
+ 'value_date': self.value_date,
+ 'maturity_date': self.maturity_date,
+ 'description': 'Reversal of %s' % (self.description or self.id),
+ }
+
+
+class TradeFinanceCreateStart(ModelView):
+ 'Create Trade Finance Start'
+ __name__ = 'trade.finance.create.start'
+
+ facility_limit = fields.Many2One(
+ 'trade_finance.facility_limit', 'Facility Limit', required=True,
+ ondelete='RESTRICT')
+ bank = fields.Function(fields.Many2One(
+ 'bank', 'Trade Finance Entity'), 'on_change_with_bank')
+ company = fields.Many2One(
+ 'company.company', 'Company', required=True, ondelete='RESTRICT')
+ party = fields.Many2One(
+ 'party.party', 'Counterparty', ondelete='RESTRICT')
+ currency = fields.Function(fields.Many2One(
+ 'currency.currency', 'Currency'), 'on_change_with_currency')
+ bank_reference = fields.Char('Bank Reference')
+ start_date = fields.Date('Start Date', required=True)
+ 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)
+ financing_type = fields.Function(fields.Many2One(
+ 'trade_finance.financing_type', 'Financing Type'),
+ 'on_change_with_financing_type')
+ signed_quantity = fields.Numeric('Signed Quantity', digits=(16, 4))
+ uom = fields.Many2One('product.uom', 'UoM', ondelete='RESTRICT')
+ 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=(16, 2))
+ financed_amount = fields.Numeric('Financed Amount', digits=(16, 2))
+ signed_financed_amount = fields.Numeric(
+ 'Signed Financed Amount', digits=(16, 2), required=True)
+ value_date = fields.Date('Value Date')
+ maturity_date = fields.Date('Maturity Date')
+ description = fields.Text('Description')
+ post = fields.Boolean('Post Presentation')
+
+ @staticmethod
+ def default_company():
+ return Transaction().context.get('company')
+
+ @staticmethod
+ def default_start_date():
+ Date = Pool().get('ir.date')
+ return Date.today()
+
+ @staticmethod
+ def default_presentation_date():
+ Date = Pool().get('ir.date')
+ return Date.today()
+
+ @staticmethod
+ def default_presentation_type():
+ return 'prefi'
+
+ @staticmethod
+ def default_finance_stage():
+ return 'prefi'
+
+ @staticmethod
+ def default_post():
+ return False
+
+ @fields.depends('facility_limit', '_parent_facility_limit.facility')
+ def on_change_with_bank(self, name=None):
+ if self.facility_limit and self.facility_limit.facility:
+ return self.facility_limit.facility.tfe
+
+ @fields.depends('facility_limit', '_parent_facility_limit.currency')
+ def on_change_with_currency(self, name=None):
+ if self.facility_limit:
+ return self.facility_limit.currency
+
+ @fields.depends('facility_limit', '_parent_facility_limit.financing_type')
+ def on_change_with_financing_type(self, name=None):
+ if self.facility_limit:
+ return self.facility_limit.financing_type
+
+ @fields.depends('signed_quantity', 'commodity_price')
+ 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'))
+
+ @fields.depends('financed_amount', 'signed_quantity')
+ def on_change_with_signed_financed_amount(self, name=None):
+ if self.financed_amount is None:
+ return None
+ sign = Decimal('1')
+ if self.signed_quantity is not None and self.signed_quantity < 0:
+ sign = Decimal('-1')
+ return self.financed_amount * sign
+
+
+class TradeFinanceCreate(Wizard):
+ 'Create Trade Finance'
+ __name__ = 'trade.finance.create'
+
+ start = StateView('trade.finance.create.start',
+ 'trade_finance.trade_finance_create_start_view_form', [
+ Button('Cancel', 'end', 'tryton-cancel'),
+ Button('Create', 'create_', 'tryton-ok', default=True),
+ ])
+ create_ = StateAction('trade_finance.act_trade_finance')
+
+ def do_create_(self, action):
+ pool = Pool()
+ TradeFinance = pool.get('trade.finance')
+ Presentation = pool.get('trade.finance.presentation')
+ Line = pool.get('trade.finance.presentation.line')
+
+ start = self.start
+ facility_limit = start.facility_limit
+ bank = facility_limit.facility.tfe
+ currency = facility_limit.currency
+ financing_type = facility_limit.financing_type
+ trade_finance, = TradeFinance.create([{
+ 'bank_reference': start.bank_reference,
+ 'bank': bank.id,
+ 'party': start.party.id if start.party else None,
+ 'company': start.company.id,
+ 'currency': currency.id,
+ 'start_date': start.start_date,
+ 'description': start.description,
+ }])
+ presentation, = Presentation.create([{
+ 'trade_finance': trade_finance.id,
+ 'presentation_date': start.presentation_date,
+ 'presentation_type': start.presentation_type,
+ 'finance_stage': start.finance_stage,
+ 'financing_type': financing_type.id if financing_type else None,
+ 'description': start.description,
+ }])
+ line, = Line.create([{
+ 'presentation': presentation.id,
+ 'facility_limit': facility_limit.id,
+ 'movement_date': start.presentation_date,
+ 'finance_stage': start.finance_stage,
+ 'signed_quantity': start.signed_quantity,
+ 'uom': start.uom.id if start.uom else None,
+ 'commodity_price': start.commodity_price,
+ 'gross_collateral_value': start.gross_collateral_value,
+ 'haircut_percent_applied': start.haircut_percent_applied,
+ 'financed_amount': start.financed_amount,
+ 'signed_financed_amount': start.signed_financed_amount,
+ 'currency': currency.id,
+ 'value_date': start.value_date,
+ 'maturity_date': start.maturity_date,
+ 'description': start.description,
+ }])
+ if start.post:
+ Presentation.post([presentation])
+ else:
+ line.apply_limit_values()
+ Line.save([line])
+
+ action['res_id'] = [trade_finance.id]
+ action['views'].reverse()
+ return action, {}
diff --git a/modules/trade_finance/trade.xml b/modules/trade_finance/trade.xml
new file mode 100644
index 0000000..085c852
--- /dev/null
+++ b/modules/trade_finance/trade.xml
@@ -0,0 +1,254 @@
+
+