diff --git a/deployment/migrations/versions/0040_e1f2a3b4c5d6_rename_credit_history_ratio_to_price.py b/deployment/migrations/versions/0040_e1f2a3b4c5d6_rename_credit_history_ratio_to_price.py new file mode 100644 index 000000000..cb2bf8bee --- /dev/null +++ b/deployment/migrations/versions/0040_e1f2a3b4c5d6_rename_credit_history_ratio_to_price.py @@ -0,0 +1,81 @@ +"""rename credit_history ratio column to price + +Revision ID: e1f2a3b4c5d6 +Revises: d0e1f2a3b4c5 +Create Date: 2025-12-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e1f2a3b4c5d6' +down_revision = 'd0e1f2a3b4c5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # First, add the new price column + op.add_column('credit_history', sa.Column('price', sa.DECIMAL(), nullable=True)) + + # Add the new bonus_amount column + op.add_column('credit_history', sa.Column('bonus_amount', sa.BigInteger(), nullable=True)) + + # Transform data: price calculation depends on payment token + # Taking into account that bonus_ratio = 1.2 + # For ALEPH token: ratio = (1 / price) * bonus_ratio, therefore price = bonus_ratio / ratio + # For other tokens: price = 1/ratio + # Only update rows where payment_method is NOT 'credit_expense' or 'credit_transfer' + # and where ratio is not null and not zero + connection = op.get_bind() + connection.execute(sa.text(""" + UPDATE credit_history + SET price = CASE + WHEN token = 'ALEPH' THEN ROUND(1.2 / ratio, 18) + ELSE ROUND(1.0 / ratio, 18) + END + WHERE ratio IS NOT NULL + AND ratio != 0 + AND (payment_method IS NULL + OR (payment_method != 'credit_expense' AND payment_method != 'credit_transfer')) + """)) + + # Update bonus_amount for ALEPH token records + connection.execute(sa.text(""" + UPDATE credit_history + SET bonus_amount = TRUNC(amount / 1.2) + WHERE token = 'ALEPH' + AND amount IS NOT NULL + """)) + + # Drop the old ratio column + op.drop_column('credit_history', 'ratio') + + +def downgrade() -> None: + # Add back the ratio column + op.add_column('credit_history', sa.Column('ratio', sa.DECIMAL(), nullable=True)) + + # Transform data back: reverse price calculation depends on payment token + # For ALEPH token: price = 1.2 / ratio, therefore ratio = 1.2 / price + # For other tokens: ratio = 1/price + connection = op.get_bind() + connection.execute(sa.text(""" + UPDATE credit_history + SET ratio = CASE + WHEN token = 'ALEPH' THEN ROUND(1.2 / price, 18) + ELSE ROUND(1.0 / price, 18) + END + WHERE price IS NOT NULL + AND price != 0 + AND (payment_method IS NULL + OR (payment_method != 'credit_expense' AND payment_method != 'credit_transfer')) + """)) + + # Drop the price column + op.drop_column('credit_history', 'price') + + # Drop the bonus_amount column + op.drop_column('credit_history', 'bonus_amount') \ No newline at end of file diff --git a/src/aleph/db/accessors/balances.py b/src/aleph/db/accessors/balances.py index c48315b75..38575a8a6 100644 --- a/src/aleph/db/accessors/balances.py +++ b/src/aleph/db/accessors/balances.py @@ -408,8 +408,8 @@ def _bulk_insert_credit_history( cursor = conn.cursor() # Column specification for credit history - # Include the new message_timestamp field - copy_columns = "address, amount, credit_ref, credit_index, message_timestamp, last_update, ratio, tx_hash, expiration_date, token, chain, origin, provider, origin_ref, payment_method" + # Include the new message_timestamp and bonus_amount fields + copy_columns = "address, amount, credit_ref, credit_index, message_timestamp, last_update, price, bonus_amount, tx_hash, expiration_date, token, chain, origin, provider, origin_ref, payment_method" csv_credit_history = StringIO("\n".join(csv_rows)) cursor.copy_expert( @@ -453,7 +453,7 @@ def update_credit_balances_distribution( """ Updates credit balances for distribution messages (aleph_credit_distribution). - Distribution messages include all fields like ratio, tx_hash, provider, + Distribution messages include all fields like price, bonus_amount, tx_hash, provider, payment_method, token, chain, and expiration_date. """ @@ -463,7 +463,7 @@ def update_credit_balances_distribution( for index, credit_entry in enumerate(credits_list): address = credit_entry["address"] amount = abs(int(credit_entry["amount"])) - ratio = Decimal(credit_entry["ratio"]) + price = Decimal(credit_entry["price"]) tx_hash = credit_entry["tx_hash"] provider = credit_entry["provider"] @@ -472,6 +472,7 @@ def update_credit_balances_distribution( origin = credit_entry.get("origin", "") origin_ref = credit_entry.get("ref", "") payment_method = credit_entry.get("payment_method", "") + bonus_amount = credit_entry.get("bonus_amount", "") # Convert expiration timestamp to datetime @@ -482,7 +483,7 @@ def update_credit_balances_distribution( ) csv_rows.append( - f"{address};{amount};{message_hash};{index};{message_timestamp};{last_update};{ratio};{tx_hash};{expiration_date or ''};{token};{chain};{origin};{provider};{origin_ref};{payment_method}" + f"{address};{amount};{message_hash};{index};{message_timestamp};{last_update};{price};{bonus_amount or ''};{tx_hash};{expiration_date or ''};{token};{chain};{origin};{provider};{origin_ref};{payment_method}" ) _bulk_insert_credit_history(session, csv_rows) @@ -500,7 +501,7 @@ def update_credit_balances_expense( Expense messages have negative amounts and can include: - execution_id (mapped to origin) - node_id (mapped to tx_hash) - - price (mapped to ratio) + - price (mapped to price) - time (skipped for now) - ref (mapped to origin_ref) """ @@ -516,11 +517,11 @@ def update_credit_balances_expense( # Map new fields origin = credit_entry.get("execution_id", "") tx_hash = credit_entry.get("node_id", "") - ratio = credit_entry.get("price", "") + price = credit_entry.get("price", "") # Skip time field for now csv_rows.append( - f"{address};{amount};{message_hash};{index};{message_timestamp};{last_update};{ratio};{tx_hash};;;;{origin};ALEPH;{origin_ref};credit_expense" + f"{address};{amount};{message_hash};{index};{message_timestamp};{last_update};{price};;{tx_hash};;;;{origin};ALEPH;{origin_ref};credit_expense" ) _bulk_insert_credit_history(session, csv_rows) @@ -562,7 +563,7 @@ def update_credit_balances_transfer( # Add positive entry for recipient (origin = sender, provider = ALEPH, payment_method = credit_transfer) csv_rows.append( - f"{recipient_address};{amount};{message_hash};{index};{message_timestamp};{last_update};;;{expiration_date or ''};;;{sender_address};ALEPH;;credit_transfer" + f"{recipient_address};{amount};{message_hash};{index};{message_timestamp};{last_update};;;;{expiration_date or ''};;;{sender_address};ALEPH;;credit_transfer" ) index += 1 @@ -570,7 +571,7 @@ def update_credit_balances_transfer( # (origin = recipient, provider = ALEPH, payment_method = credit_transfer) if sender_address not in whitelisted_addresses: csv_rows.append( - f"{sender_address};{-amount};{message_hash};{index};{message_timestamp};{last_update};;;;;;{recipient_address};ALEPH;;credit_transfer" + f"{sender_address};{-amount};{message_hash};{index};{message_timestamp};{last_update};;;;;;;{recipient_address};ALEPH;;credit_transfer" ) index += 1 @@ -602,6 +603,13 @@ def get_address_credit_history( address: str, page: int = 1, pagination: int = 0, + tx_hash: Optional[str] = None, + token: Optional[str] = None, + chain: Optional[str] = None, + provider: Optional[str] = None, + origin: Optional[str] = None, + origin_ref: Optional[str] = None, + payment_method: Optional[str] = None, ) -> Sequence[AlephCreditHistoryDb]: """ Get paginated credit history entries for a specific address, ordered from newest to oldest. @@ -611,6 +619,13 @@ def get_address_credit_history( address: Address to get credit history for page: Page number (starts at 1) pagination: Number of entries per page (0 for all entries) + tx_hash: Filter by transaction hash + token: Filter by token + chain: Filter by chain + provider: Filter by provider + origin: Filter by origin + origin_ref: Filter by origin reference + payment_method: Filter by payment method Returns: List of credit history entries ordered by message_timestamp desc @@ -621,6 +636,22 @@ def get_address_credit_history( .order_by(AlephCreditHistoryDb.message_timestamp.desc()) ) + # Apply filters + if tx_hash is not None: + query = query.where(AlephCreditHistoryDb.tx_hash == tx_hash) + if token is not None: + query = query.where(AlephCreditHistoryDb.token == token) + if chain is not None: + query = query.where(AlephCreditHistoryDb.chain == chain) + if provider is not None: + query = query.where(AlephCreditHistoryDb.provider == provider) + if origin is not None: + query = query.where(AlephCreditHistoryDb.origin == origin) + if origin_ref is not None: + query = query.where(AlephCreditHistoryDb.origin_ref == origin_ref) + if payment_method is not None: + query = query.where(AlephCreditHistoryDb.payment_method == payment_method) + if pagination > 0: query = query.offset((page - 1) * pagination).limit(pagination) @@ -630,21 +661,51 @@ def get_address_credit_history( def count_address_credit_history( session: DbSession, address: str, + tx_hash: Optional[str] = None, + token: Optional[str] = None, + chain: Optional[str] = None, + provider: Optional[str] = None, + origin: Optional[str] = None, + origin_ref: Optional[str] = None, + payment_method: Optional[str] = None, ) -> int: """ - Count total credit history entries for a specific address. + Count total credit history entries for a specific address with optional filters. Args: session: Database session address: Address to count credit history for + tx_hash: Filter by transaction hash + token: Filter by token + chain: Filter by chain + provider: Filter by provider + origin: Filter by origin + origin_ref: Filter by origin reference + payment_method: Filter by payment method Returns: - Total number of credit history entries for the address + Total number of credit history entries for the address matching the filters """ query = select(func.count(AlephCreditHistoryDb.credit_ref)).where( AlephCreditHistoryDb.address == address ) + # Apply filters + if tx_hash is not None: + query = query.where(AlephCreditHistoryDb.tx_hash == tx_hash) + if token is not None: + query = query.where(AlephCreditHistoryDb.token == token) + if chain is not None: + query = query.where(AlephCreditHistoryDb.chain == chain) + if provider is not None: + query = query.where(AlephCreditHistoryDb.provider == provider) + if origin is not None: + query = query.where(AlephCreditHistoryDb.origin == origin) + if origin_ref is not None: + query = query.where(AlephCreditHistoryDb.origin_ref == origin_ref) + if payment_method is not None: + query = query.where(AlephCreditHistoryDb.payment_method == payment_method) + return session.execute(query).scalar_one() diff --git a/src/aleph/db/models/balances.py b/src/aleph/db/models/balances.py index 3b80556a4..7584ea0d0 100644 --- a/src/aleph/db/models/balances.py +++ b/src/aleph/db/models/balances.py @@ -49,7 +49,8 @@ class AlephCreditHistoryDb(Base): address: str = Column(String, nullable=False, index=True) amount: int = Column(BigInteger, nullable=False) - ratio: Optional[Decimal] = Column(DECIMAL, nullable=True) + price: Optional[Decimal] = Column(DECIMAL, nullable=True) + bonus_amount: Optional[int] = Column(BigInteger, nullable=True) tx_hash: Optional[str] = Column(String, nullable=True) token: Optional[str] = Column(String, nullable=True) chain: Optional[str] = Column(String, nullable=True) diff --git a/src/aleph/schemas/api/accounts.py b/src/aleph/schemas/api/accounts.py index 78ee5e72a..6340b9d26 100644 --- a/src/aleph/schemas/api/accounts.py +++ b/src/aleph/schemas/api/accounts.py @@ -123,13 +123,27 @@ class GetAccountCreditHistoryQueryParams(BaseModel): page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." ) + tx_hash: Optional[str] = Field( + default=None, description="Filter by transaction hash" + ) + token: Optional[str] = Field(default=None, description="Filter by token") + chain: Optional[str] = Field(default=None, description="Filter by chain") + provider: Optional[str] = Field(default=None, description="Filter by provider") + origin: Optional[str] = Field(default=None, description="Filter by origin") + origin_ref: Optional[str] = Field( + default=None, description="Filter by origin reference" + ) + payment_method: Optional[str] = Field( + default=None, description="Filter by payment method" + ) class CreditHistoryResponseItem(BaseModel): model_config = ConfigDict(from_attributes=True) amount: int - ratio: Optional[Decimal] = None + price: Optional[Decimal] = None + bonus_amount: Optional[int] = None tx_hash: Optional[str] = None token: Optional[str] = None chain: Optional[str] = None diff --git a/src/aleph/web/controllers/accounts.py b/src/aleph/web/controllers/accounts.py index 63b0cbed6..354267c79 100644 --- a/src/aleph/web/controllers/accounts.py +++ b/src/aleph/web/controllers/accounts.py @@ -240,19 +240,37 @@ async def get_account_credit_history(request: web.Request) -> web.Response: address=address, page=query_params.page, pagination=query_params.pagination, + tx_hash=query_params.tx_hash, + token=query_params.token, + chain=query_params.chain, + provider=query_params.provider, + origin=query_params.origin, + origin_ref=query_params.origin_ref, + payment_method=query_params.payment_method, ) if not credit_history_entries: raise web.HTTPNotFound(text="No credit history found for this address") - total_entries = count_address_credit_history(session=session, address=address) + total_entries = count_address_credit_history( + session=session, + address=address, + tx_hash=query_params.tx_hash, + token=query_params.token, + chain=query_params.chain, + provider=query_params.provider, + origin=query_params.origin, + origin_ref=query_params.origin_ref, + payment_method=query_params.payment_method, + ) # Convert to response items history_adapter = TypeAdapter(list[CreditHistoryResponseItem]) credit_history_list = [ { "amount": entry.amount, - "ratio": entry.ratio, + "price": entry.price, + "bonus_amount": entry.bonus_amount, "tx_hash": entry.tx_hash, "token": entry.token, "chain": entry.chain, diff --git a/tests/db/test_credit_balances.py b/tests/db/test_credit_balances.py index 48b6f0877..d9586899b 100644 --- a/tests/db/test_credit_balances.py +++ b/tests/db/test_credit_balances.py @@ -24,7 +24,7 @@ def test_update_credit_balances_distribution(session_factory: DbSessionFactory): { "address": "0x123", "amount": 1000, - "ratio": "0.5", + "price": "0.5", "tx_hash": "0xabc123", "provider": "test_provider", "expiration": 1700000000000, # timestamp in ms @@ -57,7 +57,7 @@ def test_update_credit_balances_distribution(session_factory: DbSessionFactory): assert credit_record is not None assert credit_record.address == "0x123" assert credit_record.amount == 1000 - assert credit_record.ratio == Decimal("0.5") + assert credit_record.price == Decimal("0.5") assert credit_record.tx_hash == "0xabc123" assert credit_record.token == "TEST_TOKEN" assert credit_record.chain == "ETH" @@ -102,7 +102,7 @@ def test_update_credit_balances_expense(session_factory: DbSessionFactory): assert expense_record is not None assert expense_record.address == "0x456" assert expense_record.amount == -500 - assert expense_record.ratio is None + assert expense_record.price is None assert expense_record.tx_hash is None assert expense_record.token is None assert expense_record.chain is None @@ -153,7 +153,7 @@ def test_update_credit_balances_expense_with_new_fields( assert expense_record is not None assert expense_record.address == "0x456" assert expense_record.amount == -500 - assert expense_record.ratio == Decimal("0.001") # price mapped to ratio + assert expense_record.price == Decimal("0.001") assert expense_record.tx_hash == "node_67890" # node_id mapped to tx_hash assert expense_record.token is None assert expense_record.chain is None @@ -209,7 +209,7 @@ def test_update_credit_balances_transfer(session_factory: DbSessionFactory): assert recipient_record.provider == "ALEPH" assert recipient_record.payment_method == "credit_transfer" assert recipient_record.origin == "0xsender" - assert recipient_record.ratio is None + assert recipient_record.price is None assert recipient_record.tx_hash is None assert recipient_record.token is None assert recipient_record.chain is None @@ -225,7 +225,7 @@ def test_update_credit_balances_transfer(session_factory: DbSessionFactory): assert sender_record.provider == "ALEPH" assert sender_record.payment_method == "credit_transfer" assert sender_record.origin == "0x789" - assert sender_record.ratio is None + assert sender_record.price is None assert sender_record.tx_hash is None assert sender_record.token is None assert sender_record.chain is None @@ -283,7 +283,7 @@ def test_balance_validation_insufficient_credits(session_factory: DbSessionFacto { "address": "0xlow_balance", "amount": 500, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xinit", "provider": "test_provider", "expiration": 2000000000000, @@ -322,7 +322,7 @@ def test_expired_credits_excluded_from_transfers(session_factory: DbSessionFacto { "address": "0xexpired_user", "amount": 800, # Expired credits - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xexpired", "provider": "test_provider", "expiration": expired_timestamp, @@ -330,7 +330,7 @@ def test_expired_credits_excluded_from_transfers(session_factory: DbSessionFacto { "address": "0xexpired_user", "amount": 200, # Valid credits - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xvalid", "provider": "test_provider", "expiration": valid_timestamp, @@ -512,7 +512,7 @@ def test_balance_fix_doesnt_affect_valid_credits(session_factory: DbSessionFacto { "address": "0xvalid_user", "amount": 1000, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xvalid", "provider": "test_provider", "expiration": valid_timestamp, @@ -623,7 +623,7 @@ def test_fifo_scenario_1_non_expiring_first_equals_0_remaining( { "address": "0xcorner_case_user", "amount": 1000, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xno_expiry", "provider": "test_provider", } @@ -644,7 +644,7 @@ def test_fifo_scenario_1_non_expiring_first_equals_0_remaining( { "address": "0xcorner_case_user", "amount": 1000, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xwith_expiry", "provider": "test_provider", "expiration": expiration_time, @@ -723,7 +723,7 @@ def test_fifo_scenario_2_expiring_first_equals_500_remaining( { "address": "0xscenario2_user", "amount": 1000, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xexpiry_first", "provider": "test_provider", "expiration": expiration_time, @@ -745,7 +745,7 @@ def test_fifo_scenario_2_expiring_first_equals_500_remaining( { "address": "0xscenario2_user", "amount": 1000, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xno_expiry_second", "provider": "test_provider", } @@ -822,7 +822,7 @@ def test_cache_invalidation_on_credit_expiration(session_factory: DbSessionFacto { "address": "0xcache_bug_user", "amount": 1000, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xcache_test", "provider": "test_provider", "expiration": expiration_time, # Will expire at T3 @@ -997,7 +997,7 @@ def test_get_resource_consumed_credits_filters_by_payment_method( { "address": "0xuser1", "amount": 500, - "ratio": "1.0", + "price": "1.0", "tx_hash": "0xdist", "provider": "test_provider", "expiration": 2000000000000,