diff --git a/.gitignore b/.gitignore index 8d55972..7803749 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ caddy monero-wallet-cli.log monero-wallet-rpc.log +monero-wallet-rpc.log* diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 3f53b97..c0b81de 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -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): diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index b180cc7..fe9aefa 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -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 diff --git a/make_post_sell/models/crypto_processor.py b/make_post_sell/models/crypto_processor.py index b8d7e1c..3a8279e 100644 --- a/make_post_sell/models/crypto_processor.py +++ b/make_post_sell/models/crypto_processor.py @@ -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) diff --git a/make_post_sell/models/user_crypto_refund_address.py b/make_post_sell/models/user_crypto_refund_address.py index 3909576..8f5244f 100644 --- a/make_post_sell/models/user_crypto_refund_address.py +++ b/make_post_sell/models/user_crypto_refund_address.py @@ -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() diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index d7f78c8..2a9f393 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -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 diff --git a/make_post_sell/scripts/alembic/versions/5997b993482e_add_indexes_for_crypto_payment_address_.py b/make_post_sell/scripts/alembic/versions/5997b993482e_add_indexes_for_crypto_payment_address_.py deleted file mode 100644 index fdfefb6..0000000 --- a/make_post_sell/scripts/alembic/versions/5997b993482e_add_indexes_for_crypto_payment_address_.py +++ /dev/null @@ -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") diff --git a/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py b/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py deleted file mode 100644 index a445423..0000000 --- a/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py +++ /dev/null @@ -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") diff --git a/make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py b/make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py deleted file mode 100644 index f41b0d6..0000000 --- a/make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py +++ /dev/null @@ -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 - ) diff --git a/make_post_sell/templates/actions_new.j2 b/make_post_sell/templates/actions_new.j2 index 4b34606..2e396e2 100644 --- a/make_post_sell/templates/actions_new.j2 +++ b/make_post_sell/templates/actions_new.j2 @@ -34,6 +34,16 @@ + New Shop {% endif %} + {% else %} + + {% if request.is_saas_domain %} +
Create your first shop to get started!
+No shop configured for this domain.
+ {% endif %} + {% endif %} diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index c0eebc1..74eefb7 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -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): diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index a439088..3cd0ffd 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -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) diff --git a/make_post_sell/views/user_crypto_settings.py b/make_post_sell/views/user_crypto_settings.py index 10f036e..c62fbcb 100644 --- a/make_post_sell/views/user_crypto_settings.py +++ b/make_post_sell/views/user_crypto_settings.py @@ -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,