diff --git a/routstr/balance.py b/routstr/balance.py index 91b19ce5..cf44e612 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -259,6 +259,36 @@ async def _lookup_key_no_create( return None +async def _get_persisted_api_key_refund( + key: ApiKey, session: AsyncSession +) -> dict[str, str] | None: + result = await session.exec( + select(CashuTransaction) + .where( + CashuTransaction.api_key_hashed_key == key.hashed_key, + CashuTransaction.type == "out", + CashuTransaction.source == "apikey", + ) + .order_by(col(CashuTransaction.created_at).desc()) + ) + refund = result.first() + if refund is None: + return None + if refund.swept: + raise HTTPException(status_code=410, detail="Refund has been swept") + + refund.collected = True + session.add(refund) + await session.commit() + + persisted = {"token": refund.token} + if refund.unit == "sat": + persisted["sats"] = str(refund.amount) + else: + persisted["msats"] = str(refund.amount) + return persisted + + async def _restore_balance( session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str ) -> None: @@ -353,6 +383,8 @@ async def refund_wallet_endpoint( if key.total_balance <= 0: if cached := await _refund_cache_get(bearer_value): return cached + if persisted := await _get_persisted_api_key_refund(key, session): + return persisted if key.parent_key_hash: raise HTTPException( diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index 609e2557..f3775e22 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -221,6 +221,78 @@ def _make_api_key( return key +@pytest.mark.asyncio +async def test_apikey_refund_returns_persisted_token_after_cache_loss() -> None: + key = _make_api_key(balance=0, refund_currency="sat") + refund_token = "cashuApersisted_refund_token" + refund_tx = _make_cashu_tx( + token=refund_token, + amount=5, + unit="sat", + type="out", + request_id=None, + ) + refund_tx.source = "apikey" + refund_tx.api_key_hashed_key = key.hashed_key + + session = MagicMock() + session.get = AsyncMock(return_value=key) + session.exec = AsyncMock(return_value=_exec_result(refund_tx)) + session.add = MagicMock() + session.commit = AsyncMock() + + with ( + patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), + patch("routstr.balance.send_token", AsyncMock()) as mock_send_token, + ): + result = await refund_wallet_endpoint( + authorization="Bearer sk-testhash", + x_cashu=None, + session=session, + ) + + assert result == {"token": refund_token, "sats": "5"} + assert refund_tx.collected is True + session.add.assert_called_once_with(refund_tx) + session.commit.assert_awaited_once() + mock_send_token.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_apikey_refund_rejects_persisted_token_after_sweep() -> None: + from fastapi import HTTPException + + key = _make_api_key(balance=0, refund_currency="sat") + refund_tx = _make_cashu_tx( + token="cashuAswept_apikey_refund", + amount=5, + unit="sat", + request_id=None, + swept=True, + ) + refund_tx.source = "apikey" + refund_tx.api_key_hashed_key = key.hashed_key + + session = MagicMock() + session.get = AsyncMock(return_value=key) + session.exec = AsyncMock(return_value=_exec_result(refund_tx)) + session.add = MagicMock() + session.commit = AsyncMock() + + with patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-testhash", + x_cashu=None, + session=session, + ) + + assert exc_info.value.status_code == 410 + assert exc_info.value.detail == "Refund has been swept" + session.add.assert_not_called() + session.commit.assert_not_awaited() + + @pytest.mark.asyncio async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> None: key = _make_api_key(balance=5000, refund_currency="sat")