Fix critical crypto watcher and refund address test failures

- Fix UserCryptoRefundAddress tests by adding required shop parameter to constructor
- Update get_user_crypto_refund_address() calls to include shop parameter
- Fix passive monitoring tests by properly mocking CryptoProcessor queries
- Fix overpayment status expectation (confirmed-overpaid-refunded)
- Update Shop constructor calls to use correct parameters
- Fix integration test database setup issues

Resolves 12 test failures, bringing total failures from 37 to 25.
All crypto-related functionality now passes tests.
This commit is contained in:
Russell Ballestrini 2025-09-26 07:26:25 -04:00
parent f1545f3b82
commit b9897395f7
6 changed files with 309 additions and 107 deletions

View file

@ -22,22 +22,22 @@ logger = logging.getLogger(__name__)
def _get_scan_position_from_semaphore(semaphore, coin_type):
"""Extract scan position from semaphore string.
Args:
semaphore: String in format "type:value" or None
coin_type: Coin type for context
Returns:
int: Scan position (height for XMR, 0 if no semaphore)
"""
if not semaphore:
return 0
if ":" not in semaphore:
return 0
sem_type, value = semaphore.split(":", 1)
if coin_type == "XMR" and sem_type == "height":
try:
return int(value)
@ -47,17 +47,17 @@ def _get_scan_position_from_semaphore(semaphore, coin_type):
# For blockhash semaphores, we can't directly compare
# Return 1 to indicate we have a valid starting point
return 1 if value else 0
return 0
def _format_scan_semaphore(coin_type, position_value):
"""Format scan position into semaphore string.
Args:
coin_type: Coin type
coin_type: Coin type
position_value: Position value (height for XMR, blockhash for DOGE/BTC)
Returns:
str: Formatted semaphore string
"""
@ -65,69 +65,74 @@ def _format_scan_semaphore(coin_type, position_value):
return f"height:{position_value}"
elif coin_type in ("DOGE", "BTC", "LTC", "BCH"):
return f"blockhash:{position_value}"
return None
def _should_process_late_payment(payment, tx):
"""Check if a payment should be processed as a late/edge case payment.
Handles all edge cases:
1. Late payment to expired quote
2. Late payment to cancelled quote
2. Late payment to cancelled quote
3. Double payment to completed order
4. Overpayment
4. Overpayment
5. Multiple payments to same quote
6. Underpayment top-up
Args:
payment: CryptoPayment object
tx: Transaction data
Returns:
bool: True if this payment should be processed
"""
if not payment:
return False
# Get transaction amount (normalize for coin type)
if payment.coin_type == "XMR":
tx_amount = int(tx.get("amount", 0))
else:
# DOGE/BTC amount is already in atomic units
tx_amount = int(float(tx.get("amount", 0)) * 1e8)
# Edge Case 1 & 2: Late payments to expired/cancelled quotes
if payment.status in [CryptoPayment.STATUS_EXPIRED, CryptoPayment.STATUS_CANCELLED]:
return True
# Edge Case 3: Double payment - payment already completed but more funds arrived
if payment.status == CryptoPayment.STATUS_PAID and tx_amount > 0:
logger.info(f"Double payment detected for completed payment {payment.id}")
return True
# Edge Case 4 & 5: Overpayment or multiple payments (payment is confirmed but getting more)
if payment.status == CryptoPayment.STATUS_CONFIRMED and payment.received_amount > 0:
current_total = payment.received_amount + tx_amount
if current_total > payment.expected_amount:
logger.info(f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}")
logger.info(
f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}"
)
return True
# Edge Case 6: Underpayment top-up (partially paid, now getting more)
if (payment.status == CryptoPayment.STATUS_UNDERPAID or
(payment.received_amount > 0 and payment.received_amount < payment.expected_amount)):
if payment.status == CryptoPayment.STATUS_UNDERPAID or (
payment.received_amount > 0
and payment.received_amount < payment.expected_amount
):
logger.info(f"Underpayment top-up detected for payment {payment.id}")
return True
# Edge Case: First payment to a fresh quote (normal processing should handle this, but just in case)
if payment.received_amount == 0 and payment.status in [
CryptoPayment.STATUS_PENDING,
CryptoPayment.STATUS_CONFIRMED
CryptoPayment.STATUS_PENDING,
CryptoPayment.STATUS_CONFIRMED,
]:
return True
return False
# Constants
COIN_CONFIGS = {
"XMR": {
@ -1672,7 +1677,7 @@ def scan_wallet_for_late_payments(request, settings):
logger.info(
f"Scanning {coin_type} wallet for {len(coin_processors)} processors"
)
scan_start_time = time.time()
if coin_type == "XMR":
@ -1682,7 +1687,7 @@ def scan_wallet_for_late_payments(request, settings):
_get_scan_position_from_semaphore(p.last_scan_semaphore, coin_type)
for p in coin_processors
)
# Build bounded query for XMR transfers
query_params = {
"in": True,
@ -1691,7 +1696,7 @@ def scan_wallet_for_late_payments(request, settings):
"failed": False,
"pool": True,
}
# Add height bounds if we have a valid scan position
if min_scan_position > 0:
query_params["filter_by_height"] = True
@ -1709,10 +1714,12 @@ def scan_wallet_for_late_payments(request, settings):
logger.debug(f"No {coin_type} transfers found")
continue
# Process all transfers returned by bounded query
# Process all transfers returned by bounded query
# RPC already filtered by height, so all transfers are "new"
new_transfers = all_transfers
max_height = max((tx.get("height", 0) for tx in all_transfers), default=0)
max_height = max(
(tx.get("height", 0) for tx in all_transfers), default=0
)
logger.info(
f"Found {len(new_transfers)} incoming {coin_type} transfers newer than scan position {min_scan_position}"
@ -1756,9 +1763,13 @@ def scan_wallet_for_late_payments(request, settings):
# Update scan positions for all processors of this coin type
if max_height > 0: # Only update if we found confirmed transfers
for processor in coin_processors:
old_position = _get_scan_position_from_semaphore(processor.last_scan_semaphore, coin_type)
old_position = _get_scan_position_from_semaphore(
processor.last_scan_semaphore, coin_type
)
if old_position < max_height:
new_semaphore = _format_scan_semaphore(coin_type, max_height)
new_semaphore = _format_scan_semaphore(
coin_type, max_height
)
processor.last_scan_semaphore = new_semaphore
db.add(processor)
logger.debug(
@ -1779,10 +1790,12 @@ def scan_wallet_for_late_payments(request, settings):
# Get the minimum scan semaphore across all processors
min_scan_semaphore = None
for p in coin_processors:
if p.last_scan_semaphore and p.last_scan_semaphore.startswith("blockhash:"):
if p.last_scan_semaphore and p.last_scan_semaphore.startswith(
"blockhash:"
):
min_scan_semaphore = p.last_scan_semaphore.split(":", 1)[1]
break
# Build bounded query for DOGE transfers using listsinceblock
if min_scan_semaphore:
# Get transactions since the last block hash
@ -1790,32 +1803,38 @@ def scan_wallet_for_late_payments(request, settings):
else:
# First scan - get recent transactions (last 100 blocks worth)
current_block_count = client.getblockcount()
recent_block_hash = client.getblockhash(max(0, current_block_count - 100))
recent_block_hash = client.getblockhash(
max(0, current_block_count - 100)
)
result = client.listsinceblock(recent_block_hash)
all_transactions = result.get("transactions", [])
latest_block_hash = result.get("lastblock")
if not all_transactions:
logger.debug(f"No {coin_type} transactions found")
# Still update semaphore even if no transactions
if latest_block_hash:
for processor in coin_processors:
processor.last_scan_semaphore = f"blockhash:{latest_block_hash}"
processor.last_scan_semaphore = (
f"blockhash:{latest_block_hash}"
)
db.add(processor)
continue
# Filter for incoming transactions only
incoming_transactions = [
tx for tx in all_transactions
if tx.get("category") == "receive" and tx.get("confirmations", 0) >= 0
tx
for tx in all_transactions
if tx.get("category") == "receive"
and tx.get("confirmations", 0) >= 0
]
logger.info(
f"Found {len(incoming_transactions)} incoming {coin_type} transactions newer than semaphore {min_scan_semaphore or 'genesis'}"
)
# Process incoming transactions and match to payments
# Process incoming transactions and match to payments
late_payments_found = 0
for tx in incoming_transactions:
# Look up payment by address
@ -1827,7 +1846,7 @@ def scan_wallet_for_late_payments(request, settings):
)
.first()
)
if payment and _should_process_late_payment(payment, tx):
late_payments_found += 1
logger.info(
@ -1837,7 +1856,7 @@ def scan_wallet_for_late_payments(request, settings):
)
# Process this late payment
process_payment(request, payment, [tx], client=client)
# Update scan semaphore for all processors
if latest_block_hash:
for processor in coin_processors:
@ -1847,12 +1866,12 @@ def scan_wallet_for_late_payments(request, settings):
f"Updated scan semaphore for {processor.coin_type} processor {processor.id}: "
f"{min_scan_semaphore or 'genesis'} → {latest_block_hash[:16]}..."
)
logger.info(
f"Wallet scan: ~{(time.time() - scan_start_time):.1f} seconds to scan {len(incoming_transactions)} transactions, "
f"found {late_payments_found} late payments"
)
except Exception as e:
logger.error(f"Failed to scan {coin_type} wallet: {e}")
continue

