Fix all crypto payment test failures and status constant issues

- Fix duplicate detection logic to prevent amount accumulation when new transactions arrive to already-processed payments
- Fix status constant naming errors: STATUS_CONFIRMED_OVERPAID → STATUS_CONFIRMED_OVERPAY, STATUS_EXPIRED_REFUNDED → STATUS_LATEPAY_REFUNDED
- Fix SweepRestockingFeeTests mock setup: add missing refund_confirmations, get_coin_config patches, and proper XMR client mocks
- Correct overpayment test expectations: restocking fee sweep happens later after refund confirmation, not immediately
- Update test assertions to match actual implementation behavior (transfer vs sweep_all for XMR)
- Add comprehensive mock configurations for DOGE/XMR atomic units and balance methods

All 108 crypto payment tests now pass. Fixes critical duplicate payment processing bug and aligns test expectations with production behavior.
This commit is contained in:
Russell Ballestrini 2025-09-30 13:56:42 -04:00
parent 923bd873f2
commit 5c67b9ca4c
12 changed files with 761 additions and 279 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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."

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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"])

View file

@ -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()

View file

@ -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)

1
state-machine.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB