691 lines
26 KiB
Zig
691 lines
26 KiB
Zig
//! 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).
|
|
//! - Both `type::cash` and `type::in_kind` are wired into the
|
|
//! contributions matcher. Cash records match against a cash budget /
|
|
//! pooled destination; in-kind records (securities moved without
|
|
//! cash changing hands) match a source share-removal against a
|
|
//! destination share-addition, per-symbol.
|
|
//! - No historical reconstruction - forward-looking only.
|
|
//!
|
|
//! See `docs/reference/config/transaction-log-srf.md` 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 srf_opts = @import("../srf_opts.zig");
|
|
const Date = @import("../Date.zig");
|
|
|
|
const logger = std.log.scoped(.transaction_log);
|
|
|
|
/// Kind of transfer. Both kinds are wired into the contributions
|
|
/// classifier: `cash` matches against the destination account's cash
|
|
/// budget / pooled cash activity, while `in_kind` pairs a source
|
|
/// share-removal against a destination share-addition for the same
|
|
/// symbol across the `from` and `to` accounts.
|
|
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.
|
|
///
|
|
/// The `cash` variant returns `.initFree(...)` since no slice
|
|
/// of `str` is retained. The `lot` variant returns `.init(...)`
|
|
/// because `DestLot.lot.symbol` borrows a slice of `str`;
|
|
/// freeing `str` would dangle the symbol. Callers consuming
|
|
/// the resulting `DestLot.lot` are responsible for duping
|
|
/// `symbol` if it must outlive the source buffer.
|
|
pub fn srfParse(str: []const u8) !srf.CoercionResult(DestLot) {
|
|
if (std.ascii.eqlIgnoreCase(str, "cash")) return .initFree(.{ .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 .init(.{ .lot = .{ .symbol = sym, .open_date = date } });
|
|
}
|
|
|
|
/// srf serializer hook. Emits `cash` or `SYMBOL@YYYY-MM-DD`
|
|
/// directly to the writer using the "string" type (untyped).
|
|
pub fn srfFormat(
|
|
self: DestLot,
|
|
comptime field_name: []const u8,
|
|
writer: *std.Io.Writer,
|
|
) std.Io.Writer.Error!void {
|
|
switch (self) {
|
|
.cash => try writer.print("{s}::cash", .{field_name}),
|
|
.lot => |l| try writer.print("{s}::{s}@{f}", .{ field_name, l.symbol, l.open_date }),
|
|
}
|
|
}
|
|
|
|
/// 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::<date>,...`. 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);
|
|
}
|
|
};
|
|
|
|
/// 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.
|
|
///
|
|
/// Records come out in **file order**, deliberately not sorted by date.
|
|
/// File order is often meaningful to a human reviewer - related records
|
|
/// get grouped together - and any consumer that needs chronological
|
|
/// ordering can sort on the way out. Pinned by a test below.
|
|
///
|
|
/// The SRF record layout is `transfer::<date>,type::<t>,amount:num:<n>,
|
|
/// from::<a>,to::<b>,dest_lot::<dl>[,note::<n>]`. 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, .{ .parse_allocator = .none }) catch return error.InvalidData;
|
|
defer it.deinit();
|
|
|
|
while (try it.next()) |fields| {
|
|
const parsed = fields.to(TransferRecord, srf_opts.user_edited) 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 r = try DestLot.srfParse("cash");
|
|
try testing.expect(r.value == .cash);
|
|
}
|
|
|
|
test "DestLot.srfParse: cash token (mixed case)" {
|
|
const r = try DestLot.srfParse("Cash");
|
|
try testing.expect(r.value == .cash);
|
|
const r2 = try DestLot.srfParse("CASH");
|
|
try testing.expect(r2.value == .cash);
|
|
}
|
|
|
|
test "DestLot.srfParse: SYMBOL@DATE" {
|
|
const r = try DestLot.srfParse("SYM@2026-05-03");
|
|
try testing.expect(r.value == .lot);
|
|
try testing.expectEqualStrings("SYM", r.value.lot.symbol);
|
|
try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, r.value.lot.open_date.days);
|
|
}
|
|
|
|
test "DestLot.srfParse: multi-char symbol with hyphen" {
|
|
const r = try DestLot.srfParse("SYM-ABC@2026-05-03");
|
|
try testing.expect(r.value == .lot);
|
|
try testing.expectEqualStrings("SYM-ABC", r.value.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 w = std.Io.Writer.fixed(&buf);
|
|
try (DestLot{ .cash = {} }).srfFormat("dest_lot", &w);
|
|
try testing.expectEqualStrings("dest_lot::cash", w.buffered());
|
|
}
|
|
|
|
test "DestLot.srfFormat: lot round-trip" {
|
|
var buf: [64]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
|
try orig.srfFormat("dest_lot", &w);
|
|
try testing.expectEqualStrings("dest_lot::SYM@2026-05-03", w.buffered());
|
|
// Round-trip back through parse - strip the "dest_lot::" prefix.
|
|
const written = w.buffered();
|
|
const value_str = written[std.mem.indexOfScalar(u8, written, ':').? + 2 ..];
|
|
const parsed = try DestLot.srfParse(value_str);
|
|
try testing.expect(orig.eql(parsed.value));
|
|
}
|
|
|
|
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: a hand-typed string separator on amount still parses" {
|
|
// Hand-edited file, so `amount::50000` instead of `amount:num:50000`
|
|
// must not be fatal. See `srf_opts.user_edited` - under strict
|
|
// coercion this string reaches an unchecked `val.?.number`, which is
|
|
// undefined behaviour in ReleaseFast.
|
|
var log = try parseTransactionLogFile(testing.allocator,
|
|
\\#!srfv1
|
|
\\transfer::2026-05-02,type::cash,amount::50000,from::Acct A,to::Acct B,dest_lot::cash
|
|
\\
|
|
);
|
|
defer log.deinit();
|
|
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
|
try testing.expectEqual(@as(f64, 50000), log.transfers[0].amount);
|
|
}
|
|
|
|
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 and preserves its type" {
|
|
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 "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, NOT date-sorted - see the parser's docstring.
|
|
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);
|
|
}
|