Saltar a contenido

🧾 Transaction Service Architecture

Transaction batch execution is split across three source modules:

File Responsibility
backend/app/services/transaction_service.py Public service methods, shared validation helpers, WAC dispatch, and the execute_batch() orchestrator
backend/app/services/transaction_batch_context.py TransactionBatchContext and the balance-replay exception shared across stages
backend/app/services/transaction_batch_stages.py Ordered parse, preload/access, mutation, linking, WAC, cost-basis, balance, and response stages

The former 637-line execute_batch() C901 hotspot has been decomposed. The public orchestrator is now approximately 50 source lines and makes the execution order explicit.


🧰 Responsibilities

Area Current owner
CRUD and pair helpers TransactionService
Batch inputs and cross-stage state TransactionBatchContext
Access scope and ordered mutations transaction_batch_stages.py
Linked-pair persistence Stage code writes reciprocal related_transaction_id values
WAC _compute_wac_for_auto_items() delegates to compute_wac_iterative()
Balance validation The final mutation stage flushes, then replays each affected broker from its earliest affected date
Transaction completion The API caller commits or rolls back; the service does neither

🚪 Entry Point — execute_batch()

The /transactions/validate and /transactions/commit routes call the same method:

async def execute_batch(
    self,
    creates_raw: List[dict],
    updates_raw: List[dict],
    deletes: List[int],
    splits_raw: List[dict] | None = None,
    promotes_raw: List[dict] | None = None,
    user_id: Optional[int] = None,
    commit: bool = False,
) -> TXBatchResponse:

Its order is contractual:

# Stage call Purpose
1 parse_inputs() Parse create, update, split, and promote rows independently
2 preload_and_authorize() Load referenced rows, collect touched brokers, and require EDITOR access
3 apply_deletes() Stage valid deletions
4 apply_splits() Split linked pairs before any dependent update
5 apply_updates() Apply and validate merged update state
6 validate_updated_pairs() Re-check shared description/tags on updated linked pairs
7 apply_creates() Add and flush valid creates so generated IDs are available
8 apply_promotes() Resolve saved or same-batch rows and promote them in place
9 resolve_create_links() Resolve unconsumed create correlation groups into reciprocal database links
10 compute_wac_and_fx_issues() Run create/update auto-WAC and convert missing FX data into issues
11 validate_cost_basis() Flush and enforce required cost basis after WAC and promote handling
12 validate_balances() Flush and replay end-of-day balances from each broker's earliest affected date
13 finalize_response() Derive committed, result statuses, counts, and WAC output

🧳 Shared batch context

TransactionBatchContext.from_inputs() normalizes optional split/promote lists and stores all state that must survive across stages. This includes:

  • parsed rows with their original per-operation indices;
  • issues and per-operation results;
  • preloaded transactions and the touched-broker set;
  • the earliest affected date per broker;
  • transient create-correlation groups and the groups consumed by promote;
  • optional inline wac_results; and
  • commit_requested, which affects only final response semantics.

Keeping this state explicit lets each stage remain independently readable without changing the single-session behavior of the old implementation.

🧯 Error collection boundaries

Raw create, update, split, and promote rows are parsed leniently, and the mutation loops append row-level failures while continuing with other rows. This is not a promise that every possible error is collected:

  • any accessDenied issue causes an early response before mutation stages run;
  • inline WAC is skipped when a non-balance issue already exists; and
  • balance replay catches the first balance exception and does not continue through the remaining broker/date replays.

🔁 Session and Response Semantics

execute_batch() mutates and flushes the AsyncSession supplied to TransactionService. It never calls commit(), rollback(), begin(), creates a savepoint, or opens a replacement session. The commit argument only selects the response that finalize_response() builds.

Caller Service response Caller action
/transactions/validate (commit=False) committed=False, whether clean or not Always session.rollback()
/transactions/commit (commit=True), no issues committed=True session.commit()
/transactions/commit (commit=True), any issue committed=False; successfully staged results become simulated session.rollback()

