fix transaction log matcher reflecting real world usage

This commit is contained in:
Emil Lerch 2026-05-23 11:25:12 -07:00
parent c5bb43dfad
commit 04cf12d12e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 762 additions and 191 deletions

View file

@ -1269,26 +1269,27 @@ fn printLargeLotWarning(
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
// Amount formatted as a whole-dollar number for the `num:`
// encoding; precise-to-the-cent values are rare in practice
// and callers can edit the template if needed.
const amount_int: i64 = @intFromFloat(@round(lot.value));
// Amount formatted with cents precision so the suggested
// `amount:num:N` exactly matches the lot's value. The matcher
// has a $1 tolerance so a whole-dollar suggestion would usually
// pair, but pasting a value that lies about the actual lot is
// a poor user experience `transaction_log.srf` should record
// what actually moved.
if (lot.security_type == .cash) {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ date_str, amount_int, lot.account },
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ date_str, lot.value, lot.account },
);
} else {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ date_str, amount_int, lot.account, lot.symbol, date_str },
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ date_str, lot.value, lot.account, lot.symbol, date_str },
);
}
}
@ -2558,7 +2559,7 @@ test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);
// Template line with the expected SRF shape.
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
}
test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
@ -2578,7 +2579,32 @@ test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template"
try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
}
test "printLargeLotWarning: cents are preserved in template" {
// Regression: previously the template rounded to whole dollars,
// so a $73,158.33 lot suggested `amount:num:73158`. Pasting that
// verbatim into transaction_log.srf records a fictitious amount
// and (with $1 matcher tolerance) only barely pairs. The fix
// prints two-decimal precision so the suggested record exactly
// describes the lot it's offering to attribute.
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Joint trust",
.symbol = "",
.security_type = .cash,
.value = 73_158.33,
.open_date = Date.fromYmd(2026, 5, 20),
};
try printLargeLotWarning(&writer, lot, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null);
}
test "strLessThan: orders strings lexicographically" {

File diff suppressed because it is too large Load diff

View file

@ -115,7 +115,7 @@ pub const DestLot = union(enum) {
defer allocator.free(buf);
var out = try allocator.alloc(u8, buf.len + 10);
@memcpy(out[0..buf.len], buf);
_ = std.fmt.bufPrint(out[buf.len..][0..10], "{f}", .{l.open_date}) catch unreachable;
_ = try std.fmt.bufPrint(out[buf.len..][0..10], "{f}", .{l.open_date});
break :blk .{ .string = out };
},
};
@ -152,6 +152,34 @@ pub const TransferRecord = struct {
to: []const u8,
dest_lot: DestLot,
note: ?[]const u8 = null,
/// Total-field equality. Used by the contributions matcher to
/// identify records that already existed in the before-side
/// `transaction_log.srf` (and therefore already paired in a
/// previous diff cycle).
///
/// Any field difference including the optional `note`
/// produces a non-equal result. This treats "user edited a
/// previously-recorded transfer" as a new record for matching
/// purposes; if the edit doesn't correspond to a fresh
/// portfolio change it surfaces as `unmatched_transfer` in the
/// Flagged section, which is the correct user-visible signal.
///
/// `amount` uses exact f64 equality. Records are user-authored
/// and rounded; any auto-generated record that round-trips
/// through f64 differently would need a tolerance, but no
/// current caller produces those.
pub fn eql(a: TransferRecord, b: TransferRecord) bool {
if (a.transfer.days != b.transfer.days) return false;
if (a.type != b.type) return false;
if (a.amount != b.amount) return false;
if (!std.mem.eql(u8, a.from, b.from)) return false;
if (!std.mem.eql(u8, a.to, b.to)) return false;
if (!a.dest_lot.eql(b.dest_lot)) return false;
if (a.note == null and b.note == null) return true;
if (a.note == null or b.note == null) return false;
return std.mem.eql(u8, a.note.?, b.note.?);
}
};
/// Parsed transaction log. `transfers` is allocator-owned; all string
@ -370,6 +398,158 @@ test "DestLot.eql: different date" {
try testing.expect(!a.eql(b));
}
test "TransferRecord.eql: identical records" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.type = .cash,
.amount = 73158.0,
.from = "Fidelity Emil",
.to = "Sample Trust",
.dest_lot = .cash,
.note = null,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.type = .cash,
.amount = 73158.0,
.from = "Fidelity Emil",
.to = "Sample Trust",
.dest_lot = .cash,
.note = null,
};
try testing.expect(a.eql(b));
}
test "TransferRecord.eql: different date" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 21),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
try testing.expect(!a.eql(b));
}
test "TransferRecord.eql: different amount" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100.01,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
try testing.expect(!a.eql(b));
}
test "TransferRecord.eql: different from" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A2",
.to = "B",
.dest_lot = .cash,
};
try testing.expect(!a.eql(b));
}
test "TransferRecord.eql: different dest_lot" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .{ .lot = .{ .symbol = "AMZN", .open_date = Date.fromYmd(2026, 5, 20) } },
};
try testing.expect(!a.eql(b));
}
test "TransferRecord.eql: note difference treated as different" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
.note = "v1",
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
.note = "v2",
};
try testing.expect(!a.eql(b));
}
test "TransferRecord.eql: both notes null treated as equal" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
};
try testing.expect(a.eql(b));
}
test "TransferRecord.eql: one note null other set treated as different" {
const a: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
.note = null,
};
const b: TransferRecord = .{
.transfer = Date.fromYmd(2026, 5, 20),
.amount = 100,
.from = "A",
.to = "B",
.dest_lot = .cash,
.note = "x",
};
try testing.expect(!a.eql(b));
}
test "parseTransactionLogFile: empty file" {
var log = try parseTransactionLogFile(testing.allocator,
\\#!srfv1