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:
parent
d90f41c5d9
commit
f545e0e3e4
7 changed files with 109 additions and 62 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue