fix transaction log matcher reflecting real world usage
This commit is contained in:
parent
c5bb43dfad
commit
04cf12d12e
3 changed files with 762 additions and 191 deletions
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue