✂️ Split & Promote
Split and batch promote both change the shape of composite transactions, but they are not implemented as delete-and-recreate inverses:
| Operation | Direction | Batch behavior |
|---|---|---|
| Split | Composite pair → two standalone rows | Mutates both persisted rows in place and removes their reciprocal links |
| Promote | Two standalone rows → composite pair | Mutates the resolved rows in place and creates reciprocal links |
The durable pair is always stored with reciprocal related_transaction_id values.
link_uuid is only a request-scoped correlation value for creates; it is not a
column on Transaction.
🔢 Pipeline Position
The relevant execute_batch() stages run in this order:
Split deliberately precedes update. A batch can therefore split a pair and then apply updates whose type swaps are legal only for the resulting standalone types. Promote follows create so its references can resolve either persisted rows or rows that were flushed earlier in the same batch.
✂️ Split
⚙️ In-place behavior
For each TXSplitBatchItem, apply_splits():
- resolves both required database IDs;
- verifies that each row's
related_transaction_idpoints to the other; - determines source/destination by negative/positive quantity for
TRANSFER, or by negative/positive amount for cash pairs; - changes both row types using
SPLIT_TYPE_MAP; - clears both
related_transaction_idvalues; - clears
asset_idfor the cash split outcomes; and - records both brokers' affected dates and a two-ID
splitresult.
The rows keep their IDs. No create or delete is involved.
| Source composite type | Negative/source leg | Positive/destination leg |
|---|---|---|
CASH_TRANSFER |
WITHDRAWAL |
DEPOSIT |
TRANSFER |
ADJUSTMENT |
ADJUSTMENT |
FX_CONVERSION |
WITHDRAWAL |
DEPOSIT |
📦 Request shape
Split is a first-class item in the unified batch:
id_a and id_b must be different positive IDs and must identify the two
reciprocally linked rows. Their input order does not determine source and
destination; the signed quantity or amount does.
The Transactions page sends this item on its own, outside the bulk workspace:
Split pair in a row's actions asks for confirmation, then calls
commitTransactions({splits: [{id_a, id_b}]}). Inside the workspace a saved
pair is queued in pendingSplits and travels with Save All instead (see
Transaction Staging State).
🔗 Batch Promote
⚙️ In-place behavior
apply_promotes() accepts:
- two saved rows (
id_a+id_b); - two creates from this batch (
link_uuid_a+link_uuid_b); or - one saved and one same-batch row.
Each side must provide exactly one reference form: id_* or link_uuid_*, never
both. After resolving the references, the stage:
- rejects a row that already has
related_transaction_id; - calls
_find_promote_rule_match()to infer the target type fromTX_TYPE_METADATA.promote_fromand its field constraints; - validates both rows as they would be once promoted:
_promoted_state_issues()runsvalidate_transaction_business_rules()on each row with the target type, the per-type rules that creates and updates go through too. Each error becomes an issue on operationpromote, at the promote's index, withref_idset only for a saved row; the promote is then skipped and neither row changes; - assigns the inferred type to both existing ORM rows;
- writes reciprocal
related_transaction_idvalues; - applies recognized
resolved_fields; - marks same-batch correlation keys as consumed so the later create-link stage does not process them again; and
- emits a
promoteresult containing the same two row IDs.
No original row is deleted and no replacement row is created.
Step 3 exists for a row linked to an asset event, such as an ADJUSTMENT linked to a
SPLIT. A promote never clears asset_event_id, and the business rules accept that
link only on EVENT_COMPATIBLE_TYPES (DIVIDEND, INTEREST, ADJUSTMENT): every
promote target has event_compatible: false in TX_TYPE_METADATA, so such a row is
refused with eventTypeIncompatible. Without the check it would become a TRANSFER
still linked to the split, and the average cost, which treats every row linked to a
SPLIT event as a split whatever its type, would read it as one.
🧭 Inferred target rules
The batch payload does not contain new_type. The current metadata permits:
| Standalone input types | Inferred target | Required constraints |
|---|---|---|
ADJUSTMENT + ADJUSTMENT |
TRANSFER |
Same asset_id, different brokers, opposite quantities |
WITHDRAWAL + DEPOSIT |
FX_CONVERSION |
Same broker, different cash currencies |
WITHDRAWAL + DEPOSIT |
CASH_TRANSFER |
Different brokers, same cash currency, opposite cash amounts |
The match is symmetric: either row may be supplied as side A.
📦 Request shape
A saved-row promote can include values already resolved by the merge UI:
POST /transactions/commit
{
"promotes": [
{
"id_a": 201,
"id_b": 202,
"resolved_fields": {
"description": "Broker transfer",
"tags": ["internal"],
"date": "2026-09-11",
"cost_basis_override": {"code": "EUR", "amount": "42.50"}
}
}
]
}
The recognized resolved_fields keys are description, tags, date, and
cost_basis_override. Description, tags, and date are applied to both rows. For
an asset transfer, the cost basis is applied to the positive-quantity receiver
and cleared from the sender.
The Transactions page sends a saved-row promote on its own, outside the
workspace, when exactly two selected rows are unpaired, sit on brokers the user
can edit, and match a rule (findPromoteMatch() on the promote_from metadata
of GET /transactions/types, which finds none when a row is linked to an asset
event and the target type refuses the link). 🔗 Promote pair then opens a plain
confirmation, or PromoteMergeModal when the two rows differ in description,
tags, date or cost basis; only the merge dialog adds resolved_fields.
A mixed saved/new request uses a create correlation value:
POST /transactions/commit
{
"creates": [
{
"broker_id": 8,
"type": "DEPOSIT",
"date": "2026-09-11",
"cash": {"code": "EUR", "amount": "300"},
"link_uuid": "draft-deposit-8"
}
],
"promotes": [
{"id_a": 201, "link_uuid_b": "draft-deposit-8"}
]
}
Here link_uuid_b resolves the just-flushed create. Once consumed by promote,
that correlation group is skipped by resolve_create_links(). The persisted
rows contain only reciprocal related_transaction_id values.
🧮 Cost basis boundary
A promote item has no cost_basis_mode, and apply_promotes() does not calculate
WAC. The later _compute_wac_for_auto_items() worklist is built only from parsed
create and update items. Promote-specific cost-basis resolution therefore comes
from resolved_fields.cost_basis_override, supplied by the frontend merge flow
or another API client; without that key, the receiver keeps the cost basis it
already has.
Because apply_promotes() writes related_transaction_id before the WAC step, a
create or update in Auto that the promote makes the receiving side averages its
partner's broker, as of the partner's date and without the partner's outgoing leg.
validate_cost_basis() then checks the receiving side like any other row: a new
row as a create and a saved row also in updates[] as that update, both unless in
Auto; any other saved row without a cost basis gets costBasisRequired on
operation promote, with the promote's index and ref_id set to that row. See
WAC & Cost Basis.
🛣️ Legacy Live Promote Endpoint
POST /transactions/transfers/promote remains a separate live endpoint with a
different contract:
- request:
from_tx_id,to_tx_id,new_type, and the optionalasset_id,quantity, andcost_basis_overridefields needed forTRANSFER; - implementation: snapshot the two
DEPOSIT/WITHDRAWALrows, delete them, build two replacement create payloads with a sharedlink_uuid, and callexecute_batch()with those deletes and creates; and - response:
TXTransferPromoteResponse, whose fields include the legacyrolled_back,new_from_tx_id,new_to_tx_id, anderrors.
This delete-and-create behavior applies only to the legacy endpoint. It must not
be used to describe promotes[] in /transactions/validate or
/transactions/commit.
🧯 Errors and Rollback
Split/promote row failures are accumulated as TXValidationIssue entries while
their stage loops continue. Common stable codes are:
| Code | Cause |
|---|---|
txNotFound |
A split ID does not resolve |
splitIdsMismatch |
Split IDs are not reciprocal partners |
typeCannotSplit |
The current pair type has no split mapping |
promoteRefNotFound |
A saved or same-batch promote reference cannot be resolved |
alreadyPaired |
A promote candidate already has a persistent partner |
noPromoteRule |
No metadata rule and constraint set matches the two rows |
eventTypeIncompatible |
A promoted row is linked to an asset event, which the target type refuses (step 3) |
The service does not roll the session back itself. /transactions/validate
always rolls back; /transactions/commit commits only when
TXBatchResponse.committed is true and rolls back otherwise. Access denial may
return before either mutation stage, and balance replay stops after its first
balance exception, so the overall pipeline is not an unconditional
collect-every-error pass.
🔗 Related
- 🏗️ Transaction Service — Batch order, context, and transaction ownership
- ⚖️ WAC & Cost Basis — Create/update auto-WAC and promote's payload boundary
- 🔒 Balance Validation — Final post-flush balance replay