Improve crypto payment handling and out-of-stock refunds
- Add proper inventory checking before crypto payment completion - Implement sequential (FIFO) crypto payment processing to prevent race conditions - Add comprehensive out-of-stock refund handling with two statuses: - STATUS_OUT_OF_STOCK_REFUNDED: Full refund to customer (no restocking fee) - STATUS_OUT_OF_STOCK_NO_REFUND: Sweep to shop when no refund address - Use sweep_all with subaddr_indices for Monero to ensure complete fund recovery - Use sendtoaddress with subtractfeefromamount for Dogecoin fee handling - Add refund tracking fields (refund_reason, refund_tx_hash, refund_amount) - Update MONERO.rst with subaddress isolation and refund processing documentation - Ensure action buttons are removed for non-pending crypto quotes to prevent double payments
This commit is contained in:
parent
663c0e9546
commit
c4d9192540
3 changed files with 412 additions and 118 deletions
|
|
@ -195,6 +195,88 @@ Why This Is Amazing
|
|||
- **Security by Design**: Account isolation prevents fund mixing
|
||||
- **Operational Excellence**: Automated deployment and monitoring
|
||||
|
||||
Subaddress Isolation and Refund Processing
|
||||
===========================================
|
||||
|
||||
Our Monero implementation uses **subaddress isolation** to ensure complete security between customer payments and enable safe refund processing without affecting other transactions.
|
||||
|
||||
**Account and Subaddress Hierarchy**
|
||||
------------------------------------
|
||||
Each payment operates within a strict isolation model:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
Monero Wallet Structure:
|
||||
├── Account 0: System (unused)
|
||||
├── Account 1: Shop A
|
||||
│ ├── Subaddress 0: Shop's primary address
|
||||
│ ├── Subaddress 1: Customer Payment #1
|
||||
│ ├── Subaddress 2: Customer Payment #2
|
||||
│ └── Subaddress N: Customer Payment #N
|
||||
├── Account 2: Shop B
|
||||
│ ├── Subaddress 0: Shop's primary address
|
||||
│ ├── Subaddress 1: Customer Payment #1
|
||||
│ └── ...
|
||||
└── Account N: Shop N...
|
||||
|
||||
**Payment Isolation Security**
|
||||
------------------------------
|
||||
Each crypto payment receives a unique subaddress within the shop's account, ensuring:
|
||||
|
||||
- **Complete Financial Isolation**: Funds from different customers never mix
|
||||
- **Targeted Refunds**: Specific payments can be refunded without affecting others
|
||||
- **Safe Sweep Operations**: Only intended funds are moved during sweeps
|
||||
- **Audit Trail**: Each payment's funds are traceable throughout the lifecycle
|
||||
|
||||
**Refund Processing Flow**
|
||||
--------------------------
|
||||
When refunds are required (expired quotes, out-of-stock items, overpayments):
|
||||
|
||||
1. **Customer Refund**: Uses ``sweep_single`` to transfer customer's funds to their refund address
|
||||
2. **Restocking Fee Sweep**: Uses ``sweep_all`` with specific ``subaddr_indices`` to collect restocking fees
|
||||
3. **Transaction Ordering**: Refunds are always processed BEFORE restocking fee sweeps
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Safe refund processing with subaddress isolation
|
||||
|
||||
# Step 1: Refund customer (specific subaddress)
|
||||
refund_result = monero_rpc.sweep_single(
|
||||
address=crypto_payment.refund_address,
|
||||
account_index=crypto_payment.account_index,
|
||||
subaddr_indices=[crypto_payment.subaddress_index]
|
||||
)
|
||||
|
||||
# Step 2: Sweep restocking fee (same subaddress)
|
||||
restocking_result = monero_rpc.sweep_all(
|
||||
address=shop_cold_wallet,
|
||||
account_index=crypto_payment.account_index,
|
||||
subaddr_indices=[crypto_payment.subaddress_index]
|
||||
)
|
||||
|
||||
**Why sweep_all is Safe**
|
||||
-------------------------
|
||||
The ``sweep_all`` operation with ``subaddr_indices`` parameter ensures security:
|
||||
|
||||
- **Without subaddr_indices**: Would sweep ALL funds in the account (DANGEROUS)
|
||||
- **With subaddr_indices**: Only sweeps funds from specified subaddresses (SAFE)
|
||||
|
||||
This allows us to:
|
||||
- Collect restocking fees from specific failed payments
|
||||
- Leave other customers' payments untouched
|
||||
- Maintain complete isolation between transactions
|
||||
|
||||
**Network Fee Considerations**
|
||||
------------------------------
|
||||
Monero network fees are automatically calculated and deducted:
|
||||
|
||||
- **Standard Fee**: ~0.0001 XMR for most transactions
|
||||
- **Automatic Deduction**: Fees are subtracted from the swept amount
|
||||
- **Fee Tracking**: All network fees are recorded for accounting
|
||||
- **Confirmation Requirements**: Funds must be unlocked (~10 confirmations) before sweeping
|
||||
|
||||
This subaddress isolation architecture ensures that our multi-tenant Monero system maintains enterprise-grade security while providing the flexibility needed for complex refund scenarios.
|
||||
|
||||
The Result: A truly **amazing** multi-tenant cryptocurrency cash register that makes Monero payments as easy as traditional payment processing, while maintaining the privacy, security, and decentralization that makes cryptocurrency revolutionary.
|
||||
|
||||
**Welcome to the future of commerce.** 🚀
|
||||
|
|
@ -360,13 +360,13 @@ def create_shop_context_request(env_request, crypto_payment: CryptoPayment):
|
|||
|
||||
def check_inventory_availability(env_request, crypto_payment: CryptoPayment):
|
||||
"""Check if all physical products in the invoice have sufficient inventory.
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (is_available: bool, out_of_stock_items: list)
|
||||
"""
|
||||
invoice = crypto_payment.invoice
|
||||
out_of_stock_items = []
|
||||
|
||||
|
||||
if crypto_payment.shop_location:
|
||||
for item in invoice.line_items:
|
||||
product = item.product
|
||||
|
|
@ -376,62 +376,81 @@ def check_inventory_availability(env_request, crypto_payment: CryptoPayment):
|
|||
)
|
||||
available_qty = inv.quantity if inv else 0
|
||||
if available_qty < item.quantity:
|
||||
out_of_stock_items.append({
|
||||
'product': product,
|
||||
'requested': item.quantity,
|
||||
'available': available_qty
|
||||
})
|
||||
|
||||
out_of_stock_items.append(
|
||||
{
|
||||
"product": product,
|
||||
"requested": item.quantity,
|
||||
"available": available_qty,
|
||||
}
|
||||
)
|
||||
|
||||
return len(out_of_stock_items) == 0, out_of_stock_items
|
||||
|
||||
|
||||
def finalize_invoice(env_request, crypto_payment: CryptoPayment):
|
||||
invoice: Invoice = crypto_payment.invoice
|
||||
|
||||
|
||||
# Check inventory availability for physical goods BEFORE finalizing
|
||||
is_available, out_of_stock = check_inventory_availability(env_request, crypto_payment)
|
||||
|
||||
is_available, out_of_stock = check_inventory_availability(
|
||||
env_request, crypto_payment
|
||||
)
|
||||
|
||||
if not is_available:
|
||||
# Inventory not available - need to refund WITHOUT restocking fee
|
||||
logger.warning(
|
||||
f"Payment {crypto_payment.id} cannot be fulfilled - out of stock items: "
|
||||
f"{[item['product'].name for item in out_of_stock]}"
|
||||
)
|
||||
|
||||
|
||||
# Set refund reason for out of stock
|
||||
crypto_payment.refund_reason = f"Out of stock: {', '.join([item['product'].name for item in out_of_stock])}"
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
|
||||
# Trigger full refund without restocking fee
|
||||
|
||||
# Handle out of stock situation
|
||||
if crypto_payment.refund_address:
|
||||
# Get the crypto client
|
||||
client = get_crypto_client(env_request.registry.settings, crypto_payment.coin_type)
|
||||
# Customer provided refund address - do full refund
|
||||
client = get_crypto_client(
|
||||
env_request.registry.settings, crypto_payment.coin_type
|
||||
)
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
|
||||
|
||||
# Calculate full refund amount
|
||||
refund_amount_crypto = Decimal(crypto_payment.received_amount) / coin_config["atomic_units"]
|
||||
|
||||
refund_amount_crypto = (
|
||||
Decimal(crypto_payment.received_amount) / coin_config["atomic_units"]
|
||||
)
|
||||
|
||||
try:
|
||||
# Process the refund sweep
|
||||
# Process the refund sweep - use sweep_all to get ALL funds from this subaddress
|
||||
if crypto_payment.coin_type == "XMR":
|
||||
sweep_result = client._call("sweep_single", {
|
||||
"account_index": crypto_payment.account_index,
|
||||
"address": crypto_payment.refund_address,
|
||||
"amount": int(crypto_payment.received_amount), # Full amount, no fee deduction
|
||||
"priority": 1, # Normal priority
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
})
|
||||
sweep_result = client._call(
|
||||
"sweep_all",
|
||||
{
|
||||
"account_index": crypto_payment.account_index,
|
||||
"address": crypto_payment.refund_address,
|
||||
"subaddr_indices": [
|
||||
crypto_payment.subaddress_index
|
||||
], # Only this payment's subaddress
|
||||
"priority": 1, # Normal priority
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
},
|
||||
)
|
||||
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
|
||||
elif crypto_payment.coin_type == "DOGE":
|
||||
# For Dogecoin, use sendtoaddress
|
||||
tx_hash = client._call("sendtoaddress", [
|
||||
crypto_payment.refund_address,
|
||||
float(refund_amount_crypto)
|
||||
])
|
||||
# For Dogecoin, use sendtoaddress with fee subtraction for full refund
|
||||
tx_hash = client._call(
|
||||
"sendtoaddress",
|
||||
[
|
||||
crypto_payment.refund_address,
|
||||
float(refund_amount_crypto),
|
||||
"Out of stock refund", # comment
|
||||
"", # comment_to
|
||||
True, # subtractfeefromamount - ensures ALL funds are sent
|
||||
],
|
||||
)
|
||||
else:
|
||||
tx_hash = None
|
||||
|
||||
|
||||
if tx_hash:
|
||||
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED
|
||||
crypto_payment.refund_tx_hash = tx_hash
|
||||
|
|
@ -442,20 +461,92 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment):
|
|||
f"TX: {tx_hash}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to process full refund for payment {crypto_payment.id} - no tx hash")
|
||||
|
||||
logger.error(
|
||||
f"Failed to process full refund for payment {crypto_payment.id} - no tx hash"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process full refund for payment {crypto_payment.id}: {e}")
|
||||
logger.error(
|
||||
f"Failed to process full refund for payment {crypto_payment.id}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Cannot refund out of stock payment {crypto_payment.id} - no refund address"
|
||||
)
|
||||
|
||||
# No refund address - sweep all funds to shop's cold storage
|
||||
if crypto_payment.shop_sweep_to_address:
|
||||
client = get_crypto_client(
|
||||
env_request.registry.settings, crypto_payment.coin_type
|
||||
)
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
|
||||
try:
|
||||
# Sweep all funds from this subaddress to shop's cold storage
|
||||
if crypto_payment.coin_type == "XMR":
|
||||
sweep_result = client._call(
|
||||
"sweep_all",
|
||||
{
|
||||
"account_index": crypto_payment.account_index,
|
||||
"address": crypto_payment.shop_sweep_to_address,
|
||||
"subaddr_indices": [
|
||||
crypto_payment.subaddress_index
|
||||
], # Only this payment's subaddress
|
||||
"priority": 1, # Normal priority
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
},
|
||||
)
|
||||
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
|
||||
swept_amount = sweep_result.get("amount_list", [0])[0]
|
||||
elif crypto_payment.coin_type == "DOGE":
|
||||
# For Dogecoin, sweep ALL funds to shop's address
|
||||
refund_amount_crypto = (
|
||||
Decimal(crypto_payment.received_amount)
|
||||
/ coin_config["atomic_units"]
|
||||
)
|
||||
tx_hash = client._call(
|
||||
"sendtoaddress",
|
||||
[
|
||||
crypto_payment.shop_sweep_to_address,
|
||||
float(refund_amount_crypto),
|
||||
"Out of stock - no refund address", # comment
|
||||
"", # comment_to
|
||||
True, # subtractfeefromamount - ensures ALL funds are sent
|
||||
],
|
||||
)
|
||||
swept_amount = crypto_payment.received_amount
|
||||
else:
|
||||
tx_hash = None
|
||||
swept_amount = 0
|
||||
|
||||
if tx_hash:
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
|
||||
)
|
||||
crypto_payment.swept_tx_hash = tx_hash
|
||||
crypto_payment.swept_amount = swept_amount
|
||||
crypto_payment.swept_timestamp = now_timestamp()
|
||||
logger.info(
|
||||
f"Out of stock payment {crypto_payment.id} swept to shop cold storage "
|
||||
f"Amount: {swept_amount} atomic units TX: {tx_hash}"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to sweep out of stock payment {crypto_payment.id} to shop storage"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
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"
|
||||
)
|
||||
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
|
||||
|
||||
# Send notification email about the refund
|
||||
if invoice.user.email:
|
||||
# TODO: Send out of stock refund notification email
|
||||
pass
|
||||
|
||||
|
||||
return False # Invoice not finalized
|
||||
|
||||
# Unlock products for the purchasing user and notify via email (mirrors Stripe flow)
|
||||
|
|
@ -561,45 +652,131 @@ def process_payment(
|
|||
total_recv, _, _ = summarize_txs(incoming_transfers)
|
||||
if total_recv > 0:
|
||||
# Check if items are out of stock - if so, do FULL refund
|
||||
is_available, out_of_stock = check_inventory_availability(env_request, crypto_payment)
|
||||
|
||||
if not is_available and crypto_payment.refund_address:
|
||||
# Out of stock - do full refund
|
||||
is_available, out_of_stock = check_inventory_availability(
|
||||
env_request, crypto_payment
|
||||
)
|
||||
|
||||
if not is_available:
|
||||
# Out of stock - handle based on whether refund address exists
|
||||
crypto_payment.refund_reason = f"Expired + Out of stock: {', '.join([item['product'].name for item in out_of_stock])}"
|
||||
|
||||
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
refund_amount_crypto = Decimal(total_recv) / coin_config["atomic_units"]
|
||||
|
||||
try:
|
||||
if crypto_payment.coin_type == "XMR":
|
||||
sweep_result = client._call("sweep_single", {
|
||||
"account_index": crypto_payment.account_index,
|
||||
"address": crypto_payment.refund_address,
|
||||
"amount": int(total_recv), # Full amount
|
||||
"priority": 1,
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
})
|
||||
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
|
||||
elif crypto_payment.coin_type == "DOGE":
|
||||
tx_hash = client._call("sendtoaddress", [
|
||||
crypto_payment.refund_address,
|
||||
float(refund_amount_crypto)
|
||||
])
|
||||
else:
|
||||
tx_hash = None
|
||||
|
||||
if tx_hash:
|
||||
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED
|
||||
crypto_payment.refund_tx_hash = tx_hash
|
||||
crypto_payment.refund_amount = total_recv
|
||||
logger.info(
|
||||
f"Full refund for expired+out of stock payment {crypto_payment.id} "
|
||||
f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} TX: {tx_hash}"
|
||||
refund_amount_crypto = (
|
||||
Decimal(total_recv) / coin_config["atomic_units"]
|
||||
)
|
||||
|
||||
if crypto_payment.refund_address:
|
||||
# Customer provided refund address - do full refund
|
||||
try:
|
||||
if crypto_payment.coin_type == "XMR":
|
||||
# Use sweep_all to get ALL funds from this subaddress
|
||||
sweep_result = client._call(
|
||||
"sweep_all",
|
||||
{
|
||||
"account_index": crypto_payment.account_index,
|
||||
"address": crypto_payment.refund_address,
|
||||
"subaddr_indices": [
|
||||
crypto_payment.subaddress_index
|
||||
], # Only this payment's subaddress
|
||||
"priority": 1,
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
},
|
||||
)
|
||||
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
|
||||
elif crypto_payment.coin_type == "DOGE":
|
||||
# Use subtractfeefromamount for full refund
|
||||
tx_hash = client._call(
|
||||
"sendtoaddress",
|
||||
[
|
||||
crypto_payment.refund_address,
|
||||
float(refund_amount_crypto),
|
||||
"Out of stock refund", # comment
|
||||
"", # comment_to
|
||||
True, # subtractfeefromamount
|
||||
],
|
||||
)
|
||||
else:
|
||||
tx_hash = None
|
||||
|
||||
if tx_hash:
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED
|
||||
)
|
||||
crypto_payment.refund_tx_hash = tx_hash
|
||||
crypto_payment.refund_amount = total_recv
|
||||
logger.info(
|
||||
f"Full refund for expired+out of stock payment {crypto_payment.id} "
|
||||
f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} TX: {tx_hash}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to process full refund for expired payment {crypto_payment.id}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process full refund for expired payment {crypto_payment.id}: {e}")
|
||||
|
||||
else:
|
||||
# No refund address - sweep to shop's cold storage
|
||||
if crypto_payment.shop_sweep_to_address:
|
||||
try:
|
||||
if crypto_payment.coin_type == "XMR":
|
||||
sweep_result = client._call(
|
||||
"sweep_all",
|
||||
{
|
||||
"account_index": crypto_payment.account_index,
|
||||
"address": crypto_payment.shop_sweep_to_address,
|
||||
"subaddr_indices": [
|
||||
crypto_payment.subaddress_index
|
||||
], # Only this payment's subaddress
|
||||
"priority": 1,
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
},
|
||||
)
|
||||
tx_hash = sweep_result.get("tx_hash_list", [None])[
|
||||
0
|
||||
]
|
||||
swept_amount = sweep_result.get("amount_list", [0])[
|
||||
0
|
||||
]
|
||||
elif crypto_payment.coin_type == "DOGE":
|
||||
# Sweep ALL funds to shop with fee subtraction
|
||||
tx_hash = client._call(
|
||||
"sendtoaddress",
|
||||
[
|
||||
crypto_payment.shop_sweep_to_address,
|
||||
float(refund_amount_crypto),
|
||||
"Out of stock - no refund address", # comment
|
||||
"", # comment_to
|
||||
True, # subtractfeefromamount
|
||||
],
|
||||
)
|
||||
swept_amount = total_recv
|
||||
else:
|
||||
tx_hash = None
|
||||
swept_amount = 0
|
||||
|
||||
if tx_hash:
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
|
||||
)
|
||||
crypto_payment.swept_tx_hash = tx_hash
|
||||
crypto_payment.swept_amount = swept_amount
|
||||
crypto_payment.swept_timestamp = now_timestamp()
|
||||
logger.info(
|
||||
f"Expired+out of stock payment {crypto_payment.id} swept to shop cold storage "
|
||||
f"Amount: {swept_amount} atomic units TX: {tx_hash}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
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"
|
||||
)
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
|
||||
)
|
||||
|
||||
elif payment_rescue and crypto_payment.refund_address:
|
||||
# Not out of stock - do normal expired refund with restocking fee
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
|
|
@ -617,49 +794,76 @@ def process_payment(
|
|||
f"Refund executed for expired payment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
)
|
||||
# Track the customer refund
|
||||
crypto_payment.refund_amount = int(refund_details["refund_amount"] * coin_config["atomic_units"])
|
||||
crypto_payment.refund_amount = int(
|
||||
refund_details["refund_amount"]
|
||||
* coin_config["atomic_units"]
|
||||
)
|
||||
crypto_payment.refund_tx_hash = result["tx_hash"]
|
||||
crypto_payment.refund_reason = refund_details["reason"]
|
||||
crypto_payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED
|
||||
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_EXPIRED_REFUNDED
|
||||
)
|
||||
|
||||
# Commit the refund first
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
env_request.dbsession.flush()
|
||||
|
||||
|
||||
# Now sweep the remaining restocking fee to shop's wallet (AFTER refund)
|
||||
fee_amount = int(refund_details["fee_amount"] * coin_config["atomic_units"])
|
||||
fee_amount = int(
|
||||
refund_details["fee_amount"]
|
||||
* coin_config["atomic_units"]
|
||||
)
|
||||
if fee_amount > 0 and crypto_payment.shop_sweep_to_address:
|
||||
try:
|
||||
# Small delay to ensure refund tx is processed
|
||||
import time
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
if crypto_payment.coin_type == "XMR":
|
||||
# Send exact restocking fee amount (9%)
|
||||
sweep_result = client._call("transfer", {
|
||||
"account_index": crypto_payment.account_index,
|
||||
"destinations": [{
|
||||
# Sweep remaining balance from this specific subaddress only
|
||||
sweep_result = client._call(
|
||||
"sweep_all",
|
||||
{
|
||||
"account_index": crypto_payment.account_index,
|
||||
"subaddr_indices": [
|
||||
crypto_payment.subaddress_index
|
||||
],
|
||||
"address": crypto_payment.shop_sweep_to_address,
|
||||
"amount": fee_amount
|
||||
}],
|
||||
"priority": 1,
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
})
|
||||
fee_tx_hash = sweep_result.get("tx_hash")
|
||||
actual_swept = fee_amount
|
||||
"priority": 1,
|
||||
"get_tx_hex": True,
|
||||
"do_not_relay": False,
|
||||
},
|
||||
)
|
||||
fee_tx_hash = (
|
||||
sweep_result.get("tx_hash_list", [None])[0]
|
||||
if sweep_result.get("tx_hash_list")
|
||||
else None
|
||||
)
|
||||
# Get actual amount swept (whatever remained after refund, minus network fees)
|
||||
actual_swept = (
|
||||
sweep_result.get("amount_list", [0])[0]
|
||||
if sweep_result.get("amount_list")
|
||||
else 0
|
||||
)
|
||||
elif crypto_payment.coin_type == "DOGE":
|
||||
# Send exact restocking fee amount
|
||||
fee_crypto_amount = float(Decimal(fee_amount) / coin_config["atomic_units"])
|
||||
fee_tx_hash = client._call("sendtoaddress", [
|
||||
crypto_payment.shop_sweep_to_address,
|
||||
fee_crypto_amount
|
||||
])
|
||||
fee_crypto_amount = float(
|
||||
Decimal(fee_amount)
|
||||
/ coin_config["atomic_units"]
|
||||
)
|
||||
fee_tx_hash = client._call(
|
||||
"sendtoaddress",
|
||||
[
|
||||
crypto_payment.shop_sweep_to_address,
|
||||
fee_crypto_amount,
|
||||
],
|
||||
)
|
||||
actual_swept = fee_amount
|
||||
else:
|
||||
fee_tx_hash = None
|
||||
actual_swept = 0
|
||||
|
||||
|
||||
if fee_tx_hash and actual_swept > 0:
|
||||
crypto_payment.swept_amount = actual_swept
|
||||
crypto_payment.swept_tx_hash = fee_tx_hash
|
||||
|
|
@ -670,7 +874,9 @@ def process_payment(
|
|||
f"TX: {fee_tx_hash}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sweep restocking fee for payment {crypto_payment.id}: {e}")
|
||||
logger.error(
|
||||
f"Failed to sweep restocking fee for payment {crypto_payment.id}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Refund failed for expired payment {crypto_payment.id}: {result['error']}"
|
||||
|
|
@ -877,13 +1083,15 @@ def run_once(env, interval):
|
|||
with request.tm:
|
||||
db = request.dbsession
|
||||
|
||||
q = db.query(CryptoPayment).filter(
|
||||
CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES),
|
||||
CryptoPayment.swept_tx_hash.is_(
|
||||
None
|
||||
), # Only process payments that are not swept
|
||||
).order_by(
|
||||
CryptoPayment.created_timestamp.asc() # First come, first serve
|
||||
q = (
|
||||
db.query(CryptoPayment)
|
||||
.filter(
|
||||
CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES),
|
||||
CryptoPayment.swept_tx_hash.is_(
|
||||
None
|
||||
), # Only process payments that are not swept
|
||||
)
|
||||
.order_by(CryptoPayment.created_timestamp.asc()) # First come, first serve
|
||||
)
|
||||
payments = q.all()
|
||||
|
||||
|
|
@ -913,7 +1121,7 @@ def run_once(env, interval):
|
|||
logger.info(
|
||||
f"Processing payment {crypto_payment.id} (status: {crypto_payment.status}, coin: {crypto_payment.coin_type})"
|
||||
)
|
||||
|
||||
|
||||
# Skip if payment status changed (another process may have handled it)
|
||||
db.refresh(crypto_payment)
|
||||
if crypto_payment.status not in CryptoPayment.ACTIVE_STATUSES:
|
||||
|
|
@ -942,18 +1150,18 @@ def run_once(env, interval):
|
|||
try:
|
||||
# Process payment with a savepoint for rollback capability
|
||||
savepoint = db.begin_nested()
|
||||
|
||||
|
||||
process_payment(request, crypto_payment, incoming, client)
|
||||
|
||||
|
||||
# Commit the savepoint if successful
|
||||
savepoint.commit()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Rollback on any error
|
||||
savepoint.rollback()
|
||||
logger.error(
|
||||
f"Failed to process payment {crypto_payment.id}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class CryptoPayment(RBase, Base):
|
|||
STATUS_UNDERPAID_REFUNDED = "underpaid-refunded"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
STATUS_OUT_OF_STOCK_REFUNDED = "out-of-stock-refunded"
|
||||
STATUS_OUT_OF_STOCK_NO_REFUND = "out-of-stock-no-refund"
|
||||
|
||||
# Active statuses that should be processed by the watcher
|
||||
ACTIVE_STATUSES = [
|
||||
|
|
@ -47,6 +48,7 @@ class CryptoPayment(RBase, Base):
|
|||
STATUS_UNDERPAID_REFUNDED,
|
||||
STATUS_CANCELLED,
|
||||
STATUS_OUT_OF_STOCK_REFUNDED,
|
||||
STATUS_OUT_OF_STOCK_NO_REFUND,
|
||||
]
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
|
|
@ -89,14 +91,16 @@ class CryptoPayment(RBase, Base):
|
|||
refund_address = Column(
|
||||
Unicode(256), nullable=True
|
||||
) # Where to send refunds if payment fails/expires
|
||||
|
||||
|
||||
# Refund reason for audit trail
|
||||
refund_reason = Column(
|
||||
UnicodeText, nullable=True
|
||||
) # Why the refund was issued (out of stock, expired, etc.)
|
||||
|
||||
|
||||
# Refund tracking
|
||||
refund_tx_hash = Column(Unicode(128), nullable=True) # Transaction hash of the refund
|
||||
refund_tx_hash = Column(
|
||||
Unicode(128), nullable=True
|
||||
) # Transaction hash of the refund
|
||||
refund_amount = Column(BigInteger, nullable=True) # Amount refunded in atomic units
|
||||
|
||||
# Sweep tracking
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue