add in-kind transfer capability
This commit is contained in:
parent
5ad353ed43
commit
7e9261f92f
5 changed files with 614 additions and 46 deletions
|
|
@ -46,7 +46,7 @@ repos:
|
||||||
- id: test
|
- id: test
|
||||||
name: Run zig build test
|
name: Run zig build test
|
||||||
entry: zig
|
entry: zig
|
||||||
args: ["build", "coverage", "-Dcoverage-threshold=77"]
|
args: ["build", "coverage", "-Dcoverage-threshold=78"]
|
||||||
language: system
|
language: system
|
||||||
types: [file]
|
types: [file]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
|
||||||
22
TODO.md
22
TODO.md
|
|
@ -168,28 +168,6 @@ opts ESPP/HSA accounts into cash-based attribution.
|
||||||
Related: ESPP-style accrual blind spot in the "Audit: manual-check
|
Related: ESPP-style accrual blind spot in the "Audit: manual-check
|
||||||
accounts mechanism" section above.
|
accounts mechanism" section above.
|
||||||
|
|
||||||
## In-kind transfer support (`type::in_kind`) - priority MEDIUM
|
|
||||||
|
|
||||||
`transaction_log.srf` parses `type::in_kind` records but the
|
|
||||||
contributions matcher always rejects them with "in-kind transfers
|
|
||||||
not yet supported in v1." In-kind movements need per-symbol
|
|
||||||
matching across accounts: an in-kind transfer of 100 VTI shares
|
|
||||||
from Acct A to Acct B shows up as `lot_removed` on A + `new_stock`
|
|
||||||
on B (or a `rollup_delta` share increase if B already had a VTI
|
|
||||||
lot), neither of which can be matched by the current
|
|
||||||
amount-based cash matcher.
|
|
||||||
|
|
||||||
Proposed: a second pass in `matchTransfers` that iterates
|
|
||||||
`type::in_kind` records and looks for same-symbol matches across
|
|
||||||
`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to`
|
|
||||||
within the window. Gated on share-count and open_price sanity so
|
|
||||||
a partial transfer doesn't false-positive against an unrelated
|
|
||||||
edit.
|
|
||||||
|
|
||||||
Driver: when the user starts moving positions between accounts
|
|
||||||
directly (e.g. Roth conversion of already-held shares, 401k ->
|
|
||||||
rollover IRA in-kind) rather than liquidating and re-buying.
|
|
||||||
|
|
||||||
## Torn SRF files from server sync (root cause unknown)
|
## Torn SRF files from server sync (root cause unknown)
|
||||||
|
|
||||||
**Status:** Root cause still unidentified. We have mitigations and
|
**Status:** Root cause still unidentified. We have mitigations and
|
||||||
|
|
|
||||||
|
|
@ -442,8 +442,10 @@ fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
|
||||||
///
|
///
|
||||||
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
|
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
|
||||||
/// (or cash_contribution) destinations use `dest_lot::cash`. The
|
/// (or cash_contribution) destinations use `dest_lot::cash`. The
|
||||||
/// template always uses `type::cash` since `type::in_kind` is
|
/// template defaults to `type::cash` (cash moved in, then invested -
|
||||||
/// rejected downstream in v1.
|
/// the common case). If the securities themselves were moved between
|
||||||
|
/// accounts, change it to `type::in_kind` and fill in the `from::`
|
||||||
|
/// account that the shares left.
|
||||||
fn printLargeLotWarning(
|
fn printLargeLotWarning(
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
lot: contributions.UnmatchedLargeLot,
|
lot: contributions.UnmatchedLargeLot,
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,20 @@
|
||||||
//! a reason string; the transfer amount
|
//! a reason string; the transfer amount
|
||||||
//! stays out of attribution either way.
|
//! stays out of attribution either way.
|
||||||
//!
|
//!
|
||||||
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
|
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
|
||||||
//! |-----------------------------------------------|------------------------|------------------|:--------------:|:--------------:|
|
//! |-------------------------------------------------|--------------------------------|------------------------------------------|:--------------:|:--------------:|
|
||||||
//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no |
|
//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no |
|
||||||
//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual |
|
//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual |
|
||||||
//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no |
|
//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no |
|
||||||
//! | Record with no match (bad dest, in_kind, ...) | `unmatched_transfer` | Flagged | no | no |
|
//! | In-kind securities moved between accounts | `transfer_in` + `transfer_out` | Transfers | no | no |
|
||||||
|
//! | Record with no match (bad dest, mismatch, ...) | `unmatched_transfer` | Flagged | no | no |
|
||||||
|
//!
|
||||||
|
//! `type::cash` records and `type::in_kind` records take different
|
||||||
|
//! matching passes. Cash records match a cash budget / pooled
|
||||||
|
//! destination (see `matchCashDestination` / `matchLotDestination`).
|
||||||
|
//! In-kind records (securities moved without cash changing hands)
|
||||||
|
//! pair a source share-removal Change against a destination share-
|
||||||
|
//! addition Change on a per-symbol basis (see `matchInKindTransfer`).
|
||||||
//!
|
//!
|
||||||
//! Cash-destination records don't flip the original `cash_delta` /
|
//! Cash-destination records don't flip the original `cash_delta` /
|
||||||
//! `new_cash` Change (a single cash delta can be drained by
|
//! `new_cash` Change (a single cash delta can be drained by
|
||||||
|
|
@ -1863,6 +1871,20 @@ fn computeReport(
|
||||||
/// dollars for cash lots) without masking real discrepancies.
|
/// dollars for cash lots) without masking real discrepancies.
|
||||||
const transfer_amount_tolerance: f64 = 1.0;
|
const transfer_amount_tolerance: f64 = 1.0;
|
||||||
|
|
||||||
|
/// Relative share-count tolerance for confirming an in-kind transfer:
|
||||||
|
/// the shares removed on the `from` account must match the shares
|
||||||
|
/// added on the `to` account to within this fraction of the larger
|
||||||
|
/// of the two. Brokerages sometimes liquidate a fractional share on
|
||||||
|
/// an in-kind move (whole shares transfer, the residual fraction is
|
||||||
|
/// swept to cash), so a small relative drift is expected. Mirrors the
|
||||||
|
/// 1% direct-indexing drift tolerance used elsewhere in the diff.
|
||||||
|
const in_kind_share_rel_tolerance: f64 = 0.01;
|
||||||
|
|
||||||
|
/// Absolute floor for the in-kind share-count tolerance, so a small
|
||||||
|
/// transfer (a one-share move) still gets a sane float-rounding
|
||||||
|
/// epsilon even though 1% of one share is tiny.
|
||||||
|
const in_kind_share_abs_tolerance: f64 = 0.01;
|
||||||
|
|
||||||
/// Compute the slice of transfer records that are NEW in `after`
|
/// Compute the slice of transfer records that are NEW in `after`
|
||||||
/// relative to `before` - i.e. records the matcher should consider
|
/// relative to `before` - i.e. records the matcher should consider
|
||||||
/// for the current diff. Records present in both logs are skipped
|
/// for the current diff. Records present in both logs are skipped
|
||||||
|
|
@ -1904,8 +1926,11 @@ fn diffTransferLogs(
|
||||||
/// recorded transfer. Walks `report.changes` and the in-window
|
/// recorded transfer. Walks `report.changes` and the in-window
|
||||||
/// transfer records together:
|
/// transfer records together:
|
||||||
///
|
///
|
||||||
/// - For each record with `type::cash` (v1 only; `in_kind` always
|
/// - For each record with `type::in_kind`: pair a source share-
|
||||||
/// emits `unmatched_transfer`):
|
/// removal Change on `from` against a destination share-addition
|
||||||
|
/// Change on `to`, per-symbol (see `matchInKindTransfer`).
|
||||||
|
///
|
||||||
|
/// - For each record with `type::cash`:
|
||||||
/// - If `dest_lot` is a specific lot (`SYMBOL@DATE`): find the
|
/// - If `dest_lot` is a specific lot (`SYMBOL@DATE`): find the
|
||||||
/// matching `new_stock` / `new_drip_lot` / `new_cash` /
|
/// matching `new_stock` / `new_drip_lot` / `new_cash` /
|
||||||
/// `new_cd` / `cash_contribution` Change with the same
|
/// `new_cd` / `cash_contribution` Change with the same
|
||||||
|
|
@ -1989,7 +2014,12 @@ fn matchTransfers(
|
||||||
// after-side `transaction_log.srf` vs. the before-side).
|
// after-side `transaction_log.srf` vs. the before-side).
|
||||||
for (records) |rec| {
|
for (records) |rec| {
|
||||||
if (rec.type == .in_kind) {
|
if (rec.type == .in_kind) {
|
||||||
try appendUnmatched(allocator, changes, rec, "in-kind transfers not yet supported in v1");
|
// In-kind transfers move securities, not cash, so they
|
||||||
|
// take a separate per-symbol matching pass (source
|
||||||
|
// `lot_removed`/`drip_negative` + destination
|
||||||
|
// `new_stock`/`new_drip_lot`/`rollup_delta`) rather than
|
||||||
|
// the cash budget / from-side path below.
|
||||||
|
try matchInKindTransfer(allocator, changes, rec);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2277,6 +2307,139 @@ fn tryMatchFromSide(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Match a `type::in_kind` transfer record: securities moved between
|
||||||
|
/// accounts without any cash changing hands. Unlike the cash path,
|
||||||
|
/// both the source and destination are lot-level Changes:
|
||||||
|
///
|
||||||
|
/// - **Destination** (required): a `new_stock` / `new_drip_lot`
|
||||||
|
/// (the shares landed as a fresh lot on `to`) or a `rollup_delta`
|
||||||
|
/// (the shares were added to an existing `to` lot). Identified by
|
||||||
|
/// the record's `dest_lot::SYMBOL@DATE`. Reclassified to
|
||||||
|
/// `transfer_in` so it contributes $0 to attribution - it's
|
||||||
|
/// internal movement, not new money.
|
||||||
|
/// - **Source** (best-effort): a `lot_removed` / `drip_negative` on
|
||||||
|
/// the `from` account with the same symbol. Reclassified to
|
||||||
|
/// `transfer_out`. A missing source is NOT an error - the sending
|
||||||
|
/// account may be untracked (an external rollover origin), the
|
||||||
|
/// same tolerance the cash from-side path extends.
|
||||||
|
///
|
||||||
|
/// When both sides are present the shares removed must match the
|
||||||
|
/// shares added to within `in_kind_share_*_tolerance`; a mismatch
|
||||||
|
/// means the declared transfer doesn't cleanly correspond to the
|
||||||
|
/// portfolio diff (wrong symbol, unexpected partial fill) and is
|
||||||
|
/// surfaced as `unmatched_transfer` rather than silently swallowing a
|
||||||
|
/// real contribution.
|
||||||
|
///
|
||||||
|
/// `amount` on an in-kind record is informational - the moved value
|
||||||
|
/// comes from the destination lot's own `value()` (shares x cost
|
||||||
|
/// basis), which is what `transfer_attributed` records for display.
|
||||||
|
/// In-kind movements have no "residual new money" concept (no cash
|
||||||
|
/// funded them), so there is no `partial_transfer_in` outcome: a
|
||||||
|
/// destination Change is either fully a transfer or not one at all.
|
||||||
|
fn matchInKindTransfer(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
changes: *std.ArrayList(Change),
|
||||||
|
rec: transaction_log.TransferRecord,
|
||||||
|
) !void {
|
||||||
|
const dl: transaction_log.DestLot.LotRef = switch (rec.dest_lot) {
|
||||||
|
.lot => |l| l,
|
||||||
|
.cash => {
|
||||||
|
try appendUnmatched(allocator, changes, rec, "in-kind transfer requires a SYMBOL@DATE destination lot, not cash");
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Destination (required) ───────────────────────────────────
|
||||||
|
// First share-addition Change on the `to` account for this
|
||||||
|
// symbol. Keyed on (account, symbol, kind); open_date is
|
||||||
|
// consulted only for display (Change doesn't carry it for
|
||||||
|
// rollup_delta). Once matched, a Change is flipped to transfer_in,
|
||||||
|
// so a second record naming the same lot won't re-match it (it's
|
||||||
|
// no longer a destination kind) and falls through to not-found.
|
||||||
|
var dest_idx: ?usize = null;
|
||||||
|
for (changes.items, 0..) |c, i| {
|
||||||
|
if (!std.mem.eql(u8, c.account, rec.to)) continue;
|
||||||
|
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
|
||||||
|
switch (c.kind) {
|
||||||
|
.new_stock, .new_drip_lot, .rollup_delta => {},
|
||||||
|
else => continue,
|
||||||
|
}
|
||||||
|
dest_idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const di = dest_idx orelse {
|
||||||
|
const buf = try std.fmt.allocPrint(
|
||||||
|
allocator,
|
||||||
|
"in-kind destination lot {s}@ not found on account {s} (expected a new lot or share increase; an earlier record may have claimed it)",
|
||||||
|
.{ dl.symbol, rec.to },
|
||||||
|
);
|
||||||
|
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Source (best-effort) ─────────────────────────────────────
|
||||||
|
// First share-removal Change on the `from` account for this
|
||||||
|
// symbol. A missing source is not an error - the sending account
|
||||||
|
// may be untracked (external rollover origin).
|
||||||
|
var src_idx: ?usize = null;
|
||||||
|
for (changes.items, 0..) |c, i| {
|
||||||
|
if (!std.mem.eql(u8, c.account, rec.from)) continue;
|
||||||
|
if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
|
||||||
|
switch (c.kind) {
|
||||||
|
.lot_removed, .drip_negative => {},
|
||||||
|
else => continue,
|
||||||
|
}
|
||||||
|
src_idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share-count gate (only when both sides are present) ──────
|
||||||
|
if (src_idx) |si| {
|
||||||
|
const dest_shares = @abs(changes.items[di].delta_shares);
|
||||||
|
const src_shares = @abs(changes.items[si].delta_shares);
|
||||||
|
const tol = @max(
|
||||||
|
in_kind_share_abs_tolerance,
|
||||||
|
in_kind_share_rel_tolerance * @max(dest_shares, src_shares),
|
||||||
|
);
|
||||||
|
if (@abs(dest_shares - src_shares) > tol) {
|
||||||
|
const buf = try std.fmt.allocPrint(
|
||||||
|
allocator,
|
||||||
|
"in-kind share mismatch for {s}: {d:.4} removed from {s} vs {d:.4} added to {s}",
|
||||||
|
.{ dl.symbol, src_shares, rec.from, dest_shares, rec.to },
|
||||||
|
);
|
||||||
|
try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reclassify ───────────────────────────────────────────────
|
||||||
|
// Dupe owned strings before mutating so an allocation failure
|
||||||
|
// doesn't leave a half-rewritten Change. The moved value is the
|
||||||
|
// destination lot's own value (shares x cost basis), not the
|
||||||
|
// record's `amount` (which is an informational annotation).
|
||||||
|
const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
|
||||||
|
const from_copy = try allocator.dupe(u8, rec.from);
|
||||||
|
const moved_value = changes.items[di].value();
|
||||||
|
|
||||||
|
const dest = &changes.items[di];
|
||||||
|
dest.kind = .transfer_in;
|
||||||
|
dest.transfer_attributed = moved_value;
|
||||||
|
dest.transfer_note = note_copy;
|
||||||
|
dest.transfer_from = from_copy;
|
||||||
|
dest.transfer_date = rec.transfer;
|
||||||
|
|
||||||
|
if (src_idx) |si| {
|
||||||
|
const src = &changes.items[si];
|
||||||
|
src.kind = .transfer_out;
|
||||||
|
// The from-side display reads `transfer_attributed` for the
|
||||||
|
// moved value and `account` for the sending account; it does
|
||||||
|
// not read `transfer_from` (see `printTransferLine`).
|
||||||
|
src.transfer_attributed = moved_value;
|
||||||
|
src.transfer_date = rec.transfer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Output ───────────────────────────────────────────────────
|
// ── Output ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
|
fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
|
||||||
|
|
@ -4494,7 +4657,11 @@ test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "matchTransfers: type::in_kind always emits unmatched" {
|
test "matchInKindTransfer: happy path - new lot on dest, lot_removed on source" {
|
||||||
|
// Roth-conversion shape: 80 shares of SYM move in-kind from
|
||||||
|
// Acct A (tracked) to Acct B. A's lot disappears (lot_removed),
|
||||||
|
// B gains a fresh lot (new_stock). Both flip to transfer kinds
|
||||||
|
// and contribute $0 to attribution.
|
||||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const allocator = arena_state.allocator();
|
const allocator = arena_state.allocator();
|
||||||
|
|
@ -4502,8 +4669,9 @@ test "matchTransfers: type::in_kind always emits unmatched" {
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
try prices.put("SYM", 100.0);
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
// Even if a matching lot exists, in_kind is rejected in v1.
|
const before = [_]Lot{
|
||||||
const before = [_]Lot{};
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
|
||||||
|
};
|
||||||
const after = [_]Lot{
|
const after = [_]Lot{
|
||||||
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
|
||||||
};
|
};
|
||||||
|
|
@ -4518,18 +4686,435 @@ test "matchTransfers: type::in_kind always emits unmatched" {
|
||||||
.transfer_log = tlog.transfers,
|
.transfer_log = tlog.transfers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// new_stock stays as new_stock (not flipped); unmatched appended.
|
var n_transfer_in: usize = 0;
|
||||||
|
var n_transfer_out: usize = 0;
|
||||||
var n_new_stock: usize = 0;
|
var n_new_stock: usize = 0;
|
||||||
|
var n_lot_removed: usize = 0;
|
||||||
var n_unmatched: usize = 0;
|
var n_unmatched: usize = 0;
|
||||||
for (report.changes) |c| switch (c.kind) {
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.transfer_in => n_transfer_in += 1,
|
||||||
|
.transfer_out => n_transfer_out += 1,
|
||||||
.new_stock => n_new_stock += 1,
|
.new_stock => n_new_stock += 1,
|
||||||
|
.lot_removed => n_lot_removed += 1,
|
||||||
.unmatched_transfer => n_unmatched += 1,
|
.unmatched_transfer => n_unmatched += 1,
|
||||||
else => {},
|
else => {},
|
||||||
};
|
};
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_new_stock);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_lot_removed);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
|
||||||
|
|
||||||
|
// Dest moved value is the lot's own value (80 x $100 = $8,000).
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.kind == .transfer_in) {
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 8000.0), c.transfer_attributed, 0.01);
|
||||||
|
try std.testing.expectEqualStrings("Acct A", c.transfer_from.?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No new money on either account.
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: into existing dest lot (rollup_delta) flips to transfer_in" {
|
||||||
|
// B already held SYM; the in-kind add shows up as a rollup_delta
|
||||||
|
// (share increase on the existing lot), not a new_stock.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
|
||||||
|
.{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
// Acct A's lot gone; Acct B's lot grew by 100 shares (same key).
|
||||||
|
.{ .symbol = "SYM", .shares = 150, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2024-01-01
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_transfer_in: usize = 0;
|
||||||
|
var n_transfer_out: usize = 0;
|
||||||
|
var n_rollup: usize = 0;
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.transfer_in => n_transfer_in += 1,
|
||||||
|
.transfer_out => n_transfer_out += 1,
|
||||||
|
.rollup_delta => n_rollup += 1,
|
||||||
|
.unmatched_transfer => n_unmatched += 1,
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_rollup);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
|
||||||
|
|
||||||
|
// Rollup share delta is no longer counted on Acct B.
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.rollup, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: partial move (drip_negative source) matches" {
|
||||||
|
// 40 of A's 100 SYM shares move to B. A's lot shrinks
|
||||||
|
// (drip_negative); B gains a new lot. Share counts match.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" },
|
||||||
|
.{ .symbol = "SYM", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 90, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:4000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_transfer_in: usize = 0;
|
||||||
|
var n_transfer_out: usize = 0;
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.transfer_in => n_transfer_in += 1,
|
||||||
|
.transfer_out => n_transfer_out += 1,
|
||||||
|
.unmatched_transfer => n_unmatched += 1,
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_out);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: untracked source still flips destination" {
|
||||||
|
// The `from` account isn't in the portfolio (external rollover
|
||||||
|
// origin). The destination still reclassifies to transfer_in -
|
||||||
|
// the user declared the move, so it isn't new external money.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::External Rollover,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_transfer_in: usize = 0;
|
||||||
|
var n_transfer_out: usize = 0;
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.transfer_in => n_transfer_in += 1,
|
||||||
|
.transfer_out => n_transfer_out += 1,
|
||||||
|
.unmatched_transfer => n_unmatched += 1,
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_transfer_out); // no tracked source
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), n_unmatched);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: share-count mismatch emits unmatched, leaves Changes counted" {
|
||||||
|
// A removes 100 SYM but B only gains 73 SYM - the declared
|
||||||
|
// transfer doesn't cleanly correspond to the diff. The matcher
|
||||||
|
// refuses to pair them: both Changes keep their base kinds and an
|
||||||
|
// unmatched_transfer is flagged.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 73, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_new_stock: usize = 0;
|
||||||
|
var n_lot_removed: usize = 0;
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.new_stock => n_new_stock += 1,
|
||||||
|
.lot_removed => n_lot_removed += 1,
|
||||||
|
.unmatched_transfer => n_unmatched += 1,
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
// Base classifications survive; the bad record is surfaced.
|
||||||
try std.testing.expectEqual(@as(usize, 1), n_new_stock);
|
try std.testing.expectEqual(@as(usize, 1), n_new_stock);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_lot_removed);
|
||||||
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: cash destination is rejected as unmatched" {
|
||||||
|
// An in-kind record must name a SYMBOL@DATE lot. `dest_lot::cash`
|
||||||
|
// is nonsensical for a securities transfer.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
|
||||||
|
const before = [_]Lot{};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "cash", .shares = 8000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::cash
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
var unmatched_note: ?[]const u8 = null;
|
||||||
|
for (report.changes) |c| {
|
||||||
|
if (c.kind == .unmatched_transfer) {
|
||||||
|
n_unmatched += 1;
|
||||||
|
unmatched_note = c.transfer_note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||||||
|
try std.testing.expect(unmatched_note != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not cash") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: destination lot not found emits unmatched" {
|
||||||
|
// The record names SYM but no SYM lot appeared on the `to`
|
||||||
|
// account (typo, or the lot didn't materialize this diff). The
|
||||||
|
// base diff is left untouched and the record is flagged.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{};
|
||||||
|
const after = [_]Lot{
|
||||||
|
// A SYM lot landed on Acct C, not the record's `to` (Acct B).
|
||||||
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct C" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_new_stock: usize = 0;
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
var unmatched_note: ?[]const u8 = null;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.new_stock => n_new_stock += 1,
|
||||||
|
.unmatched_transfer => {
|
||||||
|
n_unmatched += 1;
|
||||||
|
unmatched_note = c.transfer_note;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_new_stock); // Acct C lot untouched
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||||||
|
try std.testing.expect(unmatched_note != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not found") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "matchInKindTransfer: duplicate record - first matches, second unmatched" {
|
||||||
|
// Two in-kind records name the same destination lot but the diff
|
||||||
|
// only shows one share-addition. The first claims it (flipped to
|
||||||
|
// transfer_in); the second can't re-match the now-reclassified
|
||||||
|
// Change and is flagged.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var n_transfer_in: usize = 0;
|
||||||
|
var n_unmatched: usize = 0;
|
||||||
|
for (report.changes) |c| switch (c.kind) {
|
||||||
|
.transfer_in => n_transfer_in += 1,
|
||||||
|
.unmatched_transfer => n_unmatched += 1,
|
||||||
|
else => {},
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), n_unmatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "printReport: in-kind transfer renders in Transfers section, out of totals" {
|
||||||
|
// End-to-end through the display layer: a report mixing a real
|
||||||
|
// new contribution, an in-kind transfer (in + out), a cash delta,
|
||||||
|
// and an unmatched removal should render every section and keep
|
||||||
|
// the transferred securities out of the grand total.
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("SYM", 100.0);
|
||||||
|
try prices.put("NEWX", 200.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Sample IRA" },
|
||||||
|
.{ .symbol = "OLDX", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50, .account = "Sample HSA" },
|
||||||
|
.{ .symbol = "cash", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Sample Roth IRA" },
|
||||||
|
.{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" },
|
||||||
|
.{ .symbol = "cash", .shares = 1500, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||||
|
\\#!srfv1
|
||||||
|
\\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Sample IRA,to::Sample Roth IRA,dest_lot::SYM@2026-05-03
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
|
||||||
|
.transfer_log = tlog.transfers,
|
||||||
|
});
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
|
const out = &aw.writer;
|
||||||
|
try printReport(out, &report, "test window", false);
|
||||||
|
const text = aw.written();
|
||||||
|
|
||||||
|
// Sections present.
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "Transfers (matched - not counted)") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "Cash deltas") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "Flagged for review") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "Summary by account") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "Grand total") != null);
|
||||||
|
|
||||||
|
// The real new purchase and the unmatched removal show up.
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "NEWX") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "OLDX") != null);
|
||||||
|
// The in-kind transfer's sending account is cross-referenced in
|
||||||
|
// the Transfers section.
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "Sample IRA") != null);
|
||||||
|
|
||||||
|
// Grand total = NEWX purchase ($1,000) only; the $8,000 in-kind
|
||||||
|
// SYM move and the $500 cash delta don't count as contributions.
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases: $1,000.00") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "printReport: no changes detected renders a single line" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
|
||||||
|
const before = [_]Lot{};
|
||||||
|
const after = [_]Lot{};
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
|
const out = &aw.writer;
|
||||||
|
try printReport(out, &report, "test window", false);
|
||||||
|
const text = aw.written();
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, text, "No changes detected") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "printReport: color=true emits ANSI escapes" {
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const allocator = arena_state.allocator();
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("NEWX", 200.0);
|
||||||
|
|
||||||
|
const before = [_]Lot{};
|
||||||
|
const after = [_]Lot{
|
||||||
|
.{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
|
const out = &aw.writer;
|
||||||
|
try printReport(out, &report, "test window", true);
|
||||||
|
const text = aw.written();
|
||||||
|
// ESC (0x1b) appears when color is on.
|
||||||
|
try std.testing.expect(std.mem.indexOfScalar(u8, text, 0x1b) != null);
|
||||||
|
}
|
||||||
|
|
||||||
test "matchTransfers: back-dated record matches regardless of date" {
|
test "matchTransfers: back-dated record matches regardless of date" {
|
||||||
// The matcher itself is date-agnostic now - the caller (typically
|
// The matcher itself is date-agnostic now - the caller (typically
|
||||||
// `prepareReport` via `diffTransferLogs`) is responsible for
|
// `prepareReport` via `diffTransferLogs`) is responsible for
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,11 @@
|
||||||
//!
|
//!
|
||||||
//! - Only `transfer::` records (no buys/sells/dividends - those stay
|
//! - Only `transfer::` records (no buys/sells/dividends - those stay
|
||||||
//! inferred from the portfolio diff).
|
//! inferred from the portfolio diff).
|
||||||
//! - Only `type::cash` is wired downstream. `type::in_kind` parses
|
//! - Both `type::cash` and `type::in_kind` are wired into the
|
||||||
//! successfully but is rejected by the contributions matcher with
|
//! contributions matcher. Cash records match against a cash budget /
|
||||||
//! an "in-kind transfers not yet supported" message.
|
//! 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.
|
//! - No historical reconstruction - forward-looking only.
|
||||||
//!
|
//!
|
||||||
//! See `REPORT.md` §5 for the full usage guide and
|
//! See `REPORT.md` §5 for the full usage guide and
|
||||||
|
|
@ -61,10 +63,11 @@ const Date = @import("../Date.zig");
|
||||||
|
|
||||||
const logger = std.log.scoped(.transaction_log);
|
const logger = std.log.scoped(.transaction_log);
|
||||||
|
|
||||||
/// Kind of transfer. Only `cash` is wired into the contributions
|
/// Kind of transfer. Both kinds are wired into the contributions
|
||||||
/// classifier in v1. `in_kind` parses successfully so the file format
|
/// classifier: `cash` matches against the destination account's cash
|
||||||
/// is forward-compatible, but the matcher will reject records with
|
/// budget / pooled cash activity, while `in_kind` pairs a source
|
||||||
/// this type until per-symbol in-kind matching is implemented.
|
/// share-removal against a destination share-addition for the same
|
||||||
|
/// symbol across the `from` and `to` accounts.
|
||||||
pub const TransferType = enum {
|
pub const TransferType = enum {
|
||||||
cash,
|
cash,
|
||||||
in_kind,
|
in_kind,
|
||||||
|
|
@ -626,7 +629,7 @@ test "parseTransactionLogFile: type defaults to cash when elided" {
|
||||||
try testing.expectEqual(TransferType.cash, log.transfers[0].type);
|
try testing.expectEqual(TransferType.cash, log.transfers[0].type);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseTransactionLogFile: type::in_kind parses but is preserved (rejected downstream)" {
|
test "parseTransactionLogFile: type::in_kind parses and preserves its type" {
|
||||||
var log = try parseTransactionLogFile(testing.allocator,
|
var log = try parseTransactionLogFile(testing.allocator,
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
\\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
\\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue