btctax-cli 0.11.0

btctax — an offline, single-user US Bitcoin tax ledger (CLI: import, reconcile, and compute).
Documentation
//! Binary-level CLI tests for `reconcile classify-inbound-self-transfer` (Cycle A, Task 2).
//!
//! Drives the real `btctax reconcile classify-inbound-self-transfer` subcommand via
//! `std::process::Command` to verify the dispatch builds `InboundClass::SelfTransferMine` and calls
//! the UNCHANGED `cmd::reconcile::classify_inbound`: defaults ($0 basis + receipt-date HP + honest
//! advisory), the `--basis`/`--acquired` overrides (no advisory, adjustable HP), and the wrong-target
//! (non-TransferIn) bad-target path.
//!
//! Test precedent: `reclassify_income_cli.rs` (same `CARGO_BIN_EXE_btctax` + `BTCTAX_PASSPHRASE` pattern).
use btctax_cli::{cmd, Session};
use btctax_core::{BlockerKind, EventPayload};
use btctax_store::Passphrase;
use std::path::Path;

fn pp() -> Passphrase {
    Passphrase::new("pw".into())
}

/// A Coinbase CSV with a single Receive (→ a raw `TransferIn`, the self-transfer receiving side).
fn coinbase_receive_csv(dir: &Path) -> std::path::PathBuf {
    let p = dir.join("cb_recv.csv");
    std::fs::write(
        &p,
        "\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,Recipient Address\r\n\
cb-recv,2025-03-01 12:00:00 UTC,Receive,BTC,0.05000000,USD,84000.00,,,,,bc1qsender,\r\n",
    )
    .unwrap();
    p
}

/// A Coinbase CSV with a single Buy (→ an `Acquire`, a NON-TransferIn event for the bad-target case).
fn coinbase_buy_csv(dir: &Path) -> std::path::PathBuf {
    let p = dir.join("cb_buy.csv");
    std::fs::write(
        &p,
        "\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,Recipient Address\r\n\
cb-buy,2025-03-01 12:00:00 UTC,Buy,BTC,0.10000000,USD,84000.00,8400.00,8450.00,50.00,,,\r\n",
    )
    .unwrap();
    p
}

/// Build a vault from `csv`, returning `(vault_path, first-event-of-`kind` canonical ref)`.
fn vault_with(
    dir: &Path,
    csv: std::path::PathBuf,
    want_transfer_in: bool,
) -> (std::path::PathBuf, String) {
    let vault = dir.join("vault.pgp");
    cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
    cmd::import::run(&vault, &pp(), &[csv]).unwrap();
    let s = Session::open(&vault, &pp()).unwrap();
    let events = btctax_core::persistence::load_all(s.conn()).unwrap();
    let want_ref = events
        .iter()
        .find(|e| {
            if want_transfer_in {
                matches!(e.payload, EventPayload::TransferIn(_))
            } else {
                matches!(e.payload, EventPayload::Acquire(_))
            }
        })
        .expect("target event must exist")
        .id
        .canonical();
    (vault, want_ref)
}

/// Run `btctax --vault <vault> reconcile classify-inbound-self-transfer <args...>`; returns (exit, stderr).
fn run_self_transfer(vault: &Path, args: &[&str]) -> (i32, String) {
    let bin = env!("CARGO_BIN_EXE_btctax");
    let output = std::process::Command::new(bin)
        .arg("--vault")
        .arg(vault.to_str().unwrap())
        .arg("reconcile")
        .arg("classify-inbound-self-transfer")
        .args(args)
        .env("BTCTAX_PASSPHRASE", "pw")
        .output()
        .expect("btctax binary must execute");
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    let code = output.status.code().expect("process exits normally");
    (code, stderr)
}

