feat(backend): add reconcile.run orchestrator with match + apply + CAS agg

This commit is contained in:
Tyler
2026-06-19 22:54:25 -06:00
parent c3306b882a
commit 8c92ef43bd
2 changed files with 269 additions and 6 deletions
+111 -1
View File
@@ -18,10 +18,12 @@ Match algorithm:
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Iterable, Optional, Protocol
from sqlalchemy import func
from cyclone.db import ClaimState
@@ -206,3 +208,111 @@ def split_unmatched(
[c for c in claims if c.id not in matched_claim_ids],
[r for r in remits if r.id not in matched_remit_ids],
)
# --- reconcile.run() orchestrator ---------------------------------------------
@dataclass
class ReconcileResult:
matched: int = 0
unmatched_claims: int = 0
unmatched_remittances: int = 0
skipped: int = 0
def run(session, batch_id: str) -> ReconcileResult:
"""Orchestrate reconciliation for an 835 batch.
Expects Batch + Remittance + CasAdjustment rows already persisted
(this is called from store.add() AFTER commit). Loads the new
remittances + all currently-unmatched Claims, calls match(), then
applies each match's intent inside the caller's session.
Returns counts. Does NOT commit; caller controls transaction.
Raises on failure (caller catches and writes activity event).
"""
from sqlalchemy import select
from cyclone.db import (
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
ClaimState,
)
new_remits = list(
session.execute(
select(Remittance).where(Remittance.batch_id == batch_id)
).scalars().all()
)
unmatched_claims = list(
session.execute(
select(Claim).where(Claim.matched_remittance_id.is_(None))
).scalars().all()
)
matches = match(unmatched_claims, new_remits)
applied = 0
skipped = 0
for m in matches:
if m.is_reversal:
intent = apply_reversal(m.claim, m.remittance)
else:
intent = apply_payment(
m.claim, m.remittance,
charge=m.claim.charge_amount, paid=m.remittance.total_paid,
status_code=m.remittance.status_code,
)
if intent.skipped:
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"reason": intent.reason,
"current_state": getattr(m.claim.state, "value", m.claim.state)},
))
skipped += 1
continue
session.add(Match(
claim_id=m.claim.id, remittance_id=m.remittance.id,
strategy=m.strategy,
matched_at=datetime.now(timezone.utc),
prior_claim_state=intent.prior_claim_state,
is_reversal=m.is_reversal,
))
if not m.is_reversal:
m.claim.state = intent.new_state
else:
m.claim.state = ClaimState.REVERSED
m.claim.matched_remittance_id = m.remittance.id
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"new_state": m.claim.state.value},
))
applied += 1
# Aggregate CAS adjustments per Remittance.
for r in new_remits:
total = session.execute(
select(func.sum(CasAdjustment.amount))
.where(CasAdjustment.remittance_id == r.id)
).scalar_one() or Decimal("0")
r.adjustment_amount = total
# Partition.
unmatched_claims_after, unmatched_remits_after = split_unmatched(
unmatched_claims, new_remits,
[m for m in matches if m.claim.id in {c.id for c in unmatched_claims}],
)
return ReconcileResult(
matched=applied,
unmatched_claims=len(unmatched_claims_after),
unmatched_remittances=len(unmatched_remits_after),
skipped=skipped,
)