From 381197b7bb73f42415a52109727ac2317f25d20f Mon Sep 17 00:00:00 2001 From: "AzureAD\\SylvainDUVERNAY" Date: Thu, 28 May 2026 22:31:06 +0200 Subject: [PATCH] Add trade finance ledger foundation --- .../trade_finance/Documentation/backlog.md | 414 ++++++ .../Documentation/development_guidance.md | 175 +++ .../tryton_trade_finance_architecture.md | 1268 +++++++++++++++++ modules/trade_finance/__init__.py | 8 + modules/trade_finance/tests/test_module.py | 183 +++ modules/trade_finance/trade.py | 854 +++++++++++ modules/trade_finance/trade.xml | 254 ++++ modules/trade_finance/tryton.cfg | 1 + .../view/trade_finance_create_start_form.xml | 54 + .../trade_finance/view/trade_finance_form.xml | 36 + .../view/trade_finance_presentation_form.xml | 36 + .../trade_finance_presentation_line_form.xml | 36 + .../trade_finance_presentation_line_tree.xml | 14 + .../view/trade_finance_presentation_tree.xml | 11 + .../trade_finance/view/trade_finance_tree.xml | 11 + 15 files changed, 3355 insertions(+) create mode 100644 modules/trade_finance/Documentation/backlog.md create mode 100644 modules/trade_finance/Documentation/development_guidance.md create mode 100644 modules/trade_finance/Documentation/tryton_trade_finance_architecture.md create mode 100644 modules/trade_finance/trade.py create mode 100644 modules/trade_finance/trade.xml create mode 100644 modules/trade_finance/view/trade_finance_create_start_form.xml create mode 100644 modules/trade_finance/view/trade_finance_form.xml create mode 100644 modules/trade_finance/view/trade_finance_presentation_form.xml create mode 100644 modules/trade_finance/view/trade_finance_presentation_line_form.xml create mode 100644 modules/trade_finance/view/trade_finance_presentation_line_tree.xml create mode 100644 modules/trade_finance/view/trade_finance_presentation_tree.xml create mode 100644 modules/trade_finance/view/trade_finance_tree.xml 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 @@ + + + + + + + + + + Trade Finance + + + + + + + Trade Finance + + TFR- + + + + + + + + + trade.finance.create.start + form + trade_finance_create_start_form + + + New Financing + trade.finance.create + + + + + + + + + trade.finance + tree + + trade_finance_tree + + + trade.finance + form + trade_finance_form + + + Trade Finance Files + trade.finance + + + + + + + + + + + + + Draft + + + + + + + Active + + + + + + + All + + + + + + + + trade.finance + + + + + + + trade.finance + + + + + + + + trade.finance + + + + + + + + + + + + + trade.finance.presentation + tree + + trade_finance_presentation_tree + + + trade.finance.presentation + form + trade_finance_presentation_form + + + Trade Finance Presentations + trade.finance.presentation + + + + + + + + + + + + + + + trade.finance.presentation + + + + + + + trade.finance.presentation + + + + + + + + trade.finance.presentation + + + + + + + + + + + + + trade.finance.presentation.line + tree + + trade_finance_presentation_line_tree + + + trade.finance.presentation.line + form + trade_finance_presentation_line_form + + + Trade Finance Movement Lines + trade.finance.presentation.line + + + + + + + + + + + + + + + trade.finance.presentation.line + + + + + + + trade.finance.presentation.line + + + + + + + + trade.finance.presentation.line + + + + + + + + + diff --git a/modules/trade_finance/tryton.cfg b/modules/trade_finance/tryton.cfg index 6848bac..8917db0 100644 --- a/modules/trade_finance/tryton.cfg +++ b/modules/trade_finance/tryton.cfg @@ -10,3 +10,4 @@ xml: reference.xml facility.xml constraint_type.xml + trade.xml diff --git a/modules/trade_finance/view/trade_finance_create_start_form.xml b/modules/trade_finance/view/trade_finance_create_start_form.xml new file mode 100644 index 0000000..0010448 --- /dev/null +++ b/modules/trade_finance/view/trade_finance_create_start_form.xml @@ -0,0 +1,54 @@ +
+ + + + + + + + +