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:
parent
7625563f6a
commit
c7caae8d4a
10 changed files with 106 additions and 89 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")}"'
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Add table
Add a link
Reference in a new issue