fix transaction log matcher reflecting real world usage
This commit is contained in:
parent
26e8399178
commit
4bb29f1432
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 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", .{});
|
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:`
|
// Amount formatted with cents precision so the suggested
|
||||||
// encoding; precise-to-the-cent values are rare in practice
|
// `amount:num:N` exactly matches the lot's value. The matcher
|
||||||
// and callers can edit the template if needed.
|
// has a $1 tolerance so a whole-dollar suggestion would usually
|
||||||
const amount_int: i64 = @intFromFloat(@round(lot.value));
|
// 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) {
|
if (lot.security_type == .cash) {
|
||||||
try cli.printFg(
|
try cli.printFg(
|
||||||
out,
|
out,
|
||||||
color,
|
color,
|
||||||
cli.CLR_MUTED,
|
cli.CLR_MUTED,
|
||||||
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::cash\n",
|
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
|
||||||
.{ date_str, amount_int, lot.account },
|
.{ date_str, lot.value, lot.account },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try cli.printFg(
|
try cli.printFg(
|
||||||
out,
|
out,
|
||||||
color,
|
color,
|
||||||
cli.CLR_MUTED,
|
cli.CLR_MUTED,
|
||||||
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
|
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
|
||||||
.{ date_str, amount_int, lot.account, lot.symbol, date_str },
|
.{ 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);
|
try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);
|
||||||
|
|
||||||
// Template line with the expected SRF shape.
|
// 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" {
|
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, "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, "+$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 Account",
|
||||||
|
.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" {
|
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);
|
defer allocator.free(buf);
|
||||||
var out = try allocator.alloc(u8, buf.len + 10);
|
var out = try allocator.alloc(u8, buf.len + 10);
|
||||||
@memcpy(out[0..buf.len], buf);
|
@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 };
|
break :blk .{ .string = out };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -152,6 +152,34 @@ pub const TransferRecord = struct {
|
||||||
to: []const u8,
|
to: []const u8,
|
||||||
dest_lot: DestLot,
|
dest_lot: DestLot,
|
||||||
note: ?[]const u8 = null,
|
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
|
/// Parsed transaction log. `transfers` is allocator-owned; all string
|
||||||
|
|
@ -370,6 +398,158 @@ test "DestLot.eql: different date" {
|
||||||
try testing.expect(!a.eql(b));
|
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 Sample",
|
||||||
|
.to = "Sample Trust",
|
||||||
|
.dest_lot = .cash,
|
||||||
|
.note = null,
|
||||||
|
};
|
||||||
|
const b: TransferRecord = .{
|
||||||
|
.transfer = Date.fromYmd(2026, 5, 20),
|
||||||
|
.type = .cash,
|
||||||
|
.amount = 73158.0,
|
||||||
|
.from = "Fidelity Sample",
|
||||||
|
.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" {
|
test "parseTransactionLogFile: empty file" {
|
||||||
var log = try parseTransactionLogFile(testing.allocator,
|
var log = try parseTransactionLogFile(testing.allocator,
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue