diff --git a/docs/crypto-payments-state-machine.md b/docs/crypto-payments-state-machine.md new file mode 100644 index 0000000..fa34ebe --- /dev/null +++ b/docs/crypto-payments-state-machine.md @@ -0,0 +1,150 @@ +# Crypto Payments State Machine + +This document visualizes the complete state machine for cryptocurrency payments in the make-post-sell system. + +## State Machine Diagram + +```mermaid +stateDiagram-v2 + [*] --> pending + + %% Main payment flow + pending --> received : Payment detected + pending --> expired : Payment timeout + pending --> cancelled : User cancellation + + %% From received state - multiple possible outcomes + received --> confirmed : Sufficient payment + confirmations + received --> confirmed_overpay : Overpayment detected + received --> expired : Payment timeout + received --> underpaid_refunded : Underpayment detected + received --> doublepay_refund : Duplicate payment detected + received --> out_of_stock_refunded : Product unavailable + + %% Successful payment paths + confirmed --> [*] : ✓ Terminal Success + + %% Overpayment refund flow + confirmed_overpay --> confirmed_overpay_refunded : Initiate refund + confirmed_overpay_refunded --> confirmed_overpay_refunded_complete : Refund confirmed + confirmed_overpay_refunded --> confirmed_overpay_not_refunded : No refund wallet configured + confirmed_overpay_refunded_complete --> [*] : ✓ Terminal Success + confirmed_overpay_not_refunded --> [*] : ✓ Terminal (Not Refunded) + + %% Expired payment handling (terminal - late payments create new objects) + expired --> [*] : ✓ Terminal (Expired) + latepay_refunded --> latepay_refunded_complete : Refund confirmed + latepay_refunded --> latepay_not_refunded : No refund wallet configured + latepay_refunded_complete --> [*] : ✓ Terminal Success + latepay_not_refunded --> [*] : ✓ Terminal (Not Refunded) + + %% Underpayment refund flow + underpaid_refunded --> underpaid_refunded_complete : Refund confirmed + underpaid_refunded --> underpaid_not_refunded : No refund wallet configured + underpaid_refunded_complete --> [*] : ✓ Terminal Success + underpaid_not_refunded --> [*] : ✓ Terminal (Not Refunded) + + %% Out of stock refund flow + out_of_stock_refunded --> out_of_stock_refunded_complete : Refund confirmed + out_of_stock_refunded --> out_of_stock_not_refunded : No refund wallet configured + out_of_stock_refunded_complete --> [*] : ✓ Terminal Success + out_of_stock_not_refunded --> [*] : ✓ Terminal (Not Refunded) + + %% Double payment refund flow + doublepay_refund --> doublepay_refund_complete : Refund confirmed + doublepay_refund --> doublepay_not_refunded : No refund wallet configured + doublepay_refund_complete --> [*] : ✓ Terminal Success + doublepay_not_refunded --> [*] : ✓ Terminal (Not Refunded) + + + %% User cancellation (always terminal, only from pending) + cancelled --> [*] : ✓ Terminal (Cancelled) + + %% Style the states by category + classDef successState fill:#d4edda,stroke:#155724,color:#155724 + classDef refundState fill:#fff3cd,stroke:#856404,color:#856404 + classDef terminalState fill:#f8d7da,stroke:#721c24,color:#721c24 + classDef processingState fill:#cce5ff,stroke:#004085,color:#004085 + + class confirmed,confirmed_overpay_refunded_complete,latepay_refunded_complete,underpaid_refunded_complete,out_of_stock_refunded_complete,doublepay_refund_complete successState + class confirmed_overpay_refunded,latepay_refunded,underpaid_refunded,out_of_stock_refunded,doublepay_refund refundState + class cancelled terminalState + class confirmed_overpay_not_refunded,latepay_not_refunded,underpaid_not_refunded,out_of_stock_not_refunded,doublepay_not_refunded successState + class pending,received,confirmed_overpay,expired processingState +``` + +## State Categories + +### 🟢 **Success States** (Green) +- `confirmed` - Payment successful, product delivered +- `*-refunded-complete` - Refund successfully processed +- `*-not-refunded` - Terminal success (no refund wallet configured) + +### 🟡 **Refund Processing States** (Yellow) +- `*-refunded` - Refund transaction sent, awaiting confirmation +- These are intermediate states in refund workflows + +### 🔴 **Terminal States** (Red) +- `cancelled` - User cancelled (only from pending) + +### 🔵 **Processing States** (Blue) +- `pending` - Initial state, awaiting payment +- `received` - Payment detected, processing +- `confirmed-overpay` - Overpayment detected, deciding action +- `expired` - Payment window closed (terminal) + +## Key State Machine Properties + +### **Priority-Based Processing** +1. **Priority 0 (Highest)**: Refund operations (`*-refunded` states) +2. **Priority 1**: Confirmation monitoring (`received`, `confirmed-overpay`) +3. **Priority 2**: Other processing states +4. **Priority 3**: Auto-sweep operations (`confirmed`) +5. **Priority 4 (Lowest)**: Restocking fee sweeps (`*-refunded-complete`) + +### **Terminal States** +States with no outgoing transitions (payment lifecycle complete): +- `confirmed` ✅ Success +- `expired` ⏰ Payment window closed (late payments create new objects) +- `cancelled` ❌ User cancelled (only from pending) +- `*-refunded-complete` ✅ Refunded +- `*-not-refunded` ⚠️ No refund wallet configured + +### **Business Logic Flows** + +#### **Normal Payment** +`pending` → `received` → `confirmed` ✅ + +#### **Overpayment** +`pending` → `received` → `confirmed-overpay` → `confirmed-overpay-refunded` → `confirmed-overpay-refunded-complete` ✅ + +#### **Late Payment** +`pending` → `expired` → *(new payment object)* → `latepay-refunded` → `latepay-refunded-complete` ✅ + +#### **Underpayment** +`pending` → `received` → `underpaid-refunded` → `underpaid-refunded-complete` ✅ + +#### **Duplicate Payment** +`pending` → `received` → `doublepay-refund` → `doublepay-refund-complete` ✅ + +#### **Out of Stock** +`pending` → `received` → `out-of-stock-refunded` → `out-of-stock-refunded-complete` ✅ + +## State Transition Validation + +All state transitions are validated through the `CryptoPayment.is_valid_transition()` method. Invalid transitions are logged and rejected to maintain system integrity. + +The complete test suite in `test_crypto_payment_transitions.py` validates: +- ✅ All valid transitions work correctly +- ❌ Invalid transitions are properly rejected +- 🔄 Priority system assignments are consistent +- 📊 Graph analysis confirms state machine integrity +- 🧪 Property-based testing validates system behavior +- 🛣️ All payment lifecycles are valid and complete + +## Implementation Files + +- **Model**: `make_post_sell/models/crypto_payment.py:334` - `VALID_TRANSITIONS` dict +- **Tests**: `make_post_sell/tests/test_crypto_payment_transitions.py` - Comprehensive validation +- **Views**: `make_post_sell/views/crypto.py` - Status display logic +- **Watcher**: `make_post_sell/lib/crypto_watcher.py` - State processing engine \ No newline at end of file diff --git a/fix_crypto_payment_statuses.sql b/fix_crypto_payment_statuses.sql new file mode 100644 index 0000000..cdee1ac --- /dev/null +++ b/fix_crypto_payment_statuses.sql @@ -0,0 +1,51 @@ +-- SQL Migration Script: Update Crypto Payment Status Names +-- Renames state values in mps_crypto_payment table to match new code constants +-- Run this before deploying the updated codebase + +BEGIN TRANSACTION; + +-- Update confirmed-overpaid states to confirmed-overpay +UPDATE mps_crypto_payment +SET status = 'confirmed-overpay' +WHERE status = 'confirmed-overpaid'; + +UPDATE mps_crypto_payment +SET status = 'confirmed-overpay-refunded' +WHERE status = 'confirmed-overpaid-refunded'; + +UPDATE mps_crypto_payment +SET status = 'confirmed-overpay-refunded-complete' +WHERE status = 'confirmed-overpaid-refunded-complete'; + +-- Update expired-refunded states to latepay-refunded +UPDATE mps_crypto_payment +SET status = 'latepay-refunded' +WHERE status = 'expired-refunded'; + +UPDATE mps_crypto_payment +SET status = 'latepay-refunded-complete' +WHERE status = 'expired-refunded-complete'; + +-- Update any not-refunded states (if they exist) +UPDATE mps_crypto_payment +SET status = 'confirmed-overpay-not-refunded' +WHERE status = 'confirmed-overpaid-not-refunded'; + +UPDATE mps_crypto_payment +SET status = 'latepay-not-refunded' +WHERE status = 'expired-not-refunded'; + +-- Verify the changes +SELECT 'Updated status counts:' as info; +SELECT status, COUNT(*) as count +FROM mps_crypto_payment +GROUP BY status +ORDER BY status; + +-- Commit the transaction +COMMIT; + +-- Instructions: +-- 1. Backup your database first: cp data/make_post_sell.sqlite data/make_post_sell.sqlite.backup-$(date +%Y%m%d-%H%M%S) +-- 2. Run this script: sqlite3 data/make_post_sell.sqlite < fix_crypto_payment_statuses.sql +-- 3. Verify results by checking the status counts above \ 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 1f4aa3f..78b3815 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -243,9 +243,9 @@ def _should_process_late_payment(payment, tx): 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, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, + CryptoPayment.STATUS_CONFIRMED_OVERPAY, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, ]: logger.info(f"Double payment to completed order {payment.id}") elif current_total > payment.expected_amount: @@ -296,25 +296,18 @@ COIN_CONFIGS = { def delete_invoice_for_terminal_state(dbsession, crypto_payment): """ - Delete the invoice for terminal states except confirmed ones. - Keep the crypto_payment record for transaction history. + Delete invoices for failed payments, keep invoices for successful payments. - Confirmed statuses that should keep their invoices: - - confirmed - - confirmed-overpaid - - confirmed-overpaid-refunded + Business Logic: + - Successful payments: Customer received product -> Keep invoice + - Failed payments: Customer did not receive product -> Delete invoice """ - # Skip if no invoice or if it's a confirmed payment + # Skip if no invoice if not crypto_payment.invoice: return - # Don't delete invoices for confirmed payments - if crypto_payment.status in [ - CryptoPayment.STATUS_CONFIRMED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, - ]: + # Use semantic helper method for clean business logic + if crypto_payment.should_keep_invoice(): return # Delete invoice for all other terminal states @@ -562,7 +555,7 @@ def process_confirmed_payment(env_request, crypto_payment, client, payment_rescu logger.info( f"Step 2: Processing overpayment refund for {crypto_payment.id}" ) - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY crypto_payment.refund_reason = refund_details["reason"] # Check if refund already processed to make this idempotent @@ -589,14 +582,16 @@ def process_confirmed_payment(env_request, crypto_payment, client, payment_rescu crypto_payment.refund_confirmations = 0 # Note: Restocking fee will be swept after refund confirmation - results["restocking_fee_swept"] = True + results["restocking_fee_swept"] = ( + False # Will happen later when refund is confirmed + ) # Will set final status at end of function else: logger.error( f"Overpayment refund failed for {crypto_payment.id}: {result['error']} - will retry" ) # Don't proceed to auto-sweep if refund failed - keep in overpaid status for retry - results["final_status"] = CryptoPayment.STATUS_CONFIRMED_OVERPAID + results["final_status"] = CryptoPayment.STATUS_CONFIRMED_OVERPAY return results else: # Overpayment within threshold - will confirm normally at end @@ -642,8 +637,8 @@ def process_confirmed_payment(env_request, crypto_payment, client, payment_rescu "success" ): # Overpayment refund completed successfully - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED - results["final_status"] = CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED + results["final_status"] = CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED logger.info( f"Payment {crypto_payment.id} fully processed - overpayment refunded and completed" ) @@ -1437,7 +1432,9 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment, send_emails=Tru swept_amount = 0 if tx_hash: - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED + ) crypto_payment.swept_tx_hash = tx_hash crypto_payment.swept_amount = swept_amount crypto_payment.swept_timestamp = now_timestamp() @@ -1459,10 +1456,10 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment, send_emails=Tru f"Failed to sweep out of stock payment {crypto_payment.id} to shop: {e}" ) else: - logger.warning( - f"Out of stock payment {crypto_payment.id} has no refund address and no shop sweep address" + logger.error( + f"CONFIGURATION ERROR: Out of stock payment {crypto_payment.id} has no refund address and no shop sweep address" ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED # Delete invoice for terminal state delete_invoice_for_terminal_state(env_request.dbsession, crypto_payment) @@ -1775,10 +1772,10 @@ def process_payment( f"Duplicate payment {crypto_payment.id} refund failed - will retry on next cycle" ) else: - logger.warning( - f"Duplicate payment {crypto_payment.id} has no invoice or user - cannot process refund" + logger.error( + f"DATA ERROR: Duplicate payment {crypto_payment.id} has no invoice or user - this should not happen" ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.status = CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED crypto_payment.refund_reason = ( "Duplicate payment - no invoice or user information available" ) @@ -1804,12 +1801,12 @@ def process_payment( # Check if refund address exists to determine final status immediately if crypto_payment.refund_address: - crypto_payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED + crypto_payment.status = CryptoPayment.STATUS_LATEPAY_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.status = CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED crypto_payment.refund_reason = ( "Late payment - no refund address configured" ) @@ -1831,7 +1828,7 @@ def process_payment( incoming_transfers and crypto_payment.invoice and crypto_payment.invoice.user - and crypto_payment.status == CryptoPayment.STATUS_EXPIRED_REFUNDED + and crypto_payment.status == CryptoPayment.STATUS_LATEPAY_REFUNDED ): total_recv, _, early_min_confs = summarize_txs(incoming_transfers) if ( @@ -1943,7 +1940,7 @@ def process_payment( if tx_hash: crypto_payment.status = ( - CryptoPayment.STATUS_NO_REFUND + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED ) crypto_payment.swept_tx_hash = tx_hash crypto_payment.swept_amount = swept_amount @@ -1961,10 +1958,12 @@ def process_payment( f"Failed to sweep expired+out of stock payment {crypto_payment.id}: {e}" ) else: - logger.warning( - f"Expired+out of stock payment {crypto_payment.id} has no refund or sweep address" + logger.error( + f"CONFIGURATION ERROR: Expired+out of stock payment {crypto_payment.id} has no refund or sweep address" + ) + crypto_payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND # Delete invoice for terminal state delete_invoice_for_terminal_state( env_request.dbsession, crypto_payment @@ -1983,7 +1982,7 @@ def process_payment( ) # Set status to expired-refunded immediately when refund is determined - crypto_payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED + crypto_payment.status = CryptoPayment.STATUS_LATEPAY_REFUNDED result = payment_rescue.execute_refund( refund_details, crypto_payment @@ -2107,7 +2106,7 @@ def process_payment( logger.warning( f"Expired payment {crypto_payment.id} has no refund address configured - no refund possible" ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.status = CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED crypto_payment.refund_reason = ( "Expired payment - no refund address configured" ) @@ -2189,6 +2188,9 @@ def process_payment( # For already-processed payments with new transactions: # All new transactions are duplicates txids_to_duplicate = new_txids + # Set new_sum to 0 since all new transactions are duplicates + new_sum = 0 + new_txids = [] # Don't add any txids to the original payment # Create separate payment records for each duplicate transaction # But DON'T process refunds here - let the scanner handle that @@ -2360,7 +2362,7 @@ def process_payment( # Update status to confirmed if crypto_payment.received_amount > crypto_payment.expected_amount: - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY logger.info( f"Payment {crypto_payment.id} overpaid: received {crypto_payment.received_amount}, expected {crypto_payment.expected_amount}" ) @@ -2657,7 +2659,7 @@ def process_payment( logger.warning( f"Underpayment {crypto_payment.id} has no refund address configured - marking as no-refund" ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + crypto_payment.status = CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED crypto_payment.refund_reason = ( "Underpayment - no refund address configured" ) @@ -2707,7 +2709,7 @@ def process_payment( ) # Mark as confirmed with overpayment detected (refund pending) - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY crypto_payment.refund_reason = refund_details["reason"] # Try refund immediately since we have enough confirmations @@ -2720,7 +2722,7 @@ def process_payment( ) # Mark as confirmed with overpayment refunded crypto_payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED ) crypto_payment.refund_tx_hash = result["tx_hash"] crypto_payment.refund_confirmations = 0 # Just sent @@ -2794,10 +2796,10 @@ def process_refund_confirmations(request, settings): .filter( CryptoPayment.status.in_( [ - CryptoPayment.STATUS_EXPIRED_REFUNDED, + CryptoPayment.STATUS_LATEPAY_REFUNDED, CryptoPayment.STATUS_UNDERPAID_REFUNDED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID, # Still pending refund confirmation - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED, # Refund sent, awaiting confirmation + CryptoPayment.STATUS_CONFIRMED_OVERPAY, # Still pending refund confirmation + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED, # Refund sent, awaiting confirmation CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED, ] ), @@ -2933,9 +2935,9 @@ def process_refund_confirmations(request, settings): old_status = payment.status # Transition to final refunded status - if payment.status == CryptoPayment.STATUS_CONFIRMED_OVERPAID: + if payment.status == CryptoPayment.STATUS_CONFIRMED_OVERPAY: payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE ) logger.info( f"REFUND FULLY CONFIRMED - Overpayment refund for payment {payment.id}: {old_status} → {payment.status} (refund TX: {payment.refund_tx_hash[:16]}... with {confirmations} confirmations)" @@ -2950,10 +2952,10 @@ def process_refund_confirmations(request, settings): ) elif ( payment.status - == CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + == CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED ): payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE ) logger.info( f"REFUND FULLY CONFIRMED - Overpayment refund for payment {payment.id}: {old_status} → {payment.status} (refund TX: {payment.refund_tx_hash[:16]}... with {confirmations} confirmations)" @@ -2966,8 +2968,8 @@ def process_refund_confirmations(request, settings): db, "Confirmed overpayment refund", ) - elif payment.status == CryptoPayment.STATUS_EXPIRED_REFUNDED: - payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE + elif payment.status == CryptoPayment.STATUS_LATEPAY_REFUNDED: + payment.status = CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE # Delete invoice for terminal state delete_invoice_for_terminal_state(db, payment) logger.info( @@ -3671,7 +3673,9 @@ def run_once(env, interval): # CRITICAL: Sort payments by priority - using CryptoPayment.get_processing_priority() # Priority order: 0=refunds, 1=incoming, 2=other, 3=auto-sweep, 4=restocking fees - sorted_payments = sorted(coin_payments, key=lambda p: p.get_processing_priority()) + sorted_payments = sorted( + coin_payments, key=lambda p: p.get_processing_priority() + ) logger.info( f"Processing {coin_type} payments in priority order: duplicate refunds first" ) @@ -3689,47 +3693,6 @@ def run_once(env, interval): ) continue - # Handle immediate transition for no-refund payments - if crypto_payment.status == CryptoPayment.STATUS_NO_REFUND: - logger.info( - f"Transitioning no-refund payment {crypto_payment.id} to final state" - ) - crypto_payment.status = CryptoPayment.STATUS_NO_REFUND_COMPLETE - crypto_payment.updated_timestamp = int(time.time() * 1000) - - # Send notification to shop owners about missing refund address - try: - send_no_refund_shop_notification(env_request, crypto_payment) - logger.info( - f"Sent no-refund shop notification for payment {crypto_payment.id}" - ) - except Exception as e: - logger.error( - f"Failed to send no-refund shop notification for payment {crypto_payment.id}: {e}" - ) - - # Auto-sweep funds to shop owner since they need to handle manual refund - if ( - client - and crypto_payment.shop_sweep_to_address - and not crypto_payment.is_swept - ): - logger.info( - f"No-refund payment {crypto_payment.id} - attempting sweep to shop owner" - ) - try: - sweep_success = auto_sweep_payment( - client, crypto_payment, env_request.dbsession - ) - if sweep_success and crypto_payment.is_swept: - logger.info( - f"No-refund payment {crypto_payment.id} swept successfully to shop owner" - ) - except Exception as e: - logger.error( - f"Auto-sweep failed for no-refund payment {crypto_payment.id}: {e}" - ) - # Delete invoice for terminal state delete_invoice_for_terminal_state(db, crypto_payment) db.add(crypto_payment) diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 9ef372d..53bdfa9 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -354,15 +354,15 @@ def send_refund_email(request, to_email, crypto_payment, refund_details): explanation = "We detected a duplicate payment to an address that was already paid. Your additional payment is being refunded." elif crypto_payment.status in [ - CryptoPayment.STATUS_CONFIRMED_OVERPAID, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED, ]: subject = f"Overpayment Refund - {crypto_payment.coin_type}" explanation = f"You sent {received_amount} {crypto_payment.coin_type} but only {expected_amount} {crypto_payment.coin_type} was required. The excess amount is being refunded." elif crypto_payment.status in [ - CryptoPayment.STATUS_EXPIRED_REFUNDED, - CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_REFUNDED, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, ]: subject = f"Late Payment Refund - {crypto_payment.coin_type}" explanation = "Your payment was received after the quote expired. Since we cannot fulfill your order at the original rate, your payment is being refunded." @@ -383,8 +383,11 @@ def send_refund_email(request, to_email, crypto_payment, refund_details): has_fee = False # Out of stock refunds have NO FEE elif crypto_payment.status in [ - CryptoPayment.STATUS_NO_REFUND, - CryptoPayment.STATUS_NO_REFUND_COMPLETE, + CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED, + CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED, + CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED, ]: subject = f"Payment Issue - No Refund Address - {crypto_payment.coin_type}" explanation = "We were unable to process a refund for your payment because no refund address was configured. Please contact support if you need assistance." diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index 4f6ec16..5302dcd 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -26,67 +26,117 @@ class CryptoPayment(RBase, Base): STATUS_PENDING = "pending" STATUS_RECEIVED = "received" STATUS_CONFIRMED = "confirmed" - STATUS_CONFIRMED_OVERPAID = "confirmed-overpaid" - STATUS_CONFIRMED_OVERPAID_REFUNDED = "confirmed-overpaid-refunded" - STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE = "confirmed-overpaid-refunded-complete" + STATUS_CONFIRMED_OVERPAY = "confirmed-overpay" + STATUS_CONFIRMED_OVERPAY_REFUNDED = "confirmed-overpay-refunded" + STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE = "confirmed-overpay-refunded-complete" STATUS_EXPIRED = "expired" - STATUS_EXPIRED_REFUNDED = "expired-refunded" - STATUS_EXPIRED_REFUNDED_COMPLETE = "expired-refunded-complete" + STATUS_LATEPAY_REFUNDED = "latepay-refunded" + STATUS_LATEPAY_REFUNDED_COMPLETE = "latepay-refunded-complete" STATUS_UNDERPAID_REFUNDED = "underpaid-refunded" STATUS_UNDERPAID_REFUNDED_COMPLETE = "underpaid-refunded-complete" STATUS_CANCELLED = "cancelled" STATUS_OUT_OF_STOCK_REFUNDED = "out-of-stock-refunded" 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" + # Not-refunded states (when no refund wallet configured) + STATUS_LATEPAY_NOT_REFUNDED = "latepay-not-refunded" + STATUS_UNDERPAID_NOT_REFUNDED = "underpaid-not-refunded" + STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED = "confirmed-overpay-not-refunded" + STATUS_OUT_OF_STOCK_NOT_REFUNDED = "out-of-stock-not-refunded" + STATUS_DOUBLEPAY_NOT_REFUNDED = "doublepay-not-refunded" + + # === SEMANTIC STATE GROUPS === + # These groups define business logic categories for easier maintenance + + # Successful payment statuses - customer received product, keep invoice + SUCCESSFUL_PAYMENT_STATUSES = [ + STATUS_CONFIRMED, # Normal successful payment + STATUS_CONFIRMED_OVERPAY, # Overpaid but confirmed, refund pending + STATUS_CONFIRMED_OVERPAY_REFUNDED, # Overpaid, refund in progress + STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, # Overpaid, refund complete + ] + + # Failed payment statuses - customer did not receive product, delete invoice + FAILED_PAYMENT_STATUSES = [ + STATUS_EXPIRED, # Payment window expired + STATUS_CANCELLED, # User cancelled payment + STATUS_LATEPAY_REFUNDED, # Late payment, refund in progress + STATUS_LATEPAY_REFUNDED_COMPLETE, # Late payment, refund complete + STATUS_LATEPAY_NOT_REFUNDED, # Late payment, no refund wallet + STATUS_UNDERPAID_REFUNDED, # Insufficient payment, refund in progress + STATUS_UNDERPAID_REFUNDED_COMPLETE, # Insufficient payment, refund complete + STATUS_UNDERPAID_NOT_REFUNDED, # Insufficient payment, no refund wallet + STATUS_OUT_OF_STOCK_REFUNDED, # Product unavailable, refund in progress + STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, # Product unavailable, refund complete + STATUS_OUT_OF_STOCK_NOT_REFUNDED, # Product unavailable, no refund wallet + STATUS_DOUBLEPAY_REFUND, # Duplicate payment, refund in progress + STATUS_DOUBLEPAY_REFUND_COMPLETE, # Duplicate payment, refund complete + STATUS_DOUBLEPAY_NOT_REFUNDED, # Duplicate payment, no refund wallet + STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, # Overpaid but no refund wallet configured + ] + + # === OPERATIONAL STATE GROUPS === + # These groups define processing behavior for the crypto watcher + # Active statuses that should be processed by the watcher ACTIVE_STATUSES = [ STATUS_PENDING, STATUS_RECEIVED, STATUS_CONFIRMED, - STATUS_CONFIRMED_OVERPAID, - STATUS_NO_REFUND, + STATUS_CONFIRMED_OVERPAY, STATUS_DOUBLEPAY_REFUND, # Double payment that needs refund processing ] # Statuses that need confirmation monitoring but may have refunds pending REFUND_PENDING_STATUSES = [ - STATUS_EXPIRED_REFUNDED, # Refund sent, but may need more incoming confirmations + STATUS_LATEPAY_REFUNDED, # Refund sent, but may need more incoming confirmations STATUS_UNDERPAID_REFUNDED, # Refund sent, but may need more incoming confirmations - STATUS_CONFIRMED_OVERPAID_REFUNDED, # Overpayment refund sent, needs confirmation monitoring + STATUS_CONFIRMED_OVERPAY_REFUNDED, # Overpayment refund sent, needs confirmation monitoring STATUS_OUT_OF_STOCK_REFUNDED, # Refund sent, but may need more incoming confirmations # STATUS_DOUBLEPAY_REFUND moved to ACTIVE_STATUSES - needs refund initiation, not monitoring ] - # Terminal statuses that should not be processed + # Terminal statuses that should not be processed (have no outgoing transitions) TERMINAL_STATUSES = [ - STATUS_EXPIRED, - STATUS_EXPIRED_REFUNDED_COMPLETE, - STATUS_UNDERPAID_REFUNDED_COMPLETE, - STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, - STATUS_CANCELLED, - STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, - STATUS_NO_REFUND, - STATUS_NO_REFUND_COMPLETE, - STATUS_DOUBLEPAY_REFUND_COMPLETE, + # Successful terminal states + STATUS_CONFIRMED, # Normal successful payment + STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, # Overpaid, refund complete + # Failed terminal states + STATUS_EXPIRED, # Payment window expired + STATUS_CANCELLED, # User cancelled payment + STATUS_LATEPAY_REFUNDED_COMPLETE, # Late payment, refund complete + STATUS_LATEPAY_NOT_REFUNDED, # Late payment, no refund wallet + STATUS_UNDERPAID_REFUNDED_COMPLETE, # Insufficient payment, refund complete + STATUS_UNDERPAID_NOT_REFUNDED, # Insufficient payment, no refund wallet + STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, # Product unavailable, refund complete + STATUS_OUT_OF_STOCK_NOT_REFUNDED, # Product unavailable, no refund wallet + STATUS_DOUBLEPAY_REFUND_COMPLETE, # Duplicate payment, refund complete + STATUS_DOUBLEPAY_NOT_REFUNDED, # Duplicate payment, no refund wallet + STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, # Overpaid but no refund wallet configured ] # Statuses that trigger redirect to crypto quotes history (refund/no-refund scenarios) REFUND_REDIRECT_STATUSES = [ - STATUS_EXPIRED_REFUNDED, # Late payment refunded with 9% fee - STATUS_EXPIRED_REFUNDED_COMPLETE, # Late payment refund confirmed - STATUS_UNDERPAID_REFUNDED, # Partial payment refunded with 9% fee - STATUS_UNDERPAID_REFUNDED_COMPLETE, # Partial payment refund confirmed - STATUS_CONFIRMED_OVERPAID_REFUNDED, # Overpayment excess refunded with 9% fee - STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, # Overpayment refund confirmed - 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 + # All failed payment statuses redirect to history + STATUS_EXPIRED, + STATUS_CANCELLED, + STATUS_LATEPAY_REFUNDED, + STATUS_LATEPAY_REFUNDED_COMPLETE, + STATUS_LATEPAY_NOT_REFUNDED, + STATUS_UNDERPAID_REFUNDED, + STATUS_UNDERPAID_REFUNDED_COMPLETE, + STATUS_UNDERPAID_NOT_REFUNDED, + STATUS_OUT_OF_STOCK_REFUNDED, + STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, + STATUS_OUT_OF_STOCK_NOT_REFUNDED, + STATUS_DOUBLEPAY_REFUND, + STATUS_DOUBLEPAY_REFUND_COMPLETE, + STATUS_DOUBLEPAY_NOT_REFUNDED, + STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, + # Overpay refund in progress also redirects + STATUS_CONFIRMED_OVERPAY_REFUNDED, ] id = Column(UUIDType, primary_key=True, index=True) @@ -265,8 +315,8 @@ class CryptoPayment(RBase, Base): # Must be in a status that could potentially be finalized if self.status not in [ self.STATUS_CONFIRMED, - self.STATUS_CONFIRMED_OVERPAID, - self.STATUS_CONFIRMED_OVERPAID_REFUNDED, + self.STATUS_CONFIRMED_OVERPAY, + self.STATUS_CONFIRMED_OVERPAY_REFUNDED, ]: return False @@ -299,7 +349,7 @@ class CryptoPayment(RBase, Base): # Priority 0: Refunds - highest priority (customer service) case ( self.STATUS_DOUBLEPAY_REFUND - | self.STATUS_EXPIRED_REFUNDED + | self.STATUS_LATEPAY_REFUNDED | self.STATUS_UNDERPAID_REFUNDED | self.STATUS_OUT_OF_STOCK_REFUNDED ): @@ -310,18 +360,14 @@ class CryptoPayment(RBase, Base): return 1 # Process new payments # Priority 3: Auto-sweep operations to shop owner - case ( - self.STATUS_CONFIRMED - | self.STATUS_CONFIRMED_OVERPAID - | self.STATUS_NO_REFUND - ): + case self.STATUS_CONFIRMED | self.STATUS_CONFIRMED_OVERPAY: return 3 # Auto-sweep to shop owner # Priority 4: Restocking fee sweeps - most dangerous, dead last case ( - self.STATUS_EXPIRED_REFUNDED_COMPLETE + self.STATUS_LATEPAY_REFUNDED_COMPLETE | self.STATUS_UNDERPAID_REFUNDED_COMPLETE - | self.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE + | self.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE | self.STATUS_DOUBLEPAY_REFUND_COMPLETE ): return 4 # Restocking fee sweeps - most dangerous @@ -335,38 +381,49 @@ class CryptoPayment(RBase, Base): STATUS_PENDING: [STATUS_RECEIVED, STATUS_EXPIRED, STATUS_CANCELLED], STATUS_RECEIVED: [ STATUS_CONFIRMED, - STATUS_CONFIRMED_OVERPAID, + STATUS_CONFIRMED_OVERPAY, STATUS_EXPIRED, - STATUS_CANCELLED, STATUS_UNDERPAID_REFUNDED, # Underpayment detected STATUS_DOUBLEPAY_REFUND, # Duplicate detected STATUS_OUT_OF_STOCK_REFUNDED, # Out of stock ], - STATUS_CONFIRMED: [STATUS_CANCELLED], # Terminal unless cancelled - STATUS_CONFIRMED_OVERPAID: [ - STATUS_CONFIRMED_OVERPAID_REFUNDED, - STATUS_CANCELLED, + STATUS_CONFIRMED: [], # Terminal + STATUS_CONFIRMED_OVERPAY: [ + STATUS_CONFIRMED_OVERPAY_REFUNDED, ], - STATUS_CONFIRMED_OVERPAID_REFUNDED: [ - STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE + STATUS_CONFIRMED_OVERPAY_REFUNDED: [ + STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, + STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, # No refund wallet configured ], - STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE: [], # Terminal - STATUS_EXPIRED: [ - STATUS_EXPIRED_REFUNDED, - STATUS_NO_REFUND, - STATUS_CANCELLED, + STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE: [], # Terminal + STATUS_EXPIRED: [], # Terminal - late payments create new payment objects + STATUS_LATEPAY_REFUNDED: [ + STATUS_LATEPAY_REFUNDED_COMPLETE, + STATUS_LATEPAY_NOT_REFUNDED, # No refund wallet configured + ], + STATUS_LATEPAY_REFUNDED_COMPLETE: [], # Terminal + STATUS_UNDERPAID_REFUNDED: [ + STATUS_UNDERPAID_REFUNDED_COMPLETE, + STATUS_UNDERPAID_NOT_REFUNDED, # No refund wallet configured ], - STATUS_EXPIRED_REFUNDED: [STATUS_EXPIRED_REFUNDED_COMPLETE], - STATUS_EXPIRED_REFUNDED_COMPLETE: [], # Terminal - STATUS_UNDERPAID_REFUNDED: [STATUS_UNDERPAID_REFUNDED_COMPLETE], STATUS_UNDERPAID_REFUNDED_COMPLETE: [], # Terminal STATUS_CANCELLED: [], # Terminal - STATUS_OUT_OF_STOCK_REFUNDED: [STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE], + STATUS_OUT_OF_STOCK_REFUNDED: [ + STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, + STATUS_OUT_OF_STOCK_NOT_REFUNDED, # No refund wallet configured + ], STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE: [], # Terminal - STATUS_NO_REFUND: [STATUS_NO_REFUND_COMPLETE], - STATUS_NO_REFUND_COMPLETE: [], # Terminal - STATUS_DOUBLEPAY_REFUND: [STATUS_DOUBLEPAY_REFUND_COMPLETE], + STATUS_DOUBLEPAY_REFUND: [ + STATUS_DOUBLEPAY_REFUND_COMPLETE, + STATUS_DOUBLEPAY_NOT_REFUNDED, # No refund wallet configured + ], STATUS_DOUBLEPAY_REFUND_COMPLETE: [], # Terminal + # Not-refunded states (terminal) + STATUS_LATEPAY_NOT_REFUNDED: [], # Terminal + STATUS_UNDERPAID_NOT_REFUNDED: [], # Terminal + STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED: [], # Terminal + STATUS_OUT_OF_STOCK_NOT_REFUNDED: [], # Terminal + STATUS_DOUBLEPAY_NOT_REFUNDED: [], # Terminal } def is_valid_transition(self, new_status: str) -> bool: @@ -427,3 +484,32 @@ class CryptoPayment(RBase, Base): list: List of valid next status strings """ return self.VALID_TRANSITIONS.get(self.status, []) + + def is_successful_payment(self) -> bool: + """ + Check if this payment represents a successful transaction. + + Returns: + bool: True if customer received product, False otherwise + """ + return self.status in self.SUCCESSFUL_PAYMENT_STATUSES + + def is_failed_payment(self) -> bool: + """ + Check if this payment represents a failed transaction. + + Returns: + bool: True if customer did not receive product, False otherwise + """ + return self.status in self.FAILED_PAYMENT_STATUSES + + def should_keep_invoice(self) -> bool: + """ + Check if this payment's invoice should be preserved. + + Business Logic: Keep invoices for successful payments. + + Returns: + bool: True if invoice should be kept, False if it should be deleted + """ + return self.is_successful_payment() diff --git a/make_post_sell/tests/test_crypto_payment_transitions.py b/make_post_sell/tests/test_crypto_payment_transitions.py index 3cdb644..9d80fed 100644 --- a/make_post_sell/tests/test_crypto_payment_transitions.py +++ b/make_post_sell/tests/test_crypto_payment_transitions.py @@ -72,10 +72,11 @@ class TestCryptoPaymentTransitions(unittest.TestCase): # Valid transitions self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED)) self.assertTrue( - payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAID) + payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAY) ) self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_EXPIRED)) - self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_CANCELLED)) + # Cancelled is no longer valid from received (only from pending) + self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_CANCELLED)) self.assertTrue( payment.is_valid_transition(CryptoPayment.STATUS_UNDERPAID_REFUNDED) ) @@ -89,7 +90,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase): # Invalid transitions self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_PENDING)) self.assertFalse( - payment.is_valid_transition(CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE) + payment.is_valid_transition(CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE) ) def test_valid_transitions_from_confirmed(self): @@ -97,28 +98,27 @@ class TestCryptoPaymentTransitions(unittest.TestCase): payment = self._create_test_payment() payment.status = CryptoPayment.STATUS_CONFIRMED - # Valid transitions - self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_CANCELLED)) + # Confirmed is now fully terminal - no valid transitions + self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_CANCELLED)) - # Invalid transitions - confirmed is mostly terminal + # Invalid transitions - confirmed is now fully terminal self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_RECEIVED)) self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_EXPIRED)) self.assertFalse( - payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAID) + payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAY) ) def test_valid_transitions_from_confirmed_overpaid(self): - """Test valid transitions from STATUS_CONFIRMED_OVERPAID.""" + """Test valid transitions from STATUS_CONFIRMED_OVERPAY.""" payment = self._create_test_payment() - payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY # Valid transitions self.assertTrue( - payment.is_valid_transition( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED - ) + payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED) ) - self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_CANCELLED)) + # Cancelled is no longer valid from confirmed_overpaid (only from pending) + self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_CANCELLED)) # Invalid transitions self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED)) @@ -144,7 +144,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase): # Verify priority matches expected business logic if status in [ CryptoPayment.STATUS_DOUBLEPAY_REFUND, - CryptoPayment.STATUS_EXPIRED_REFUNDED, + CryptoPayment.STATUS_LATEPAY_REFUNDED, CryptoPayment.STATUS_UNDERPAID_REFUNDED, CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED, ]: @@ -159,17 +159,16 @@ class TestCryptoPaymentTransitions(unittest.TestCase): elif status in [ CryptoPayment.STATUS_CONFIRMED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID, - CryptoPayment.STATUS_NO_REFUND, + CryptoPayment.STATUS_CONFIRMED_OVERPAY, ]: self.assertEqual( priority, 3, f"Auto-sweep status {status} should be Priority 3" ) elif status in [ - CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, ]: self.assertEqual( @@ -178,6 +177,53 @@ class TestCryptoPaymentTransitions(unittest.TestCase): f"Restocking fee status {status} should be Priority 4", ) + def test_new_not_refunded_states(self): + """Test the new *-not-refunded states for when no refund wallet is configured.""" + payment = self._create_test_payment() + + # Test confirmed_overpaid_refunded → confirmed_overpaid_not_refunded + payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED + self.assertTrue( + payment.is_valid_transition( + CryptoPayment.STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED + ) + ) + + # Test underpaid_refunded → underpaid_not_refunded + payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED + self.assertTrue( + payment.is_valid_transition(CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED) + ) + + # Test out_of_stock_refunded → out_of_stock_not_refunded + payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED + self.assertTrue( + payment.is_valid_transition(CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED) + ) + + # Test doublepay_refund → doublepay_not_refund + payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND + self.assertTrue( + payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED) + ) + + # Test that all *_not_refunded states are terminal + terminal_not_refunded_states = [ + CryptoPayment.STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, + CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED, + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED, + CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED, + CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED, + ] + + for state in terminal_not_refunded_states: + payment.status = state + self.assertEqual( + payment.get_valid_next_statuses(), + [], + f"{state} should be terminal with no outgoing transitions", + ) + def test_validate_and_set_status_success(self): """Test successful status validation and setting.""" payment = self._create_test_payment() @@ -237,9 +283,9 @@ class TestCryptoPaymentTransitions(unittest.TestCase): payment.status = CryptoPayment.STATUS_RECEIVED expected = [ CryptoPayment.STATUS_CONFIRMED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID, + CryptoPayment.STATUS_CONFIRMED_OVERPAY, CryptoPayment.STATUS_EXPIRED, - CryptoPayment.STATUS_CANCELLED, + # CryptoPayment.STATUS_CANCELLED, # Only from pending now CryptoPayment.STATUS_UNDERPAID_REFUNDED, CryptoPayment.STATUS_DOUBLEPAY_REFUND, CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED, @@ -284,8 +330,19 @@ class TestCryptoPaymentTransitions(unittest.TestCase): reachable, terminal_paths = find_reachable_states(CryptoPayment.STATUS_PENDING) - # Verify all terminal states are reachable + # Verify all terminal states are reachable from PENDING + # Note: expired_refunded* and expired_not_refunded states are not reachable from original PENDING + # payment since they represent NEW payment objects created for late payments + excluded_from_pending = { + # These states represent separate payment objects for late payments + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED, + } + for terminal in terminal_states: + if terminal in excluded_from_pending: + # These states represent separate payment objects for late payments + continue self.assertIn( terminal, terminal_paths, @@ -371,11 +428,10 @@ class TestCryptoPaymentTransitions(unittest.TestCase): # Terminal states should have no or limited transitions if from_status in [ CryptoPayment.STATUS_CANCELLED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, - CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE, CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, - CryptoPayment.STATUS_NO_REFUND_COMPLETE, CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, ]: self.assertEqual( @@ -399,11 +455,10 @@ class TestCryptoPaymentTransitions(unittest.TestCase): terminal_states = [ CryptoPayment.STATUS_CANCELLED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED_COMPLETE, - CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE, CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, - CryptoPayment.STATUS_NO_REFUND_COMPLETE, CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, ] @@ -570,6 +625,118 @@ class TestCryptoPaymentTransitions(unittest.TestCase): f"Status {status} missing from VALID_TRANSITIONS", ) + def test_semantic_helper_methods(self): + """Test the semantic helper methods with real CryptoPayment objects.""" + # Test successful payment statuses + successful_statuses = [ + CryptoPayment.STATUS_CONFIRMED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, + ] + + for status in successful_statuses: + payment = self._create_test_payment() + payment.status = status + + with self.subTest(status=status): + self.assertTrue( + payment.is_successful_payment(), + f"{status} should be considered successful", + ) + self.assertFalse( + payment.is_failed_payment(), + f"{status} should not be considered failed", + ) + self.assertTrue( + payment.should_keep_invoice(), f"{status} should keep invoice" + ) + + # Test failed payment statuses + failed_statuses = [ + CryptoPayment.STATUS_EXPIRED, + CryptoPayment.STATUS_CANCELLED, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED, + CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE, + CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED, + CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED, + CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, + CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, + ] + + for status in failed_statuses: + payment = self._create_test_payment() + payment.status = status + + with self.subTest(status=status): + self.assertFalse( + payment.is_successful_payment(), + f"{status} should not be considered successful", + ) + self.assertTrue( + payment.is_failed_payment(), f"{status} should be considered failed" + ) + self.assertFalse( + payment.should_keep_invoice(), f"{status} should delete invoice" + ) + + # Test intermediate statuses (neither successful nor failed) + intermediate_statuses = [ + CryptoPayment.STATUS_PENDING, + CryptoPayment.STATUS_RECEIVED, + CryptoPayment.STATUS_LATEPAY_REFUNDED, + CryptoPayment.STATUS_UNDERPAID_REFUNDED, + CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED, + CryptoPayment.STATUS_DOUBLEPAY_REFUND, + ] + + for status in intermediate_statuses: + payment = self._create_test_payment() + payment.status = status + + with self.subTest(status=status): + self.assertFalse( + payment.is_successful_payment(), + f"{status} should not be considered successful (intermediate)", + ) + # These could be in FAILED_PAYMENT_STATUSES or not, depending on business logic + # We'll test what should_keep_invoice returns based on the actual groups + expected_keep = status in CryptoPayment.SUCCESSFUL_PAYMENT_STATUSES + self.assertEqual( + payment.should_keep_invoice(), + expected_keep, + f"{status} invoice handling should match semantic groups", + ) + + def test_semantic_groups_are_mutually_exclusive(self): + """Test that successful and failed payment groups don't overlap.""" + successful_set = set(CryptoPayment.SUCCESSFUL_PAYMENT_STATUSES) + failed_set = set(CryptoPayment.FAILED_PAYMENT_STATUSES) + + # These groups should not overlap + overlap = successful_set & failed_set + self.assertEqual( + overlap, + set(), + f"Successful and failed payment statuses should not overlap: {overlap}", + ) + + def test_terminal_statuses_logic(self): + """Test that terminal status logic is consistent with semantic groups.""" + terminal_set = set(CryptoPayment.TERMINAL_STATUSES) + successful_set = set(CryptoPayment.SUCCESSFUL_PAYMENT_STATUSES) + failed_set = set(CryptoPayment.FAILED_PAYMENT_STATUSES) + + # All terminal statuses should be either successful or failed + for status in terminal_set: + self.assertTrue( + status in successful_set or status in failed_set, + f"Terminal status {status} should be in either successful or failed groups", + ) + def test_state_machine_completeness(self): """Test that the state machine covers all expected scenarios.""" payment = self._create_test_payment() @@ -584,24 +751,21 @@ class TestCryptoPaymentTransitions(unittest.TestCase): # Test overpayment flow payment.status = CryptoPayment.STATUS_RECEIVED self.assertTrue( - payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAID) + payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAY) ) - payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY self.assertTrue( - payment.is_valid_transition( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED - ) + payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED) ) - # Test expiration flow + # Test expiration flow - expired is now terminal (late payments create new objects) payment.status = CryptoPayment.STATUS_PENDING self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_EXPIRED)) + # Expired is now terminal - no transitions out (late payments create new payment objects) payment.status = CryptoPayment.STATUS_EXPIRED - self.assertTrue( - payment.is_valid_transition(CryptoPayment.STATUS_EXPIRED_REFUNDED) - ) + self.assertEqual(payment.get_valid_next_statuses(), []) # Test duplicate payment flow payment.status = CryptoPayment.STATUS_RECEIVED diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 25921ee..a94a892 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -729,8 +729,8 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): mock_client = MagicMock() process_payment(mock_request, payment, transfers, mock_client) - # Payment should be confirmed-overpaid-refunded after successful refund - self.assertEqual(payment.status, "confirmed-overpaid-refunded") + # Payment should be confirmed-overpay-refunded after successful refund + self.assertEqual(payment.status, "confirmed-overpay-refunded") self.assertEqual(payment.received_amount, 150000000000) # Refund should have been executed @@ -807,10 +807,10 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): process_payment(mock_request, payment, transfers, client=mock_client) - # Payment should be expired-refunded (refund succeeded) - # Note: With 10 confirmations, it may immediately transition to expired-refunded-complete + # Payment should be latepay-refunded (refund succeeded) + # Note: With 10 confirmations, it may immediately transition to latepay-refunded-complete self.assertIn( - payment.status, ["expired-refunded", "expired-refunded-complete"] + payment.status, ["latepay-refunded", "latepay-refunded-complete"] ) # Refund should be attempted @@ -2453,6 +2453,9 @@ class SweepRestockingFeeTests(unittest.TestCase): self.mock_xmr_payment.shop_sweep_to_address = "45nBNFrADasdf123456789" self.mock_xmr_payment.account_index = 1 self.mock_xmr_payment.subaddress_index = 5 + self.mock_xmr_payment.refund_confirmations = ( + 25 # Sufficient for XMR (20 required) + ) self.mock_doge_payment = MagicMock() self.mock_doge_payment.id = "test-doge-payment-id" @@ -2460,6 +2463,9 @@ class SweepRestockingFeeTests(unittest.TestCase): self.mock_doge_payment.shop_sweep_to_address = ( "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh" ) + self.mock_doge_payment.refund_confirmations = ( + 15 # Sufficient for DOGE (12 required) + ) # Mock refund details self.refund_details = { @@ -2472,12 +2478,19 @@ class SweepRestockingFeeTests(unittest.TestCase): self.mock_settings = {} self.mock_dbsession = MagicMock() + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") @patch("make_post_sell.lib.crypto_watcher.time.sleep") - def test_sweep_restocking_fee_doge_success(self, mock_sleep, mock_get_client): + def test_sweep_restocking_fee_doge_success( + self, mock_sleep, mock_get_client, mock_get_coin_config + ): """Test successful DOGE restocking fee sweep.""" + # Setup mock DOGE configuration + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE koinu + # Setup mock DOGE client mock_doge_client = MagicMock() + mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" mock_get_client.return_value = mock_doge_client @@ -2499,16 +2512,30 @@ class SweepRestockingFeeTests(unittest.TestCase): # Verify sleep was called (2 second delay) mock_sleep.assert_called_once_with(2) + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") @patch("make_post_sell.lib.crypto_watcher.time.sleep") - def test_sweep_restocking_fee_xmr_success(self, mock_sleep, mock_get_client): + def test_sweep_restocking_fee_xmr_success( + self, mock_sleep, mock_get_client, mock_get_coin_config + ): """Test successful XMR restocking fee sweep.""" + # Setup mock XMR configuration + mock_get_coin_config.return_value = { + "atomic_units": 1000000000000 + } # XMR piconero + # Setup mock XMR client mock_xmr_client = MagicMock() - mock_xmr_client._call.return_value = { - "tx_hash_list": ["xmr_tx_hash_456"], - "amount_list": [360000000000], # 0.36 XMR in piconero - } + # Mock get_balance call for specific subaddress + mock_xmr_client._call.side_effect = [ + { + "per_subaddress": [{"balance": 1000000000000}] + }, # get_balance response (1 XMR) + { + "tx_hash_list": ["xmr_tx_hash_456"], + "amount_list": [360000000000], # 0.36 XMR in piconero + }, # transfer response + ] mock_get_client.return_value = mock_xmr_client # Execute sweep @@ -2522,17 +2549,25 @@ class SweepRestockingFeeTests(unittest.TestCase): # Verify client was called correctly mock_get_client.assert_called_once_with(self.mock_settings, "XMR") - mock_xmr_client._call.assert_called_once_with( - "sweep_all", - { - "account_index": 1, - "subaddr_indices": [5], - "address": "45nBNFrADasdf123456789", - "priority": 1, - "get_tx_hex": True, - "do_not_relay": False, - }, - ) + + # Verify that get_balance was called first + expected_calls = [ + call("get_balance", {"account_index": 1, "address_indices": [5]}), + call( + "transfer", + { + "destinations": [ + {"amount": 360000000000, "address": "45nBNFrADasdf123456789"} + ], + "account_index": 1, + "subaddr_indices": [5], + "priority": 1, + "get_tx_hex": True, + "do_not_relay": False, + }, + ), + ] + mock_xmr_client._call.assert_has_calls(expected_calls) # Verify sleep was called mock_sleep.assert_called_once_with(2) @@ -2578,13 +2613,20 @@ class SweepRestockingFeeTests(unittest.TestCase): # Verify no client calls were made mock_get_client.assert_not_called() + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") - def test_sweep_restocking_fee_unsupported_coin(self, mock_get_client): + def test_sweep_restocking_fee_unsupported_coin( + self, mock_get_client, mock_get_coin_config + ): """Test handling of unsupported coin types.""" + # Setup mock configuration + mock_get_coin_config.return_value = {"atomic_units": 100000000} + unsupported_payment = MagicMock() unsupported_payment.id = "test-unsupported-payment" unsupported_payment.coin_type = "UNSUPPORTED" unsupported_payment.shop_sweep_to_address = "bc1qtest123" + unsupported_payment.refund_confirmations = 25 # Sufficient confirmations with patch( "make_post_sell.lib.crypto_watcher.logging.getLogger" @@ -2608,12 +2650,19 @@ class SweepRestockingFeeTests(unittest.TestCase): # Verify no client calls were made mock_get_client.assert_not_called() + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") @patch("make_post_sell.lib.crypto_watcher.time.sleep") - def test_sweep_restocking_fee_doge_exception(self, mock_sleep, mock_get_client): + def test_sweep_restocking_fee_doge_exception( + self, mock_sleep, mock_get_client, mock_get_coin_config + ): """Test exception handling for DOGE sweep failures.""" + # Setup mock DOGE configuration + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE koinu + # Setup mock DOGE client that throws exception mock_doge_client = MagicMock() + mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance mock_doge_client.sendtoaddress.side_effect = Exception("RPC connection error") mock_get_client.return_value = mock_doge_client @@ -2636,10 +2685,18 @@ class SweepRestockingFeeTests(unittest.TestCase): "Failed to sweep test restocking fee for payment test-doge-payment-id: RPC connection error" ) + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") @patch("make_post_sell.lib.crypto_watcher.time.sleep") - def test_sweep_restocking_fee_xmr_exception(self, mock_sleep, mock_get_client): + def test_sweep_restocking_fee_xmr_exception( + self, mock_sleep, mock_get_client, mock_get_coin_config + ): """Test exception handling for XMR sweep failures.""" + # Setup mock XMR configuration + mock_get_coin_config.return_value = { + "atomic_units": 1000000000000 + } # XMR piconero + # Setup mock XMR client that throws exception mock_xmr_client = MagicMock() mock_xmr_client._call.side_effect = Exception("Wallet locked") @@ -2664,12 +2721,19 @@ class SweepRestockingFeeTests(unittest.TestCase): "Failed to sweep test restocking fee for payment test-xmr-payment-id: Wallet locked" ) + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") @patch("make_post_sell.lib.crypto_watcher.time.sleep") - def test_sweep_restocking_fee_context_logging(self, mock_sleep, mock_get_client): + def test_sweep_restocking_fee_context_logging( + self, mock_sleep, mock_get_client, mock_get_coin_config + ): """Test that context parameter is used correctly in logging.""" + # Setup mock DOGE configuration + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE koinu + # Setup mock DOGE client mock_doge_client = MagicMock() + mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" mock_get_client.return_value = mock_doge_client @@ -2688,13 +2752,18 @@ class SweepRestockingFeeTests(unittest.TestCase): ) # Verify success log includes context - mock_log.info.assert_called_once() - log_call_args = mock_log.info.call_args[0][0] - self.assertIn("Underpayment restocking fee swept", log_call_args) - self.assertIn("test-doge-payment-id", log_call_args) - self.assertIn("0.36", log_call_args) - self.assertIn("DOGE", log_call_args) - self.assertIn("doge_tx_hash_123", log_call_args) + # Check that one of the info calls contains the expected context message + info_calls = [call[0][0] for call in mock_log.info.call_args_list] + context_log_found = False + for log_message in info_calls: + if "Underpayment restocking fee swept" in log_message: + context_log_found = True + self.assertIn("test-doge-payment-id", log_message) + self.assertIn("0.36", log_message) + self.assertIn("DOGE", log_message) + self.assertIn("doge_tx_hash_123", log_message) + break + self.assertTrue(context_log_found, "Expected context log message not found") def test_sweep_restocking_fee_fee_amount_calculation(self): """Test that fee amounts are calculated correctly in atomic units.""" @@ -3290,6 +3359,9 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.mock_xmr_payment.confirmations_required = 10 self.mock_xmr_payment.is_finalized.return_value = False # Mock method self.mock_xmr_payment.refund_tx_hash = None # No refund processed yet + self.mock_xmr_payment.refund_confirmations = ( + 25 # Sufficient for XMR (20 required) + ) # Mock client self.mock_client = MagicMock() @@ -3345,7 +3417,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): def test_overpayment_order_finalize_refund_fee_sweep( self, mock_sweep_fee, mock_get_coin_config, mock_finalize ): - """Test overpayment: 1) finalize, 2) refund excess, 3) sweep fee, 4) auto-sweep invoice amount.""" + """Test overpayment: 1) finalize, 2) refund excess, 3) auto-sweep invoice amount. Fee sweep happens later.""" from make_post_sell.lib.crypto_watcher import process_confirmed_payment from make_post_sell.lib.crypto_payment_rescue import PaymentRescue @@ -3402,14 +3474,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): overpayment_details, self.mock_payment ) - # 3) Sweep restocking fee - mock_sweep_fee.assert_called_once_with( - self.mock_settings, - self.mock_payment, - overpayment_details, - self.mock_env_request.dbsession, - "Overpayment", - ) + # 3) Restocking fee sweep is NOT called immediately - happens later after refund confirmation + mock_sweep_fee.assert_not_called() # 4) Auto-sweep invoice amount (4 DOGE, not entire balance) self.mock_client.sendtoaddress.assert_called_once_with( @@ -3419,12 +3485,14 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Verify results self.assertTrue(result["invoice_finalized"]) self.assertTrue(result["overpayment_refund"]["success"]) - self.assertTrue(result["restocking_fee_swept"]) + self.assertFalse( + result["restocking_fee_swept"] + ) # Fee sweep happens later, not immediately self.assertTrue(result["auto_sweep"]["success"]) from make_post_sell.models.crypto_payment import CryptoPayment self.assertEqual( - result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED ) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @@ -3480,9 +3548,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # 4) Payment should be in "confirmed-overpaid" status (waiting for retry) from make_post_sell.models.crypto_payment import CryptoPayment - self.assertEqual( - result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID - ) + self.assertEqual(result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAY) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @@ -3662,7 +3728,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): def test_xmr_overpayment_order_finalize_refund_fee_sweep( self, mock_sweep_fee, mock_get_coin_config, mock_finalize ): - """Test XMR overpayment: 1) finalize, 2) refund excess, 3) sweep fee, 4) auto-sweep invoice amount.""" + """Test XMR overpayment: 1) finalize, 2) refund excess, 3) auto-sweep invoice amount. Fee sweep happens later.""" from make_post_sell.lib.crypto_watcher import process_confirmed_payment from make_post_sell.lib.crypto_payment_rescue import PaymentRescue @@ -3726,14 +3792,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): overpayment_details, self.mock_xmr_payment ) - # 3) Sweep restocking fee - mock_sweep_fee.assert_called_once_with( - self.mock_settings, - self.mock_xmr_payment, - overpayment_details, - self.mock_env_request.dbsession, - "Overpayment", - ) + # 3) Restocking fee sweep is NOT called immediately - happens later after refund confirmation + mock_sweep_fee.assert_not_called() # 4) Auto-sweep invoice amount (2.0 XMR = 2,000,000,000,000 piconero) transfer_call = self.mock_client._call.call_args_list[1] @@ -3747,12 +3807,14 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Verify results self.assertTrue(result["invoice_finalized"]) self.assertTrue(result["overpayment_refund"]["success"]) - self.assertTrue(result["restocking_fee_swept"]) + self.assertFalse( + result["restocking_fee_swept"] + ) # Fee sweep happens later, not immediately self.assertTrue(result["auto_sweep"]["success"]) from make_post_sell.models.crypto_payment import CryptoPayment self.assertEqual( - result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED ) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @@ -3809,9 +3871,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # 4) Payment should be in "confirmed-overpaid" status (waiting for retry) from make_post_sell.models.crypto_payment import CryptoPayment - self.assertEqual( - result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID - ) + self.assertEqual(result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAY) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @@ -4011,7 +4071,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Verify payment in non-terminal state (can be retried) self.assertEqual( - result1["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID + result1["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAY ) self.assertFalse(result1["overpayment_refund"]["success"]) @@ -4037,7 +4097,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Verify successful completion self.assertTrue(result2["overpayment_refund"]["success"]) self.assertEqual( - result2["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + result2["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED ) # Verify auto-sweep happened (5 DOGE invoice amount) diff --git a/make_post_sell/tests/test_double_spend_integration.py b/make_post_sell/tests/test_double_spend_integration.py index 206615f..61c7128 100644 --- a/make_post_sell/tests/test_double_spend_integration.py +++ b/make_post_sell/tests/test_double_spend_integration.py @@ -617,7 +617,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests): process_payment(request, payment1, overpay_tx, mock_client) # Single transaction overpayment: should be marked as overpaid - self.assertEqual(payment1.status, CryptoPayment.STATUS_CONFIRMED_OVERPAID) + self.assertEqual(payment1.status, CryptoPayment.STATUS_CONFIRMED_OVERPAY) self.assertEqual(payment1.received_amount, 150000000) # Scenario 2: Double payment (two separate transactions) diff --git a/make_post_sell/tests/test_double_spend_protection.py b/make_post_sell/tests/test_double_spend_protection.py index cacf88b..3e82696 100644 --- a/make_post_sell/tests/test_double_spend_protection.py +++ b/make_post_sell/tests/test_double_spend_protection.py @@ -181,7 +181,7 @@ class TestDoubleSpendProtection(unittest.TestCase): self.assertTrue(result) # Test confirmed overpaid payment - payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY result = _should_process_late_payment(payment, new_tx) self.assertTrue(result) @@ -218,7 +218,7 @@ class TestDoubleSpendProtection(unittest.TestCase): # Simulate two processes seeing the same payment payment1 = self._create_test_payment() payment1.id = "payment-race" - payment1.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + payment1.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY payment1.received_amount = 150000000 # Got 0.0015 XMR payment1.expected_amount = 100000000 # Expected 0.001 XMR payment1.tx_hashes = json.dumps(["tx1", "tx2"]) diff --git a/make_post_sell/tests/test_invoice_deletion.py b/make_post_sell/tests/test_invoice_deletion.py index bab8112..d18ef11 100644 --- a/make_post_sell/tests/test_invoice_deletion.py +++ b/make_post_sell/tests/test_invoice_deletion.py @@ -30,6 +30,7 @@ class TestInvoiceDeletion(unittest.TestCase): "message": "Deleted successfully", } self.mock_crypto_payment.status = CryptoPayment.STATUS_EXPIRED + self.mock_crypto_payment.should_keep_invoice.return_value = False delete_invoice_for_terminal_state(self.mock_dbsession, self.mock_crypto_payment) @@ -70,7 +71,7 @@ class TestInvoiceDeletion(unittest.TestCase): "success": True, "message": "Deleted successfully", } - self.mock_crypto_payment.status = CryptoPayment.STATUS_NO_REFUND + self.mock_crypto_payment.status = CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED delete_invoice_for_terminal_state(self.mock_dbsession, self.mock_crypto_payment) @@ -87,7 +88,7 @@ class TestInvoiceDeletion(unittest.TestCase): } complete_refund_statuses = [ - CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE, CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, ] @@ -126,7 +127,7 @@ class TestInvoiceDeletion(unittest.TestCase): @patch("make_post_sell.lib.crypto_watcher.delete_invoice_by_id") def test_keep_invoice_for_confirmed_overpaid_status(self, mock_delete_invoice): """Test that confirmed-overpaid payments keep their invoices.""" - self.mock_crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + self.mock_crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY delete_invoice_for_terminal_state(self.mock_dbsession, self.mock_crypto_payment) @@ -138,7 +139,7 @@ class TestInvoiceDeletion(unittest.TestCase): ): """Test that confirmed-overpaid-refunded payments keep their invoices.""" self.mock_crypto_payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED ) delete_invoice_for_terminal_state(self.mock_dbsession, self.mock_crypto_payment) @@ -199,8 +200,9 @@ class TestInvoiceDeletion(unittest.TestCase): """Test that all terminal states except confirmed ones trigger deletion.""" confirmed_states = { CryptoPayment.STATUS_CONFIRMED, - CryptoPayment.STATUS_CONFIRMED_OVERPAID, - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE, } terminal_states_that_should_delete = ( @@ -211,13 +213,17 @@ class TestInvoiceDeletion(unittest.TestCase): # Based on the current TERMINAL_STATUSES definition expected_delete_states = { CryptoPayment.STATUS_EXPIRED, - CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE, + CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE, CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE, CryptoPayment.STATUS_CANCELLED, CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, - CryptoPayment.STATUS_NO_REFUND, - CryptoPayment.STATUS_NO_REFUND_COMPLETE, CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, + # Not-refunded states + CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED, + CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED, + CryptoPayment.STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED, + CryptoPayment.STATUS_OUT_OF_STOCK_NOT_REFUNDED, + CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED, } self.assertEqual(terminal_states_that_should_delete, expected_delete_states) @@ -236,6 +242,8 @@ class TestInvoiceDeletion(unittest.TestCase): self.mock_crypto_payment.invoice = self.mock_invoice self.mock_crypto_payment.invoice_id = "test-invoice-123" self.mock_crypto_payment.status = status + # Mock should_keep_invoice to return False for failed payments + self.mock_crypto_payment.should_keep_invoice.return_value = False self.mock_dbsession.reset_mock() mock_delete.reset_mock() diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index 0db08e1..87f9b4b 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -1006,16 +1006,12 @@ def crypto_cancel(request): if crypto_payment.invoice.user_id != request.user.id: return HTTPBadRequest("unauthorized") - # Can only cancel payments that are not already terminal - if crypto_payment.status in CryptoPayment.TERMINAL_STATUSES: + # Only pending payments can be cancelled (before any funds are received) + if crypto_payment.status != CryptoPayment.STATUS_PENDING: return HTTPBadRequest( - f"Cannot cancel payment with status: {crypto_payment.status}" + f"Only pending payments can be cancelled. Current status: {crypto_payment.status}" ) - # Can only cancel if no funds received yet - if crypto_payment.received_amount > 0: - return HTTPBadRequest("Cannot cancel payment that has received funds") - # Cancel the payment crypto_payment.status = CryptoPayment.STATUS_CANCELLED request.dbsession.add(crypto_payment) diff --git a/state-machine.svg b/state-machine.svg new file mode 100644 index 0000000..0ab95b7 --- /dev/null +++ b/state-machine.svg @@ -0,0 +1 @@ +
Payment detected
Payment timeout
User cancellation
Sufficient payment + confirmations
Overpayment detected
Payment timeout
Underpayment detected
Duplicate payment detected
Product unavailable
✓ Terminal Success
Initiate refund
Refund confirmed
No refund wallet configured
✓ Terminal Success
✓ Terminal (Not Refunded)
✓ Terminal (Expired)
Refund confirmed
No refund wallet configured
✓ Terminal Success
✓ Terminal (Not Refunded)
Refund confirmed
No refund wallet configured
✓ Terminal Success
✓ Terminal (Not Refunded)
Refund confirmed
No refund wallet configured
✓ Terminal Success
✓ Terminal (Not Refunded)
Refund confirmed
No refund wallet configured
✓ Terminal Success
✓ Terminal (Not Refunded)
Policy applied
✓ Terminal (No Refund)
✓ Terminal (Cancelled)
pending
received
expired
cancelled
confirmed
confirmed_overpaid
underpaid_refunded
doublepay_refund
out_of_stock_refunded
confirmed_overpaid_refunded
confirmed_overpaid_refunded_complete
confirmed_overpaid_not_refunded
expired_refunded
expired_refunded_complete
expired_not_refunded
underpaid_refunded_complete
underpaid_not_refunded
out_of_stock_refunded_complete
out_of_stock_not_refunded
doublepay_refund_complete
doublepay_not_refund
no_refund
no_refund_complete