📝 Transaction Form Modal
The Transaction Form Modal (TransactionFormModal.svelte, in
frontend/src/lib/components/transactions/modals/) creates, edits or displays one transaction —
or one linked pair, shown as a single two-sided form. It adapts its fields to the selected
transaction type, validates against the backend while the user types, and either hands the result
back to the bulk workspace or commits it itself.
🏗️ Architecture
The form works on a local copy of one row; nothing it holds is shared until the user presses the footer button.
mode |
Footer button | Behaviour |
|---|---|---|
create |
Apply / Save | Blank form, or pre-filled from items[0] (e.g. a staged row) |
edit |
Apply / Save | Existing row; type and broker are locked unless unlockImmutable (then the type list is limited to the original type's swap group) |
view |
— | Read-only; the pencil switches to edit through onSwitchToEdit, shown only when canEdit |
commitOnSave decides where the result goes:
commitOnSave={false}— Apply.onPushDraft(payload)hands a create-shaped payload back to the caller, and the modal closes.TransactionBulkModalmounts the form this way (withunlockImmutablein edit mode), so every row created or edited in the workspace goes through it.commitOnSave={true}(default) — Save. The form posts toPOST /transactions/commititself and reportsonCommitted. The Transactions, broker-detail and dashboard pages mount the form inviewmode only; on the Transactions page, the pencil reopens the row in the bulk workspace.
Closing the form with unsaved changes asks for confirmation (Discard Changes?).
🔀 Form State vs Staging State
It is important to keep the single-item form state apart from the workspace's staging state:
- Form state —
draft: FormDraft, plusdualTo: DualDraftTofor a pair, is local Svelte 5$stateinside the modal. It is rebuilt fromitemseach time the modal opens (a newopenKeyforces a rebuild even whenopennever went back tofalse) and may be incomplete or invalid while the user types. - Staging state — on Apply,
TransactionBulkModalwrites the pushed payload into the row'sDraftFields(applyFormPayload) and keeps the row as aPendingOp. Read more in the Transaction Staging State guide.
🗄️ State Schema
Which fields are shown, required or forbidden comes from the type rules that
transactionTypeStore loads from GET /transactions/types (assetField, cashField,
quantityMode, the quantity and cash sign rules, eventLinkable). Changing the type clears the
values the new type forbids.
📋 Common Fields
FormDraft (declared in TransactionFormModal.svelte):
| Field | Type | Description |
|---|---|---|
type |
TransactionTypeCode |
Core discriminator (BUY, SELL, DIVIDEND, …) |
broker_id |
number |
0 until chosen; pre-filled from forcedBroker (locked), defaultBrokerId, or the only broker the user can edit. Create new in the broker select opens a stacked BrokerModal (zIndex + 10) |
date |
string |
YYYY-MM-DD; today on a blank form |
cash |
{code, amount} \| null |
ISO 4217 code and decimal string, edited with CompactCashCell |
tags |
string[] |
Free tags, with suggestions from availableTags |
description |
string |
Free-text memo |
asset_event_id |
number \| null |
Linked asset event (Linked Event, AssetEventPicker), for the event-linkable types (DIVIDEND, INTEREST, ADJUSTMENT); the picker shows once an asset and a date are set (canShowAssetEvent) |
cost_basis_override |
{code, amount} \| null |
Manual cost basis (see WAC State) |
link_uuid |
string \| null |
Pairing UUID; shown read-only when set |
📦 Asset Operations (BUY / SELL / TRANSFER)
Shown when the type's rules allow them:
| Field | Type | Description |
|---|---|---|
asset_id |
number \| null |
Selected asset (AssetSelect); New asset opens a stacked AssetModal |
quantity |
string |
Exact decimal string, edited with ExactQuantityInput (built on ExactDecimalInput; at most 12 integer and 6 fraction digits); '0' when the type forbids a quantity |
💱 Paired layouts and FX conversion
When the type requires a link (requiresPair, the backend's requires_link), the form switches to
a two-sided layout chosen by the type's pairFormLayout: transfer_asset for TRANSFER,
transfer_cash for CASH_TRANSFER and fx for FX_CONVERSION. Each side, From and To,
has its own date. Swap sides (swapDualSides()) exchanges the two dates and, for the transfer
layouts, the two brokers; for FX it exchanges the amounts instead (see below). The To side
lives in dualTo:
interface DualDraftTo {
broker_id: number; // transfers: the receiving broker
cash: {code: string; amount: string} | null; // fx: the currency bought
date: string; // this leg's own date
quantity?: string; // transfer_asset: receiver quantity
cost_basis_override?: {code: string; amount: string} | null; // transfer_asset: receiver cost basis
}
🧾 The two FX legs
An FX_CONVERSION exchanges one currency for another inside one broker: one leg spends a
currency, the other receives a different one.
| Part | Fields | Notes |
|---|---|---|
| Shared | type, broker_id |
One broker field for both legs. Outside view mode, effectiveDualTo always puts the To leg on the source broker, as the type's pair_field_constraints ask (broker_id equal, cash_currency different) |
| From | date, cash |
The currency sold and its amount (tx-form-cash-from) |
| To | dualTo.date, dualTo.cash |
The currency bought and its amount (tx-form-cash-to); its date may differ from the From date |
| Shared | tags, description |
Copied onto both legs |
Both amounts are typed as magnitudes; the signs are applied when the payload is built. The swap
arrow (tx-form-dual-swap) exchanges the amounts and the dates of the two legs and keeps the
broker. Once both amounts are set in two different currencies, a badge (tx-form-fx-info) shows
the implied rate (To ÷ From) and, in its tooltip, the market rate on the From date
(lookupFxRate from fxStoreRegistry); ⚠️ marks a market rate carried over from an earlier day.
✅ Pair validation
- Different currencies —
dualValidationErrorreports Currencies must be different (transactions.form.sameCurrencyError) and disables the footer button. Transfers check the reverse: their two brokers must differ (sameBrokerError). - Completeness —
isFormCompleteneeds the From fields the type requires, a destination broker and a destination date and, for FX, a currency and a finite, non-zero amount on both legs. It gates the automatic validation, ⚡ Validate now and, in the bulk workspace, Apply. - Sign — the paired cash editors use the
positiverule: a negative amount on either leg (cashSignResult,cashToSignViolation) disables the footer button. - Server — both legs are validated together in one
POST /transactions/validate; the issues the two halves share are shown once (deduplicateIssues, by issue code).
📤 How the pair is submitted
collectDualCreates() calls buildDualCreatePayloads('fx', …)
(lib/utils/transactions/txPayloadHelpers.ts), which returns two FX_CONVERSION items:
| Field | From item | To item |
|---|---|---|
broker_id |
source broker | source broker |
date |
From date | To date (the From date when empty) |
quantity |
'0' |
'0' |
cash |
sold currency, negative amount | bought currency, positive amount |
link_uuid |
shared | shared |
tags, description |
when set | same as From |
The signs are applied on the decimal strings (applySign), never through a float. The two items
share one link_uuid: draft.link_uuid when it is set, otherwise a fresh generateUUID().
- Apply (bulk workspace) — the pushed payload carries
_dual: trueand_items: [from, to](with_partnerBrokerId,_partnerCash,_partnerDate).TransactionBulkModalturns it into a visible row plus a hidden partner op that points to it throughpairedWith; both keep the sharedlink_uuid. - Save (standalone) — a new pair is sent as
{creates: [from, to]}. An edited pair, once its partner is loaded, is sent as{updates: …}:collectDualUpdates()matches each leg to its original by the sign of its cash and keeps only the legs that changed (diffDualItem).
Opening an existing pair loads the partner (passed in items[1], or read with
GET /transactions?ids=…), puts the spending leg on From, and merges diverging descriptions
and tags of the two legs.
✅ Client-Side Validation
The form checks locally only what it can know without the ledger:
- Required fields —
isDraftReadyForValidation()(transactionTypeStore) needs a broker, the asset when the type requires one, a non-zero cash amount when cash is required and a non-zero quantity unless the type forbids it; the form also needs a date, and for a pair the destination side (Pair validation). Until then there is no automatic validation and ⚡ Validate now stays disabled. - Quantity —
ExactQuantityInputchecks the syntax, the 12 + 6 digit budget and the type's sign rule, and reports its validity (onvaliditychange); an invalid quantity disables the footer button. - Cash sign — for a type whose cash rule is
negativethe user types a magnitude and the payload builder negates it (applySignRules). An amount that still breaks the rule (computeSignHint) disables the footer button. - Hints — the single-layout cash editor always carries 💡 Total amount (not per share)
(
transactions.form.cashLabelTotal). The quantity and cash editors also show the hint of the type's sign rule (signHintText()→signHintKey()inlib/utils/transactions/txFormFields.ts: must be > 0, enter positive — auto-negated on save, must be 0, must be ≠ 0). Negative quantity rules are auto-flipped like cash (effectiveQtyRule), so aSELLquantity is typed as a positive number.
Everything deeper — balances, pairing rules, access — belongs to the backend's
Balance Validation. While the form is
complete, every change schedules POST /transactions/validate (createValidateScheduler, debounced
500 ms by default). Inside the bulk workspace the request also carries the other staged rows
(getBulkContext()), so the balance walk sees the whole batch; for a single-row layout the banner
then keeps only this row's issues plus the global ones.
💰 WAC State
The cost-basis editor is WacPreviewSection
(frontend/src/lib/components/transactions/wac/WacPreviewSection.svelte). The form shows it where
a cost basis applies: on the receiving side of a paired TRANSFER, and on an ADJUSTMENT. BUY and
SELL do not show it.
costBasisModeis'auto'(the backend computes the weighted average cost) or'manual'(the user'scost_basis_override). In Auto mode the item is sent withcost_basis_mode: 'auto'and, oncewacCurrencyHintholds a currency (the user's pick, the first WAC result's, or the one in the row's sentinel), the hint{code, amount: '0'}as override.- In Auto mode the backend writes the WAC into
cost_basis_override(TransactionService._compute_wac_for_auto_items()→compute_wac_iterative(), as of the row's date): the WAC of the linked partner's broker for a create carryinglink_uuid(a newTRANSFERpair), otherwise of the row's own broker, without the row. When that broker has no other row of the asset up to that date — the transfer's own outgoing leg aside — the WAC is0, and the row is saved with a zero cost basis. That is by design (developer decision of 2026-10-09): the user corrects that transaction later if needed. Auto rows skip the required-cost-basis check (_auto_mode_indices()); split-linked adjustments and missing FX are covered in WAC & Cost Basis. - In Manual mode, a
TRANSFERorADJUSTMENTwith a positive quantity and nocost_basis_override(TransactionService._requires_cost_basis()) gets acostBasisRequiredissue (validate_cost_basis(), which skips only the creates that a promote consumes in the same batch). Like any issue, it makes the batchcommitted=False, andPOST /transactions/commitrolls the whole batch back: the row is not saved. A free acquisition is entered as0. - Known mismatch: for an
ADJUSTMENTwith a positive quantity, not in Auto mode and with an empty manual field, the form showstransactions.costBasisOverride.warningAdjustment(tx-form-cost-basis-warning), which says the lot will be created with zero cost. The backend does not create it: it rejects that row withcostBasisRequired. - For validation,
upgradeAutoToDetail()turns'auto'into'auto-detail', so the response'swac_resultscarries the WAC with its qualifying transactions. Standalone, the form keeps that result informWacResult; inside the bulk workspace it first reads the workspace's own result (getWacResult(editingTempId)). - A
wacFxUnavailableissue comes with a link that opensFxSyncModalon the missing pairs and dates (handleSyncFx).
🔗 Related
- 📝 Transaction Staging State — Staging area and bulk operations
- 🧭 Import Wizard — Hands imported rows to the bulk workspace
- 🔒 Backend Balance Validation — Balance walk validation rules
- ⚖️ Backend WAC & Cost Basis — Cost basis computation engine
- ✂️ Backend Split & Promote — Linking and unlinking pairs