Fix duplicate payment refunds by inheriting user's saved refund address
- Add UserCryptoRefundAddress import and lookup function - Update _create_duplicate_payment to lookup saved refund address - Fix refund processing to check saved addresses if not set on payment - Add test for refund address inheritance - Add Makefile target for sweeping all wallet funds (sweep-all) This fixes the issue where duplicate payments weren't being refunded because they had no refund_address set. Now the system will: 1. First check if original payment has refund_address 2. If not, lookup user's saved refund address for that coin type 3. Use the found address for the duplicate payment record Also added sweep-all target to collect dust from all accounts.
This commit is contained in:
parent
7a1dcfb7e4
commit
30a5088ea0
3 changed files with 151 additions and 5 deletions
47
Makefile
47
Makefile
|
|
@ -62,6 +62,7 @@ help:
|
|||
@echo "WALLET MANAGEMENT:"
|
||||
@echo " make sweep-check - Check hot wallet balances (dry run)"
|
||||
@echo " make sweep - Sweep funds to cold storage"
|
||||
@echo " make sweep-all - Sweep ALL wallet funds to cold storage (dust collection)"
|
||||
@echo " make monero-transactions - View recent wallet transactions"
|
||||
@echo ""
|
||||
@echo "CLEANUP:"
|
||||
|
|
@ -206,6 +207,52 @@ monero-transactions:
|
|||
-d '{"jsonrpc":"2.0","id":"0","method":"get_transfers","params":{"in":true,"out":true}}' | \
|
||||
python3 -c "import sys, json; data = json.load(sys.stdin); print(json.dumps(data, indent=2))"
|
||||
|
||||
# Sweep ALL wallet funds to cold storage (dust collection)
|
||||
sweep-all: venv config
|
||||
@echo "=== SWEEPING ALL WALLET FUNDS TO COLD STORAGE ==="
|
||||
@echo "CAUTION: This will send ALL unlocked balance from ALL accounts!"
|
||||
@echo ""
|
||||
@if [ -z "$${COLD_WALLET_ADDRESS}" ]; then \
|
||||
echo "ERROR: COLD_WALLET_ADDRESS environment variable not set!"; \
|
||||
echo "Usage: COLD_WALLET_ADDRESS=<your-address> make sweep-all"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Checking ALL account balances..."
|
||||
@all_balance=$$(curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":"0","method":"get_balance","params":{"all_accounts":true}}' | \
|
||||
python3 -c "import sys, json; d=json.load(sys.stdin); result = d.get('result', {}); total_balance = result.get('balance', 0); total_unlocked = result.get('unlocked_balance', 0); accounts = result.get('per_subaddress', []); print('Total balance: {} atomic units ({:.12f} XMR)'.format(total_balance, total_balance/1e12)); print('Total unlocked: {} atomic units ({:.12f} XMR)'.format(total_unlocked, total_unlocked/1e12)); print('Per account breakdown:'); [print(' Account {}:{} - Balance: {:.12f} XMR (unlocked: {:.12f} XMR)'.format(acc.get('account_index', 0), acc.get('address_index', 0), acc.get('balance', 0)/1e12, acc.get('unlocked_balance', 0)/1e12)) for acc in accounts if acc.get('balance', 0) > 0]; print(total_unlocked)"); \
|
||||
total_unlocked=$$(echo "$$all_balance" | tail -1); \
|
||||
if [ -z "$$total_unlocked" ] || [ "$$total_unlocked" = "0" ]; then \
|
||||
echo "No unlocked balance to sweep across all accounts!"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
echo "Target address: $${COLD_WALLET_ADDRESS}"; \
|
||||
echo ""; \
|
||||
read -p "Are you sure you want to sweep ALL unlocked funds from ALL accounts? (yes/no): " confirm; \
|
||||
if [ "$$confirm" != "yes" ]; then \
|
||||
echo "Cancelled."; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
echo "Getting all accounts for sweeping..."; \
|
||||
accounts=$$(curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":"0","method":"get_accounts"}' | \
|
||||
python3 -c "import sys, json; d=json.load(sys.stdin); accounts = d.get('result', {}).get('subaddress_accounts', []); account_indices = [acc.get('account_index', 0) for acc in accounts]; print(','.join(map(str, account_indices)))"); \
|
||||
echo "Found accounts: $$accounts"; \
|
||||
echo ""; \
|
||||
for account in $$(echo $$accounts | tr ',' ' '); do \
|
||||
echo "Sweeping account $$account..."; \
|
||||
response=$$(curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":\"0\",\"method\":\"sweep_all\",\"params\":{\"address\":\"$${COLD_WALLET_ADDRESS}\",\"account_index\":$$account,\"subaddr_indices\":[],\"priority\":0,\"get_tx_metadata\":false}}"); \
|
||||
echo "$$response" | python3 -c "import sys, json; d=json.load(sys.stdin); r=d.get('result',{}); amounts=r.get('amount_list',[]); fees=r.get('fee_list',[]); hashes=r.get('tx_hash_list',[]); [print('✓ Account $$account: {:.12f} XMR (fee: {:.12f}) Hash: {}'.format(amt/1e12, fee/1e12, tx_hash)) for amt, fee, tx_hash in zip(amounts, fees, hashes)] if amounts else print('Account $$account: No funds') if 'error' not in d else print('Account $$account:', d['error'].get('message','Error'))"; \
|
||||
done; \
|
||||
echo ""; \
|
||||
echo "All account sweep operations completed!"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Monero Infrastructure Targets
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -16,10 +16,30 @@ from .mail import send_purchase_email, send_sale_email, send_refund_email
|
|||
from ..models.inventory import get_inventory_by_product_and_shop_location
|
||||
from .crypto_payment_rescue import PaymentRescue
|
||||
from ..models.meta import now_timestamp
|
||||
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_user_refund_address(dbsession, user_id, coin_type):
|
||||
"""Get user's saved refund address for the given coin type."""
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
refund_address = (
|
||||
dbsession.query(UserCryptoRefundAddress)
|
||||
.filter(
|
||||
UserCryptoRefundAddress.user_id == user_id,
|
||||
UserCryptoRefundAddress.coin_type == coin_type,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if refund_address:
|
||||
return refund_address.address
|
||||
return None
|
||||
|
||||
|
||||
def _get_scan_position_from_semaphore(semaphore, coin_type):
|
||||
"""Extract scan position from semaphore string.
|
||||
|
||||
|
|
@ -69,7 +89,7 @@ def _format_scan_semaphore(coin_type, position_value):
|
|||
return None
|
||||
|
||||
|
||||
def _create_duplicate_payment(original_payment, tx, coin_type):
|
||||
def _create_duplicate_payment(original_payment, tx, coin_type, dbsession=None):
|
||||
"""
|
||||
Create a new payment object for a duplicate transaction.
|
||||
|
||||
|
|
@ -101,6 +121,17 @@ def _create_duplicate_payment(original_payment, tx, coin_type):
|
|||
# DOGE/BTC amount is already in atomic units
|
||||
tx_amount = int(float(tx.get("amount", 0)) * 1e8)
|
||||
|
||||
# Determine refund address - use original payment's or lookup user's saved address
|
||||
refund_address = original_payment.refund_address
|
||||
if not refund_address and dbsession and original_payment.user_id:
|
||||
refund_address = _get_user_refund_address(
|
||||
dbsession, original_payment.user_id, coin_type
|
||||
)
|
||||
if refund_address:
|
||||
logger.info(
|
||||
f"Using saved refund address for user {original_payment.user_id}: {refund_address[:16]}..."
|
||||
)
|
||||
|
||||
# Create new payment object for this duplicate using proper constructor
|
||||
duplicate_payment = CryptoPayment(
|
||||
invoice=None, # No invoice - this is a duplicate
|
||||
|
|
@ -118,7 +149,7 @@ def _create_duplicate_payment(original_payment, tx, coin_type):
|
|||
confirmations_required=original_payment.confirmations_required,
|
||||
shop_location=original_payment.shop_location,
|
||||
shop_sweep_to_address=original_payment.shop_sweep_to_address,
|
||||
refund_address=original_payment.refund_address,
|
||||
refund_address=refund_address,
|
||||
)
|
||||
|
||||
# Override specific properties for duplicate
|
||||
|
|
@ -983,7 +1014,21 @@ def process_payment(
|
|||
|
||||
# Handle duplicate payment refunds (special case - always refund with 9% fee)
|
||||
if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND:
|
||||
if incoming_transfers and payment_rescue and crypto_payment.refund_address:
|
||||
# Check for refund address - use payment's or lookup saved address
|
||||
refund_address = crypto_payment.refund_address
|
||||
if not refund_address and crypto_payment.user_id:
|
||||
refund_address = _get_user_refund_address(
|
||||
env_request.dbsession, crypto_payment.user_id, crypto_payment.coin_type
|
||||
)
|
||||
if refund_address:
|
||||
# Update payment with the saved refund address
|
||||
crypto_payment.refund_address = refund_address
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
logger.info(
|
||||
f"Updated duplicate payment {crypto_payment.id} with saved refund address: {refund_address[:16]}..."
|
||||
)
|
||||
|
||||
if incoming_transfers and payment_rescue and refund_address:
|
||||
total_recv, _, _ = summarize_txs(incoming_transfers)
|
||||
if total_recv > 0:
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
|
|
@ -1498,7 +1543,10 @@ def process_payment(
|
|||
if duplicate_tx:
|
||||
# Create separate duplicate payment record
|
||||
duplicate_payment = _create_duplicate_payment(
|
||||
crypto_payment, duplicate_tx, crypto_payment.coin_type
|
||||
crypto_payment,
|
||||
duplicate_tx,
|
||||
crypto_payment.coin_type,
|
||||
env_request.dbsession,
|
||||
)
|
||||
env_request.dbsession.add(duplicate_payment)
|
||||
env_request.dbsession.flush()
|
||||
|
|
@ -2278,7 +2326,7 @@ def scan_wallet_for_double_or_late_payments(request, settings):
|
|||
else:
|
||||
# Create new payment object for each duplicate transaction
|
||||
duplicate_payment = _create_duplicate_payment(
|
||||
payment, tx, coin_type
|
||||
payment, tx, coin_type, db
|
||||
)
|
||||
db.add(duplicate_payment)
|
||||
db.flush() # Get the ID assigned
|
||||
|
|
|
|||
|
|
@ -975,6 +975,57 @@ class TestEdgeCasesAndErrorHandling(unittest.TestCase):
|
|||
# This would be caught in the main processing loop
|
||||
# The payment would be skipped due to status change
|
||||
|
||||
def test_create_duplicate_payment_inherits_saved_refund_address(self):
|
||||
"""Test that duplicate payments inherit user's saved refund address when original has none."""
|
||||
from make_post_sell.lib.crypto_watcher import _create_duplicate_payment
|
||||
from make_post_sell.models.user_crypto_refund_address import (
|
||||
UserCryptoRefundAddress,
|
||||
)
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
original = self._create_test_payment()
|
||||
original.refund_address = None # No refund address on original
|
||||
original.user_id = "test-user-123"
|
||||
|
||||
# Mock user and shop
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "test-user-123"
|
||||
mock_shop = MagicMock()
|
||||
mock_shop.id = "test-shop-456"
|
||||
|
||||
# Create saved refund address for user
|
||||
saved_address = UserCryptoRefundAddress(
|
||||
user=mock_user,
|
||||
shop=mock_shop,
|
||||
coin_type="XMR",
|
||||
address="saved_xmr_refund_address_12345",
|
||||
)
|
||||
saved_address.user_id = "test-user-123" # Set ID directly for query
|
||||
|
||||
# Mock the query to return our saved address
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_first = MagicMock(return_value=saved_address)
|
||||
|
||||
self.request.dbsession.query = MagicMock(return_value=mock_query)
|
||||
mock_query.filter = MagicMock(return_value=mock_filter)
|
||||
mock_filter.first = mock_first
|
||||
|
||||
tx = {
|
||||
"txid": "duplicate-tx-456",
|
||||
"amount": 75000000, # 0.075 XMR
|
||||
"confirmations": 3,
|
||||
}
|
||||
|
||||
duplicate = _create_duplicate_payment(
|
||||
original, tx, "XMR", self.request.dbsession
|
||||
)
|
||||
|
||||
# Verify duplicate inherits saved refund address
|
||||
self.assertEqual(duplicate.refund_address, "saved_xmr_refund_address_12345")
|
||||
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND)
|
||||
self.assertEqual(duplicate.received_amount, 75000000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue