Fix crypto payment state machine logic and naming consistency

Major fixes:
1. Remove invalid received→expired transition
   - Payments that reach 'received' state cannot expire
   - Only pending payments can expire
   - Updated VALID_TRANSITIONS and tests accordingly

2. Rename STATUS_DOUBLEPAY_REFUND to STATUS_DOUBLEPAY_REFUNDED
   - Maintains consistency with all other past-tense status names
   - Updated all references across codebase and tests

3. Add INITIAL_WAITING_STATUSES semantic group
   - Groups states that are entry points: pending, latepay_refunded, doublepay_refunded
   - These states don't transition from 'received' - they represent initial states
   - Added is_initial_waiting_state() helper method

4. Update semantic groups
   - Removed latepay_refunded and doublepay_refunded from FAILED_PAYMENT_STATUSES
   - These are now in INITIAL_WAITING_STATUSES as they represent entry points

5. Fix mermaid diagram label
   - confirmed_overpay_not_refunded now correctly labeled as "Terminal Success (Not Refunded)"
   - Remove invalid received→expired transition from documentation

This improves the logical consistency of the state machine and aligns
the naming conventions across all status constants.
This commit is contained in:
Russell Ballestrini 2025-09-30 14:34:11 -04:00
parent 7625563f6a
commit c7caae8d4a
10 changed files with 106 additions and 89 deletions

View file

@ -16,9 +16,8 @@ stateDiagram-v2
%% 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 --> doublepay_refunded : Duplicate payment detected
received --> out_of_stock_refunded : Product unavailable
%% Successful payment paths
@ -29,7 +28,7 @@ stateDiagram-v2
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)
confirmed_overpay_not_refunded --> [*] : ✓ Terminal Success (Not Refunded)
%% Expired payment handling (terminal - late payments create new objects)
expired --> [*] : ✓ Terminal (Expired)
@ -51,9 +50,9 @@ stateDiagram-v2
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_refunded --> doublepay_refunded_complete : Refund confirmed
doublepay_refunded --> doublepay_not_refunded : No refund wallet configured
doublepay_refunded_complete --> [*] : ✓ Terminal Success
doublepay_not_refunded --> [*] : ✓ Terminal (Not Refunded)
@ -66,8 +65,8 @@ stateDiagram-v2
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 confirmed,confirmed_overpay_refunded_complete,latepay_refunded_complete,underpaid_refunded_complete,out_of_stock_refunded_complete,doublepay_refunded_complete successState
class confirmed_overpay_refunded,latepay_refunded,underpaid_refunded,out_of_stock_refunded,doublepay_refunded 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

View file

