diff --git a/CLAUDE.md b/CLAUDE.md index 76c668d..fd97de3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,13 @@ env/bin/py.test --cov=make_post_sell.models.cart --cov-report=term-missing make_ The SQLite database is located at: `data/make_post_sell.sqlite` +**CRITICAL WARNING**: NEVER delete or remove database files without explicit user permission. The database contains production data and cannot be easily recovered. Always ask before any destructive operations. + +**MANDATORY**: ALWAYS create a backup of the database before any database operations (migrations, schema changes, etc.): +```bash +cp data/make_post_sell.sqlite data/make_post_sell.sqlite.backup-$(date +%Y%m%d-%H%M%S) +``` + Query crypto payments: ```sql SELECT * FROM mps_crypto_payment WHERE id = 'payment-uuid-here'; diff --git a/fix_stuck_refund.sql b/fix_stuck_refund.sql new file mode 100644 index 0000000..b4defe7 --- /dev/null +++ b/fix_stuck_refund.sql @@ -0,0 +1,27 @@ +-- Fix the payment that had a successful refund but missing transaction hash +-- Payment ID: a7d015e7-9adf-11f0-a73e-02dfe05770ee +-- Refund TX: 86797dcc2f1a32a23142199844470b4890d8137421bb1e647608add9783c5e2a +-- Refund amount: 0.00156780318058 XMR = 1567803180 piconero + +UPDATE mps_crypto_payment +SET + refund_tx_hash = '86797dcc2f1a32a23142199844470b4890d8137421bb1e647608add9783c5e2a', + refund_amount = 1567803180, + refund_reason = 'Underpayment: received 0.001722860638 but expected 0.003628767358', + status = 'underpaid-refunded-complete', + updated_timestamp = CAST(strftime('%s', 'now') AS INTEGER) * 1000 +WHERE + id = 'a7d015e7-9adf-11f0-a73e-02dfe05770ee' + AND status = 'underpaid-refunded' + AND refund_tx_hash IS NULL; + +-- Verify the update +SELECT + id, + status, + refund_tx_hash, + refund_amount, + refund_reason, + datetime(updated_timestamp/1000, 'unixepoch', 'localtime') as updated_at +FROM mps_crypto_payment +WHERE id = 'a7d015e7-9adf-11f0-a73e-02dfe05770ee'; \ No newline at end of file diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index e1f7b8b..90aab19 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -69,6 +69,71 @@ def _format_scan_semaphore(coin_type, position_value): return None +def _create_duplicate_payment(original_payment, tx, coin_type): + """ + Create a new payment object for a duplicate transaction. + + This allows tracking each duplicate payment separately with its own + refund transaction and status transitions. + """ + import uuid + import time + from ..models.crypto_payment import CryptoPayment + from ..models.meta import get_coin_config + + # Get coin configuration for amount conversion + coin_config = get_coin_config(coin_type) + atomic_units = coin_config["atomic_units"] + + # Convert transaction amount to atomic units + if 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) + + # Create new payment object for this duplicate + duplicate_payment = CryptoPayment() + duplicate_payment.id = uuid.uuid1() + + # Copy key details from original payment + duplicate_payment.invoice_id = None # No invoice - this is a duplicate + duplicate_payment.user_id = original_payment.user_id + duplicate_payment.shop_id = original_payment.shop_id + duplicate_payment.shop_location_id = original_payment.shop_location_id + + # Copy crypto details + duplicate_payment.coin_type = original_payment.coin_type + duplicate_payment.account_index = original_payment.account_index + duplicate_payment.subaddress_index = original_payment.subaddress_index + duplicate_payment.address = original_payment.address + + # Set amounts - this duplicate payment is the exact amount received + duplicate_payment.expected_amount = ( + tx_amount # What we "expected" (the duplicate amount) + ) + duplicate_payment.received_amount = tx_amount # What we received + + # Set status and confirmations + duplicate_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND + duplicate_payment.current_confirmations = tx.get("confirmations", 0) + duplicate_payment.confirmations_required = original_payment.confirmations_required + + # Set refund address if original has one + duplicate_payment.refund_address = original_payment.refund_address + duplicate_payment.shop_sweep_to_address = original_payment.shop_sweep_to_address + + # Set timestamps + now_ms = int(time.time() * 1000) + duplicate_payment.created_timestamp = now_ms + duplicate_payment.updated_timestamp = now_ms + + # Set expiry (copy from original but mark as immediate for refund processing) + duplicate_payment.expires_at = now_ms # Already "expired" since it's a duplicate + + return duplicate_payment + + def _should_process_late_payment(payment, tx): """Check if a payment should be processed as a late/edge case payment. @@ -97,30 +162,35 @@ def _should_process_late_payment(payment, tx): # 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 & 4: Double payment - payment already confirmed but more funds arrived - if payment.status in [ - CryptoPayment.STATUS_CONFIRMED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED - ] and payment.received_amount > 0: + # Edge Case: Any payment with non-pending status means invalid multiple payment + # ALL multiple payments are invalid - no legitimate double payments exist + if payment.status != CryptoPayment.STATUS_PENDING and tx_amount > 0: current_total = payment.received_amount + tx_amount - logger.info(f"Double payment detected for {payment.status} payment {payment.id}: received {payment.received_amount}, new {tx_amount}, total {current_total}") - if current_total > payment.expected_amount: + logger.info( + f"INVALID multiple payment detected for {payment.status} payment {payment.id}: received {payment.received_amount}, new {tx_amount}, total {current_total}" + ) + + # Log specific scenarios for clarity + if payment.status in [ + CryptoPayment.STATUS_EXPIRED, + CryptoPayment.STATUS_CANCELLED, + ]: + logger.info(f"Late payment to {payment.status} quote {payment.id}") + elif payment.status in [ + CryptoPayment.STATUS_CONFIRMED, + CryptoPayment.STATUS_CONFIRMED_OVERPAID, + CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED, + ]: + logger.info(f"Double payment to completed order {payment.id}") + elif current_total > payment.expected_amount: logger.info( f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}" ) - return True + else: + logger.info( + f"Invalid multiple payment to {payment.status} payment {payment.id}" + ) - # 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) @@ -809,8 +879,9 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment, send_emails=Tru delete_invoice_for_terminal_state(env_request.dbsession, crypto_payment) # Send notification email about the refund - if invoice.user.email: + if invoice.user.email and not crypto_payment.refund_email_sent: # TODO: Send out of stock refund notification email + # When implemented, set: crypto_payment.refund_email_sent = True pass return False # Invoice not finalized @@ -862,18 +933,25 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment, send_emails=Tru # Create a request wrapper with shop's domain context for emails email_request = create_shop_context_request(env_request, crypto_payment) - send_purchase_email( - email_request, - invoice.user.email, - [item.product for item in invoice.line_items], - invoice.total, - ) - send_sale_email( - email_request, - invoice.shop, - [item.product for item in invoice.line_items], - invoice.total, - ) + # Check if purchase email has already been sent + if not crypto_payment.purchase_email_sent: + send_purchase_email( + email_request, + invoice.user.email, + [item.product for item in invoice.line_items], + invoice.total, + ) + crypto_payment.purchase_email_sent = True + + # Check if sales email has already been sent + if not crypto_payment.sales_email_sent: + send_sale_email( + email_request, + invoice.shop, + [item.product for item in invoice.line_items], + invoice.total, + ) + crypto_payment.sales_email_sent = True # Deduct inventory for physical products if a shop location is known if crypto_payment.shop_location: @@ -903,11 +981,79 @@ def process_payment( # Initialize payment rescue if client is available payment_rescue = PaymentRescue(env_request.dbsession, client) if client else None - # Handle expiry + # Handle duplicate payment refunds (special case - always refund with 9% fee) + if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND: + if incoming_transfers and payment_rescue and crypto_payment.refund_address: + total_recv, _, _ = summarize_txs(incoming_transfers) + if total_recv > 0: + coin_config = get_coin_config(crypto_payment.coin_type) + received_crypto = Decimal(total_recv) / coin_config["atomic_units"] + + # Use the user from original payment (find by subaddress) + original_payment = ( + env_request.dbsession.query(CryptoPayment) + .filter( + CryptoPayment.coin_type == crypto_payment.coin_type, + CryptoPayment.account_index == crypto_payment.account_index, + CryptoPayment.subaddress_index + == crypto_payment.subaddress_index, + CryptoPayment.id != crypto_payment.id, # Not this duplicate + CryptoPayment.received_amount > 0, # Has received payment + ) + .first() + ) + + if ( + original_payment + and original_payment.invoice + and original_payment.invoice.user + ): + user = original_payment.invoice.user + refund_details = payment_rescue.handle_expired_payment( + crypto_payment, received_crypto, user + ) + + if refund_details: + # Update refund reason to be more specific + refund_details["reason"] = ( + f"Duplicate payment: received {received_crypto} {crypto_payment.coin_type} to already-paid quote" + ) + + logger.info( + f"Duplicate payment {crypto_payment.id} eligible for refund: {refund_details}" + ) + + result = payment_rescue.execute_refund( + refund_details, crypto_payment + ) + if result["success"]: + logger.info( + f"Refund executed for duplicate payment {crypto_payment.id}: TX {result['tx_hash']}" + ) + # Track the refund details + crypto_payment.refund_amount = int( + refund_details["refund_amount"] + * coin_config["atomic_units"] + ) + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_reason = refund_details["reason"] + else: + logger.error( + f"Refund failed for duplicate payment {crypto_payment.id}: {result['error']}" + ) + else: + logger.warning( + f"Could not find original payment or user for duplicate {crypto_payment.id}" + ) + crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.refund_reason = ( + "Duplicate payment - could not find original user" + ) + return # Early return - duplicate payments don't need further processing + + # Handle expiry - early detection for refund scenario if crypto_payment.is_expired: - crypto_payment.status = CryptoPayment.STATUS_EXPIRED crypto_payment.updated_timestamp = now_ms - env_request.dbsession.add(crypto_payment) # Check if we received funds after expiry and can refund if ( @@ -917,6 +1063,48 @@ def process_payment( ): total_recv, _, _ = summarize_txs(incoming_transfers) if total_recv > 0: + # EARLY DETECTION: Mark for refund immediately (even in mempool) + logger.info( + f"EARLY DETECTION - Late payment to expired quote {crypto_payment.id}: " + f"received {total_recv} atomic units - determining refund scenario" + ) + + # Check if refund address exists to determine final status immediately + if crypto_payment.refund_address: + crypto_payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED + logger.info( + f"Expired payment {crypto_payment.id} marked for refund (will process when confirmed)" + ) + else: + crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.refund_reason = ( + "Late payment - no refund address configured" + ) + # Delete invoice for terminal state + delete_invoice_for_terminal_state( + env_request.dbsession, crypto_payment + ) + logger.info( + f"Expired payment {crypto_payment.id} marked as no-refund (no refund address)" + ) + else: + # No funds received - just mark as expired + crypto_payment.status = CryptoPayment.STATUS_EXPIRED + + env_request.dbsession.add(crypto_payment) + + # Execute refund logic if we have enough confirmations + if ( + incoming_transfers + and crypto_payment.invoice + and crypto_payment.invoice.user + and crypto_payment.status == CryptoPayment.STATUS_EXPIRED_REFUNDED + ): + total_recv, _, early_min_confs = summarize_txs(incoming_transfers) + if ( + early_min_confs >= int(crypto_payment.confirmations_required) + and total_recv > 0 + ): # Check if items are out of stock - if so, do FULL refund is_available, out_of_stock = check_inventory_availability( env_request, crypto_payment @@ -1305,77 +1493,69 @@ def process_payment( f"Auto-sweep failed for confirmed payment {crypto_payment.id}: {e}" ) else: - if ( - crypto_payment.received_amount > 0 - and crypto_payment.status != CryptoPayment.STATUS_RECEIVED - ): - crypto_payment.status = CryptoPayment.STATUS_RECEIVED + # Early detection - as soon as we receive ANY funds, determine the refund scenario + # This gets payments out of the active processing queue immediately + if crypto_payment.received_amount > 0: + # Calculate confirmations for early detection + if incoming_transfers: + _, _, early_min_confs = summarize_txs(incoming_transfers) + else: + early_min_confs = 0 - # Check for underpayment detection and processing - if ( - payment_rescue - and crypto_payment.invoice - and crypto_payment.invoice.user - and crypto_payment.received_amount < crypto_payment.expected_amount - ): - # Underpayment detected - handle immediately if we have enough confirmations, - # or mark for future processing if still in mempool - if min_confs >= int(crypto_payment.confirmations_required): + # Check for underpayment first (most common case) + if ( + payment_rescue + and crypto_payment.invoice + and crypto_payment.invoice.user + and crypto_payment.received_amount < crypto_payment.expected_amount + and crypto_payment.status != CryptoPayment.STATUS_UNDERPAID_REFUNDED + ): + # Underpayment detected - immediately mark for refund (even in mempool) coin_config = get_coin_config(crypto_payment.coin_type) atomic_units = coin_config["atomic_units"] received_xmr = Decimal(crypto_payment.received_amount) / atomic_units expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units + logger.info( + f"EARLY DETECTION - Underpayment for payment {crypto_payment.id}: " + f"received {received_xmr} {crypto_payment.coin_type}, expected {expected_xmr} {crypto_payment.coin_type} " + f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - marking for refund" + ) + # Check if refund address is available if crypto_payment.refund_address: - refund_details = payment_rescue.handle_underpayment( - crypto_payment, - expected_xmr, - received_xmr, - crypto_payment.invoice.user, - ) + # Set status immediately to get out of active queue + crypto_payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED + crypto_payment.refund_reason = f"Underpayment: received {received_xmr} but expected {expected_xmr}" - if refund_details: - logger.info( - f"Underpayment {crypto_payment.id} eligible for refund: {refund_details}" + # Try refund if we have enough confirmations, otherwise wait + if early_min_confs >= int(crypto_payment.confirmations_required): + refund_details = payment_rescue.handle_underpayment( + crypto_payment, + expected_xmr, + received_xmr, + crypto_payment.invoice.user, ) - # Set status to underpaid-refunded immediately when refund is determined - crypto_payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED - - result = payment_rescue.execute_refund( - refund_details, crypto_payment - ) - if result["success"]: - logger.info( - f"Refund executed for underpayment {crypto_payment.id}: TX {result['tx_hash']}" + if refund_details: + result = payment_rescue.execute_refund( + refund_details, crypto_payment ) - # Status already set above - # Track the refund details - crypto_payment.refund_amount = int( - refund_details["refund_amount"] - * coin_config["atomic_units"] - ) - crypto_payment.refund_tx_hash = result["tx_hash"] - crypto_payment.refund_reason = refund_details["reason"] - else: - logger.error( - f"Refund failed for underpayment {crypto_payment.id}: {result['error']}" - ) - # Status remains underpaid-refunded, refund will be retried later - else: - # Refund address exists but refund not possible for other reasons - logger.warning( - f"Underpayment {crypto_payment.id} has refund address but refund not possible" - ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND - crypto_payment.refund_reason = ( - "Underpayment - refund conditions not met" - ) - # Delete invoice for terminal state - delete_invoice_for_terminal_state( - env_request.dbsession, crypto_payment - ) + if result["success"]: + logger.info( + f"Refund executed for underpayment {crypto_payment.id}: TX {result['tx_hash']}" + ) + # Track the refund details + crypto_payment.refund_amount = int( + refund_details["refund_amount"] + * coin_config["atomic_units"] + ) + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_reason = refund_details["reason"] + else: + logger.error( + f"Refund failed for underpayment {crypto_payment.id}: {result['error']}" + ) else: # No refund possible - no refund address configured logger.warning( @@ -1389,17 +1569,39 @@ def process_payment( delete_invoice_for_terminal_state( env_request.dbsession, crypto_payment ) - else: - # Underpayment detected but not enough confirmations yet - log and wait - coin_config = get_coin_config(crypto_payment.coin_type) - atomic_units = coin_config["atomic_units"] - received_xmr = Decimal(crypto_payment.received_amount) / atomic_units - expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units - logger.info( - f"Underpayment detected for payment {crypto_payment.id}: " - f"received {received_xmr} {crypto_payment.coin_type}, expected {expected_xmr} {crypto_payment.coin_type} " - f"({min_confs}/{crypto_payment.confirmations_required} confirmations) - waiting for more confirmations" - ) + + # Check for overpayment (exact match or overpaid) + elif ( + crypto_payment.received_amount >= crypto_payment.expected_amount + and crypto_payment.status != CryptoPayment.STATUS_RECEIVED + ): + crypto_payment.status = CryptoPayment.STATUS_RECEIVED + + # If this will become confirmed, check for overpayment scenario immediately + if early_min_confs >= int(crypto_payment.confirmations_required): + # Already confirmed logic will handle this below + pass + else: + # Just received, check if it's an overpayment for early detection + if crypto_payment.received_amount > crypto_payment.expected_amount: + coin_config = get_coin_config(crypto_payment.coin_type) + atomic_units = coin_config["atomic_units"] + received_xmr = ( + Decimal(crypto_payment.received_amount) / atomic_units + ) + expected_xmr = ( + Decimal(crypto_payment.expected_amount) / atomic_units + ) + + logger.info( + f"EARLY DETECTION - Overpayment for payment {crypto_payment.id}: " + f"received {received_xmr} {crypto_payment.coin_type}, expected {expected_xmr} {crypto_payment.coin_type} " + f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - will refund excess when confirmed" + ) + + # Legacy case - payment already marked as received + elif crypto_payment.status != CryptoPayment.STATUS_RECEIVED: + crypto_payment.status = CryptoPayment.STATUS_RECEIVED # Note: Auto-sweep is now handled immediately when payment is confirmed (based on RPC confirmation data) @@ -1568,6 +1770,12 @@ def process_refund_confirmations(request, settings): logger.info( f"REFUND FULLY CONFIRMED - Out-of-stock refund for payment {payment.id}: {old_status} → {payment.status} (refund TX: {payment.refund_tx_hash[:16]}... with {confirmations} confirmations)" ) + elif payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND: + payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE + # No invoice to delete - duplicate payments don't have invoices + logger.info( + f"REFUND FULLY CONFIRMED - Double payment refund for payment {payment.id}: {old_status} → {payment.status} (refund TX: {payment.refund_tx_hash[:16]}... with {confirmations} confirmations)" + ) else: # Fallback for any other status logger.info( @@ -1758,14 +1966,40 @@ def scan_wallet_for_late_payments(request, settings): 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, height {tx.get('height', 0)}" - ) - # Process this late payment - incoming = [tx] # Process just this transfer - process_payment(request, payment, incoming, client=client) + + # Check if this is a duplicate payment (original already has funds) + if payment.received_amount > 0: + logger.info( + f"DUPLICATE payment detected for {payment.status} quote {payment.id}: " + f"{tx.get('amount', 0) / 1e12:.12f} XMR, " + f"{tx.get('confirmations', 0)} confirmations, height {tx.get('height', 0)}" + ) + + # Create new payment object for this duplicate + duplicate_payment = _create_duplicate_payment( + payment, tx, coin_type + ) + db.add(duplicate_payment) + db.flush() # Get the ID assigned + + logger.info( + f"Created duplicate payment {duplicate_payment.id} for refund processing" + ) + + # Process the duplicate payment through refund pipeline + incoming = [tx] + process_payment( + request, duplicate_payment, incoming, client=client + ) + else: + # First payment to this address - process normally + 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, height {tx.get('height', 0)}" + ) + incoming = [tx] + 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 diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index fe9aefa..af3660e 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -9,6 +9,7 @@ from sqlalchemy import ( Numeric, UnicodeText, Unicode, + Boolean, ) from sqlalchemy.orm import relationship @@ -37,6 +38,8 @@ class CryptoPayment(RBase, Base): STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE = "out-of-stock-refunded-complete" STATUS_NO_REFUND = "no-refund" STATUS_NO_REFUND_COMPLETE = "no-refund-complete" + STATUS_DOUBLEPAY_REFUND = "doublepay-refund" + STATUS_DOUBLEPAY_REFUND_COMPLETE = "doublepay-refund-complete" # Active statuses that should be processed by the watcher ACTIVE_STATUSES = [ @@ -52,6 +55,7 @@ class CryptoPayment(RBase, Base): STATUS_EXPIRED_REFUNDED, # Refund sent, but may need more incoming confirmations STATUS_UNDERPAID_REFUNDED, # Refund sent, but may need more incoming confirmations STATUS_OUT_OF_STOCK_REFUNDED, # Refund sent, but may need more incoming confirmations + STATUS_DOUBLEPAY_REFUND, # Double payment refund sent, needs confirmation ] # Terminal statuses that should not be processed @@ -64,6 +68,7 @@ class CryptoPayment(RBase, Base): STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, STATUS_NO_REFUND, STATUS_NO_REFUND_COMPLETE, + STATUS_DOUBLEPAY_REFUND_COMPLETE, ] # Statuses that trigger redirect to crypto quotes history (refund/no-refund scenarios) @@ -76,6 +81,8 @@ class CryptoPayment(RBase, Base): STATUS_OUT_OF_STOCK_REFUNDED, # Out of stock - full refund (no fee) STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, # Out of stock refund confirmed STATUS_NO_REFUND, # No refund possible (no address configured) + STATUS_DOUBLEPAY_REFUND, # Double payment refunded with 9% fee + STATUS_DOUBLEPAY_REFUND_COMPLETE, # Double payment refund confirmed ] id = Column(UUIDType, primary_key=True, index=True) @@ -154,6 +161,17 @@ class CryptoPayment(RBase, Base): Integer, nullable=False, default=0 ) # Current number of confirmations + # Email tracking to prevent duplicates + sales_email_sent = Column( + Boolean, nullable=False, default=False + ) # Boolean: whether sale confirmation email has been sent + purchase_email_sent = Column( + Boolean, nullable=False, default=False + ) # Boolean: whether purchase confirmation email has been sent + refund_email_sent = Column( + Boolean, nullable=False, default=False + ) # Boolean: whether refund notification email has been sent + created_timestamp = Column(BigInteger, nullable=False) updated_timestamp = Column(BigInteger, nullable=False) diff --git a/make_post_sell/scripts/alembic/versions/07908c8c840d_add_email_tracking_columns_to_crypto_.py b/make_post_sell/scripts/alembic/versions/07908c8c840d_add_email_tracking_columns_to_crypto_.py new file mode 100644 index 0000000..2f2a2a2 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/07908c8c840d_add_email_tracking_columns_to_crypto_.py @@ -0,0 +1,42 @@ +"""add_email_tracking_columns_to_crypto_payment + +Revision ID: 07908c8c840d +Revises: 0f59018f6537 +Create Date: 2025-09-26 13:35:10.443559 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "07908c8c840d" +down_revision = "0f59018f6537" +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +def upgrade(): + # Add email tracking columns to prevent duplicate emails + op.add_column( + "mps_crypto_payment", + sa.Column("sales_email_sent", sa.Boolean(), nullable=False, default=False), + ) + op.add_column( + "mps_crypto_payment", + sa.Column("purchase_email_sent", sa.Boolean(), nullable=False, default=False), + ) + op.add_column( + "mps_crypto_payment", + sa.Column("refund_email_sent", sa.Boolean(), nullable=False, default=False), + ) + + +def downgrade(): + # Remove email tracking columns + op.drop_column("mps_crypto_payment", "refund_email_sent") + op.drop_column("mps_crypto_payment", "purchase_email_sent") + op.drop_column("mps_crypto_payment", "sales_email_sent") diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index bcc5090..a3f640b 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -1109,6 +1109,7 @@ class PassiveMonitoringTests(unittest.TestCase): mock_payment.id = "payment_123" mock_payment.status = CryptoPayment.STATUS_EXPIRED mock_payment.coin_type = "XMR" + mock_payment.received_amount = 0 # No previous funds received # Configure query chain - need to handle both CryptoProcessor and CryptoPayment queries def query_side_effect(model): @@ -1237,6 +1238,7 @@ class PassiveMonitoringTests(unittest.TestCase): mock_payment.id = "payment_456" mock_payment.status = CryptoPayment.STATUS_CANCELLED mock_payment.coin_type = "XMR" + mock_payment.received_amount = 0 # No previous funds received # Configure query chain def query_side_effect(model): diff --git a/make_post_sell/tests/test_invoice_deletion.py b/make_post_sell/tests/test_invoice_deletion.py index 3bfcc60..bab8112 100644 --- a/make_post_sell/tests/test_invoice_deletion.py +++ b/make_post_sell/tests/test_invoice_deletion.py @@ -217,6 +217,7 @@ class TestInvoiceDeletion(unittest.TestCase): CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, CryptoPayment.STATUS_NO_REFUND, CryptoPayment.STATUS_NO_REFUND_COMPLETE, + CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, } self.assertEqual(terminal_states_that_should_delete, expected_delete_states)