/// Defaults: no `--basis` / `--acquired` → creates a $0-basis lot, clears `UnknownBasisInbound`, and
/// fires the honest `SelfTransferInboundZeroBasis` advisory.
#[test]
fn classify_inbound_self_transfer_defaults_creates_lot_and_fires_advisory() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);

    let (code, stderr) = run_self_transfer(&vault, &[&in_ref]);
    assert_eq!(code, 0, "defaults must exit 0; stderr: {stderr}");

    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert_eq!(state.lots.len(), 1, "a non-taxable lot is created");
    assert_eq!(state.lots[0].usd_basis, rust_decimal_macros::dec!(0));
    assert!(
        !state.lots[0].basis_pending,
        "$0 basis is computable — never pending"
    );
    assert!(state.income_recognized.is_empty(), "non-taxable: no income");
    assert!(
        state
            .blockers
            .iter()
            .all(|b| b.kind != BlockerKind::UnknownBasisInbound),
        "UnknownBasisInbound must be cleared"
    );
    let zero_basis = state
        .blockers
        .iter()
        .find(|b| b.kind == BlockerKind::SelfTransferInboundZeroBasis)
        .expect("the zero-basis advisory must fire when --basis is omitted");
    // UX-P4-12(f): this advisory is surfaced by the CLI `verify`, so the void remedy names the CLI
    // command and QUALIFIES the keybind as editor-only — not a bare "press 'v'" that means nothing
    // in a terminal.
    assert!(
        zero_basis.detail.contains("btctax reconcile void")
            && zero_basis.detail.contains("in the TUI editor"),
        "the void remedy is surface-neutral (CLI command + editor-qualified keybind): {}",
        zero_basis.detail
    );
    assert!(
        !zero_basis.detail.contains("(press 'v',"),
        "no bare 'press v' TUI keybind leaks into CLI advisory text: {}",
        zero_basis.detail
    );
    // UX-P4-12(f) [fold r1-M2]: the SECOND advisory (defaulted long-term acquisition) is reworded the
    // same way — omitting --acquired defaults the holding period to long-term and fires it.
    let defaulted_acq = state
        .blockers
        .iter()
        .find(|b| b.kind == BlockerKind::SelfTransferInboundDefaultedAcquired)
        .expect("omitting --acquired fires the defaulted-acquisition advisory");
    assert!(
        defaulted_acq.detail.contains("btctax reconcile void")
            && defaulted_acq.detail.contains("in the TUI editor"),
        "the defaulted-acquired remedy is surface-neutral too: {}",
        defaulted_acq.detail
    );
    assert!(
        !defaulted_acq.detail.contains("press 'v', or run btctax"),
        "no bare 'press v' in the defaulted-acquired advisory: {}",
        defaulted_acq.detail
    );
}

/// `--basis` + `--acquired`: real cost + old date → basis set, NO advisory, and the supplied
/// acquisition date rides onto the lot (a 2015 date → long-term while landing in the 2025 wallet pool).
#[test]
fn classify_inbound_self_transfer_with_basis_and_acquired_has_no_advisory() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);

    let (code, stderr) = run_self_transfer(
        &vault,
        &[&in_ref, "--basis", "1234.56", "--acquired", "2015-01-02"],
    );
    assert_eq!(code, 0, "with overrides must exit 0; stderr: {stderr}");

    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert_eq!(state.lots.len(), 1);
    assert_eq!(state.lots[0].usd_basis, rust_decimal_macros::dec!(1234.56));
    assert_eq!(
        state.lots[0].acquired_at,
        time::macros::date!(2015 - 01 - 02)
    );
    assert!(
        state
            .blockers
            .iter()
            .all(|b| b.kind != BlockerKind::SelfTransferInboundZeroBasis),
        "a supplied basis must NOT fire the advisory"
    );
}

/// UX-P4-4a: the `--basis=-N` clap `=`-form bypass is CLOSED at record time. `--basis -5000` (space form)
/// is rejected by clap as an unknown flag, but `--basis=-5000` slips past clap's `-`-prefix detection — the
/// value guard must catch it and refuse (nonzero, naming the flag) before anything is recorded, else a
/// negative basis rides into gain math (gain > proceeds). (★ fault-inject: revert `--basis` to
/// `parse_usd_arg` and this goes RED.)
#[test]
fn classify_inbound_self_transfer_negative_basis_equals_form_is_refused() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);

    let (code, stderr) = run_self_transfer(&vault, &[&in_ref, "--basis=-5000.00"]);
    assert_ne!(
        code, 0,
        "a negative --basis (=-form) must be refused; stderr: {stderr}"
    );
    assert!(
        stderr.contains("--basis") && stderr.contains(">= 0"),
        "the refusal must name --basis + the >= 0 rule: {stderr}"
    );

    // Fail-closed: the refuse is at record time (before append), so NO lot is written.
    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert!(
        state.lots.is_empty(),
        "a refused classify must record no lot: {:?}",
        state.lots
    );
}

/// UX-P4-4(b): an `--acquired` date STRICTLY AFTER the receipt date is impossible (coins cannot have
/// been acquired after they were received) and is refused at record time. The receipt is 2025-03-01
/// (UTC), so `--acquired 2025-03-02` is refused; the message names the flag, states the receipt-post-date
/// impossibility, and PRINTS the receipt date + its tz basis (the two dates come from different sources).
/// Fail-closed: no lot is written. (★ fault-inject: revert the guard and this goes RED.)
#[test]
fn classify_inbound_self_transfer_acquired_after_receipt_is_refused() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);

    let (code, stderr) = run_self_transfer(&vault, &[&in_ref, "--acquired", "2025-03-02"]);
    assert_ne!(
        code, 0,
        "an --acquired date after the receipt must be refused; stderr: {stderr}"
    );
    assert!(
        stderr.contains("--acquired") && stderr.contains("2025-03-01") && stderr.contains("receipt"),
        "the refusal must name --acquired, the receipt date 2025-03-01, and the word receipt: {stderr}"
    );
    assert!(
        stderr.contains("UTC"),
        "the refusal must print the receipt tz basis (UTC): {stderr}"
    );

    // Fail-closed: the refuse is before append, so NO lot is written.
    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert!(
        state.lots.is_empty(),
        "a refused classify must record no lot: {:?}",
        state.lots
    );
}

/// UX-P4-4(b) boundary: `--acquired` EQUAL to the receipt date (same-day) is ALLOWED — a same-day
/// acquire-then-receive is legitimate, and the two dates may already skew by a tz day. Receipt is
/// 2025-03-01; `--acquired 2025-03-01` succeeds and rides onto the lot.
#[test]
fn classify_inbound_self_transfer_acquired_same_day_as_receipt_is_allowed() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);

    let (code, stderr) = run_self_transfer(&vault, &[&in_ref, "--acquired", "2025-03-01"]);
    assert_eq!(
        code, 0,
        "same-day --acquired must be allowed; stderr: {stderr}"
    );

    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert_eq!(state.lots.len(), 1);
    assert_eq!(
        state.lots[0].acquired_at,
        time::macros::date!(2025 - 03 - 01)
    );
}

/// [reconcile-defaults] The MANUAL `classify-inbound-self-transfer` with no `--acquired` also backdates
/// to LONG-TERM: the created lot's acquisition date is 1yr+1day before receipt (leap-safe), and the
/// defaulted-acquisition advisory fires.
#[test]
fn manual_classify_inbound_self_transfer_also_long_term() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);

    let (code, stderr) = run_self_transfer(&vault, &[&in_ref]);
    assert_eq!(code, 0, "defaults must exit 0; stderr: {stderr}");

    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert_eq!(state.lots.len(), 1);
    // Receipt 2025-03-01 → default acquisition = 1yr+1day earlier = 2024-02-29 (2024 is leap).
    assert_eq!(
        state.lots[0].acquired_at,
        time::macros::date!(2024 - 02 - 29)
    );
    assert!(
        btctax_core::conventions::is_long_term(
            state.lots[0].acquired_at,
            time::macros::date!(2025 - 03 - 01)
        ),
        "the manual default acquisition must be long-term relative to the receipt date"
    );
    assert!(
        state
            .blockers
            .iter()
            .any(|b| b.kind == BlockerKind::SelfTransferInboundDefaultedAcquired),
        "the manual default (no --acquired) must fire the long-term-assumption advisory"
    );
}

/// Wrong target: pointing the subcommand at a NON-TransferIn event (an Acquire) is now REFUSED at
/// RECORD TIME (UX-P4-3) — the resolver would adjudicate it as a wrong-type `DecisionConflict`, so
/// record-time mirrors that and refuses BEFORE any append (fail-closed), instead of the old
/// accept-then-surface-at-verify behavior.
#[test]
fn classify_inbound_self_transfer_wrong_target_is_refused_at_record_time() {
    let dir = tempfile::tempdir().unwrap();
    let (vault, acquire_ref) = vault_with(dir.path(), coinbase_buy_csv(dir.path()), false);

    let (code, stderr) = run_self_transfer(&vault, &[&acquire_ref]);
    assert_ne!(
        code, 0,
        "a non-TransferIn target must be refused; stderr: {stderr}"
    );
    assert!(
        stderr.contains("non-TransferIn") && stderr.contains("events list"),
        "the refusal must name the wrong-type conflict + the events-list remedy: {stderr}"
    );

    // Fail-closed: nothing was appended, so no DecisionConflict blocker surfaces at projection either.
    let s = Session::open(&vault, &pp()).unwrap();
    let (state, _) = s.project().unwrap();
    assert!(
        state
            .blockers
            .iter()
            .all(|b| b.kind != BlockerKind::DecisionConflict),
        "a refused decision must append nothing (no DecisionConflict at verify)"
    );
    assert!(
        !btctax_core::persistence::load_all(s.conn())
            .unwrap()
            .iter()
            .any(|e| matches!(e.payload, EventPayload::ClassifyInbound(_))),
        "the wrong-target classify must not be recorded"
    );
}