@ -110,7 +110,7 @@ def _create_duplicate_payment(original_payment, tx, coin_type, dbsession=None):
dbsession: Database session for looking up user refund addresses
Returns:
CryptoPayment: New duplicate payment record with STATUS_DOUBLEPAY_REFUND
CryptoPayment: New duplicate payment record with STATUS_DOUBLEPAY_REFUNDED
Note:
- For DOGE/BTC: tx.amount is already in atomic units from get_dogecoin_incoming_transfers
@ -182,7 +182,7 @@ def _create_duplicate_payment(original_payment, tx, coin_type, dbsession=None):
# Override specific properties for duplicate
duplicate_payment.received_amount = tx_amount # What we received
duplicate_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
duplicate_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
duplicate_payment.current_confirmations = tx.get("confirmations", 0)
# Store the transaction ID so scanner can find this duplicate
@ -869,7 +869,7 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None
.filter(
CryptoPayment.coin_type == "XMR",
CryptoPayment.account_index == crypto_payment.account_index,
CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.refund_amount > 0,
)
.all()
@ -1092,7 +1092,7 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non
.filter(
CryptoPayment.coin_type == "DOGE",
CryptoPayment.address == crypto_payment.address,
CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.refund_amount > 0,
)
.all()
@ -1582,7 +1582,7 @@ def process_payment(
# 2. Calculates refund with 9% restocking fee
# 3. Transitions to doublepay-refund-complete when successful
# 4. Supports multiple duplicates (triple-pay, etc.) independently
if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND:
if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED:
# Check for refund address - use payment's or lookup saved address
refund_address = crypto_payment.refund_address
if not refund_address and crypto_payment.user_id:
@ -1729,13 +1729,13 @@ def process_payment(
# Transition to refund monitoring status
crypto_payment.status = (
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
)
env_request.dbsession.add(crypto_payment)
# CRITICAL: Flush immediately to prevent double-refund bug
env_request.dbsession.flush()
logger.info(
f"Duplicate payment {crypto_payment.id} transitioned to {CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE}"
f"Duplicate payment {crypto_payment.id} transitioned to {CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE}"
)
# Send refund email notification if not already sent
@ -1767,7 +1767,7 @@ def process_payment(
f"Refund failed for duplicate payment {crypto_payment.id}: {result['error']}"
)
# For failed refunds due to insufficient funds, keep trying but log the failure
# Status remains STATUS_DOUBLEPAY_REFUND for retry, but we could implement backoff logic here
# Status remains STATUS_DOUBLEPAY_REFUNDED for retry, but we could implement backoff logic here
logger.info(
f"Duplicate payment {crypto_payment.id} refund failed - will retry on next cycle"
)
@ -2216,8 +2216,8 @@ def process_payment(
== crypto_payment.subaddress_index,
CryptoPayment.status.in_(
[
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]
),
)
@ -2232,8 +2232,8 @@ def process_payment(
CryptoPayment.address == crypto_payment.address,
CryptoPayment.status.in_(
[
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]
),
)
@ -2992,8 +2992,8 @@ def process_refund_confirmations(request, settings):
logger.info(
f"REFUND FULLY CONFIRMED - Out-of-stock refund for payment {payment.id}: {old_status} → {payment.status} (refund TX: {payment.refund_tx_hash[:16]}... with {confirmations} confirmations)"
)
elif payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND:
payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE
elif payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED:
payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
# No invoice to delete - duplicate payments don't have invoices
logger.info(
f"REFUND FULLY CONFIRMED - Double payment refund for payment {payment.id}: {old_status} → {payment.status} (refund TX: {payment.refund_tx_hash[:16]}... with {confirmations} confirmations)"
@ -3065,7 +3065,7 @@ def update_payment_confirmations_only(client, crypto_payment, coin_type):
# For duplicate payments, find confirmation count for specific transaction
if (
crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND
crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
and crypto_payment.tx_hashes
):
try:
@ -3335,7 +3335,7 @@ def scan_wallet_for_double_or_late_payments(request, settings):
CryptoPayment.account_index == account_idx,
CryptoPayment.subaddress_index == subaddr_idx,
CryptoPayment.status
== CryptoPayment.STATUS_DOUBLEPAY_REFUND,
== CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.tx_hashes.contains(
f'"{tx.get("txid")}"'
),
@ -3537,7 +3537,7 @@ def scan_wallet_for_double_or_late_payments(request, settings):
CryptoPayment.coin_type == coin_type,
CryptoPayment.address == tx.get("address"),
CryptoPayment.status
== CryptoPayment.STATUS_DOUBLEPAY_REFUND,
== CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.tx_hashes.contains(
f'"{tx.get("txid")}"'
),

View file

@ -347,8 +347,8 @@ def send_refund_email(request, to_email, crypto_payment, refund_details):
has_fee = True # Most refunds have a 9% restocking fee
if crypto_payment.status in [
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]:
subject = f"Duplicate Payment Refund - {crypto_payment.coin_type}"
explanation = "We detected a duplicate payment to an address that was already paid. Your additional payment is being refunded."

View file

@ -37,8 +37,8 @@ class CryptoPayment(RBase, Base):
STATUS_CANCELLED = "cancelled"
STATUS_OUT_OF_STOCK_REFUNDED = "out-of-stock-refunded"
STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE = "out-of-stock-refunded-complete"
STATUS_DOUBLEPAY_REFUND = "doublepay-refund"
STATUS_DOUBLEPAY_REFUND_COMPLETE = "doublepay-refund-complete"
STATUS_DOUBLEPAY_REFUNDED = "doublepay-refunded"
STATUS_DOUBLEPAY_REFUNDED_COMPLETE = "doublepay-refund-complete"
# Not-refunded states (when no refund wallet configured)
STATUS_LATEPAY_NOT_REFUNDED = "latepay-not-refunded"
@ -50,6 +50,14 @@ class CryptoPayment(RBase, Base):
# === SEMANTIC STATE GROUPS ===
# These groups define business logic categories for easier maintenance
# Initial/waiting states - payments that are starting or waiting for processing
# These states don't transition from 'received' - they are entry points
INITIAL_WAITING_STATUSES = [
STATUS_PENDING, # Initial state for new payments
STATUS_LATEPAY_REFUNDED, # Initial state for late payment objects (refund in progress)
STATUS_DOUBLEPAY_REFUNDED, # Initial state for duplicate payment objects (refund in progress)
]
# Successful payment statuses - customer received product, keep invoice
SUCCESSFUL_PAYMENT_STATUSES = [
STATUS_CONFIRMED, # Normal successful payment
@ -62,7 +70,6 @@ class CryptoPayment(RBase, Base):
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
@ -71,8 +78,7 @@ class CryptoPayment(RBase, Base):
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_REFUNDED_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
]
@ -86,7 +92,7 @@ class CryptoPayment(RBase, Base):
STATUS_RECEIVED,
STATUS_CONFIRMED,
STATUS_CONFIRMED_OVERPAY,
STATUS_DOUBLEPAY_REFUND, # Double payment that needs refund processing
STATUS_DOUBLEPAY_REFUNDED, # Double payment that needs refund processing
]
# Statuses that need confirmation monitoring but may have refunds pending
@ -95,7 +101,7 @@ class CryptoPayment(RBase, Base):
STATUS_UNDERPAID_REFUNDED, # Refund sent, but may need more incoming confirmations
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
# STATUS_DOUBLEPAY_REFUNDED moved to ACTIVE_STATUSES - needs refund initiation, not monitoring
]
# Terminal statuses that should not be processed (have no outgoing transitions)
@ -112,7 +118,7 @@ class CryptoPayment(RBase, Base):
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_REFUNDED_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
]
@ -131,8 +137,8 @@ class CryptoPayment(RBase, Base):
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_REFUNDED,
STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
STATUS_DOUBLEPAY_NOT_REFUNDED,
STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED,
# Overpay refund in progress also redirects
@ -348,7 +354,7 @@ class CryptoPayment(RBase, Base):
match self.status:
# Priority 0: Refunds - highest priority (customer service)
case (
self.STATUS_DOUBLEPAY_REFUND
self.STATUS_DOUBLEPAY_REFUNDED
| self.STATUS_LATEPAY_REFUNDED
| self.STATUS_UNDERPAID_REFUNDED
| self.STATUS_OUT_OF_STOCK_REFUNDED
@ -368,7 +374,7 @@ class CryptoPayment(RBase, Base):
self.STATUS_LATEPAY_REFUNDED_COMPLETE
| self.STATUS_UNDERPAID_REFUNDED_COMPLETE
| self.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE
| self.STATUS_DOUBLEPAY_REFUND_COMPLETE
| self.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
):
return 4 # Restocking fee sweeps - most dangerous
@ -382,9 +388,8 @@ class CryptoPayment(RBase, Base):
STATUS_RECEIVED: [
STATUS_CONFIRMED,
STATUS_CONFIRMED_OVERPAY,
STATUS_EXPIRED,
STATUS_UNDERPAID_REFUNDED, # Underpayment detected
STATUS_DOUBLEPAY_REFUND, # Duplicate detected
STATUS_DOUBLEPAY_REFUNDED, # Duplicate detected
STATUS_OUT_OF_STOCK_REFUNDED, # Out of stock
],
STATUS_CONFIRMED: [], # Terminal
@ -413,11 +418,11 @@ class CryptoPayment(RBase, Base):
STATUS_OUT_OF_STOCK_NOT_REFUNDED, # No refund wallet configured
],
STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE: [], # Terminal
STATUS_DOUBLEPAY_REFUND: [
STATUS_DOUBLEPAY_REFUND_COMPLETE,
STATUS_DOUBLEPAY_REFUNDED: [
STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
STATUS_DOUBLEPAY_NOT_REFUNDED, # No refund wallet configured
],
STATUS_DOUBLEPAY_REFUND_COMPLETE: [], # Terminal
STATUS_DOUBLEPAY_REFUNDED_COMPLETE: [], # Terminal
# Not-refunded states (terminal)
STATUS_LATEPAY_NOT_REFUNDED: [], # Terminal
STATUS_UNDERPAID_NOT_REFUNDED: [], # Terminal
@ -513,3 +518,17 @@ class CryptoPayment(RBase, Base):
bool: True if invoice should be kept, False if it should be deleted
"""
return self.is_successful_payment()
def is_initial_waiting_state(self) -> bool:
"""
Check if this payment is in an initial or waiting state.
These states represent entry points that don't transition from 'received':
- pending: Initial state for new payments
- latepay_refunded: Initial state for late payment objects
- doublepay_refund: Initial state for duplicate payment objects
Returns:
bool: True if payment is in initial/waiting state, False otherwise
"""
return self.status in self.INITIAL_WAITING_STATUSES

View file

@ -61,7 +61,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
# Invalid transitions
self.assertFalse(payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED))
self.assertFalse(
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUND)
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
)
def test_valid_transitions_from_received(self):
@ -74,14 +74,13 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
self.assertTrue(
payment.is_valid_transition(CryptoPayment.STATUS_CONFIRMED_OVERPAY)
)
self.assertTrue(payment.is_valid_transition(CryptoPayment.STATUS_EXPIRED))
# 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)
)
self.assertTrue(
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUND)
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
)
self.assertTrue(
payment.is_valid_transition(CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED)
@ -89,6 +88,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))
self.assertFalse(
payment.is_valid_transition(CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE)
)
@ -143,7 +143,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
# Verify priority matches expected business logic
if status in [
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.STATUS_LATEPAY_REFUNDED,
CryptoPayment.STATUS_UNDERPAID_REFUNDED,
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED,
@ -169,7 +169,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE,
CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE,
CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]:
self.assertEqual(
priority,
@ -202,7 +202,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
)
# Test doublepay_refund → doublepay_not_refund
payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
self.assertTrue(
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED)
)
@ -284,10 +284,9 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
expected = [
CryptoPayment.STATUS_CONFIRMED,
CryptoPayment.STATUS_CONFIRMED_OVERPAY,
CryptoPayment.STATUS_EXPIRED,
# CryptoPayment.STATUS_CANCELLED, # Only from pending now
CryptoPayment.STATUS_UNDERPAID_REFUNDED,
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED,
]
self.assertEqual(set(payment.get_valid_next_statuses()), set(expected))
@ -432,7 +431,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE,
CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE,
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]:
self.assertEqual(
valid_targets,
@ -459,7 +458,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
CryptoPayment.STATUS_LATEPAY_REFUNDED_COMPLETE,
CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE,
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]
for terminal_status in terminal_states:
@ -662,7 +661,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
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_REFUNDED_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED,
CryptoPayment.STATUS_CONFIRMED_OVERPAY_NOT_REFUNDED,
]
@ -690,7 +689,7 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
CryptoPayment.STATUS_LATEPAY_REFUNDED,
CryptoPayment.STATUS_UNDERPAID_REFUNDED,
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED,
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
]
for status in intermediate_statuses:
@ -770,12 +769,12 @@ class TestCryptoPaymentTransitions(unittest.TestCase):
# Test duplicate payment flow
payment.status = CryptoPayment.STATUS_RECEIVED
self.assertTrue(
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUND)
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
)
payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
self.assertTrue(
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE)
payment.is_valid_transition(CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE)
)

View file

@ -109,7 +109,7 @@ class CryptoWatcherUnitTests(unittest.TestCase):
# Create mock crypto payment (duplicate payment)
crypto_payment = MagicMock()
crypto_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
crypto_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
crypto_payment.account_index = 0
crypto_payment.subaddress_index = 1
crypto_payment.current_confirmations = 0
@ -197,7 +197,7 @@ class CryptoWatcherUnitTests(unittest.TestCase):
# Create mock crypto payment (duplicate payment)
crypto_payment = MagicMock()
crypto_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
crypto_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
crypto_payment.account_index = 0
crypto_payment.subaddress_index = 1
crypto_payment.current_confirmations = 0
@ -869,14 +869,14 @@ class CryptoWatcherIntegrationTests(unittest.TestCase):
# Verify separate duplicate payment record was created in database
duplicate_payments = (
dbsession.query(CryptoPayment)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
.all()
)
self.assertEqual(len(duplicate_payments), 1)
duplicate_payment = duplicate_payments[0]
self.assertEqual(
duplicate_payment.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND
duplicate_payment.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
)
self.assertEqual(
duplicate_payment.received_amount, 30000000000

View file

@ -166,7 +166,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
duplicate = [p for p in all_payments if p.id != original_id][0]
# Verify duplicate properties
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND)
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
self.assertEqual(duplicate.received_amount, 50000000)
self.assertEqual(duplicate.expected_amount, 50000000)
self.assertEqual(
@ -247,7 +247,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
# Verify separate duplicate payments were created
duplicate_payments = (
self.dbsession.query(CryptoPayment)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
.all()
)
self.assertGreater(len(duplicate_payments), 0)
@ -322,7 +322,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
# Verify separate duplicate payments were created
duplicates = (
self.dbsession.query(CryptoPayment)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
.all()
)
@ -430,7 +430,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
p for p in all_payments if p.status == CryptoPayment.STATUS_CONFIRMED
]
duplicate_payments = [
p for p in all_payments if p.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND
p for p in all_payments if p.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
]
# Verify counts
@ -452,7 +452,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
dup.invoice_id, payment.invoice_id
) # Same invoice as original
self.assertEqual(dup.refund_address, payment.refund_address)
self.assertEqual(dup.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND)
self.assertEqual(dup.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
@patch("make_post_sell.lib.crypto_watcher.send_refund_email")
@patch("make_post_sell.lib.crypto_watcher.send_purchase_email")
@ -557,8 +557,8 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
.filter(
CryptoPayment.status.in_(
[
CryptoPayment.STATUS_DOUBLEPAY_REFUND,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
]
)
)
@ -660,7 +660,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
# Verify separate duplicate payment was created in database
duplicate_payments = (
self.dbsession.query(CryptoPayment)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
.all()
)
self.assertEqual(len(duplicate_payments), 1)
@ -711,7 +711,7 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
confirmations_required=10,
refund_address="test-refund-address-12345",
)
duplicate_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
duplicate_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
duplicate_payment.received_amount = 37080000 # Some amount received
duplicate_payment.current_confirmations = 0 # Starting with 0 - the bug!
duplicate_payment.tx_hashes = json.dumps(["duplicate-tx-456"])

View file

@ -207,7 +207,7 @@ class TestDoubleSpendProtection(unittest.TestCase):
# Verify duplicate has correct properties
self.assertNotEqual(duplicate.id, original.id)
self.assertIsNone(duplicate.invoice_id) # No invoice for duplicates
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND)
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
self.assertEqual(duplicate.expected_amount, 75000000)
self.assertEqual(duplicate.received_amount, 75000000)
self.assertEqual(duplicate.refund_address, original.refund_address)
@ -309,7 +309,7 @@ class TestDoubleSpendProtection(unittest.TestCase):
def mock_add(record):
if (
hasattr(record, "status")
and record.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND
and record.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
):
created_records.append(record)
@ -320,7 +320,7 @@ class TestDoubleSpendProtection(unittest.TestCase):
filter_mock.all.return_value = [
r
for r in created_records
if r.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND
if r.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
]
query_mock.filter.return_value = filter_mock
return query_mock
@ -827,7 +827,7 @@ class TestWalletDrainPrevention(unittest.TestCase):
) as mock_create:
mock_duplicate = self._create_test_payment()
mock_duplicate.id = "duplicate-1"
mock_duplicate.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
mock_duplicate.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
mock_create.return_value = mock_duplicate
with patch(
@ -843,7 +843,7 @@ class TestWalletDrainPrevention(unittest.TestCase):
# Test duplicate creation
duplicate = _create_duplicate_payment(mock_payment, tx, "XMR")
self.assertEqual(
duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND
duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
)
self.assertEqual(duplicate.received_amount, 50000000)
@ -1030,7 +1030,7 @@ class TestEdgeCasesAndErrorHandling(unittest.TestCase):
# Verify duplicate inherits saved refund address
self.assertEqual(duplicate.refund_address, "saved_xmr_refund_address_12345")
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND)
self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
self.assertEqual(duplicate.received_amount, 75000000)
def test_duplicate_payment_refund_status_transitions(self):
@ -1040,44 +1040,44 @@ class TestEdgeCasesAndErrorHandling(unittest.TestCase):
# Test successful refund transition
duplicate_success = self._create_test_payment()
duplicate_success.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
duplicate_success.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
duplicate_success.received_amount = 50000000
duplicate_success.refund_address = "test_address"
# Simulate successful refund by setting the status directly (as crypto_watcher.py does)
duplicate_success.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE
duplicate_success.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
duplicate_success.refund_tx_hash = "refund-tx-hash-123"
duplicate_success.refund_amount = 4550000000 # 0.0455 XMR in atomic units
duplicate_success.refund_reason = "Duplicate payment refund"
# Verify successful transition
self.assertEqual(
duplicate_success.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE
duplicate_success.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
)
self.assertIsNotNone(duplicate_success.refund_tx_hash)
self.assertIsNotNone(duplicate_success.refund_amount)
# Verify STATUS_DOUBLEPAY_REFUND_COMPLETE is in TERMINAL_STATUSES (stops processing)
# Verify STATUS_DOUBLEPAY_REFUNDED_COMPLETE is in TERMINAL_STATUSES (stops processing)
self.assertIn(
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
CryptoPayment.TERMINAL_STATUSES,
)
# Test failed refund (status remains for retry)
duplicate_fail = self._create_test_payment()
duplicate_fail.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
duplicate_fail.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
duplicate_fail.received_amount = 50000000
duplicate_fail.refund_address = "test_address"
# Failed refund keeps the same status for retry (as crypto_watcher.py does)
# No status change, no refund details saved
self.assertEqual(duplicate_fail.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND)
self.assertEqual(duplicate_fail.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
self.assertIsNone(duplicate_fail.refund_tx_hash)
self.assertIsNone(duplicate_fail.refund_amount)
# Verify STATUS_DOUBLEPAY_REFUND is in ACTIVE_STATUSES (continues processing)
# Verify STATUS_DOUBLEPAY_REFUNDED is in ACTIVE_STATUSES (continues processing)
self.assertIn(
CryptoPayment.STATUS_DOUBLEPAY_REFUND, CryptoPayment.ACTIVE_STATUSES
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED, CryptoPayment.ACTIVE_STATUSES
)

View file

@ -222,7 +222,7 @@ class TestInvoiceDeletion(unittest.TestCase):
CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE,
CryptoPayment.STATUS_CANCELLED,
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
# Not-refunded states
CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED,
CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED,

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After