package app import ( "context" "errors" "finance-duck/internal/domain" ) // LinkTransfer links a transaction to its own-account counterpart, or unlinks it // when peerID is empty. // // Reciprocity is a validated invariant: each side must name the other, with // opposite money, one currency and different accounts. So relinking has to // rewrite the old pair and the new pair in a single commit — applied one side // at a time, the dataset is invalid halfway through and the commit is refused. func (a *App) LinkTransfer(ctx context.Context, rev, id, peerID string) (State, error) { return a.Mutate(ctx, rev, func(d *domain.Dataset) error { return Link(d, id, peerID) }) } // Link rewrites both sides of a transfer decision at once. func Link(d *domain.Dataset, id, peerID string) error { if id == "" { return errors.New("select a transaction to link") } if id == peerID { return errors.New("a transaction cannot be its own counterpart") } index := make(map[string]int, len(d.Transactions)) for i, t := range d.Transactions { index[t.Facts.ID] = i } self, ok := index[id] if !ok { return errors.New("unknown transaction") } // Releasing a side also releases whatever it currently names, or the old // counterpart is left pointing at a transaction that no longer points back. release := func(i int) { peer := d.Transactions[i].Enrichment.TransferPeerID d.Transactions[i].Enrichment = unlinked(d.Transactions[i]) if j, found := index[peer]; found && j != i { d.Transactions[j].Enrichment = unlinked(d.Transactions[j]) } } release(self) if peerID == "" { return nil } other, ok := index[peerID] if !ok { return errors.New("unknown counterpart transaction") } release(other) for _, ends := range [][2]int{{self, other}, {other, self}} { t := &d.Transactions[ends[0]] t.Enrichment = domain.Enrichment{ Kind: "transfer", TagIDs: t.Enrichment.TagIDs, TransferPeerID: d.Transactions[ends[1]].Facts.ID, Classification: domain.Provenance{Source: "manual"}, } } return nil } // unlinked is what a transaction becomes when it stops being a transfer: a // broker fact returns to the investment ledger, anything else to the sign-based // fallback. Either way the decision is recorded as manual, because the import // matcher skips manual rows — otherwise unlinking a pair that is not really a // transfer would be undone by the next import, every time. func unlinked(t domain.Transaction) domain.Enrichment { e := domain.Fallback(t.Facts) e.TagIDs = append([]string{}, t.Enrichment.TagIDs...) e.Classification = domain.Provenance{Source: "manual"} return e }