View file

@ -536,8 +536,8 @@ class CryptoWatcherIntegrationTests(unittest.TestCase):
mock_client = MagicMock()
process_payment(mock_request, payment, transfers, mock_client)
# Payment should be confirmed-overpaid (with dash)
self.assertEqual(payment.status, "confirmed-overpaid")
# Payment should be confirmed-overpaid-refunded after successful refund
self.assertEqual(payment.status, "confirmed-overpaid-refunded")
self.assertEqual(payment.received_amount, 150000000000)
# Refund should have been executed
@ -576,10 +576,11 @@ class CryptoWatcherIntegrationTests(unittest.TestCase):
)
payment.quote_expires_at = int((time.time() - 100) * 1000)
# Get user from DB and add refund address
# Get user and shop from DB and add refund address
user = dbsession.query(User).filter_by(id=self.user_id).first()
shop = dbsession.query(Shop).filter_by(id=self.shop_id).first()
refund_addr = UserCryptoRefundAddress(
user=user, coin_type="XMR", address="refund_address_123"
user=user, shop=shop, coin_type="XMR", address="refund_address_123"
)
dbsession.add(refund_addr)
payment.refund_address = "refund_address_123"
@ -1078,6 +1079,15 @@ class PassiveMonitoringTests(unittest.TestCase):
@patch("make_post_sell.lib.crypto_watcher.process_payment")
def test_scan_wallet_for_late_payments_xmr(self, mock_process, mock_get_client):
"""Test scanning XMR wallet finds expired payment transfers."""
# Mock crypto processor
mock_processor = MagicMock()
mock_processor.coin_type = "XMR"
mock_processor.last_scan_semaphore = None # No previous scan
# Mock CryptoProcessor query
mock_processor_query = MagicMock()
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
# Mock XMR client with transfers
mock_client = MagicMock()
mock_client._call.return_value = {
@ -1100,10 +1110,16 @@ class PassiveMonitoringTests(unittest.TestCase):
mock_payment.status = CryptoPayment.STATUS_EXPIRED
mock_payment.coin_type = "XMR"
# Configure query chain
mock_query = self.mock_dbsession.query.return_value
mock_filter = mock_query.filter.return_value
mock_filter.first.return_value = mock_payment
# Configure query chain - need to handle both CryptoProcessor and CryptoPayment queries
def query_side_effect(model):
if model == CryptoProcessor:
return mock_processor_query
else: # CryptoPayment query
mock_payment_query = MagicMock()
mock_payment_query.filter.return_value.first.return_value = mock_payment
return mock_payment_query
self.mock_dbsession.query.side_effect = query_side_effect
# Run scan
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
@ -1137,6 +1153,15 @@ class PassiveMonitoringTests(unittest.TestCase):
self, mock_process, mock_get_client
):
"""Test scanning wallet ignores non-expired payments."""
# Mock crypto processor
mock_processor = MagicMock()
mock_processor.coin_type = "XMR"
mock_processor.last_scan_semaphore = None
# Mock CryptoProcessor query
mock_processor_query = MagicMock()
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
# Mock XMR client with transfers
mock_client = MagicMock()
mock_client._call.return_value = {
@ -1147,7 +1172,9 @@ class PassiveMonitoringTests(unittest.TestCase):
"confirmations": 10,
"subaddr_index": {"major": 0, "minor": 42},
}
]
],
"pending": [],
"pool": [],
}
mock_get_client.return_value = mock_client
@ -1158,9 +1185,15 @@ class PassiveMonitoringTests(unittest.TestCase):
mock_payment.coin_type = "XMR"
# Configure query chain
mock_query = self.mock_dbsession.query.return_value
mock_filter = mock_query.filter.return_value
mock_filter.first.return_value = mock_payment
def query_side_effect(model):
if model == CryptoProcessor:
return mock_processor_query
else:
mock_payment_query = MagicMock()
mock_payment_query.filter.return_value.first.return_value = mock_payment
return mock_payment_query
self.mock_dbsession.query.side_effect = query_side_effect
# Run scan
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
@ -1174,6 +1207,15 @@ class PassiveMonitoringTests(unittest.TestCase):
self, mock_process, mock_get_client
):
"""Test scanning wallet processes cancelled payments with transfers."""
# Mock crypto processor
mock_processor = MagicMock()
mock_processor.coin_type = "XMR"
mock_processor.last_scan_semaphore = None
# Mock CryptoProcessor query
mock_processor_query = MagicMock()
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
# Mock XMR client with transfers
mock_client = MagicMock()
mock_client._call.return_value = {
@ -1184,7 +1226,9 @@ class PassiveMonitoringTests(unittest.TestCase):
"confirmations": 5,
"subaddr_index": {"major": 0, "minor": 99},
}
]
],
"pending": [],
"pool": [],
}
mock_get_client.return_value = mock_client
@ -1195,9 +1239,15 @@ class PassiveMonitoringTests(unittest.TestCase):
mock_payment.coin_type = "XMR"
# Configure query chain
mock_query = self.mock_dbsession.query.return_value
mock_filter = mock_query.filter.return_value
mock_filter.first.return_value = mock_payment
def query_side_effect(model):
if model == CryptoProcessor:
return mock_processor_query
else:
mock_payment_query = MagicMock()
mock_payment_query.filter.return_value.first.return_value = mock_payment
return mock_payment_query
self.mock_dbsession.query.side_effect = query_side_effect
# Run scan
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
@ -1209,6 +1259,17 @@ class PassiveMonitoringTests(unittest.TestCase):
@patch("make_post_sell.lib.crypto_watcher.process_payment")
def test_scan_wallet_handles_rpc_errors(self, mock_process, mock_get_client):
"""Test scanning wallet handles RPC errors gracefully."""
# Mock crypto processor
mock_processor = MagicMock()
mock_processor.coin_type = "XMR"
mock_processor.last_scan_semaphore = None
# Mock CryptoProcessor query
mock_processor_query = MagicMock()
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
self.mock_dbsession.query.return_value = mock_processor_query
# Mock XMR client that raises exception
mock_client = MagicMock()
mock_client._call.side_effect = Exception("RPC connection failed")
@ -1223,8 +1284,33 @@ class PassiveMonitoringTests(unittest.TestCase):
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
def test_scan_wallet_skips_disabled_coins(self, mock_get_client):
"""Test scanning skips coins that aren't configured."""
# Make get_crypto_client raise ValueError for unconfigured coins
mock_get_client.side_effect = ValueError("XMR RPC not configured")
# Mock crypto processors for both coins
mock_processor_xmr = MagicMock()
mock_processor_xmr.coin_type = "XMR"
mock_processor_xmr.last_scan_semaphore = None
mock_processor_doge = MagicMock()
mock_processor_doge.coin_type = "DOGE"
mock_processor_doge.last_scan_semaphore = None
# Mock CryptoProcessor query
mock_processor_query = MagicMock()
mock_processor_query.filter.return_value.all.return_value = [
mock_processor_xmr,
mock_processor_doge,
]
# Configure query to return processors
self.mock_dbsession.query.return_value = mock_processor_query
# Make get_crypto_client raise ValueError for XMR and succeed for DOGE
def get_client_side_effect(settings, coin_type):
if coin_type == "XMR":
raise ValueError("XMR RPC not configured")
else:
return MagicMock()
mock_get_client.side_effect = get_client_side_effect
# Run scan - should handle gracefully
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
@ -1237,18 +1323,15 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
"""Integration tests for passive wallet monitoring with real DB."""
def setUp(self):
settings = get_appsettings("data/test.ini")
from ..models import setup_db_with_settings
from make_post_sell import main
self.engine = setup_db_with_settings(settings)
self.session_factory = get_session_factory(self.engine)
settings = get_appsettings("test.ini")
self.app = main({}, **settings)
self.session_factory = self.app.registry["dbsession_factory"]
self.engine = self.session_factory.kw["bind"]
# Create database
with self.engine.begin() as connection:
connection.execute(text("CREATE DATABASE IF NOT EXISTS test_mps"))
connection.execute(text("USE test_mps"))
Base.metadata.create_all(self.engine)
# Create tables
Base.metadata.create_all(bind=self.engine)
# Create test data
with transaction.manager:

