Skip to content

Commit b787bd4

Browse files
committed
fix(compliance-bridge): honor zero balance in ledger providers
1 parent 58fe2be commit b787bd4

2 files changed

Lines changed: 96 additions & 5 deletions

File tree

src/compliance_bridge/reconciliation_worker.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -769,10 +769,18 @@ def fetch_balance(self, account_id: str) -> ReconciliationResult: # type: ignor
769769
# - "balance" or "balances.{account_id}" -> balance_usd
770770
# - "timestamp" -> verified_at (converted to Unix float)
771771
# - "kms_signature" -> signature (if present)
772+
# Resolve with explicit membership so a genuine 0.0 balance is honoured
773+
# rather than treated as "absent" and overwritten by the top-level
774+
# fallback — a drained account is the case the cash barrier must catch.
772775
balances = data.get("balances", {})
773-
balance_value = balances.get(account_id, balances.get("default_account", 0.0))
774-
if not balance_value and "balance" in data:
776+
if account_id in balances:
777+
balance_value = balances[account_id]
778+
elif "default_account" in balances:
779+
balance_value = balances["default_account"]
780+
elif "balance" in data:
775781
balance_value = data["balance"]
782+
else:
783+
balance_value = 0.0
776784

777785
# Parse timestamp if present, otherwise use current time
778786
verified_at = time.time()
@@ -892,11 +900,19 @@ def fetch_balance(self, account_id: str) -> ReconciliationResult: # type: ignor
892900
raw = response["Body"].read().decode("utf-8")
893901
data = _json.loads(raw)
894902

895-
# Map the S3 snapshot schema to ReconciliationResult fields
903+
# Map the S3 snapshot schema to ReconciliationResult fields.
904+
# Resolve with explicit membership so a genuine 0.0 balance is honoured
905+
# rather than treated as "absent" and overwritten by the top-level
906+
# fallback — a drained account is the case the cash barrier must catch.
896907
balances = data.get("balances", {})
897-
balance_value = balances.get(account_id, balances.get("default_account", 0.0))
898-
if not balance_value and "balance" in data:
908+
if account_id in balances:
909+
balance_value = balances[account_id]
910+
elif "default_account" in balances:
911+
balance_value = balances["default_account"]
912+
elif "balance" in data:
899913
balance_value = data["balance"]
914+
else:
915+
balance_value = 0.0
900916

901917
# Parse timestamp if present, otherwise use current time
902918
verified_at = time.time()

tests/test_reconciliation_worker.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,78 @@ def test_returns_none_on_redis_error(self):
421421

422422
result = mod.read_verified_balance(mock_redis)
423423
assert result is None
424+
425+
426+
# ---------------------------------------------------------------------------
427+
# GcsLedgerProvider / ObjectStoreLedgerProvider — zero-balance mapping
428+
#
429+
# A drained (0.0) account in a multi-account snapshot must be reported as 0.0,
430+
# not silently replaced by the top-level "balance" fallback. The barrier the
431+
# reconciler feeds, h(x) = cash - min_cash, relies on the real balance; a
432+
# masked zero inflates it and lets trades clear against an empty account.
433+
# ---------------------------------------------------------------------------
434+
435+
436+
class TestLedgerProviderZeroBalanceMapping:
437+
"""Ledger providers must not treat a present 0.0 balance as absent."""
438+
439+
def _gcs_client_for(self, snapshot: dict) -> MagicMock:
440+
blob = MagicMock()
441+
blob.download_as_text.return_value = json.dumps(snapshot)
442+
bucket = MagicMock()
443+
bucket.blob.return_value = blob
444+
client = MagicMock()
445+
client.bucket.return_value = bucket
446+
return client
447+
448+
def _s3_provider_with_snapshot(self, mod, monkeypatch, snapshot: dict):
449+
monkeypatch.setenv("S3_RECONCILIATION_BUCKET", "cage-ledger")
450+
body = MagicMock()
451+
body.read.return_value = json.dumps(snapshot).encode("utf-8")
452+
client = MagicMock()
453+
client.get_object.return_value = {"Body": body}
454+
provider = mod.ObjectStoreLedgerProvider()
455+
monkeypatch.setattr(provider, "_make_client", lambda: client)
456+
return provider
457+
458+
def test_gcs_present_zero_balance_not_masked(self, monkeypatch):
459+
"""A 0.0 target balance must survive even when a top-level fallback exists."""
460+
mod = _get_module()
461+
monkeypatch.setenv("GCS_RECONCILIATION_BUCKET", "cage-ledger")
462+
snapshot = {"balances": {"acct1": 0.0}, "balance": 100_000.0}
463+
client = self._gcs_client_for(snapshot)
464+
provider = mod.GcsLedgerProvider()
465+
with patch("google.cloud.storage.Client", return_value=client):
466+
result = provider.fetch_balance("acct1")
467+
assert result.balance_usd == 0.0
468+
469+
def test_s3_present_zero_balance_not_masked(self, monkeypatch):
470+
"""Same containment for the S3-compatible provider."""
471+
mod = _get_module()
472+
snapshot = {"balances": {"acct1": 0.0}, "balance": 100_000.0}
473+
provider = self._s3_provider_with_snapshot(mod, monkeypatch, snapshot)
474+
475+
result = provider.fetch_balance("acct1")
476+
assert result.balance_usd == 0.0
477+
478+
def test_gcs_nonzero_balance_preferred_over_fallback(self, monkeypatch):
479+
"""A real per-account balance still wins over the top-level fallback."""
480+
mod = _get_module()
481+
monkeypatch.setenv("GCS_RECONCILIATION_BUCKET", "cage-ledger")
482+
snapshot = {"balances": {"acct1": 4_200.0}, "balance": 100_000.0}
483+
client = self._gcs_client_for(snapshot)
484+
provider = mod.GcsLedgerProvider()
485+
with patch("google.cloud.storage.Client", return_value=client):
486+
result = provider.fetch_balance("acct1")
487+
assert result.balance_usd == 4_200.0
488+
489+
def test_gcs_absent_account_falls_back_to_top_level_balance(self, monkeypatch):
490+
"""When the account is absent, the top-level balance is still used."""
491+
mod = _get_module()
492+
monkeypatch.setenv("GCS_RECONCILIATION_BUCKET", "cage-ledger")
493+
snapshot = {"balances": {}, "balance": 100_000.0}
494+
client = self._gcs_client_for(snapshot)
495+
provider = mod.GcsLedgerProvider()
496+
with patch("google.cloud.storage.Client", return_value=client):
497+
result = provider.fetch_balance("acct1")
498+
assert result.balance_usd == 100_000.0

0 commit comments

Comments
 (0)