Fix Review 63 snapshot and balance regressions

This commit is contained in:
2026-10-07 08:16:30 +00:00
parent 3b5101128d
commit 77070b8164
4 changed files with 306 additions and 15 deletions
@@ -25,6 +25,8 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "",
try:
basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else ""
except Exception as e: return fail("validation_error", str(e))
if basis_date and (last_balance is None or last_balance == ""):
return fail("validation_error", "last_balance_basis_date requires last_balance")
with db(db_path) as conn:
conn.execute("INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_balance_basis_date,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id,name.strip(),type,institution,currency.upper(),int(active),memo,account_number_masked,holder_name,last_balance,basis_date,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
+66 -15
View File
@@ -261,7 +261,9 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
"""Load one immutable close run and only the rows persisted by that run."""
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone()
run = conn.execute(
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
).fetchone()
if not run:
return [], {}
basis_date = run["basis_date"]
@@ -272,6 +274,19 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
total_rows = conn.execute(
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
).fetchone()[0]
previous_rows = conn.execute(
"""SELECT COUNT(*)
FROM asset_snapshots AS snapshot
LEFT JOIN asset_snapshot_runs AS snapshot_run
ON snapshot_run.id=snapshot.snapshot_run_id
WHERE snapshot.basis_date=?
AND (
snapshot.snapshot_run_id IS NULL
OR snapshot.snapshot_run_id=''
OR snapshot_run.rowid < ?
)""",
(basis_date, run["_run_rowid"]),
).fetchone()[0]
metadata = {
"snapshot_run_id": run["id"],
"is_correction": bool(run["correction_of_run_id"]),
@@ -287,6 +302,7 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
"excluded_evidence": json.loads(run["excluded_evidence"] or "[]"),
"carried_evidence": json.loads(run["carried_evidence"] or "[]"),
"closed_at": run["created_at"] or "",
"previous_snapshot_row_count": previous_rows,
"total_snapshot_rows_for_basis_date": total_rows,
}
return rows, metadata
@@ -319,14 +335,15 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
)
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
carried_forward = bool(evidence.get("carried_forward", False))
items.append(
_public_evidence({
public_item = _public_evidence({
"id": snap.get("id", ""),
"source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""),
"authority_source": "asset_snapshots",
"valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""),
"currency": snap.get("currency", ""),
"valuation_id": snap.get("valuation_id", ""),
"liquidity_bucket": bucket,
"amount": int(snap["amount"]) if snap.get("amount") is not None else None,
"valuation_date": valuation_date,
@@ -341,7 +358,9 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
"correction_of_run_id": snap.get("correction_of_run_id", ""),
"correction_reason": snap.get("correction_reason", ""),
})
)
if not snap.get("valuation_id"):
public_item.pop("valuation_id", None)
items.append(public_item)
return items
@@ -408,6 +427,14 @@ def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlit
valuation_date = str(evidence.get("valuation_date") or "")[:10]
if valuation_date and valuation_date < basis_date:
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
previous_rows = int(metadata.get("previous_snapshot_row_count") or 0)
if previous_rows:
warnings.append({
"code": "existing_snapshot_rows",
"basis_date": basis_date,
"snapshot_row_count": previous_rows,
"policy": "append_only_correction_required",
})
return warnings
@@ -607,7 +634,6 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
return fail("validation_error", str(e))
with read_db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
preview.pop("_included_items", None)
preview["selection_policy"] = selection_policy
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
@@ -618,7 +644,10 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
preview["close_status"] = "incomplete_draft"
else:
preview["close_status"] = "complete_exact"
# Fingerprint the private authority collection before redacting it from
# the public preview. Close performs the same calculation.
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
preview.pop("_included_items", None)
preview.pop("_candidate_state_hashes", None)
return ok(**preview)
@@ -649,9 +678,10 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
conditional = bool(missing_exclusions or carried_items)
fingerprint_items = preview.get("_included_items", preview["included_items"])
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in fingerprint_items], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
authority_fingerprint = _preview_fingerprint(preview, basis, selection_policy)
request_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference}
request_fingerprint = hashlib.sha256(json.dumps(request_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
fingerprint = json.dumps({"authority": authority_fingerprint, "request": request_fingerprint}, sort_keys=True, separators=(",", ":"))
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions")
if carried_items and (not allow_as_of_carry_forward or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
@@ -659,7 +689,22 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
if idempotency_key:
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if existing_run:
if existing_run["request_fingerprint"] == fingerprint:
try:
stored_fingerprint = json.loads(existing_run["request_fingerprint"])
except (TypeError, json.JSONDecodeError):
stored_fingerprint = {"authority": existing_run["request_fingerprint"], "request": ""}
latest_basis_run = conn.execute(
"SELECT correction_of_run_id FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1",
(basis,),
).fetchone()
authority_changed_after_correction = bool(
latest_basis_run
and latest_basis_run["correction_of_run_id"] == existing_run["id"]
)
if stored_fingerprint.get("request") == request_fingerprint and (
stored_fingerprint.get("authority") == authority_fingerprint
or authority_changed_after_correction
):
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
@@ -679,13 +724,16 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
close_status=existing_run["close_status"],
selection_policy=existing_run["selection_policy"],
inserted_count=len(replay_rows),
inserted_snapshot_ids=[row["id"] for row in replay_rows],
inserted_snapshot_ids=[
next((row["id"] for row in replay_rows if row["source_type"] == item.get("source_type") and row["source_id"] == item.get("source_id")), "")
for item in replay_meta.get("applied_evidence", [])
],
warnings=replay_warnings,
carried_items=replay_public["carried_items"],
approval=approval, correction=correction,
provenance=_canonical_provenance(
valuation_date=replay_provenance["valuation_date"],
age_days=replay_provenance["age_days"],
valuation_date=(replay_items[0].get("valuation_date", "") if replay_items else ""),
age_days=(replay_items[0].get("age_days") if replay_items else None),
carried=replay_provenance["carried_forward"],
verification_status=replay_provenance["verification_status"],
evidence_kind=replay_provenance["evidence_kind"],
@@ -740,7 +788,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
),
)
snapshot_ids.append(snapshot_id)
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included]
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included]
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
excluded_evidence.extend(
{
@@ -945,10 +993,13 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
return fail("validation_error", str(e))
with read_db(db_path) as conn:
basis_rows = conn.execute(
"""SELECT DISTINCT basis_date FROM asset_snapshots
"""SELECT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=?
UNION
SELECT basis_date FROM asset_snapshot_runs
WHERE basis_date>=? AND basis_date<=?
ORDER BY basis_date ASC""",
(start_norm, end_norm),
(start_norm, end_norm, start_norm, end_norm),
).fetchall()
points = []
for row in basis_rows: