mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
add transaction view
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""add cashu_refunds table
|
"""add cashu_transactions table
|
||||||
|
|
||||||
Revision ID: a776ca70e5fe
|
Revision ID: a776ca70e5fe
|
||||||
Revises: 614c0a740e68
|
Revises: 614c0a740e68
|
||||||
@@ -10,25 +10,33 @@ import sqlmodel
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = 'a776ca70e5fe'
|
revision = "a776ca70e5fe"
|
||||||
down_revision = '614c0a740e68'
|
down_revision = "614c0a740e68"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'cashu_refunds',
|
"cashu_transactions",
|
||||||
sa.Column('payment_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('refund_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('amount', sa.Integer(), nullable=False),
|
sa.Column("amount", sa.Integer(), nullable=False),
|
||||||
sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
sa.Column('mint_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
sa.Column('created_at', sa.Integer(), nullable=False),
|
sa.Column(
|
||||||
sa.Column('collected', sa.Boolean(), nullable=False),
|
"type",
|
||||||
sa.Column('swept', sa.Boolean(), nullable=False),
|
sqlmodel.sql.sqltypes.AutoString(),
|
||||||
sa.PrimaryKeyConstraint('payment_token_hash'),
|
nullable=False,
|
||||||
|
server_default="out",
|
||||||
|
),
|
||||||
|
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("collected", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("swept", sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_table('cashu_refunds')
|
op.drop_table("cashu_transactions")
|
||||||
|
|||||||
+3
-3
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from .auth import get_billing_key, validate_bearer_key
|
from .auth import get_billing_key, validate_bearer_key
|
||||||
from .core.db import ApiKey, AsyncSession, CashuRefund, get_session
|
from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session
|
||||||
from .core.logging import get_logger
|
from .core.logging import get_logger
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
from .lightning import lightning_router
|
from .lightning import lightning_router
|
||||||
@@ -407,7 +407,7 @@ async def get_cashu_refund(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
||||||
result = await session.get(CashuRefund, payment_token_hash)
|
result = await session.get(CashuTransaction, payment_token_hash)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(status_code=404, detail="Refund not found")
|
raise HTTPException(status_code=404, detail="Refund not found")
|
||||||
if result.swept:
|
if result.swept:
|
||||||
@@ -416,7 +416,7 @@ async def get_cashu_refund(
|
|||||||
session.add(result)
|
session.add(result)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {
|
return {
|
||||||
"refund_token": result.refund_token,
|
"refund_token": result.token,
|
||||||
"amount": result.amount,
|
"amount": result.amount,
|
||||||
"unit": result.unit,
|
"unit": result.unit,
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-4
@@ -16,7 +16,7 @@ from ..wallet import (
|
|||||||
send_token,
|
send_token,
|
||||||
slow_filter_spend_proofs,
|
slow_filter_spend_proofs,
|
||||||
)
|
)
|
||||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
from .db import ApiKey, CashuTransaction, ModelRow, UpstreamProviderRow, create_session
|
||||||
from .log_manager import log_manager
|
from .log_manager import log_manager
|
||||||
from .logging import get_logger
|
from .logging import get_logger
|
||||||
from .settings import SettingsService, settings
|
from .settings import SettingsService, settings
|
||||||
@@ -747,7 +747,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
db_model_ids = {model.id for model in db_models}
|
db_model_ids = {model.id for model in db_models}
|
||||||
filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids]
|
filtered_remote_models = [
|
||||||
|
m for m in upstream_models if m.id not in db_model_ids
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"provider": {
|
"provider": {
|
||||||
@@ -893,7 +895,9 @@ async def initiate_provider_topup(
|
|||||||
"purpose": "topup",
|
"purpose": "topup",
|
||||||
"api_key": provider.api_key,
|
"api_key": provider.api_key,
|
||||||
},
|
},
|
||||||
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
|
headers={"Authorization": f"Bearer {provider.api_key}"}
|
||||||
|
if provider.api_key
|
||||||
|
else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
@@ -977,7 +981,9 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
|||||||
clean_url = provider.base_url.rstrip("/")
|
clean_url = provider.base_url.rstrip("/")
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
|
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
|
||||||
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
|
headers={"Authorization": f"Bearer {provider.api_key}"}
|
||||||
|
if provider.api_key
|
||||||
|
else {},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
status_data = resp.json()
|
status_data = resp.json()
|
||||||
@@ -1208,6 +1214,49 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
|||||||
return {"dates": dates}
|
return {"dates": dates}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
||||||
|
async def get_transactions_api(
|
||||||
|
type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict:
|
||||||
|
async with create_session() as session:
|
||||||
|
from sqlmodel import col
|
||||||
|
|
||||||
|
stmt = select(CashuTransaction)
|
||||||
|
if type:
|
||||||
|
stmt = stmt.where(CashuTransaction.type == type)
|
||||||
|
if status:
|
||||||
|
if status == "collected":
|
||||||
|
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
|
||||||
|
elif status == "swept":
|
||||||
|
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
|
||||||
|
elif status == "pending":
|
||||||
|
stmt = stmt.where(
|
||||||
|
not CashuTransaction.collected,
|
||||||
|
CashuTransaction.swept == False, # noqa: E712
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
(col(CashuTransaction.id).like(search_pattern))
|
||||||
|
| (col(CashuTransaction.token).like(search_pattern))
|
||||||
|
| (col(CashuTransaction.request_id).like(search_pattern))
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
|
||||||
|
|
||||||
|
results = await session.exec(stmt)
|
||||||
|
transactions = results.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transactions": [tx.dict() for tx in transactions],
|
||||||
|
"total": len(transactions),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post(
|
@admin_router.post(
|
||||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
|
|||||||
+47
-20
@@ -7,6 +7,7 @@ from typing import AsyncGenerator
|
|||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from alembic.util.exc import CommandError
|
||||||
from sqlalchemy import UniqueConstraint
|
from sqlalchemy import UniqueConstraint
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||||
@@ -128,19 +129,19 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
|||||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||||
|
|
||||||
|
|
||||||
class CashuRefund(SQLModel, table=True): # type: ignore
|
class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||||
__tablename__ = "cashu_refunds"
|
__tablename__ = "cashu_transactions"
|
||||||
|
|
||||||
payment_token_hash: str = Field(
|
id: str = Field(
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
description="SHA-256 hash of the original x-cashu payment token",
|
description="SHA-256 hash of the x-cashu token or unique identifier",
|
||||||
)
|
)
|
||||||
refund_token: str = Field(description="Serialized Cashu refund token")
|
token: str = Field(description="Serialized Cashu token")
|
||||||
amount: int = Field(description="Refund amount in the token's unit")
|
amount: int = Field(description="Amount in the token's unit")
|
||||||
unit: str = Field(description="Token unit (sat or msat)")
|
unit: str = Field(description="Token unit (sat or msat)")
|
||||||
mint_url: str | None = Field(
|
mint_url: str | None = Field(default=None, description="Mint URL for the token")
|
||||||
default=None, description="Mint URL for the refund token"
|
type: str = Field(default="out", description="Transaction type: in or out")
|
||||||
)
|
request_id: str | None = Field(default=None, description="Associated request ID")
|
||||||
created_at: int = Field(
|
created_at: int = Field(
|
||||||
default_factory=lambda: int(time.time()),
|
default_factory=lambda: int(time.time()),
|
||||||
description="Unix timestamp",
|
description="Unix timestamp",
|
||||||
@@ -149,28 +150,32 @@ class CashuRefund(SQLModel, table=True): # type: ignore
|
|||||||
swept: bool = Field(default=False)
|
swept: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
async def store_cashu_refund(
|
async def store_cashu_transaction(
|
||||||
payment_token_hash: str,
|
id: str,
|
||||||
refund_token: str,
|
token: str,
|
||||||
amount: int,
|
amount: int,
|
||||||
unit: str,
|
unit: str,
|
||||||
mint_url: str | None = None,
|
mint_url: str | None = None,
|
||||||
|
type: str = "out",
|
||||||
|
request_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
refund = CashuRefund(
|
tx = CashuTransaction(
|
||||||
payment_token_hash=payment_token_hash,
|
id=id,
|
||||||
refund_token=refund_token,
|
token=token,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
mint_url=mint_url,
|
mint_url=mint_url,
|
||||||
|
type=type,
|
||||||
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
session.add(refund)
|
session.add(tx)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to store cashu refund",
|
"Failed to store cashu transaction",
|
||||||
extra={"error": str(e), "payment_token_hash": payment_token_hash},
|
extra={"error": str(e), "id": id, "type": type},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +278,17 @@ def fix_cashu_migrations() -> None:
|
|||||||
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_alembic_version() -> None:
|
||||||
|
"""Clear the alembic_version table so stamp/upgrade can proceed."""
|
||||||
|
sync_url = DATABASE_URL.replace("+aiosqlite", "")
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
|
eng = create_engine(sync_url)
|
||||||
|
with eng.begin() as conn:
|
||||||
|
conn.execute(text("DELETE FROM alembic_version"))
|
||||||
|
eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
def run_migrations() -> None:
|
def run_migrations() -> None:
|
||||||
"""Run Alembic migrations programmatically."""
|
"""Run Alembic migrations programmatically."""
|
||||||
try:
|
try:
|
||||||
@@ -294,8 +310,19 @@ def run_migrations() -> None:
|
|||||||
# Set the database URL in the config
|
# Set the database URL in the config
|
||||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||||
|
|
||||||
# Run migrations to the latest revision
|
try:
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
except CommandError as e:
|
||||||
|
if "Can't locate revision" in str(e):
|
||||||
|
logger.warning(
|
||||||
|
"Database stamped with unknown revision (likely from another branch). "
|
||||||
|
"Re-stamping to current head.",
|
||||||
|
extra={"error": str(e)},
|
||||||
|
)
|
||||||
|
_clear_alembic_version()
|
||||||
|
command.stamp(alembic_cfg, "head")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
logger.info("Database migrations completed successfully")
|
logger.info("Database migrations completed successfully")
|
||||||
|
|
||||||
|
|||||||
@@ -238,9 +238,10 @@ class SettingsService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
db_json = {}
|
db_json = {}
|
||||||
|
|
||||||
|
valid_fields = set(env_resolved.dict().keys())
|
||||||
merged_dict: dict[str, Any] = dict(env_resolved.dict())
|
merged_dict: dict[str, Any] = dict(env_resolved.dict())
|
||||||
merged_dict.update(
|
merged_dict.update(
|
||||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
|
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
||||||
@@ -306,8 +307,10 @@ class SettingsService:
|
|||||||
raise RuntimeError("Settings row missing")
|
raise RuntimeError("Settings row missing")
|
||||||
(data_str,) = row
|
(data_str,) = row
|
||||||
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
|
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
|
||||||
|
valid_fields = set(settings.dict().keys())
|
||||||
# Update in-place
|
# Update in-place
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
setattr(settings, k, v)
|
if k in valid_fields:
|
||||||
|
setattr(settings, k, v)
|
||||||
cls._current = settings
|
cls._current = settings
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from ..core.db import (
|
|||||||
AsyncSession,
|
AsyncSession,
|
||||||
UpstreamProviderRow,
|
UpstreamProviderRow,
|
||||||
create_session,
|
create_session,
|
||||||
store_cashu_refund,
|
store_cashu_transaction,
|
||||||
)
|
)
|
||||||
from ..core.exceptions import UpstreamError
|
from ..core.exceptions import UpstreamError
|
||||||
from ..payment.cost_calculation import (
|
from ..payment.cost_calculation import (
|
||||||
@@ -1725,15 +1725,16 @@ class BaseUpstreamProvider:
|
|||||||
|
|
||||||
if payment_token_hash:
|
if payment_token_hash:
|
||||||
try:
|
try:
|
||||||
await store_cashu_refund(
|
await store_cashu_transaction(
|
||||||
payment_token_hash=payment_token_hash,
|
id=payment_token_hash,
|
||||||
refund_token=refund_token,
|
token=refund_token,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
mint_url=mint,
|
mint_url=mint,
|
||||||
|
type="out",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # store_cashu_refund already logs
|
pass # store_cashu_transaction already logs
|
||||||
|
|
||||||
return refund_token
|
return refund_token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2033,8 +2034,13 @@ class BaseUpstreamProvider:
|
|||||||
response.headers["X-Cashu"] = refund_token
|
response.headers["X-Cashu"] = refund_token
|
||||||
if payment_token_hash:
|
if payment_token_hash:
|
||||||
try:
|
try:
|
||||||
await store_cashu_refund(
|
await store_cashu_transaction(
|
||||||
payment_token_hash, refund_token, emergency_refund, unit, mint
|
id=payment_token_hash,
|
||||||
|
token=refund_token,
|
||||||
|
amount=emergency_refund,
|
||||||
|
unit=unit,
|
||||||
|
mint_url=mint,
|
||||||
|
type="out",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -2899,8 +2905,13 @@ class BaseUpstreamProvider:
|
|||||||
response.headers["X-Cashu"] = refund_token
|
response.headers["X-Cashu"] = refund_token
|
||||||
if payment_token_hash:
|
if payment_token_hash:
|
||||||
try:
|
try:
|
||||||
await store_cashu_refund(
|
await store_cashu_transaction(
|
||||||
payment_token_hash, refund_token, emergency_refund, unit, mint
|
id=payment_token_hash,
|
||||||
|
token=refund_token,
|
||||||
|
amount=emergency_refund,
|
||||||
|
unit=unit,
|
||||||
|
mint_url=mint,
|
||||||
|
type="out",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+25
-8
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
@@ -35,6 +36,21 @@ async def recieve_token(
|
|||||||
|
|
||||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||||
|
|
||||||
|
# Store incoming transaction
|
||||||
|
try:
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
await db.store_cashu_transaction(
|
||||||
|
id=token_hash,
|
||||||
|
token=token,
|
||||||
|
amount=token_obj.amount,
|
||||||
|
unit=token_obj.unit,
|
||||||
|
mint_url=token_obj.mint,
|
||||||
|
type="in",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||||
|
|
||||||
|
|
||||||
@@ -359,23 +375,24 @@ async def periodic_refund_sweep() -> None:
|
|||||||
try:
|
try:
|
||||||
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
|
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
|
||||||
async with db.create_session() as session:
|
async with db.create_session() as session:
|
||||||
stmt = select(db.CashuRefund).where(
|
stmt = select(db.CashuTransaction).where(
|
||||||
db.CashuRefund.collected == False, # noqa: E712
|
db.CashuTransaction.type == "out",
|
||||||
db.CashuRefund.swept == False, # noqa: E712
|
db.CashuTransaction.collected == False, # noqa: E712
|
||||||
db.CashuRefund.created_at < cutoff,
|
db.CashuTransaction.swept == False, # noqa: E712
|
||||||
|
db.CashuTransaction.created_at < cutoff,
|
||||||
)
|
)
|
||||||
results = await session.exec(stmt)
|
results = await session.exec(stmt)
|
||||||
refunds = results.all()
|
refunds = results.all()
|
||||||
|
|
||||||
for refund in refunds:
|
for refund in refunds:
|
||||||
try:
|
try:
|
||||||
await recieve_token(refund.refund_token)
|
await recieve_token(refund.token)
|
||||||
refund.swept = True
|
refund.swept = True
|
||||||
session.add(refund)
|
session.add(refund)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Swept uncollected refund",
|
"Swept uncollected refund",
|
||||||
extra={
|
extra={
|
||||||
"payment_token_hash": refund.payment_token_hash,
|
"id": refund.id,
|
||||||
"amount": refund.amount,
|
"amount": refund.amount,
|
||||||
"unit": refund.unit,
|
"unit": refund.unit,
|
||||||
},
|
},
|
||||||
@@ -388,14 +405,14 @@ async def periodic_refund_sweep() -> None:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Refund already spent (client collected), marking swept",
|
"Refund already spent (client collected), marking swept",
|
||||||
extra={
|
extra={
|
||||||
"payment_token_hash": refund.payment_token_hash,
|
"id": refund.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to sweep refund",
|
"Failed to sweep refund",
|
||||||
extra={
|
extra={
|
||||||
"payment_token_hash": refund.payment_token_hash,
|
"id": refund.id,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AppPageShell } from '@/components/app-page-shell';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ArrowDownLeft,
|
||||||
|
ArrowUpRight,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export default function TransactionsPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [type, setType] = useState<string>('all');
|
||||||
|
const [status, setStatus] = useState<string>('all');
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||||
|
queryKey: ['transactions', type, status, search],
|
||||||
|
queryFn: () =>
|
||||||
|
AdminService.getTransactions(
|
||||||
|
type === 'all' ? undefined : type,
|
||||||
|
status === 'all' ? undefined : status,
|
||||||
|
search || undefined,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, id: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopiedId(id);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (tx: Transaction) => {
|
||||||
|
if (tx.swept)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
|
||||||
|
>
|
||||||
|
Swept
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (tx.collected)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className='border-green-500/20 bg-green-500/10 text-green-500'
|
||||||
|
>
|
||||||
|
Collected
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppPageShell contentClassName='mx-auto w-full max-w-7xl'>
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<PageHeader
|
||||||
|
title='Transactions'
|
||||||
|
description='View all incoming and outgoing X-Cashu token transactions.'
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={isRefetching}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<div className='relative'>
|
||||||
|
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||||
|
<Input
|
||||||
|
placeholder='Search by ID, token or request ID...'
|
||||||
|
className='pl-8'
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Type' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All Types</SelectItem>
|
||||||
|
<SelectItem value='in'>Incoming (Payments)</SelectItem>
|
||||||
|
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={status} onValueChange={setStatus}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Status' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All Statuses</SelectItem>
|
||||||
|
<SelectItem value='pending'>Pending</SelectItem>
|
||||||
|
<SelectItem value='collected'>Collected</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Transaction History</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className='h-12 w-full' />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className='h-[600px]'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>ID / Request ID</TableHead>
|
||||||
|
<TableHead>Mint</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className='text-right'>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.transactions.map((tx) => (
|
||||||
|
<TableRow key={tx.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
{tx.type === 'in' ? (
|
||||||
|
<ArrowDownLeft className='h-4 w-4 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className='h-4 w-4 text-blue-500' />
|
||||||
|
)}
|
||||||
|
<span className='capitalize'>{tx.type}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='font-mono'>
|
||||||
|
{tx.amount} {tx.unit}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(tx)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<div className='text-muted-foreground flex items-center gap-1 text-xs'>
|
||||||
|
<span className='max-w-[100px] truncate font-mono'>
|
||||||
|
{tx.id}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-4 w-4'
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(tx.id, tx.id + '-id')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copiedId === tx.id + '-id' ? (
|
||||||
|
<Check className='h-3 w-3' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3 w-3' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{tx.request_id && (
|
||||||
|
<div className='text-primary flex items-center gap-1 text-xs'>
|
||||||
|
<span className='max-w-[100px] truncate'>
|
||||||
|
{tx.request_id}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='text-primary h-4 w-4'
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
tx.request_id!,
|
||||||
|
tx.id + '-req'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copiedId === tx.id + '-req' ? (
|
||||||
|
<Check className='h-3 w-3' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3 w-3' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
|
||||||
|
<span className='truncate'>{tx.mint_url}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-xs whitespace-nowrap'>
|
||||||
|
{formatDistanceToNow(tx.created_at * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-right'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-8 w-8'
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(tx.token, tx.id + '-token')
|
||||||
|
}
|
||||||
|
title='Copy Token'
|
||||||
|
>
|
||||||
|
{copiedId === tx.id + '-token' ? (
|
||||||
|
<Check className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.transactions.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className='text-muted-foreground py-10 text-center'
|
||||||
|
>
|
||||||
|
No transactions found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
WalletIcon,
|
WalletIcon,
|
||||||
|
ArrowRightLeftIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -41,6 +42,7 @@ const NAV_ITEMS = [
|
|||||||
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
||||||
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
||||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||||
|
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
|
||||||
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
WalletIcon,
|
WalletIcon,
|
||||||
|
ArrowRightLeftIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -45,6 +46,11 @@ const data = {
|
|||||||
url: '/balances',
|
url: '/balances',
|
||||||
icon: WalletIcon,
|
icon: WalletIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Transactions',
|
||||||
|
url: '/transactions',
|
||||||
|
icon: ArrowRightLeftIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Logs',
|
title: 'Logs',
|
||||||
url: '/logs',
|
url: '/logs',
|
||||||
|
|||||||
@@ -869,6 +869,23 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getTransactions(
|
||||||
|
type?: string,
|
||||||
|
status?: string,
|
||||||
|
search?: string,
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<TransactionsResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (type) params.append('type', type);
|
||||||
|
if (status) params.append('status', status);
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
params.append('limit', limit.toString());
|
||||||
|
|
||||||
|
return await apiClient.get<TransactionsResponse>(
|
||||||
|
`/admin/api/transactions?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static async createProviderAccountByType(providerType: string): Promise<{
|
static async createProviderAccountByType(providerType: string): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
account_data: Record<string, unknown>;
|
account_data: Record<string, unknown>;
|
||||||
@@ -1042,3 +1059,21 @@ export interface LogResponse {
|
|||||||
search: string | null;
|
search: string | null;
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
amount: number;
|
||||||
|
unit: string;
|
||||||
|
mint_url: string;
|
||||||
|
type: 'in' | 'out';
|
||||||
|
request_id?: string;
|
||||||
|
created_at: number;
|
||||||
|
collected: boolean;
|
||||||
|
swept: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionsResponse {
|
||||||
|
transactions: Transaction[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user