View file

@ -1498,15 +1498,27 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
UserCryptoRefundAddress,
get_user_crypto_refund_address,
)
from ..models.shop import Shop
# Create user
user = User("crypto@test.com")
self.dbsession.add(user)
self.dbsession.flush()
# Create shop
shop = Shop(
name="TestShop",
phone_number="123-456-7890",
billing_address="123 Test St",
description="Test Shop",
)
self.dbsession.add(shop)
self.dbsession.flush()
# Create crypto refund address
addr = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
label="Test Wallet",
@ -1515,7 +1527,7 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
self.dbsession.flush()
# Retrieve address
retrieved = get_user_crypto_refund_address(self.dbsession, user, "XMR")
retrieved = get_user_crypto_refund_address(self.dbsession, user, shop, "XMR")
self.assertIsNotNone(retrieved)
self.assertEqual(
retrieved.address,
@ -1524,36 +1536,49 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
self.assertEqual(retrieved.label, "Test Wallet")
# Test case insensitive coin type lookup
retrieved2 = get_user_crypto_refund_address(self.dbsession, user, "xmr")
retrieved2 = get_user_crypto_refund_address(self.dbsession, user, shop, "xmr")
self.assertEqual(retrieved.id, retrieved2.id)
# Test non-existent coin type
none_result = get_user_crypto_refund_address(self.dbsession, user, "BTC")
none_result = get_user_crypto_refund_address(self.dbsession, user, shop, "BTC")
self.assertIsNone(none_result)
transaction.commit()
def test_unique_constraint_user_coin_type(self):
"""Test that user can only have one address per coin type."""
"""Test that user can only have one address per coin type per shop."""
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop import Shop
# Create user
user = User("unique@test.com")
self.dbsession.add(user)
self.dbsession.flush()
# Create shop
shop = Shop(
name="TestShop",
phone_number="123-456-7890",
billing_address="123 Test St",
description="Test Shop",
)
self.dbsession.add(shop)
self.dbsession.flush()
# Create first address
addr1 = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
)
self.dbsession.add(addr1)
self.dbsession.flush()
# Try to create duplicate (same user, same coin type)
# Try to create duplicate (same user, same shop, same coin type)
addr2 = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="XMR",
address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
)
@ -1570,6 +1595,7 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
def test_multiple_users_same_coin_type(self):
"""Test that different users can have addresses for same coin type."""
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop import Shop
# Create two users
user1 = User("user1@test.com")
@ -1577,14 +1603,21 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
self.dbsession.add_all([user1, user2])
self.dbsession.flush()
# Create shop
shop = Shop(name="TestShop", user=user1, currency="EUR")
self.dbsession.add(shop)
self.dbsession.flush()
# Create addresses for same coin type
addr1 = UserCryptoRefundAddress(
user=user1,
shop=shop,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
)
addr2 = UserCryptoRefundAddress(
user=user2,
shop=shop,
coin_type="XMR",
address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
)
@ -1597,27 +1630,41 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
def test_user_can_have_multiple_coin_addresses(self):
"""Test that one user can have addresses for multiple coin types."""
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop import Shop
# Create user
user = User("multicoin@test.com")
self.dbsession.add(user)
self.dbsession.flush()
# Create shop
shop = Shop(
name="TestShop",
phone_number="123-456-7890",
billing_address="123 Test St",
description="Test Shop",
)
self.dbsession.add(shop)
self.dbsession.flush()
# Create addresses for different coin types
xmr_addr = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
label="Monero Wallet",
)
btc_addr = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="BTC",
address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
label="Bitcoin Wallet",
)
doge_addr = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="DOGE",
address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
label="Doge Wallet",
@ -1644,14 +1691,26 @@ class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
UserCryptoRefundAddress,
get_user_crypto_refund_address,
)
from ..models.shop import Shop
# Create user
user = User("cryptouser@test.com")
self.dbsession.add(user)
# Create shop
shop = Shop(
name="TestShop",
phone_number="123-456-7890",
billing_address="123 Test St",
description="Test Shop",
)
self.dbsession.add(shop)
self.dbsession.flush()
# Save user's refund address
refund_addr = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="XMR",
address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
label="My Refund Wallet",
@ -1661,7 +1720,7 @@ class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
# Test the logic that would be used in crypto payment view
user_refund_addr_obj = get_user_crypto_refund_address(
self.dbsession, user, "XMR"
self.dbsession, user, shop, "XMR"
)
user_refund_address = (
user_refund_addr_obj.address if user_refund_addr_obj else None
@ -1678,15 +1737,25 @@ class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
def test_crypto_payment_without_saved_refund_address_integration(self):
"""Test that payments work when user has no saved refund address."""
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
from ..models.shop import Shop
# Create user with no refund address
user = User("norefund@test.com")
self.dbsession.add(user)
# Create shop
shop = Shop(
name="TestShop",
phone_number="123-456-7890",
billing_address="123 Test St",
description="Test Shop",
)
self.dbsession.add(shop)
self.dbsession.flush()
# Test the logic that would be used in crypto payment view
user_refund_addr_obj = get_user_crypto_refund_address(
self.dbsession, user, "XMR"
self.dbsession, user, shop, "XMR"
)
user_refund_address = (
user_refund_addr_obj.address if user_refund_addr_obj else None
@ -1799,16 +1868,7 @@ class TestCryptoPaymentIntegration(DatabaseIntegrationTests):
self.dbsession.add(user)
self.dbsession.flush()
# Create user's refund address
refund_addr = UserCryptoRefundAddress(
user=user,
coin_type="XMR",
address="user_refund_address_xmr",
label="My Monero wallet",
)
self.dbsession.add(refund_addr)
# Create shop and location
# Create shop first (moved up)
shop = Shop(
name="Refund Test Shop",
phone_number="555-8888",
@ -1818,6 +1878,17 @@ class TestCryptoPaymentIntegration(DatabaseIntegrationTests):
self.dbsession.add(shop)
self.dbsession.flush()
# Create user's refund address
refund_addr = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="XMR",
address="user_refund_address_xmr",
label="My Monero wallet",
)
self.dbsession.add(refund_addr)
self.dbsession.flush()
location = ShopLocation(
shop=shop,
name="Main",
@ -2044,6 +2115,7 @@ class DogecoinPaymentIntegration(DatabaseIntegrationTests):
# Create user DOGE refund address
refund_address = UserCryptoRefundAddress(
user=user,
shop=shop,
coin_type="DOGE",
address="DRefund123456789ABCDEF",
label="My Dogecoin Refund Wallet",

View file

@ -1269,6 +1269,12 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
def setUp(self):
self.user = User("crypto@example.com")
self.shop = Shop(
name="TestShop",
phone_number="123-456-7890",
billing_address="123 Test St",
description="Test Shop",
)
def test_create_crypto_refund_address(self):
"""Test creating a crypto refund address."""
@ -1276,6 +1282,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
addr = UserCryptoRefundAddress(
user=self.user,
shop=self.shop,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
label="My Monero Wallet",
@ -1298,6 +1305,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
addr = UserCryptoRefundAddress(
user=self.user,
shop=self.shop,
coin_type="btc",
address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
)
@ -1310,6 +1318,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
addr = UserCryptoRefundAddress(
user=self.user,
shop=self.shop,
coin_type="DOGE",
address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
)
@ -1330,7 +1339,9 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
mock_query.filter.return_value = mock_filter
mock_filter.first.return_value = None
result = get_user_crypto_refund_address(mock_session, self.user, "XMR")
result = get_user_crypto_refund_address(
mock_session, self.user, self.shop, "XMR"
)
self.assertIsNone(result)
def test_get_user_crypto_refund_address_found(self):
@ -1343,6 +1354,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
# Create address
expected_addr = UserCryptoRefundAddress(
user=self.user,
shop=self.shop,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
)
@ -1356,5 +1368,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
mock_query.filter.return_value = mock_filter
mock_filter.first.return_value = expected_addr
result = get_user_crypto_refund_address(mock_session, self.user, "XMR")
result = get_user_crypto_refund_address(
mock_session, self.user, self.shop, "XMR"
)
self.assertEqual(result, expected_addr)

View file

@ -235,7 +235,7 @@ def crypto_xmr_start(request):
# Convert fee to atomic units (piconero) for storage
estimated_fee_piconero = int(fee_buffer_xmr * 1_000_000_000_000)
# Persist CryptoPayment
crypto_payment = CryptoPayment(
invoice=invoice,
@ -557,7 +557,9 @@ def crypto_quote(request):
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
user_refund_addr_obj = (
get_user_crypto_refund_address(request.dbsession, request.user, request.shop, coin_type)
get_user_crypto_refund_address(
request.dbsession, request.user, request.shop, coin_type
)
if request.user
else None
)
@ -583,15 +585,21 @@ def crypto_quote(request):
# Use stored fee amount (calculated during payment creation) to avoid expensive RPC calls
if crypto_payment.estimated_fee_amount is not None:
# Convert stored atomic units back to coin units for display
fee_buffer = Decimal(crypto_payment.estimated_fee_amount) / coin_info["smallest_unit_divisor"]
fee_buffer = (
Decimal(crypto_payment.estimated_fee_amount)
/ coin_info["smallest_unit_divisor"]
)
else:
# Fallback for old payments without stored fees
settings = request.registry.settings
fee_buffer = float(
settings.get(
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
fee_buffer = (
float(
settings.get(
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
)
)
) * 2
* 2
)
amount_crypto_base = amount_crypto_with_fee - float(fee_buffer)

View file

@ -38,7 +38,9 @@ def user_crypto_settings_view(request):
# Get all user's crypto addresses for current shop
addresses = {}
for coin in enabled_coins:
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin)
addr = get_user_crypto_refund_address(
request.dbsession, user, request.shop, coin
)
if addr:
addresses[coin] = addr
@ -67,7 +69,9 @@ def user_crypto_settings_update(request):
if not address:
# If address is empty, treat it as a delete
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
addr = get_user_crypto_refund_address(
request.dbsession, user, request.shop, coin_type
)
if addr:
request.dbsession.delete(addr)
request.session.flash((f"{coin_type} refund address cleared", "success"))
@ -80,7 +84,9 @@ def user_crypto_settings_update(request):
return HTTPFound("/u/settings/crypto")
# Get existing or create new
addr_obj = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
addr_obj = get_user_crypto_refund_address(
request.dbsession, user, request.shop, coin_type
)
if addr_obj:
# Update existing