modified: make_post_sell/lib/crypto_watcher/__init__.py

modified:   make_post_sell/lib/crypto_watcher/crypto_clients.py
	modified:   make_post_sell/models/invoice.py
	modified:   make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py
	modified:   make_post_sell/tests/test_crypto_watcher.py
	modified:   make_post_sell/tests/test_multi_output_refunds.py
	modified:   make_post_sell/views/crypto.py
This commit is contained in:
Russell Ballestrini 2025-10-02 19:22:38 -04:00
parent d90f41c5d9
commit f545e0e3e4
7 changed files with 109 additions and 62 deletions

View file

@ -3358,7 +3358,7 @@ def process_sweep_confirmations(request, settings):
# Query payments that need sweep confirmation monitoring
sweep_queue = []
# Query each coin type with its specific threshold
for coin_type, required_confirmations in OUTBOUND_CONFIRMATIONS_REQUIRED.items():
coin_sweeps = (

View file

@ -300,9 +300,15 @@ class DogecoinClient:
"""Send Dogecoin to an address. Returns transaction ID."""
return self._call("sendtoaddress", [address, amount, comment])
def sendmany(self, from_label: str, addresses_amounts: Dict[str, float], minconf: int = 1, comment: str = "") -> str:
def sendmany(
self,
from_label: str,
addresses_amounts: Dict[str, float],
minconf: int = 1,
comment: str = "",
) -> str:
"""Send to multiple addresses at once. More efficient for sweeping.
Args:
from_label: Account label (use "" for default account)
addresses_amounts: Dict mapping addresses to amounts

View file

@ -288,7 +288,12 @@ class Invoice(RBase, Base):
@property
def is_paid(self):
"""Check if this invoice has been successfully paid."""
return self.payment_status in ["confirmed", "confirmed-overpay", "confirmed-complete", "paid"]
return self.payment_status in [
"confirmed",
"confirmed-overpay",
"confirmed-complete",
"paid",
]
def get_invoice_by_id(dbsession, invoice_id):

View file

@ -5,13 +5,14 @@ Revises: 07908c8c840d
Create Date: 2025-10-02 19:00:08.788508
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0915b3ff883d'
down_revision = '07908c8c840d'
revision = "0915b3ff883d"
down_revision = "07908c8c840d"
branch_labels = None
depends_on = None
@ -20,11 +21,14 @@ from make_post_sell.models.meta import UUIDType
def upgrade():
# Add swept_confirmations column to mps_crypto_payment table
op.add_column('mps_crypto_payment',
sa.Column('swept_confirmations', sa.Integer(), nullable=False, server_default='10')
op.add_column(
"mps_crypto_payment",
sa.Column(
"swept_confirmations", sa.Integer(), nullable=False, server_default="10"
),
)
def downgrade():
# Remove swept_confirmations column from mps_crypto_payment table
op.drop_column('mps_crypto_payment', 'swept_confirmations')
op.drop_column("mps_crypto_payment", "swept_confirmations")

View file

@ -1230,7 +1230,9 @@ class AutoSweepTests(unittest.TestCase):
# Verify that swept_tx_hash was set (from transfer, not sweep)
self.assertEqual(payment.swept_tx_hash, "transfer_tx_123")
self.assertEqual(payment.swept_amount, 494500000000) # 0.5 XMR - (0.005 XMR fee * 1.1 margin)
self.assertEqual(
payment.swept_amount, 494500000000
) # 0.5 XMR - (0.005 XMR fee * 1.1 margin)
self.assertEqual(payment.swept_network_fee, 5000000000)
self.assertIsNotNone(payment.swept_timestamp)
@ -3113,7 +3115,9 @@ class RefundTypeTests(unittest.TestCase):
doge_payment.coin_type = "DOGE"
doge_payment.current_confirmations = 2 # Sufficient confirmations
doge_payment.account_index = None # DOGE doesn't use account_index
doge_payment.shop_sweep_to_address = "DShopSweepAddress789" # Shop address for fee
doge_payment.shop_sweep_to_address = (
"DShopSweepAddress789" # Shop address for fee
)
mock_client = MagicMock()
mock_client._call.return_value = "doge-tx-hash-123"
@ -3158,7 +3162,9 @@ class RefundTypeTests(unittest.TestCase):
xmr_payment.coin_type = "XMR"
xmr_payment.current_confirmations = 10 # Sufficient confirmations
xmr_payment.account_index = 5
xmr_payment.shop_sweep_to_address = "4ShopSweepAddressXMR789" # Shop address for fee
xmr_payment.shop_sweep_to_address = (
"4ShopSweepAddressXMR789" # Shop address for fee
)
mock_client = MagicMock()
mock_client._call.side_effect = [
@ -3335,15 +3341,19 @@ class RefundTypeTests(unittest.TestCase):
# Verify successful result
self.assertTrue(result["success"])
self.assertEqual(result["tx_hash"], "doge-precision-tx-123")
# Verify sendmany was called with properly rounded amounts
mock_client._call.assert_called_once()
call_args = mock_client._call.call_args[0]
self.assertEqual(call_args[0], "sendmany")
outputs = call_args[1][1]
# Verify amounts are rounded to 8 decimal places
self.assertEqual(outputs["DPrecisionTest123"], 1.03859035) # Rounded from 1.0385903528
self.assertEqual(outputs["DShopPrecisionAddress"], 0.11429455) # Rounded from 0.1142945472
self.assertEqual(
outputs["DPrecisionTest123"], 1.03859035
) # Rounded from 1.0385903528
self.assertEqual(
outputs["DShopPrecisionAddress"], 0.11429455
) # Rounded from 0.1142945472
def test_xmr_refund_amount_precision_handling(self):
"""Test XMR refund execution handles high precision amounts correctly."""

View file

@ -11,7 +11,10 @@ from unittest.mock import MagicMock, patch
from decimal import Decimal
import uuid
from ..lib.crypto_watcher.crypto_payment_rescue import PaymentRescue, RESTOCKING_FEE_PERCENT
from ..lib.crypto_watcher.crypto_payment_rescue import (
PaymentRescue,
RESTOCKING_FEE_PERCENT,
)
from ..models.crypto_payment import CryptoPayment
@ -23,7 +26,7 @@ class TestMultiOutputRefunds(unittest.TestCase):
self.mock_dbsession = MagicMock()
self.mock_client = MagicMock()
self.rescue = PaymentRescue(self.mock_dbsession, self.mock_client)
# Create a mock payment with shop sweep address
self.payment = MagicMock(spec=CryptoPayment)
self.payment.id = uuid.uuid4()
@ -39,7 +42,7 @@ class TestMultiOutputRefunds(unittest.TestCase):
received_amount = Decimal("100.0") # 100 DOGE received
fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 9 DOGE fee
refund_amount = received_amount - fee_amount # 91 DOGE refund
refund_details = {
"type": "overpayment",
"payment_id": self.payment.id,
@ -47,29 +50,29 @@ class TestMultiOutputRefunds(unittest.TestCase):
"received_amount": received_amount,
"refund_amount": refund_amount,
"fee_amount": fee_amount,
"reason": "Test overpayment refund"
"reason": "Test overpayment refund",
}
# Mock the sendmany call and getbalance
self.mock_client.sendmany.return_value = "test_tx_hash_123"
self.mock_client.getbalance.return_value = 100.0 # Sufficient balance
self.mock_client._call.return_value = "" # For getaccount
# Execute refund
result = self.rescue.execute_refund(refund_details, self.payment)
# Verify multi-output sendmany was called
self.mock_client.sendmany.assert_called_once()
call_args = self.mock_client.sendmany.call_args[0]
self.assertEqual(call_args[0], "") # fromaccount
# Check outputs
outputs = call_args[1]
self.assertEqual(len(outputs), 2) # Two outputs
self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=2)
self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=2)
# Verify result
self.assertTrue(result["success"])
self.assertEqual(result["tx_hash"], "test_tx_hash_123")
@ -79,12 +82,12 @@ class TestMultiOutputRefunds(unittest.TestCase):
"""Test XMR refund with multi-output (customer + shop)."""
# Set up payment for XMR
self.payment.coin_type = "XMR"
# Set up refund details
received_amount = Decimal("1.0") # 1 XMR received
fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 0.09 XMR fee
refund_amount = received_amount - fee_amount # 0.91 XMR refund
refund_details = {
"type": "overpayment",
"payment_id": self.payment.id,
@ -92,43 +95,45 @@ class TestMultiOutputRefunds(unittest.TestCase):
"received_amount": received_amount,
"refund_amount": refund_amount,
"fee_amount": fee_amount,
"reason": "Test overpayment refund"
"reason": "Test overpayment refund",
}
# Mock the RPC calls
def mock_call(method, params=None):
if method == "transfer":
return {"tx_hash": "xmr_test_tx_hash_456"}
else:
raise ValueError(f"Unexpected method: {method}")
self.mock_client._call.side_effect = mock_call
# Execute refund
result = self.rescue.execute_refund(refund_details, self.payment)
# Find the transfer call
transfer_call = None
for call in self.mock_client._call.call_args_list:
if call[0][0] == "transfer":
transfer_call = call
break
self.assertIsNotNone(transfer_call)
transfer_params = transfer_call[0][1]
# Check destinations
destinations = transfer_params["destinations"]
self.assertEqual(len(destinations), 2) # Two destinations
# Customer refund destination
self.assertEqual(destinations[0]["address"], "4CustomerRefundAddressXMR123")
self.assertEqual(destinations[0]["amount"], 910000000000) # 0.91 XMR in piconero
self.assertEqual(
destinations[0]["amount"], 910000000000
) # 0.91 XMR in piconero
# Shop fee destination
self.assertEqual(destinations[1]["address"], "DShopSweepAddressTest123")
self.assertEqual(destinations[1]["amount"], 90000000000) # 0.09 XMR in piconero
# Verify result
self.assertTrue(result["success"])
self.assertEqual(result["tx_hash"], "xmr_test_tx_hash_456")
@ -138,12 +143,12 @@ class TestMultiOutputRefunds(unittest.TestCase):
"""Test refund fails when shop has no sweep address."""
# Remove shop sweep address
self.payment.shop_sweep_to_address = None
# Set up refund details
received_amount = Decimal("100.0")
fee_amount = received_amount * RESTOCKING_FEE_PERCENT
refund_amount = received_amount - fee_amount
refund_details = {
"type": "overpayment",
"payment_id": self.payment.id,
@ -151,16 +156,16 @@ class TestMultiOutputRefunds(unittest.TestCase):
"received_amount": received_amount,
"refund_amount": refund_amount,
"fee_amount": fee_amount,
"reason": "Test overpayment refund"
"reason": "Test overpayment refund",
}
# Execute refund
result = self.rescue.execute_refund(refund_details, self.payment)
# Verify refund failed
self.assertFalse(result["success"])
self.assertIn("Shop sweep address is required", result["error"])
# Verify no transaction was attempted
self.mock_client._call.assert_not_called()
self.mock_client.sendtoaddress.assert_not_called()
@ -175,17 +180,17 @@ class TestMultiOutputRefunds(unittest.TestCase):
"received_amount": Decimal("100.0"),
"refund_amount": Decimal("100.0"),
"fee_amount": Decimal("0"),
"reason": "Test refund with zero fee"
"reason": "Test refund with zero fee",
}
# Mock the sendmany call
self.mock_client.sendmany.return_value = "zero_fee_tx_123"
self.mock_client.getbalance.return_value = 100.0
self.mock_client._call.return_value = ""
# Execute refund
result = self.rescue.execute_refund(refund_details, self.payment)
# Verify multi-output sendmany was still used
self.mock_client.sendmany.assert_called_once()
call_args = self.mock_client.sendmany.call_args[0]
@ -193,7 +198,7 @@ class TestMultiOutputRefunds(unittest.TestCase):
# With 0.005 fee buffer, customer gets slightly less
self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 99.995, places=3)
self.assertEqual(outputs["DShopSweepAddressTest123"], 0.0) # Zero fee
# Verify result
self.assertTrue(result["success"])
self.assertEqual(result["tx_hash"], "zero_fee_tx_123")
@ -202,7 +207,7 @@ class TestMultiOutputRefunds(unittest.TestCase):
"""Test refund is delayed when insufficient confirmations."""
# Set insufficient confirmations
self.payment.current_confirmations = 1
refund_details = {
"type": "overpayment",
"payment_id": self.payment.id,
@ -210,21 +215,21 @@ class TestMultiOutputRefunds(unittest.TestCase):
"received_amount": Decimal("100.0"),
"refund_amount": Decimal("91.0"),
"fee_amount": Decimal("9.0"),
"reason": "Test refund"
"reason": "Test refund",
}
# Execute refund
result = self.rescue.execute_refund(refund_details, self.payment)
# Verify refund was delayed
self.assertFalse(result["success"])
self.assertIn("confirmations", result["error"])
self.assertEqual(result["confirmations_needed"], 1) # Need 1 more confirmation
# Verify no transaction was attempted
self.mock_client._call.assert_not_called()
self.mock_client.sendtoaddress.assert_not_called()
if __name__ == "__main__":
unittest.main()
unittest.main()

View file

@ -872,7 +872,8 @@ def crypto_xmr_status(request):
# Add smart redirect URL when payment is confirmed (any confirmed status)
if (
crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"]
crypto_payment.status
in ["confirmed", "confirmed-complete", "confirmed-overpay"]
and crypto_payment.invoice
):
from ..views.cart import get_smart_purchase_redirect_url
@ -971,7 +972,8 @@ def crypto_doge_status(request):
# Add smart redirect URL when payment is confirmed (any confirmed status)
if (
crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"]
crypto_payment.status
in ["confirmed", "confirmed-complete", "confirmed-overpay"]
and crypto_payment.invoice
):
from ..views.cart import get_smart_purchase_redirect_url
@ -1244,11 +1246,26 @@ def get_payment_status_info(status):
"label": "✓ Duplicate Payment - Refunded",
"color": "#fd7e14",
},
"latepay-not-refunded": {"label": "Late Payment - No Refund", "color": "#dc3545"},
"underpaid-not-refunded": {"label": "Underpaid - No Refund", "color": "#dc3545"},
"confirmed-overpay-not-refunded": {"label": "Overpaid - No Refund", "color": "#dc3545"},
"out-of-stock-not-refunded": {"label": "Out of Stock - No Refund", "color": "#dc3545"},
"doublepay-not-refunded": {"label": "Duplicate Payment - No Refund", "color": "#dc3545"},
"latepay-not-refunded": {
"label": "Late Payment - No Refund",
"color": "#dc3545",
},
"underpaid-not-refunded": {
"label": "Underpaid - No Refund",
"color": "#dc3545",
},
"confirmed-overpay-not-refunded": {
"label": "Overpaid - No Refund",
"color": "#dc3545",
},
"out-of-stock-not-refunded": {
"label": "Out of Stock - No Refund",
"color": "#dc3545",
},
"doublepay-not-refunded": {
"label": "Duplicate Payment - No Refund",
"color": "#dc3545",
},
}
return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"})