from __future__ import annotations import logging from datetime import UTC, datetime from nats.aio.msg import Msg from app.nats_bus import NatsBus from app.schemas import AccessAttempted, FraudScored from app.scoring import HeuristicScorer, risk_band logger = logging.getLogger(__name__) SUBJECT_ACCESS_ATTEMPTED = "guest.access.attempted" SUBJECT_FRAUD_SCORED = "fraud.scored" class FraudConsumer: def __init__(self, bus: NatsBus, durable: str, scorer: HeuristicScorer) -> None: self._bus = bus self._durable = durable self._scorer = scorer self._subscription = None async def start(self) -> None: self._subscription = await self._bus.subscribe( subject=SUBJECT_ACCESS_ATTEMPTED, durable=self._durable, handler=self._handle, manual_ack=True, ) logger.info("subscribed", extra={"subject": SUBJECT_ACCESS_ATTEMPTED, "durable": self._durable}) async def stop(self) -> None: if self._subscription is not None: await self._subscription.unsubscribe() self._subscription = None async def _handle(self, msg: Msg) -> None: try: evt = AccessAttempted.model_validate_json(msg.data) except Exception: logger.exception("invalid access.attempted payload — terminating message") await msg.term() return try: result = self._scorer.score(evt) scored = FraudScored( event_id=evt.event_id, guest_id=evt.guest_id, token_id=evt.token_id, access_log_id=evt.access_log_id, score=result.score, risk=risk_band(result.score), reasons=result.reasons, scored_at=datetime.now(UTC), ) await self._bus.publish( SUBJECT_FRAUD_SCORED, scored.model_dump_json().encode("utf-8"), ) logger.info( "scored access", extra={ "guest_id": str(evt.guest_id), "score": result.score, "risk": scored.risk, }, ) await msg.ack() except Exception: logger.exception("failed to score access — nak") await msg.nak(delay=2)