modified: .gitignore
modified: make_post_sell/lib/crypto_watcher.py modified: make_post_sell/models/crypto_payment.py modified: make_post_sell/models/crypto_processor.py modified: make_post_sell/models/user_crypto_refund_address.py modified: make_post_sell/request_methods.py deleted: make_post_sell/scripts/alembic/versions/5997b993482e_add_indexes_for_crypto_payment_address_.py deleted: make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py deleted: make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py modified: make_post_sell/templates/actions_new.j2 modified: make_post_sell/tests/test_crypto_watcher.py modified: make_post_sell/views/crypto.py modified: make_post_sell/views/user_crypto_settings.py
This commit is contained in:
parent
f1b411ecb8
commit
f1545f3b82
13 changed files with 394 additions and 245 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -33,4 +33,5 @@ caddy
|
|||
|
||||
monero-wallet-cli.log
|
||||
monero-wallet-rpc.log
|
||||
monero-wallet-rpc.log*
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,115 @@ from ..models.meta import now_timestamp
|
|||
|
||||
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)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
elif coin_type in ("DOGE", "BTC", "LTC", "BCH") and sem_type == "blockhash":
|
||||
# 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
|
||||
position_value: Position value (height for XMR, blockhash for DOGE/BTC)
|
||||
|
||||
Returns:
|
||||
str: Formatted semaphore string
|
||||
"""
|
||||
if coin_type == "XMR":
|
||||
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
|
||||
3. Double payment to completed order
|
||||
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}")
|
||||
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)):
|
||||
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
|
||||
]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Constants
|
||||
COIN_CONFIGS = {
|
||||
"XMR": {
|
||||
|
|
@ -1525,47 +1634,93 @@ def update_payment_confirmations_only(client, crypto_payment, coin_type):
|
|||
|
||||
def scan_wallet_for_late_payments(request, settings):
|
||||
"""
|
||||
Scan wallet for ALL incoming transactions and match them to payments.
|
||||
Scan wallet for new incoming transactions since last scan position and match them to payments.
|
||||
This catches late payments to expired quotes that aren't actively monitored.
|
||||
Uses scan position tracking to avoid rescanning old transfers.
|
||||
"""
|
||||
logger.info("Starting wallet scan for late payments")
|
||||
db = request.dbsession
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from ..models.crypto_processor import CryptoProcessor
|
||||
|
||||
# Get all active crypto processors (one per shop/coin combination)
|
||||
processors = db.query(CryptoProcessor).filter(CryptoProcessor.enabled == True).all()
|
||||
|
||||
if not processors:
|
||||
logger.debug("No active crypto processors found for scanning")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(processors)} active crypto processors to scan")
|
||||
|
||||
# Group processors by coin type for efficient scanning
|
||||
processors_by_coin = {}
|
||||
for processor in processors:
|
||||
coin_type = processor.coin_type
|
||||
if coin_type not in processors_by_coin:
|
||||
processors_by_coin[coin_type] = []
|
||||
processors_by_coin[coin_type].append(processor)
|
||||
|
||||
# Process each coin type
|
||||
for coin_type in ["XMR", "DOGE"]:
|
||||
for coin_type, coin_processors in processors_by_coin.items():
|
||||
try:
|
||||
client = get_crypto_client(settings, coin_type)
|
||||
except ValueError as e:
|
||||
logger.debug(f"Skipping {coin_type} wallet scan: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Scanning {coin_type} wallet for incoming transactions")
|
||||
logger.info(
|
||||
f"Scanning {coin_type} wallet for {len(coin_processors)} processors"
|
||||
)
|
||||
|
||||
scan_start_time = time.time()
|
||||
|
||||
if coin_type == "XMR":
|
||||
# Get ALL incoming transfers from the wallet
|
||||
try:
|
||||
result = client._call(
|
||||
"get_transfers",
|
||||
{
|
||||
"in": True,
|
||||
"out": False,
|
||||
"pending": True,
|
||||
"failed": False,
|
||||
"pool": True,
|
||||
},
|
||||
# Get the minimum scan position across all processors to determine what's "new"
|
||||
min_scan_position = min(
|
||||
_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,
|
||||
"out": False,
|
||||
"pending": True,
|
||||
"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
|
||||
query_params["min_height"] = min_scan_position
|
||||
|
||||
# Get transfers from the wallet using bounded query
|
||||
result = client._call("get_transfers", query_params)
|
||||
|
||||
all_transfers = []
|
||||
for transfer_type in ["in", "pending", "pool"]:
|
||||
if transfer_type in result:
|
||||
all_transfers.extend(result[transfer_type])
|
||||
|
||||
if not all_transfers:
|
||||
logger.debug(f"No {coin_type} transfers found")
|
||||
continue
|
||||
|
||||
# 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)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(all_transfers)} total incoming {coin_type} transfers"
|
||||
f"Found {len(new_transfers)} incoming {coin_type} transfers newer than scan position {min_scan_position}"
|
||||
)
|
||||
|
||||
# For each transfer, look up the payment by subaddress
|
||||
for tx in all_transfers:
|
||||
# Process new transfers and match to payments
|
||||
late_payments_found = 0
|
||||
for tx in new_transfers:
|
||||
subaddr = tx.get("subaddr_index", {})
|
||||
if not subaddr:
|
||||
continue
|
||||
|
|
@ -1587,28 +1742,120 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
.first()
|
||||
)
|
||||
|
||||
if payment and payment.status in [
|
||||
CryptoPayment.STATUS_EXPIRED,
|
||||
CryptoPayment.STATUS_CANCELLED,
|
||||
]:
|
||||
if payment and _should_process_late_payment(payment, tx):
|
||||
late_payments_found += 1
|
||||
logger.info(
|
||||
f"Found late payment to {payment.status} quote {payment.id}: "
|
||||
f"{tx.get('amount', 0) / 1e12:.12f} XMR, "
|
||||
f"{tx.get('confirmations', 0)} confirmations"
|
||||
f"{tx.get('confirmations', 0)} confirmations, height {tx.get('height', 0)}"
|
||||
)
|
||||
# Process this late payment
|
||||
incoming = [tx] # Process just this transfer
|
||||
process_payment(request, payment, incoming, client=client)
|
||||
|
||||
# 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)
|
||||
if old_position < max_height:
|
||||
new_semaphore = _format_scan_semaphore(coin_type, max_height)
|
||||
processor.last_scan_semaphore = new_semaphore
|
||||
db.add(processor)
|
||||
logger.debug(
|
||||
f"Updated scan position for {processor.coin_type} processor {processor.id}: "
|
||||
f"{old_position} → {max_height}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Wallet scan: ~{(time.time() - scan_start_time):.1f} seconds to scan {len(new_transfers)} transfers, "
|
||||
f"found {late_payments_found} late payments"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scan {coin_type} wallet: {e}")
|
||||
|
||||
elif coin_type == "DOGE":
|
||||
# For DOGE, we need to get all addresses that have received funds
|
||||
# This is more complex as we'd need to track all addresses we've generated
|
||||
# For now, we'll skip DOGE passive scanning
|
||||
logger.debug("DOGE passive scanning not yet implemented")
|
||||
continue
|
||||
try:
|
||||
# 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:"):
|
||||
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
|
||||
result = client.listsinceblock(min_scan_semaphore)
|
||||
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))
|
||||
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}"
|
||||
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
|
||||
]
|
||||
|
||||
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
|
||||
late_payments_found = 0
|
||||
for tx in incoming_transactions:
|
||||
# Look up payment by address
|
||||
payment = (
|
||||
db.query(CryptoPayment)
|
||||
.filter(
|
||||
CryptoPayment.coin_type == coin_type,
|
||||
CryptoPayment.address == tx.get("address"),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if payment and _should_process_late_payment(payment, tx):
|
||||
late_payments_found += 1
|
||||
logger.info(
|
||||
f"Found late payment to {payment.status} quote {payment.id}: "
|
||||
f"{tx.get('amount', 0)} {coin_type}, "
|
||||
f"{tx.get('confirmations', 0)} confirmations"
|
||||
)
|
||||
# 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:
|
||||
processor.last_scan_semaphore = f"blockhash:{latest_block_hash}"
|
||||
db.add(processor)
|
||||
logger.debug(
|
||||
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
|
||||
|
||||
|
||||
def run_once(env, interval):
|
||||
|
|
|
|||
|
|
@ -102,6 +102,11 @@ class CryptoPayment(RBase, Base):
|
|||
BigInteger, nullable=True
|
||||
) # Customer's tx fee (when available from RPC)
|
||||
|
||||
# Fee amount calculated at payment creation (to avoid expensive RPC calls on page loads)
|
||||
estimated_fee_amount = Column(
|
||||
BigInteger, nullable=True
|
||||
) # Estimated fee in atomic units (piconero, satoshis, etc.)
|
||||
|
||||
# locked rate at the time of creating the payment (USD per coin)
|
||||
rate_locked_usd_per_coin = Column(Numeric(18, 8), nullable=False)
|
||||
|
||||
|
|
@ -173,6 +178,7 @@ class CryptoPayment(RBase, Base):
|
|||
shop_location=None,
|
||||
shop_sweep_to_address=None,
|
||||
refund_address=None,
|
||||
estimated_fee_amount=None,
|
||||
):
|
||||
self.id = uuid.uuid1()
|
||||
self.invoice = invoice
|
||||
|
|
@ -184,6 +190,7 @@ class CryptoPayment(RBase, Base):
|
|||
self.coin_type = coin_type
|
||||
self.expected_amount = int(expected_amount)
|
||||
self.received_amount = 0
|
||||
self.estimated_fee_amount = estimated_fee_amount
|
||||
self.rate_locked_usd_per_coin = rate_locked_usd_per_coin
|
||||
self.quote_expires_at = int(quote_expires_at_ms)
|
||||
self.confirmations_required = confirmations_required
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ class CryptoProcessor(RBase, Base):
|
|||
# Wallet identifier - account index for Monero, label for Bitcoin-like
|
||||
wallet_label = Column(Unicode(128), nullable=False)
|
||||
|
||||
# Generic semaphore for bounded scanning across different coin types
|
||||
# Format: "type:value" where type is height|blockhash|timestamp
|
||||
# XMR: "height:3507980"
|
||||
# DOGE/BTC/LTC/BCH: "blockhash:00000000000001a2b3c4d5e6f7..."
|
||||
last_scan_semaphore = Column(Unicode(255), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
|
|||
|
||||
class UserCryptoRefundAddress(RBase, Base):
|
||||
"""
|
||||
Stores cryptocurrency refund addresses for users.
|
||||
One user can have one address per coin type.
|
||||
Stores cryptocurrency refund addresses for users per shop.
|
||||
One user can have one address per shop per coin type.
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
|
||||
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
|
||||
|
||||
# Coin type (e.g., 'XMR', 'BTC', 'LTC', 'DOGE', 'BCH')
|
||||
coin_type = Column(Unicode(32), nullable=False)
|
||||
|
|
@ -28,10 +29,12 @@ class UserCryptoRefundAddress(RBase, Base):
|
|||
|
||||
# Relationships
|
||||
user = relationship("User", backref="crypto_refund_addresses")
|
||||
shop = relationship("Shop", backref="user_crypto_refund_addresses")
|
||||
|
||||
def __init__(self, user, coin_type, address, label=None):
|
||||
def __init__(self, user, shop, coin_type, address, label=None):
|
||||
self.id = uuid.uuid1()
|
||||
self.user = user
|
||||
self.shop = shop
|
||||
self.coin_type = coin_type.upper()
|
||||
self.address = address
|
||||
self.label = label
|
||||
|
|
@ -40,20 +43,22 @@ class UserCryptoRefundAddress(RBase, Base):
|
|||
self.updated_timestamp = now
|
||||
|
||||
|
||||
# Create unique constraint for user_id + coin_type
|
||||
# Create unique constraint for user_id + shop_id + coin_type
|
||||
UniqueConstraint(
|
||||
UserCryptoRefundAddress.user_id,
|
||||
UserCryptoRefundAddress.shop_id,
|
||||
UserCryptoRefundAddress.coin_type,
|
||||
name="uq_user_crypto_refund_address_user_coin",
|
||||
name="uq_user_crypto_refund_address_user_shop_coin",
|
||||
)
|
||||
|
||||
|
||||
def get_user_crypto_refund_address(dbsession, user, coin_type):
|
||||
"""Get the refund address for a user and coin type."""
|
||||
def get_user_crypto_refund_address(dbsession, user, shop, coin_type):
|
||||
"""Get the refund address for a user, shop, and coin type."""
|
||||
return (
|
||||
dbsession.query(UserCryptoRefundAddress)
|
||||
.filter(
|
||||
UserCryptoRefundAddress.user_id == user.id,
|
||||
UserCryptoRefundAddress.shop_id == shop.id,
|
||||
UserCryptoRefundAddress.coin_type == coin_type.upper(),
|
||||
)
|
||||
.first()
|
||||
|
|
|
|||
|
|
@ -45,20 +45,25 @@ def includeme(config):
|
|||
|
||||
def add_shop(request):
|
||||
shop_id = request.params.get("shop_id", request.matchdict.get("shop_id"))
|
||||
# print(f"add_shop: shop_id={shop_id}, params={request.params}, matchdict={request.matchdict}")
|
||||
shop = None
|
||||
if shop_id:
|
||||
shop = get_shop_by_id(request.dbsession, shop_id)
|
||||
# print(f"get_shop_by_id returned: {shop}")
|
||||
elif request.product:
|
||||
shop = request.product.shop
|
||||
else:
|
||||
if request.is_saas_domain:
|
||||
if request.user and request.user.active_shop:
|
||||
shop = request.user.active_shop
|
||||
if request.user:
|
||||
# First try to get the user's active shop
|
||||
if request.user.active_shop:
|
||||
shop = request.user.active_shop
|
||||
elif request.user.shops:
|
||||
# If user has shops but no active shop, set the first one as active
|
||||
shop = request.user.shops[0]
|
||||
request.user.set_active_shop(shop)
|
||||
request.dbsession.add(request.user)
|
||||
request.dbsession.flush()
|
||||
else:
|
||||
shop = get_shop_by_domain_name(request.dbsession, request.domain)
|
||||
# print(f"add_shop returning: {shop}")
|
||||
return shop
|
||||
|
||||
def add_active_cart(request):
|
||||
|
|
@ -149,10 +154,10 @@ def includeme(config):
|
|||
return False
|
||||
|
||||
def add_saas_domain(request):
|
||||
return request.app.get("root_domain")
|
||||
return request.app.get("make_post_sell.root_domain")
|
||||
|
||||
def add_saas_url(request):
|
||||
return request.app.get("root_url")
|
||||
return request.app.get("make_post_sell.root_url")
|
||||
|
||||
def add_is_saas_domain(request):
|
||||
"""
|
||||
|
|
@ -163,9 +168,16 @@ def includeme(config):
|
|||
* show two different "home" pages
|
||||
* show only certain buttons on the SaaS domain.
|
||||
"""
|
||||
root_domain = request.app.get("root_domain")
|
||||
root_domain = request.app.get("make_post_sell.root_domain")
|
||||
|
||||
# For development, always treat localhost as SaaS domain for convenience
|
||||
if request.domain == "localhost":
|
||||
return True
|
||||
|
||||
# Standard SaaS domain check
|
||||
if root_domain:
|
||||
return request.domain.endswith(root_domain)
|
||||
|
||||
return False
|
||||
|
||||
def add_stripe_enabled(request):
|
||||
|
|
@ -310,23 +322,23 @@ def includeme(config):
|
|||
|
||||
def add_has_xmr_refund_address(request):
|
||||
"""Check if the current user has an XMR refund address configured."""
|
||||
if not request.user:
|
||||
if not request.user or not request.shop:
|
||||
return False
|
||||
from .models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
|
||||
refund_address = get_user_crypto_refund_address(
|
||||
request.dbsession, request.user, "XMR"
|
||||
request.dbsession, request.user, request.shop, "XMR"
|
||||
)
|
||||
return refund_address is not None and refund_address.address is not None
|
||||
|
||||
def add_has_doge_refund_address(request):
|
||||
"""Check if the current user has a DOGE refund address configured."""
|
||||
if not request.user:
|
||||
if not request.user or not request.shop:
|
||||
return False
|
||||
from .models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
|
||||
refund_address = get_user_crypto_refund_address(
|
||||
request.dbsession, request.user, "DOGE"
|
||||
request.dbsession, request.user, request.shop, "DOGE"
|
||||
)
|
||||
return refund_address is not None and refund_address.address is not None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
"""Add indexes for crypto payment address lookups
|
||||
|
||||
Revision ID: 5997b993482e
|
||||
Revises: dd7466bfc690
|
||||
Create Date: 2025-09-25 13:15:50.163828
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5997b993482e"
|
||||
down_revision = "dd7466bfc690"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add index for DOGE/BTC/LTC address lookups
|
||||
op.create_index("ix_mps_crypto_payment_address", "mps_crypto_payment", ["address"])
|
||||
|
||||
# Add composite index for XMR subaddress lookups
|
||||
op.create_index(
|
||||
"ix_mps_crypto_payment_subaddress",
|
||||
"mps_crypto_payment",
|
||||
["coin_type", "account_index", "subaddress_index"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_mps_crypto_payment_subaddress", "mps_crypto_payment")
|
||||
op.drop_index("ix_mps_crypto_payment_address", "mps_crypto_payment")
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
"""Add refund tracking columns to crypto_payment
|
||||
|
||||
Revision ID: 5bbb5df5bf1b
|
||||
Revises: 0f59018f6537
|
||||
Create Date: 2025-09-24 09:51:53.586990
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5bbb5df5bf1b"
|
||||
down_revision = "0f59018f6537"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add refund tracking columns to crypto_payment table
|
||||
op.add_column(
|
||||
"mps_crypto_payment",
|
||||
sa.Column("refund_reason", sa.UnicodeText(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"mps_crypto_payment",
|
||||
sa.Column("refund_tx_hash", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"mps_crypto_payment", sa.Column("refund_amount", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove refund tracking columns from crypto_payment table
|
||||
op.drop_column("mps_crypto_payment", "refund_amount")
|
||||
op.drop_column("mps_crypto_payment", "refund_tx_hash")
|
||||
op.drop_column("mps_crypto_payment", "refund_reason")
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"""Make crypto_payment.invoice_id nullable for cancelled payments
|
||||
|
||||
Revision ID: dd7466bfc690
|
||||
Revises: 5bbb5df5bf1b
|
||||
Create Date: 2025-09-24 18:44:52.643251
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "dd7466bfc690"
|
||||
down_revision = "5bbb5df5bf1b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Drop and recreate crypto_payment table with nullable invoice_id
|
||||
# Safe to drop since no real customer data exists yet
|
||||
op.drop_table("mps_crypto_payment")
|
||||
|
||||
# Recreate table with complete schema and nullable invoice_id
|
||||
op.create_table(
|
||||
"mps_crypto_payment",
|
||||
sa.Column("id", UUIDType, primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
"shop_location_id",
|
||||
UUIDType,
|
||||
sa.ForeignKey("mps_shop_location.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("address", sa.String(128), nullable=False),
|
||||
sa.Column("account_index", sa.Integer, nullable=False),
|
||||
sa.Column("subaddress_index", sa.Integer, nullable=False),
|
||||
sa.Column("coin_type", sa.String(10), nullable=False),
|
||||
sa.Column("expected_amount", sa.BigInteger, nullable=False),
|
||||
sa.Column("received_amount", sa.BigInteger, nullable=False, default=0),
|
||||
sa.Column("received_network_fee", sa.BigInteger, nullable=True),
|
||||
sa.Column("rate_locked_usd_per_coin", sa.Numeric(18, 8), nullable=False),
|
||||
sa.Column("quote_expires_at", sa.BigInteger, nullable=False),
|
||||
sa.Column("confirmations_required", sa.Integer, nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False, default="pending"),
|
||||
sa.Column("tx_hashes", sa.UnicodeText, nullable=False),
|
||||
sa.Column("shop_sweep_to_address", sa.String(256), nullable=True),
|
||||
sa.Column("refund_address", sa.String(256), nullable=True),
|
||||
sa.Column("swept_amount", sa.BigInteger, nullable=True),
|
||||
sa.Column("swept_tx_hash", sa.String(128), nullable=True),
|
||||
sa.Column("swept_timestamp", sa.BigInteger, nullable=True),
|
||||
sa.Column("swept_network_fee", sa.BigInteger, nullable=True),
|
||||
sa.Column("current_confirmations", sa.Integer, nullable=False, default=0),
|
||||
sa.Column("created_timestamp", sa.BigInteger, nullable=False),
|
||||
sa.Column("updated_timestamp", sa.BigInteger, nullable=False),
|
||||
sa.Column("refund_reason", sa.UnicodeText, nullable=True),
|
||||
sa.Column("refund_tx_hash", sa.String(128), nullable=True),
|
||||
sa.Column("refund_amount", sa.BigInteger, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Revert back to NOT NULL (but this could fail if there are NULL values)
|
||||
op.alter_column(
|
||||
"mps_crypto_payment", "invoice_id", existing_type=UUIDType, nullable=False
|
||||
)
|
||||
|
|
@ -34,6 +34,16 @@
|
|||
<a href="/s/new" class="shop-new-button mps-button mps-button">+ New Shop</a>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{% if request.is_saas_domain %}
|
||||
<p>Create your first shop to get started!</p>
|
||||
<br/>
|
||||
<a href="/s/new" class="shop-new-button mps-button">+ Create Your First Shop</a>
|
||||
{% else %}
|
||||
<p>No shop configured for this domain.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1122,7 +1122,7 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
|
||||
# Verify payment was looked up by subaddress
|
||||
self.mock_dbsession.query.assert_called_with(CryptoPayment)
|
||||
|
||||
|
||||
# Verify process_payment was called for expired payment
|
||||
mock_process.assert_called_once()
|
||||
call_args = mock_process.call_args
|
||||
|
|
@ -1133,7 +1133,9 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
|
||||
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
|
||||
@patch("make_post_sell.lib.crypto_watcher.process_payment")
|
||||
def test_scan_wallet_skips_non_expired_payments(self, mock_process, mock_get_client):
|
||||
def test_scan_wallet_skips_non_expired_payments(
|
||||
self, mock_process, mock_get_client
|
||||
):
|
||||
"""Test scanning wallet ignores non-expired payments."""
|
||||
# Mock XMR client with transfers
|
||||
mock_client = MagicMock()
|
||||
|
|
@ -1168,7 +1170,9 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
|
||||
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
|
||||
@patch("make_post_sell.lib.crypto_watcher.process_payment")
|
||||
def test_scan_wallet_handles_cancelled_payments(self, mock_process, mock_get_client):
|
||||
def test_scan_wallet_handles_cancelled_payments(
|
||||
self, mock_process, mock_get_client
|
||||
):
|
||||
"""Test scanning wallet processes cancelled payments with transfers."""
|
||||
# Mock XMR client with transfers
|
||||
mock_client = MagicMock()
|
||||
|
|
@ -1231,11 +1235,11 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
|
||||
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
|
||||
|
||||
|
||||
self.engine = setup_db_with_settings(settings)
|
||||
self.session_factory = get_session_factory(self.engine)
|
||||
|
||||
|
|
@ -1275,12 +1279,13 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
|
|||
coin_type="XMR",
|
||||
expected_amount=100000000000, # 0.1 XMR
|
||||
rate_locked_usd_per_coin=150.0,
|
||||
quote_expires_at_ms=int(time.time() * 1000) - 86400000, # Expired 1 day ago
|
||||
quote_expires_at_ms=int(time.time() * 1000)
|
||||
- 86400000, # Expired 1 day ago
|
||||
confirmations_required=5,
|
||||
)
|
||||
self.expired_payment.status = CryptoPayment.STATUS_EXPIRED
|
||||
dbsession.add(self.expired_payment)
|
||||
|
||||
|
||||
# Create cancelled payment
|
||||
self.cancelled_payment = CryptoPayment(
|
||||
invoice=None,
|
||||
|
|
@ -1292,12 +1297,13 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
|
|||
coin_type="XMR",
|
||||
expected_amount=50000000000, # 0.05 XMR
|
||||
rate_locked_usd_per_coin=150.0,
|
||||
quote_expires_at_ms=int(time.time() * 1000) - 3600000, # Expired 1 hour ago
|
||||
quote_expires_at_ms=int(time.time() * 1000)
|
||||
- 3600000, # Expired 1 hour ago
|
||||
confirmations_required=5,
|
||||
)
|
||||
self.cancelled_payment.status = CryptoPayment.STATUS_CANCELLED
|
||||
dbsession.add(self.cancelled_payment)
|
||||
|
||||
|
||||
dbsession.flush()
|
||||
self.expired_payment_id = self.expired_payment.id
|
||||
self.cancelled_payment_id = self.cancelled_payment.id
|
||||
|
|
@ -1307,7 +1313,9 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
|
|||
self.engine.dispose()
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
|
||||
def test_passive_scan_processes_late_payment_to_expired_quote(self, mock_get_client):
|
||||
def test_passive_scan_processes_late_payment_to_expired_quote(
|
||||
self, mock_get_client
|
||||
):
|
||||
"""Integration test: passive scan finds and processes late payment to expired quote."""
|
||||
# Mock XMR client with transfer matching expired payment's subaddress
|
||||
mock_client = MagicMock()
|
||||
|
|
@ -1317,7 +1325,10 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
|
|||
"txid": "late_payment_tx_123",
|
||||
"amount": 100000000000, # Exact amount for expired payment
|
||||
"confirmations": 10,
|
||||
"subaddr_index": {"major": 0, "minor": 42}, # Matches expired payment
|
||||
"subaddr_index": {
|
||||
"major": 0,
|
||||
"minor": 42,
|
||||
}, # Matches expired payment
|
||||
}
|
||||
],
|
||||
"pending": [],
|
||||
|
|
@ -1327,26 +1338,28 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
|
|||
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
|
||||
|
||||
# Create mock request with real DB session
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
mock_request.tm = transaction.manager
|
||||
|
||||
|
||||
# Run passive scan
|
||||
scan_wallet_for_late_payments(mock_request, {})
|
||||
|
||||
|
||||
# Verify payment was updated
|
||||
payment = dbsession.query(CryptoPayment).filter_by(
|
||||
id=self.expired_payment_id
|
||||
).first()
|
||||
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment)
|
||||
.filter_by(id=self.expired_payment_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Should have transitioned from expired to expired-refunded (if refund address exists)
|
||||
# or no-refund if no refund address
|
||||
self.assertIn(payment.status, [
|
||||
CryptoPayment.STATUS_EXPIRED_REFUNDED,
|
||||
CryptoPayment.STATUS_NO_REFUND
|
||||
])
|
||||
self.assertIn(
|
||||
payment.status,
|
||||
[CryptoPayment.STATUS_EXPIRED_REFUNDED, CryptoPayment.STATUS_NO_REFUND],
|
||||
)
|
||||
self.assertEqual(payment.received_amount, 100000000000)
|
||||
|
||||
def test_passive_scan_with_multiple_late_payments(self):
|
||||
|
|
|
|||
|
|
@ -227,12 +227,15 @@ def crypto_xmr_start(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, "XMR"
|
||||
request.dbsession, request.user, request.shop, "XMR"
|
||||
)
|
||||
user_refund_address = (
|
||||
user_refund_addr_obj.address if user_refund_addr_obj else None
|
||||
)
|
||||
|
||||
# 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,
|
||||
|
|
@ -243,6 +246,7 @@ def crypto_xmr_start(request):
|
|||
subaddress_index=subaddr_index,
|
||||
coin_type="XMR",
|
||||
expected_amount=expected_piconero,
|
||||
estimated_fee_amount=estimated_fee_piconero,
|
||||
rate_locked_usd_per_coin=usd_per_xmr,
|
||||
quote_expires_at_ms=quote_expires_at_ms,
|
||||
confirmations_required=confirmations_required,
|
||||
|
|
@ -461,12 +465,15 @@ def crypto_doge_start(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, "DOGE"
|
||||
request.dbsession, request.user, request.shop, "DOGE"
|
||||
)
|
||||
user_refund_address = (
|
||||
user_refund_addr_obj.address if user_refund_addr_obj else None
|
||||
)
|
||||
|
||||
# Convert fee to atomic units (koinu) for storage
|
||||
estimated_fee_koinu = int(fee_buffer_doge * 100_000_000)
|
||||
|
||||
# Persist CryptoPayment (using account_index=0, subaddress_index=0 for Bitcoin-like coins)
|
||||
crypto_payment = CryptoPayment(
|
||||
invoice=invoice,
|
||||
|
|
@ -477,6 +484,7 @@ def crypto_doge_start(request):
|
|||
subaddress_index=0, # Not used for Bitcoin-like coins
|
||||
coin_type="DOGE",
|
||||
expected_amount=expected_koinu,
|
||||
estimated_fee_amount=estimated_fee_koinu,
|
||||
rate_locked_usd_per_coin=usd_per_doge,
|
||||
quote_expires_at_ms=quote_expires_at_ms,
|
||||
confirmations_required=confirmations_required,
|
||||
|
|
@ -549,7 +557,7 @@ 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, coin_type)
|
||||
get_user_crypto_refund_address(request.dbsession, request.user, request.shop, coin_type)
|
||||
if request.user
|
||||
else None
|
||||
)
|
||||
|
|
@ -572,31 +580,18 @@ def crypto_quote(request):
|
|||
crypto_payment.expected_amount / coin_info["smallest_unit_divisor"]
|
||||
)
|
||||
|
||||
# Estimate base amount and fee (this is approximate since we don't store it separately)
|
||||
settings = request.registry.settings
|
||||
|
||||
# Calculate the fee the same way it was calculated during payment creation
|
||||
if coin_type == "XMR" and crypto_payment.shop_sweep_to_address:
|
||||
# Calculate base amount from the total
|
||||
base_amount_crypto = usd_total / float(crypto_payment.rate_locked_usd_per_coin)
|
||||
base_amount_piconero = int(
|
||||
base_amount_crypto * coin_info["smallest_unit_divisor"]
|
||||
)
|
||||
fee_buffer = estimate_monero_fee_for_quote(
|
||||
settings, crypto_payment.shop_sweep_to_address, base_amount_piconero
|
||||
)
|
||||
elif coin_type == "DOGE":
|
||||
fee_buffer = estimate_dogecoin_fee_for_quote(settings)
|
||||
# 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"]
|
||||
else:
|
||||
# Fallback to settings (but double it since all fees are doubled now)
|
||||
fee_buffer = (
|
||||
float(
|
||||
settings.get(
|
||||
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
|
||||
)
|
||||
# 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"]
|
||||
)
|
||||
* 2
|
||||
)
|
||||
) * 2
|
||||
|
||||
amount_crypto_base = amount_crypto_with_fee - float(fee_buffer)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ def user_crypto_settings_view(request):
|
|||
user = request.user
|
||||
enabled_coins = get_enabled_coins(request)
|
||||
|
||||
# Get all user's crypto addresses
|
||||
# Get all user's crypto addresses for current shop
|
||||
addresses = {}
|
||||
for coin in enabled_coins:
|
||||
addr = get_user_crypto_refund_address(request.dbsession, user, coin)
|
||||
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin)
|
||||
if addr:
|
||||
addresses[coin] = addr
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ 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, 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 +80,7 @@ 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, coin_type)
|
||||
addr_obj = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
|
||||
|
||||
if addr_obj:
|
||||
# Update existing
|
||||
|
|
@ -92,6 +92,7 @@ def user_crypto_settings_update(request):
|
|||
# Create new
|
||||
addr_obj = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=request.shop,
|
||||
coin_type=coin_type,
|
||||
address=address,
|
||||
label=label if label else None,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue