Show which legs a transfer cannot pair, and what would pair them
The transfers screen counted unpaired legs per definition but never said
which transactions they were; finding them meant retyping the definition
into the builder. A ⚠ row now opens onto its legs, newest first, each
naming the missing side and what that side would have had to be:
revolut → ? arriving leg +250.00 in other matching *FROM REVOLUT*,
dated 2026-02-01 to 2026-02-11
That description comes from transfers.Engine.Want, next to
bestCounterpart, so it is stated in the terms the pairing actually uses:
the other half of the definition, the window, and the amount -- exact,
widened only by the definition's own tolerance, and "any amount" across
currencies, where amounts are not compared. When the other account has
nothing imported at all, the line says so, since that is the usual cause.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+78
-9
@@ -892,6 +892,20 @@ type transferRow struct {
|
||||
Tolerance string `json:"tolerance"` // blank for the exact-amount default
|
||||
Paired int `json:"paired"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
// Legs are the transactions behind Orphaned, newest first, so a ⚠ can be
|
||||
// opened to see which movement is missing its other side.
|
||||
Legs []unpairedLeg `json:"legs"`
|
||||
}
|
||||
|
||||
type unpairedLeg struct {
|
||||
Date string `json:"date"`
|
||||
Amount string `json:"amount"`
|
||||
Movement string `json:"movement"` // "checking → ?" or "? → savings"
|
||||
Description string `json:"description"`
|
||||
// Missing names the side not found, "arriving" or "leaving", and Want
|
||||
// what it would have had to look like to pair.
|
||||
Missing string `json:"missing"`
|
||||
Want string `json:"want"`
|
||||
}
|
||||
|
||||
func (s *Server) transferList(*http.Request) (any, error) {
|
||||
@@ -899,18 +913,80 @@ func (s *Server) transferList(*http.Request) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts, err := s.db.Accounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currency := map[string]string{}
|
||||
digits := map[string]int{}
|
||||
for _, a := range accounts {
|
||||
currency[a.Slug], digits[a.Slug] = a.Currency, a.MinorDigits
|
||||
}
|
||||
|
||||
defs := s.links.Transfers()
|
||||
res := s.links.Analyze(txns)
|
||||
rows := make([]transferRow, 0, len(defs))
|
||||
for i, t := range defs {
|
||||
rows = append(rows, transferRow{
|
||||
transferJSON: toTransferJSON(t), Pos: i, Tolerance: formatTolerance(t.TolerancePct),
|
||||
Paired: res.Paired[i], Orphaned: res.Orphaned[i],
|
||||
Paired: res.Paired[i], Orphaned: res.Orphaned[i], Legs: []unpairedLeg{},
|
||||
})
|
||||
}
|
||||
for _, l := range res.Unmatched {
|
||||
row := legRow(l)
|
||||
w := s.links.Want(l, func(slug string) string { return currency[slug] })
|
||||
d, imported := digits[w.Account]
|
||||
if !imported {
|
||||
d = l.Txn.MinorDigits
|
||||
}
|
||||
leg := unpairedLeg{
|
||||
Date: row.Date, Amount: row.Amount, Movement: row.Movement, Description: row.Description,
|
||||
Missing: "arriving", Want: describeWant(w, d),
|
||||
}
|
||||
if !l.Out {
|
||||
leg.Missing = "leaving"
|
||||
}
|
||||
if !imported {
|
||||
// The commonest cause of all, so it is named outright.
|
||||
leg.Want += fmt.Sprintf(" — nothing from %s is imported", w.Account)
|
||||
}
|
||||
rows[l.Def].Legs = append(rows[l.Def].Legs, leg)
|
||||
}
|
||||
for i := range rows {
|
||||
sort.SliceStable(rows[i].Legs, func(a, b int) bool { return rows[i].Legs[a].Date > rows[i].Legs[b].Date })
|
||||
}
|
||||
return map[string]any{"transfers": rows, "unpaired": len(res.Unmatched)}, nil
|
||||
}
|
||||
|
||||
// describeWant puts transfers.Wanted into the words the transfers screen uses.
|
||||
// The amount is signed as the other statement would show it.
|
||||
func describeWant(w transfers.Wanted, digits int) string {
|
||||
amount := model.FormatMinor(w.Amount, digits)
|
||||
if w.Amount > 0 {
|
||||
amount = "+" + amount
|
||||
}
|
||||
switch {
|
||||
case w.AnyAmount:
|
||||
amount = "any amount (another currency)"
|
||||
case w.TolerancePct > 0:
|
||||
amount += " ±" + formatTolerance(w.TolerancePct)
|
||||
}
|
||||
return fmt.Sprintf("%s in %s matching %s, dated %s to %s", amount, w.Account, w.Desc, w.From, w.To)
|
||||
}
|
||||
|
||||
// legRow is an unpaired leg as the builder's preview and the transfers screen
|
||||
// both show it: the amount that moved, and the side that has no partner.
|
||||
func legRow(l transfers.Leg) transferPreviewRow {
|
||||
amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug
|
||||
if l.Out {
|
||||
amount, movement = -amount, l.Txn.AccountSlug+" → ?"
|
||||
}
|
||||
return transferPreviewRow{
|
||||
Marker: "⚠", Date: l.Txn.Date, Amount: model.FormatMinor(amount, l.Txn.MinorDigits),
|
||||
Movement: movement, Description: l.Txn.Description,
|
||||
}
|
||||
}
|
||||
|
||||
// formatTolerance leaves the default blank, so the one or two definitions
|
||||
// actually pairing on slack are what the column shows.
|
||||
func formatTolerance(pct float64) string {
|
||||
@@ -1009,14 +1085,7 @@ func (s *Server) transferPreview(r *http.Request) (any, error) {
|
||||
if l.Def != mine {
|
||||
continue
|
||||
}
|
||||
amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug
|
||||
if l.Out {
|
||||
amount, movement = -amount, l.Txn.AccountSlug+" → ?"
|
||||
}
|
||||
out.Rows = append(out.Rows, transferPreviewRow{
|
||||
Marker: "⚠", Date: l.Txn.Date, Amount: model.FormatMinor(amount, l.Txn.MinorDigits),
|
||||
Movement: movement, Description: l.Txn.Description,
|
||||
})
|
||||
out.Rows = append(out.Rows, legRow(l))
|
||||
}
|
||||
sort.SliceStable(out.Rows, func(i, j int) bool { return out.Rows[i].Date > out.Rows[j].Date })
|
||||
if fees != 0 {
|
||||
|
||||
Reference in New Issue
Block a user