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:
parent
923bd873f2
commit
5c67b9ca4c
12 changed files with 761 additions and 279 deletions
150
docs/crypto-payments-state-machine.md
Normal file
150
docs/crypto-payments-state-machine.md
Normal 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
|
||||
51
fix_crypto_payment_statuses.sql
Normal file
51
fix_crypto_payment_statuses.sql
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
state-machine.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 48 KiB |
Loading…
Add table
Add a link
Reference in a new issue