//! Transaction log — the wire format for `transaction_log.srf`. //! //! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf` //! that declares real-world transactions which adjust interpretation of //! the portfolio diff. In v1, the only record kind is `transfer::` — //! used to mark money moving between accounts the user owns, so that //! the contributions pipeline doesn't double-count transfers as new //! contributions. //! //! ## Why this file exists //! //! `portfolio.srf` answers "what do I have" — state. But some events //! that affect contribution attribution aren't state; they're //! transactions. The biggest current gap: account transfers get //! double-counted as contributions because the receiving side's //! `new_*` lots count toward attribution and the sending side's //! `lot_removed` is silently ignored. A six-figure transfer from one //! account to another inflates reported "contributions" by that full //! amount. Existing classifications can't tell a transfer from a real //! external contribution/withdrawal just from the diff. //! //! ## One record per destination //! //! A transfer record pins exactly ONE destination — a specific lot (by //! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial //! investments are recorded as multiple records sharing //! `(date, from, to)` but differing in `dest_lot`. This keeps each //! record self-validating and avoids multi-lot allocation ordering //! concerns. //! //! ## Example records //! //! ``` //! # Simple cash deposit //! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash //! //! # Partial attribution: pre-existing cash + transfer → single stock lot //! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 //! //! # Sweep into basket + residual (two records, same date/from/to) //! transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 //! transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash //! ``` //! //! ## v1 scope //! //! - Only `transfer::` records (no buys/sells/dividends — those stay //! inferred from the portfolio diff). //! - Only `type::cash` is wired downstream. `type::in_kind` parses //! successfully but is rejected by the contributions matcher with //! an "in-kind transfers not yet supported" message. //! - No historical reconstruction — forward-looking only. //! //! See `REPORT.md` §5 for the full usage guide and //! `src/commands/contributions.zig` for the classifier integration. const std = @import("std"); const builtin = @import("builtin"); const srf = @import("srf"); const Date = @import("../Date.zig"); const logger = std.log.scoped(.transaction_log); /// Kind of transfer. Only `cash` is wired into the contributions /// classifier in v1. `in_kind` parses successfully so the file format /// is forward-compatible, but the matcher will reject records with /// this type until per-symbol in-kind matching is implemented. pub const TransferType = enum { cash, in_kind, }; /// Where a transfer landed inside the destination account. /// /// Either a specific lot (identified by its symbol + open_date, which /// together disambiguate lots within a single account in the common /// case) or the literal token `cash` (the transfer ended up as cash /// balance on the destination account). pub const DestLot = union(enum) { lot: LotRef, cash: void, pub const LotRef = struct { symbol: []const u8, open_date: Date, }; /// srf parser hook. Accepts `cash` (case-insensitive) or /// `SYMBOL@YYYY-MM-DD`. Any other shape is rejected. pub fn srfParse(str: []const u8) !DestLot { if (std.ascii.eqlIgnoreCase(str, "cash")) return .{ .cash = {} }; const at = std.mem.indexOfScalar(u8, str, '@') orelse return error.InvalidDestLot; if (at == 0) return error.InvalidDestLot; if (at + 1 >= str.len) return error.InvalidDestLot; const sym = str[0..at]; const date_str = str[at + 1 ..]; const date = Date.parse(date_str) catch return error.InvalidDestLot; return .{ .lot = .{ .symbol = sym, .open_date = date } }; } /// srf serializer hook. Emits `cash` or `SYMBOL@YYYY-MM-DD`. /// Allocates the output buffer via `allocator` — caller (the SRF /// OwnedRecord machinery) manages the lifetime. pub fn srfFormat( self: DestLot, allocator: std.mem.Allocator, comptime field_name: []const u8, ) !srf.Value { _ = field_name; return switch (self) { .cash => .{ .string = try allocator.dupe(u8, "cash") }, .lot => |l| blk: { // SYMBOL up to ~20 chars + '@' + 10-char date. const buf = try std.fmt.allocPrint(allocator, "{s}@", .{l.symbol}); defer allocator.free(buf); var out = try allocator.alloc(u8, buf.len + 10); @memcpy(out[0..buf.len], buf); _ = try std.fmt.bufPrint(out[buf.len..][0..10], "{f}", .{l.open_date}); break :blk .{ .string = out }; }, }; } /// Equality for tests and duplicate-dest_lot detection in the matcher. pub fn eql(self: DestLot, other: DestLot) bool { return switch (self) { .cash => other == .cash, .lot => |a| switch (other) { .cash => false, .lot => |b| a.open_date.days == b.open_date.days and std.mem.eql(u8, a.symbol, b.symbol), }, }; } }; /// One transfer record. All string fields are owned by the containing /// `TransactionLog` when the record was produced by /// `parseTransactionLogFile` — the log's allocator frees them on /// `deinit`. Records constructed by hand for tests can use any /// lifetime the caller prefers. /// /// The first field `transfer` is named to match the SRF on-wire record /// tag — `transfer::,...`. SRF's `fields.to(T)` coerces fields /// by name-matching against the struct, so `transfer: Date` maps the /// record tag's value (the date) into this field. Other code refers to /// it as `r.transfer` (reads as "the date this transfer is keyed by"). pub const TransferRecord = struct { transfer: Date, type: TransferType = .cash, amount: f64, from: []const u8, 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 /// fields on each record (including the symbol inside `dest_lot.lot`) /// are also owned by the log's allocator. pub const TransactionLog = struct { transfers: []TransferRecord, allocator: std.mem.Allocator, pub fn deinit(self: *TransactionLog) void { for (self.transfers) |r| { self.allocator.free(r.from); self.allocator.free(r.to); if (r.note) |n| self.allocator.free(n); switch (r.dest_lot) { .cash => {}, .lot => |l| self.allocator.free(l.symbol), } } self.allocator.free(self.transfers); } /// Return transfers whose `transfer` date falls within `[start, end]` /// inclusive. The returned slice is allocator-owned — caller must /// free it. /// /// Works only because `parseTransactionLogFile` preserves file /// order. If callers ever need chronological ordering regardless /// of file layout, sort on the way out instead of on the way in — /// file order is sometimes meaningful for a human reviewer /// (grouping related records together). pub fn transfersInWindow( self: *const TransactionLog, allocator: std.mem.Allocator, start: Date, end: Date, ) ![]const TransferRecord { var out: std.ArrayList(TransferRecord) = .empty; errdefer out.deinit(allocator); for (self.transfers) |r| { if (r.transfer.days < start.days) continue; if (r.transfer.days > end.days) continue; try out.append(allocator, r); } return try out.toOwnedSlice(allocator); } }; /// Parse `data` (the contents of a `transaction_log.srf` file) into a /// `TransactionLog`. String fields on each returned record are duped /// into `allocator`, so `data` can be freed immediately after this /// call returns successfully. /// /// Malformed records are silently skipped — matches the resilience /// pattern in `parseAccountsFile` / `parseClassificationFile`. The /// only hard errors are allocator failures and SRF-level parse errors /// that prevent the iterator from starting at all. /// /// The SRF record layout is `transfer::,type::,amount:num:, /// from::,to::,dest_lot::
[,note::]`. SRF's /// `fields.to(TransferRecord)` does the coercion: each key matches a /// struct field by name, defaults fill in elided optional fields, /// `DestLot.srfParse` handles `dest_lot`, and `TransferType` gets its /// enum value from the string. pub fn parseTransactionLogFile( allocator: std.mem.Allocator, data: []const u8, ) !TransactionLog { var out: std.ArrayList(TransferRecord) = .empty; errdefer { for (out.items) |r| { allocator.free(r.from); allocator.free(r.to); if (r.note) |n| allocator.free(n); switch (r.dest_lot) { .cash => {}, .lot => |l| allocator.free(l.symbol), } } out.deinit(allocator); } var reader = std.Io.Reader.fixed(data); var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; defer it.deinit(); while (try it.next()) |fields| { const parsed = fields.to(TransferRecord) catch |err| { // Tests intentionally feed malformed records to exercise the // skip path; real parse failures stay visible outside tests. if (!builtin.is_test) { logger.warn("skipping malformed transfer record: {s}", .{@errorName(err)}); } continue; }; // String fields on `parsed` point into the iterator's internal // buffer — dupe them before the next `it.next()` call. const dest_lot_owned: DestLot = switch (parsed.dest_lot) { .cash => .{ .cash = {} }, .lot => |l| .{ .lot = .{ .symbol = try allocator.dupe(u8, l.symbol), .open_date = l.open_date, } }, }; errdefer switch (dest_lot_owned) { .cash => {}, .lot => |l| allocator.free(l.symbol), }; try out.append(allocator, .{ .transfer = parsed.transfer, .type = parsed.type, .amount = parsed.amount, .from = try allocator.dupe(u8, parsed.from), .to = try allocator.dupe(u8, parsed.to), .dest_lot = dest_lot_owned, .note = if (parsed.note) |n| try allocator.dupe(u8, n) else null, }); } return .{ .transfers = try out.toOwnedSlice(allocator), .allocator = allocator, }; } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; test "DestLot.srfParse: cash token (lowercase)" { const d = try DestLot.srfParse("cash"); try testing.expect(d == .cash); } test "DestLot.srfParse: cash token (mixed case)" { const d = try DestLot.srfParse("Cash"); try testing.expect(d == .cash); const d2 = try DestLot.srfParse("CASH"); try testing.expect(d2 == .cash); } test "DestLot.srfParse: SYMBOL@DATE" { const d = try DestLot.srfParse("SYM@2026-05-03"); try testing.expect(d == .lot); try testing.expectEqualStrings("SYM", d.lot.symbol); try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, d.lot.open_date.days); } test "DestLot.srfParse: multi-char symbol with hyphen" { const d = try DestLot.srfParse("SYM-ABC@2026-05-03"); try testing.expect(d == .lot); try testing.expectEqualStrings("SYM-ABC", d.lot.symbol); } test "DestLot.srfParse: missing @ rejected (non-cash)" { try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM2026-05-03")); } test "DestLot.srfParse: empty symbol rejected" { try testing.expectError(error.InvalidDestLot, DestLot.srfParse("@2026-05-03")); } test "DestLot.srfParse: missing date rejected" { try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@")); } test "DestLot.srfParse: malformed date rejected" { try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@2026/05/03")); try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@not-a-date")); } test "DestLot.srfFormat: cash" { var buf: [64]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); const v = try (DestLot{ .cash = {} }).srfFormat(fba.allocator(), "dest_lot"); try testing.expectEqualStrings("cash", v.string); } test "DestLot.srfFormat: lot round-trip" { var buf: [64]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; const v = try orig.srfFormat(fba.allocator(), "dest_lot"); try testing.expectEqualStrings("SYM@2026-05-03", v.string); // Round-trip back through parse const parsed = try DestLot.srfParse(v.string); try testing.expect(orig.eql(parsed)); } test "DestLot.eql: cash vs cash" { try testing.expect((DestLot{ .cash = {} }).eql(.{ .cash = {} })); } test "DestLot.eql: cash vs lot" { const c: DestLot = .{ .cash = {} }; const l: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; try testing.expect(!c.eql(l)); try testing.expect(!l.eql(c)); } test "DestLot.eql: same lot" { const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; const b: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; try testing.expect(a.eql(b)); } test "DestLot.eql: different symbol" { const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; const b: DestLot = .{ .lot = .{ .symbol = "SYM2", .open_date = Date.fromYmd(2026, 5, 3) } }; try testing.expect(!a.eql(b)); } test "DestLot.eql: different date" { const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; const b: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 4) } }; 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 = "Sample Source", .to = "Sample Trust", .dest_lot = .cash, .note = null, }; const b: TransferRecord = .{ .transfer = Date.fromYmd(2026, 5, 20), .type = .cash, .amount = 73158.0, .from = "Sample Source", .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 \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 0), log.transfers.len); } test "parseTransactionLogFile: single cash transfer" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 1), log.transfers.len); const r = log.transfers[0]; try testing.expectEqual(Date.fromYmd(2026, 5, 2).days, r.transfer.days); try testing.expectEqual(TransferType.cash, r.type); try testing.expectEqual(@as(f64, 5000), r.amount); try testing.expectEqualStrings("Acct A", r.from); try testing.expectEqualStrings("Acct B", r.to); try testing.expect(r.dest_lot == .cash); try testing.expect(r.note == null); } test "parseTransactionLogFile: single lot-destination transfer" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03,note::paycheck \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 1), log.transfers.len); const r = log.transfers[0]; try testing.expect(r.dest_lot == .lot); try testing.expectEqualStrings("SYM", r.dest_lot.lot.symbol); try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, r.dest_lot.lot.open_date.days); try testing.expect(r.note != null); try testing.expectEqualStrings("paycheck", r.note.?); } test "parseTransactionLogFile: sweep encoded as two records" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 2), log.transfers.len); try testing.expectEqual(@as(f64, 145300), log.transfers[0].amount); try testing.expect(log.transfers[0].dest_lot == .lot); try testing.expectEqualStrings("SYM", log.transfers[0].dest_lot.lot.symbol); try testing.expectEqual(@as(f64, 4700), log.transfers[1].amount); try testing.expect(log.transfers[1].dest_lot == .cash); // Sanity: both records preserved their (date, from, to) pairing. try testing.expectEqual(log.transfers[0].transfer.days, log.transfers[1].transfer.days); try testing.expectEqualStrings(log.transfers[0].from, log.transfers[1].from); try testing.expectEqualStrings(log.transfers[0].to, log.transfers[1].to); } test "parseTransactionLogFile: type defaults to cash when elided" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 1), log.transfers.len); try testing.expectEqual(TransferType.cash, log.transfers[0].type); } test "parseTransactionLogFile: type::in_kind parses but is preserved (rejected downstream)" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 1), log.transfers.len); try testing.expectEqual(TransferType.in_kind, log.transfers[0].type); } test "parseTransactionLogFile: malformed record skipped, subsequent record survives" { // Resilience contract: one bad record doesn't wedge the parser. // // This is the only malformation shape we can test cleanly. The // other two fail in ways that break the test runner itself: // // 1. Bad `dest_lot` value (e.g. `dest_lot::garbage-no-at`): // `DestLot.srfParse` returns `error.InvalidDestLot`. SRF // logs that at `err` level from inside `fields.to`, and the // Zig test runner counts `log.err` calls BEFORE applying // `std.testing.log_level` — so the test is marked "logged // errors" regardless of how the test tries to suppress it. // 2. Wrong value shape for a typed field (e.g. `amount::text` // where `amount: f64` expects a `:num:` value): SRF's // `coerce` panics on the union-tag mismatch // (`@floatCast(val.?.number)` while `val.?` is `.string`). // // Missing-required-field is the one path SRF handles cleanly — // `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue` // (logged only at `debug` level). That's what we exercise here. // // TODO: upstream a per-call suppression knob on SRF (or downgrade // the custom-parse log to `warn`) and re-enable a `dest_lot`- // shape malformation test. Until then, `DestLot.srfParse`'s unit // tests above cover that parser in isolation. var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,dest_lot::cash \\transfer::2026-05-03,type::cash,amount:num:3000,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); // First record missing `to` → skipped; second record survives. try testing.expectEqual(@as(usize, 1), log.transfers.len); try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount); } test "transfersInWindow: inclusive on both ends" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-04-30,type::cash,amount:num:100,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-01,type::cash,amount:num:200,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-15,type::cash,amount:num:300,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-31,type::cash,amount:num:400,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-06-01,type::cash,amount:num:500,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); const slice = try log.transfersInWindow( testing.allocator, Date.fromYmd(2026, 5, 1), Date.fromYmd(2026, 5, 31), ); defer testing.allocator.free(slice); try testing.expectEqual(@as(usize, 3), slice.len); try testing.expectEqual(@as(f64, 200), slice[0].amount); try testing.expectEqual(@as(f64, 300), slice[1].amount); try testing.expectEqual(@as(f64, 400), slice[2].amount); } test "transfersInWindow: empty window returns empty slice" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-01,type::cash,amount:num:100,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); const slice = try log.transfersInWindow( testing.allocator, Date.fromYmd(2027, 1, 1), Date.fromYmd(2027, 12, 31), ); defer testing.allocator.free(slice); try testing.expectEqual(@as(usize, 0), slice.len); } test "parseTransactionLogFile: preserves file order (not sorted)" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-15,type::cash,amount:num:3,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-01,type::cash,amount:num:1,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-08,type::cash,amount:num:2,from::Acct A,to::Acct B,dest_lot::cash \\ ); defer log.deinit(); try testing.expectEqual(@as(usize, 3), log.transfers.len); // File order preserved — see `transfersInWindow` docstring for why. try testing.expectEqual(@as(f64, 3), log.transfers[0].amount); try testing.expectEqual(@as(f64, 1), log.transfers[1].amount); try testing.expectEqual(@as(f64, 2), log.transfers[2].amount); }