TXBatchResponse has no rolled_back field. Its transaction-specific contract is:

Field Meaning
committed Whether the commit route is allowed to commit this batch
issues Structured TXValidationIssue entries
results Per-operation operation, original index, affected ids, optional create link_uuid, and status
success_count Number of operations staged successfully; this is not proof that their rows persisted
wac_results Optional WAC output for create/update rows using auto or auto-detail

The response also inherits errors from BaseBulkResponse; transaction validation failures are reported through issues. On a dry run, a successfully staged result keeps status success even though the route rolls back. On a failed commit request, those same staged results are marked simulated.

Multi-broker atomicity at the route boundary means a batch may span brokers, but the commit route persists none of it when committed=False.


🔐 Access Control

preload_and_authorize() derives the access scope from parsed creates and from database rows referenced by updates, deletes, splits, and saved promote references. It calls _check_broker_access(..., min_role=UserRole.EDITOR) once per distinct touched broker. A denied broker adds an accessDenied issue and returns before any delete, split, update, create, or promote is applied.

Role hierarchy is OWNER > EDITOR > VIEWER; mutation requires at least EDITOR.


🔗 Linked Pairs

TRANSFER, FX_CONVERSION, and CASH_TRANSFER are two-row composite transactions. A create request uses link_uuid only as transient correlation:

  1. apply_creates() records each flushed row in context.link_uuid_map;
  2. resolve_create_links() validates each unconsumed two-row group; and
  3. the stage writes reciprocal related_transaction_id values.

Transaction has no persisted link_uuid column. The durable relationship is A.related_transaction_id = B.id and B.related_transaction_id = A.id.

_validate_linked_pair() requires equal pair types (pairTypeMismatch), distinct brokers for TRANSFER and CASH_TRANSFER (pairSameBroker), and allows an intra-broker FX_CONVERSION. A TRANSFER pair must also move one asset (pairAssetMismatch, params assetA/assetB) by exactly opposite quantities, compared as Decimal (pairQuantityMismatch, params qtyA/qtyB): what leaves one broker arrives at the other, and no unit is created or lost. The first rule that fails is the group's only issue, on operation create at the first leg's index, and the two rows stay unlinked. Pair description and tags are checked separately by _validate_pair_description_tags().


✂️ Split Type Map

The split stage re-types the negative/source and positive/destination legs deterministically:

Paired type From leg To leg
CASH_TRANSFER WITHDRAWAL DEPOSIT
TRANSFER ADJUSTMENT ADJUSTMENT
FX_CONVERSION WITHDRAWAL DEPOSIT

See Split & Promote for the in-place mutation rules and the separate legacy promote endpoint.


⚖️ Balance Queries

The service exposes two public query methods used by the broker summary:

await svc.get_cash_balances(broker_id)   # Dict[currency, Decimal]
await svc.get_asset_holdings(broker_id)  # Dict[asset_id, Decimal]

The holdings' cost no longer comes from this service: the broker summary computes it with the shared average cost (see WAC & Cost Basis).


🧭 Valuation Boundary

TransactionService does not build market marks, portfolio NAV, or lot valuation. Its transaction prices are persisted facts; valuation consumers later feed those facts into build_asset_price_series() together with price_history.

Current flow:

flowchart LR
    TX[transaction_service.py<br/>CRUD + stored trade prices] --> FIFO["financial_math/fifo/<br/>realized matching from trades"]
    TX --> Resolver["financial_math/price_marks.py<br/>MARKET / TRADE_AVG / CARRIED / MISSING"]
    Prices[price_history] --> Resolver
    Resolver --> Lots[lots_analysis_service.py<br/>open-lot valuation + price history]
    Resolver --> Portfolio["portfolio_service / financial_math/portfolio/<br/>portfolio marks"]

So the transaction layer can affect marks only by storing transaction facts (BUY/SELL amounts, priced ADJUSTMENT cost basis). It never reads current prices and never performs estimated-at-cost valuation.