diff --git a/migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py b/migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py new file mode 100644 index 00000000..840b1ad7 --- /dev/null +++ b/migrations/versions/d4e5f6a7b8c9_add_api_key_to_cashu_transactions.py @@ -0,0 +1,46 @@ +"""add api key link to cashu_transactions + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-04-20 00:00:00.000000 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d4e5f6a7b8c9" +down_revision = "c3d4e5f6a7b8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col["name"] for col in inspector.get_columns("cashu_transactions")] + indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")} + + if "api_key_hashed_key" not in columns: + op.add_column( + "cashu_transactions", + sa.Column( + "api_key_hashed_key", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + ), + ) + + if "ix_cashu_transactions_api_key_hashed_key" not in indexes: + op.create_index( + "ix_cashu_transactions_api_key_hashed_key", + "cashu_transactions", + ["api_key_hashed_key"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions") + op.drop_column("cashu_transactions", "api_key_hashed_key") diff --git a/routstr/balance.py b/routstr/balance.py index ff892ad3..7128916d 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -7,7 +7,7 @@ from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel -from sqlmodel import select +from sqlmodel import col, select, update from .auth import get_billing_key, validate_bearer_key from .core.db import ( @@ -211,6 +211,26 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None: _refund_cache[key] = (expiry, value) +async def _restore_balance( + session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int +) -> None: + """Restore balance after a failed refund mint attempt.""" + restore_stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == hashed_key) + .values( + balance=col(ApiKey.balance) + balance, + reserved_balance=col(ApiKey.reserved_balance) + reserved_balance, + ) + ) + await session.exec(restore_stmt) # type: ignore[call-overload] + await session.commit() + logger.info( + "refund_wallet_endpoint: balance restored after mint failure", + extra={"hashed_key": hashed_key, "restored_balance": balance}, + ) + + @router.post("/refund", response_model=None) async def refund_wallet_endpoint( authorization: Annotated[str | None, Header()] = None, @@ -292,7 +312,31 @@ async def refund_wallet_endpoint( elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") - # Perform refund operation first, before modifying balance + # Capture values before debit — the session may refresh key after commit + pre_debit_balance = key.balance + pre_debit_reserved = key.reserved_balance + + # --- DEBIT FIRST: atomically zero the balance before minting tokens --- + # This prevents the race where a concurrent topup/spend happens between + # reading the balance and minting the refund token (double-spend). + debit_stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .where(col(ApiKey.balance) == pre_debit_balance) + .where(col(ApiKey.reserved_balance) == pre_debit_reserved) + .values(balance=0, reserved_balance=0) + ) + debit_result = await session.exec(debit_stmt) # type: ignore[call-overload] + await session.commit() + + if debit_result.rowcount == 0: + # Balance changed between read and debit — another request is active + raise HTTPException( + status_code=409, + detail="Balance changed concurrently. Please retry the refund.", + ) + + # --- MINT: balance is locked at zero, safe to create the refund token --- try: if key.refund_address: from .core.settings import settings as global_settings @@ -328,10 +372,12 @@ async def refund_wallet_endpoint( ) except HTTPException: - # Re-raise HTTP exceptions (like 400 for balance too small) + # Minting failed — restore the debited balance + await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved) raise except Exception as e: - # If refund fails, don't modify the database + # Minting failed — restore the debited balance + await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved) error_msg = str(e) if ( "mint" in error_msg.lower() @@ -345,12 +391,6 @@ async def refund_wallet_endpoint( await _refund_cache_set(bearer_value, result) - previous_reserved_balance = key.reserved_balance - key.balance = 0 - key.reserved_balance = 0 - session.add(key) - await session.commit() - if "token" in result: try: await store_cashu_transaction( @@ -361,6 +401,7 @@ async def refund_wallet_endpoint( typ="out", collected=False, source="apikey", + api_key_hashed_key=key.hashed_key, ) except Exception: pass # store_cashu_transaction already logs @@ -369,13 +410,48 @@ async def refund_wallet_endpoint( "refund_wallet_endpoint: refund successful", extra={ "refunded_msats": remaining_balance_msats, - "previous_reserved_balance": previous_reserved_balance, + "previous_reserved_balance": key.reserved_balance, }, ) return result +@router.get("/history") +async def wallet_history( + key: ApiKey = Depends(get_key_from_header), + session: AsyncSession = Depends(get_session), +) -> dict[str, list[dict[str, str | int | bool | None]]]: + if key.parent_key_hash: + raise HTTPException( + status_code=400, + detail="Cannot view child key history. Please use the parent key instead.", + ) + + result = await session.exec( + select(CashuTransaction) + .where(CashuTransaction.api_key_hashed_key == key.hashed_key) + .order_by(col(CashuTransaction.created_at).desc()) + ) + transactions = result.all() + return { + "transactions": [ + { + "id": tx.id, + "type": tx.type, + "source": tx.source, + "amount": tx.amount, + "unit": tx.unit, + "mint_url": tx.mint_url, + "created_at": tx.created_at, + "collected": tx.collected, + "swept": tx.swept, + } + for tx in transactions + ] + } + + @router.post("/donate") async def donate(token: str, ref: str | None = None) -> str: try: diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 45cb9be7..40878202 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1388,41 +1388,56 @@ async def get_transactions_api( type: str | None = None, status: str | None = None, search: str | None = None, - limit: int = 100, + source: str | None = None, + limit: int = 50, + offset: int = 0, ) -> dict: async with create_session() as session: - from sqlmodel import col + from sqlmodel import col, func - stmt = select(CashuTransaction) + base = select(CashuTransaction) if type: - stmt = stmt.where(CashuTransaction.type == type) + base = base.where(CashuTransaction.type == type) + if source: + if source == "x-cashu": + base = base.where( + (CashuTransaction.source == "x-cashu") + | (CashuTransaction.source == None) # noqa: E711 + ) + else: + base = base.where(CashuTransaction.source == source) if status: if status == "collected": - stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712 + base = base.where(CashuTransaction.collected == True) # noqa: E712 elif status == "swept": - stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712 + base = base.where(CashuTransaction.swept == True) # noqa: E712 elif status == "pending": - stmt = stmt.where( + base = base.where( CashuTransaction.collected == False, # noqa: E712 CashuTransaction.swept == False, # noqa: E712 ) if search: search_pattern = f"%{search}%" - stmt = stmt.where( + base = base.where( (col(CashuTransaction.id).like(search_pattern)) | (col(CashuTransaction.token).like(search_pattern)) | (col(CashuTransaction.request_id).like(search_pattern)) + | (col(CashuTransaction.api_key_hashed_key).like(search_pattern)) ) - stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit) + count_result = await session.exec( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.one() + stmt = base.order_by(col(CashuTransaction.created_at).desc()).offset(offset).limit(limit) results = await session.exec(stmt) transactions = results.all() return { "transactions": [tx.dict() for tx in transactions], - "total": len(transactions), + "total": total, } diff --git a/routstr/core/db.py b/routstr/core/db.py index db1d9d5c..657ef007 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -159,6 +159,12 @@ class CashuTransaction(SQLModel, table=True): # type: ignore default="x-cashu", description="Payment source: x-cashu or apikey", ) + api_key_hashed_key: str | None = Field( + default=None, + foreign_key="api_keys.hashed_key", + index=True, + description="Associated API key hash for wallet history", + ) async def store_cashu_transaction( @@ -171,6 +177,7 @@ async def store_cashu_transaction( collected: bool = False, created_at: int | None = None, source: str = "x-cashu", + api_key_hashed_key: str | None = None, ) -> None: try: async with create_session() as session: @@ -184,6 +191,7 @@ async def store_cashu_transaction( collected=collected, created_at=created_at or int(time.time()), source=source, + api_key_hashed_key=api_key_hashed_key, ) session.add(tx) await session.commit() diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 24ac29cd..b9cca0b0 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -423,75 +423,83 @@ class BaseUpstreamProvider: """ pass - async def map_upstream_error_response( + async def forward_upstream_error_response( self, request: Request, path: str, upstream_response: httpx.Response ) -> Response: - """Map upstream error responses to appropriate proxy error responses. - - Args: - request: Original FastAPI request - path: Request path - upstream_response: Response from upstream service - - Returns: - Mapped error response with appropriate status code and error type - """ + """Log upstream errors and forward the upstream response unchanged.""" status_code = upstream_response.status_code headers = dict(upstream_response.headers) - content_type = headers.get("content-type", "") + content_type = headers.get("content-type") or headers.get("Content-Type", "") + upstream_request_id = ( + headers.get("request-id") + or headers.get("Request-Id") + or headers.get("x-request-id") + or headers.get("X-Request-Id") + or headers.get("anthropic-request-id") + or headers.get("openai-request-id") + ) + + body_read_error = None try: body_bytes = await upstream_response.aread() - except Exception: + except Exception as exc: body_bytes = b"" + body_read_error = f"{type(exc).__name__}: {exc}" message, upstream_code = self._extract_upstream_error_message(body_bytes) - lowered_message = message.lower() - lowered_code = (upstream_code or "").lower() + body_preview = body_bytes.decode("utf-8", errors="ignore").strip()[:500] - error_type = "upstream_error" - mapped_status = 502 - - if status_code in (400, 422): - error_type = "invalid_request_error" - mapped_status = 400 - elif status_code in (401, 403): - error_type = "upstream_auth_error" - mapped_status = 502 - elif status_code == 404: - if path.endswith("chat/completions"): - error_type = "invalid_model" - mapped_status = 400 - if not message or message == "Upstream request failed": - message = "Requested model is not available upstream" - elif "model" in lowered_message or "model" in lowered_code: - error_type = "invalid_model" - mapped_status = 400 - if not message or message == "Upstream request failed": - message = "Requested model is not available upstream" - else: - error_type = "upstream_error" - mapped_status = 502 - elif status_code == 429: - error_type = "rate_limit_exceeded" - mapped_status = 429 - elif status_code >= 500: - error_type = "upstream_error" - mapped_status = 502 - - logger.debug( - "Mapped upstream error", + logger.warning( + "Forwarding upstream error response as-is", extra={ "path": path, + "provider": self.provider_type, "upstream_status": status_code, - "mapped_status": mapped_status, - "error_type": error_type, + "upstream_code": upstream_code, "upstream_content_type": content_type, + "upstream_request_id": upstream_request_id, "message_preview": message[:200], + "body_preview": body_preview, + "body_read_error": body_read_error, + "method": request.method, }, ) - return create_error_response( - error_type, message, mapped_status, request=request + for header_name in ( + "content-length", + "Content-Length", + "transfer-encoding", + "Transfer-Encoding", + "content-encoding", + "Content-Encoding", + "connection", + "Connection", + "keep-alive", + "Keep-Alive", + "proxy-authenticate", + "Proxy-Authenticate", + "proxy-authorization", + "Proxy-Authorization", + "te", + "TE", + "trailer", + "Trailer", + "upgrade", + "Upgrade", + ): + headers.pop(header_name, None) + + if not content_type: + headers.pop("content-type", None) + headers.pop("Content-Type", None) + + media_type = content_type or None + + return Response( + content=body_bytes, + status_code=status_code, + headers=headers, + media_type=media_type, ) async def handle_streaming_chat_completion( @@ -1502,7 +1510,7 @@ class BaseUpstreamProvider: ) try: - mapped_error = await self.map_upstream_error_response( + mapped_error = await self.forward_upstream_error_response( request, path, response ) finally: @@ -1809,7 +1817,7 @@ class BaseUpstreamProvider: ) try: - mapped_error = await self.map_upstream_error_response( + mapped_error = await self.forward_upstream_error_response( request, path, response ) finally: @@ -1981,7 +1989,7 @@ class BaseUpstreamProvider: ) if response.status_code != 200: try: - mapped = await self.map_upstream_error_response( + mapped = await self.forward_upstream_error_response( request, path, response ) finally: diff --git a/routstr/wallet.py b/routstr/wallet.py index d93f4054..9c27ca56 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -8,6 +8,7 @@ from cashu.wallet.wallet import Wallet from sqlmodel import col, select, update from .core import db, get_logger +from .core.db import store_cashu_transaction from .core.settings import settings from .payment.lnurl import raw_send_to_lnurl @@ -279,6 +280,8 @@ async def credit_balance( try: amount, unit, mint_url = await recieve_token(cashu_token) + original_amount = amount + original_unit = unit logger.info( "credit_balance: Token redeemed successfully", extra={"amount": amount, "unit": unit, "mint_url": mint_url}, @@ -310,6 +313,19 @@ async def credit_balance( extra={"new_balance": key.balance}, ) + try: + await store_cashu_transaction( + token=cashu_token, + amount=original_amount, + unit=original_unit, + mint_url=mint_url, + typ="in", + source="apikey", + api_key_hashed_key=key.hashed_key, + ) + except Exception: + pass + logger.info( "Cashu token successfully redeemed and stored", extra={"amount": amount, "unit": unit, "mint_url": mint_url}, diff --git a/tests/integration/test_wallet_refund.py b/tests/integration/test_wallet_refund.py index 6d586845..4c79675c 100644 --- a/tests/integration/test_wallet_refund.py +++ b/tests/integration/test_wallet_refund.py @@ -13,7 +13,7 @@ import pytest from httpx import AsyncClient from sqlmodel import select -from routstr.core.db import ApiKey +from routstr.core.db import ApiKey, CashuTransaction @pytest.mark.integration @@ -356,6 +356,70 @@ async def test_concurrent_refund_requests( assert len(successful) + len(failed) == 5 +@pytest.mark.integration +@pytest.mark.asyncio +async def test_refund_rejects_concurrent_topup_on_same_key( + authenticated_client: AsyncClient, + testmint_wallet: Any, +) -> None: + """Test refund returns 409 when a concurrent topup changes the balance first.""" + from routstr import balance as balance_module + + wallet_response = await authenticated_client.get("/v1/wallet/") + assert wallet_response.status_code == 200 + initial_balance = wallet_response.json()["balance"] + + topup_amount_sat = 500 + topup_token = await testmint_wallet.mint_tokens(topup_amount_sat) + + validate_called = asyncio.Event() + allow_refund_to_continue = asyncio.Event() + original_validate_bearer_key = balance_module.validate_bearer_key + delayed_once = False + + async def delayed_validate_bearer_key(*args: Any, **kwargs: Any) -> ApiKey: + nonlocal delayed_once + key = await original_validate_bearer_key(*args, **kwargs) + if not delayed_once: + delayed_once = True + validate_called.set() + await allow_refund_to_continue.wait() + return key + + async def issue_refund() -> Any: + return await authenticated_client.post("/v1/wallet/refund") + + async def issue_topup() -> Any: + await validate_called.wait() + try: + return await authenticated_client.post( + "/v1/wallet/topup", params={"cashu_token": topup_token} + ) + finally: + allow_refund_to_continue.set() + + with patch( + "routstr.balance.validate_bearer_key", new=delayed_validate_bearer_key + ): + refund_response, topup_response = await asyncio.gather( + issue_refund(), issue_topup() + ) + + assert topup_response.status_code == 200 + assert topup_response.json()["msats"] == topup_amount_sat * 1000 + assert refund_response.status_code == 409 + assert ( + refund_response.json()["detail"] + == "Balance changed concurrently. Please retry the refund." + ) + + final_balance_response = await authenticated_client.get("/v1/wallet/") + assert final_balance_response.status_code == 200 + assert final_balance_response.json()["balance"] == ( + initial_balance + topup_amount_sat * 1000 + ) + + @pytest.mark.integration @pytest.mark.asyncio async def test_refund_during_active_usage( @@ -394,6 +458,42 @@ async def test_refund_during_active_usage( assert response.json()["balance"] == 0 +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_history_returns_apikey_transactions( + authenticated_client: AsyncClient, + testmint_wallet: Any, + integration_session: Any, +) -> None: + wallet_response = await authenticated_client.get("/v1/wallet/") + api_key = wallet_response.json()["api_key"] + hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key + + topup_token = await testmint_wallet.mint_tokens(250) + topup_response = await authenticated_client.post( + "/v1/wallet/topup", params={"cashu_token": topup_token} + ) + assert topup_response.status_code == 200 + + refund_response = await authenticated_client.post("/v1/wallet/refund") + assert refund_response.status_code == 200 + + history_response = await authenticated_client.get("/v1/wallet/history") + assert history_response.status_code == 200 + transactions = history_response.json()["transactions"] + assert len(transactions) >= 2 + assert all("api_key_hashed_key" not in tx for tx in transactions) + assert {tx["type"] for tx in transactions} >= {"in", "out"} + + db_result = await integration_session.execute( + select(CashuTransaction).where( + CashuTransaction.api_key_hashed_key == hashed_key + ) + ) + db_transactions = db_result.scalars().all() + assert len(db_transactions) >= 2 + + @pytest.mark.integration @pytest.mark.asyncio async def test_mint_unavailability_handling( diff --git a/tests/integration/test_wallet_topup.py b/tests/integration/test_wallet_topup.py index 06d66a6c..e8835290 100644 --- a/tests/integration/test_wallet_topup.py +++ b/tests/integration/test_wallet_topup.py @@ -11,7 +11,7 @@ import pytest from httpx import AsyncClient from sqlmodel import select -from routstr.core.db import ApiKey +from routstr.core.db import ApiKey, CashuTransaction from .utils import ( CashuTokenGenerator, @@ -71,6 +71,16 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def] assert db_key.balance == new_balance assert db_key.balance == initial_balance + (topup_amount * 1000) + tx_result = await integration_session.execute( + select(CashuTransaction).where( + CashuTransaction.token == token, + CashuTransaction.type == "in", + ) + ) + tx = tx_result.scalar_one() + assert tx.api_key_hashed_key == hashed_key + assert tx.source == "apikey" + @pytest.mark.integration @pytest.mark.asyncio diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index ef3d3f91..11456367 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse from routstr.balance import refund_wallet_endpoint from routstr.core.db import ApiKey, CashuTransaction +from routstr.wallet import credit_balance def _make_cashu_tx( @@ -29,6 +30,12 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock: return result +def _update_result(rowcount: int) -> MagicMock: + result = MagicMock() + result.rowcount = rowcount + return result + + @pytest.mark.asyncio async def test_refund_x_cashu_returns_token() -> None: x_cashu_token = "cashuAtest_token_value" @@ -161,6 +168,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No refund_token = "cashuArefund_apikey_token" session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() @@ -186,6 +194,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No assert call_kwargs["source"] == "apikey" assert call_kwargs["token"] == refund_token assert call_kwargs["typ"] == "out" + assert call_kwargs["api_key_hashed_key"] == key.hashed_key @pytest.mark.asyncio @@ -194,6 +203,7 @@ async def test_apikey_refund_logs_token() -> None: refund_token = "cashuAlogged_token" session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() @@ -222,6 +232,7 @@ async def test_apikey_refund_log_includes_path() -> None: refund_token = "cashuApath_token" session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() @@ -248,3 +259,99 @@ async def test_apikey_refund_log_includes_path() -> None: assert len(token_issued_calls) == 1 extra = token_issued_calls[0].kwargs.get("extra", {}) assert extra.get("path") == "/v1/wallet/refund" + + +@pytest.mark.asyncio +async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None: + """When the debit CAS fails (rowcount=0), no token is minted and 409 is returned.""" + from fastapi import HTTPException + + key = _make_api_key(balance=5000, refund_currency="sat") + + session = MagicMock() + # Debit returns rowcount=0 → balance changed concurrently + session.exec = AsyncMock(return_value=_update_result(0)) + session.commit = AsyncMock() + + mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted") + + with ( + patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)), + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch("routstr.balance.send_token", mock_send_token), + patch("routstr.balance.store_cashu_transaction", AsyncMock()), + patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), + patch("routstr.balance._refund_cache_set", AsyncMock()), + ): + 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 == 409 + # Crucially: send_token must NOT have been called + mock_send_token.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_credit_balance_stores_apikey_transaction_history() -> None: + key = _make_api_key(balance=1000) + session = MagicMock() + session.exec = AsyncMock(return_value=_update_result(1)) + session.commit = AsyncMock() + session.refresh = AsyncMock() + + with ( + patch( + "routstr.wallet.recieve_token", + AsyncMock(return_value=(100, "sat", "https://mint.example")), + ), + patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store, + ): + amount = await credit_balance("cashuAtopup_token", key, session) + + assert amount == 100_000 + mock_store.assert_awaited_once() + call_kwargs = mock_store.call_args.kwargs + assert call_kwargs["typ"] == "in" + assert call_kwargs["source"] == "apikey" + assert call_kwargs["api_key_hashed_key"] == key.hashed_key + assert call_kwargs["amount"] == 100 + assert call_kwargs["unit"] == "sat" + assert call_kwargs["token"] == "cashuAtopup_token" + assert call_kwargs["mint_url"] == "https://mint.example" + + +@pytest.mark.asyncio +async def test_apikey_refund_restores_balance_on_mint_failure() -> None: + """When debit succeeds but minting fails, balance must be restored.""" + from fastapi import HTTPException + + key = _make_api_key(balance=5000, refund_currency="sat") + + # First exec call = debit (succeeds), second = restore + session = MagicMock() + session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)]) + session.commit = AsyncMock() + + with ( + patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)), + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))), + patch("routstr.balance.store_cashu_transaction", AsyncMock()), + patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), + patch("routstr.balance._refund_cache_set", AsyncMock()), + patch("routstr.balance.logger"), + ): + 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 == 503 + # Verify two exec calls: debit + restore + assert session.exec.await_count == 2 diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index 19e0f032..8704515f 100644 --- a/ui/app/transactions/page.tsx +++ b/ui/app/transactions/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { AppPageShell } from '@/components/app-page-shell'; import { PageHeader } from '@/components/page-header'; import { @@ -31,7 +31,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { Empty, @@ -50,6 +50,8 @@ import { Receipt, Key, Zap, + ChevronLeft, + ChevronRight, } from 'lucide-react'; import { AdminService, type Transaction } from '@/lib/api/services/admin'; import { format } from 'date-fns'; @@ -86,85 +88,114 @@ function TransactionTable({ return ( - - - - Type - Amount - Status - Request ID - Mint - Date - Actions - - - - {transactions.map((tx) => ( - - -
- {tx.type === 'in' ? ( - - ) : ( - - )} - {tx.type} -
-
- - {tx.amount} {tx.unit} - - {getStatusBadge(tx)} - - {tx.request_id ? ( -
- - {tx.request_id} - - -
- ) : ( - - )} -
- -
- {tx.mint_url} -
-
- - {format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} - - - - +
+
+ + + Type + Amount + Status + API Key + Request ID + Mint + Date + Actions - ))} - -
+ + + {transactions.map((tx) => ( + + +
+ {tx.type === 'in' ? ( + + ) : ( + + )} + {tx.type} +
+
+ + {tx.amount} {tx.unit} + + {getStatusBadge(tx)} + + {tx.api_key_hashed_key ? ( +
+ + {tx.api_key_hashed_key.slice(0, 12)}... + + +
+ ) : ( + + )} +
+ + {tx.request_id ? ( +
+ + {tx.request_id} + + +
+ ) : ( + + )} +
+ +
+ {tx.mint_url} +
+
+ + {format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} + + + + +
+ ))} +
+ + +
); } @@ -196,21 +227,63 @@ export default function TransactionsPage() { localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); }, [search, type, status]); - const { data, isLoading, refetch, isRefetching } = useQuery({ - queryKey: ['transactions', type, status, search], + const PAGE_SIZE = 50; + const [activeTab, setActiveTab] = useState('x-cashu'); + const [xcashuPage, setXcashuPage] = useState(0); + const [apikeyPage, setApikeyPage] = useState(0); + + const typeParam = type === 'all' ? undefined : type; + const statusParam = status === 'all' ? undefined : status; + const searchParam = search || undefined; + + const xcashuQuery = useQuery({ + queryKey: [ + 'transactions', + 'x-cashu', + typeParam, + statusParam, + searchParam, + xcashuPage, + ], queryFn: () => AdminService.getTransactions( - type === 'all' ? undefined : type, - status === 'all' ? undefined : status, - search || undefined, - 100 + typeParam, + statusParam, + searchParam, + 'x-cashu', + PAGE_SIZE, + xcashuPage * PAGE_SIZE ), + placeholderData: keepPreviousData, + }); + + const apikeyQuery = useQuery({ + queryKey: [ + 'transactions', + 'apikey', + typeParam, + statusParam, + searchParam, + apikeyPage, + ], + queryFn: () => + AdminService.getTransactions( + typeParam, + statusParam, + searchParam, + 'apikey', + PAGE_SIZE, + apikeyPage * PAGE_SIZE + ), + placeholderData: keepPreviousData, }); const handleClearFilters = () => { setSearch(''); setType('all'); setStatus('all'); + setXcashuPage(0); + setApikeyPage(0); }; const copyToClipboard = (text: string, id: string) => { @@ -260,14 +333,20 @@ export default function TransactionsPage() { .filter(Boolean) .join(' • '); - const xcashuTxs = - data?.transactions.filter((tx) => !tx.source || tx.source === 'x-cashu') ?? - []; - const apikeyTxs = - data?.transactions.filter((tx) => tx.source === 'apikey') ?? []; + // Reset pages when filters change + useEffect(() => { + setXcashuPage(0); + setApikeyPage(0); + }, [type, status, search]); - const renderCardContent = (txs: Transaction[]) => { - if (isLoading) { + const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching; + + const renderCardContent = ( + query: typeof xcashuQuery, + page: number, + setPage: (p: number) => void + ) => { + if (query.isLoading) { return (
{Array.from({ length: 8 }).map((_, index) => ( @@ -279,13 +358,51 @@ export default function TransactionsPage() {
); } + + const transactions = query.data?.transactions ?? []; + const total = query.data?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + return ( - + <> + {totalPages > 1 && ( +
+ + {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{' '} + of {total} + +
+ + + {page + 1} / {totalPages} + + +
+
+ )} + + ); }; @@ -297,7 +414,10 @@ export default function TransactionsPage() { description='View all incoming and outgoing Cashu token transactions.' actions={