Add email deduplication to prevent duplicate crypto payment emails
- Add sales_email_sent, purchase_email_sent, refund_email_sent columns to CryptoPayment model - Update finalize_invoice to check email flags before sending confirmation emails - Add alembic migration for new email tracking columns - Update CLAUDE.md with database safety warnings
This commit is contained in:
parent
aa477b129f
commit
f58bd3e628
7 changed files with 444 additions and 113 deletions
|
|
@ -71,6 +71,13 @@ env/bin/py.test --cov=make_post_sell.models.cart --cov-report=term-missing make_
|
|||
|
||||
The SQLite database is located at: `data/make_post_sell.sqlite`
|
||||
|
||||
**CRITICAL WARNING**: NEVER delete or remove database files without explicit user permission. The database contains production data and cannot be easily recovered. Always ask before any destructive operations.
|
||||
|
||||
**MANDATORY**: ALWAYS create a backup of the database before any database operations (migrations, schema changes, etc.):
|
||||
```bash
|
||||
cp data/make_post_sell.sqlite data/make_post_sell.sqlite.backup-$(date +%Y%m%d-%H%M%S)
|
||||
```
|
||||
|
||||
Query crypto payments:
|
||||
```sql
|
||||
SELECT * FROM mps_crypto_payment WHERE id = 'payment-uuid-here';
|
||||
|
|
|
|||
27
fix_stuck_refund.sql
Normal file
27
fix_stuck_refund.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- Fix the payment that had a successful refund but missing transaction hash
|
||||
-- Payment ID: a7d015e7-9adf-11f0-a73e-02dfe05770ee
|
||||
-- Refund TX: 86797dcc2f1a32a23142199844470b4890d8137421bb1e647608add9783c5e2a
|
||||
-- Refund amount: 0.00156780318058 XMR = 1567803180 piconero
|
||||
|
||||
UPDATE mps_crypto_payment
|
||||
SET
|
||||
refund_tx_hash = '86797dcc2f1a32a23142199844470b4890d8137421bb1e647608add9783c5e2a',
|
||||
refund_amount = 1567803180,
|
||||
refund_reason = 'Underpayment: received 0.001722860638 but expected 0.003628767358',
|
||||
status = 'underpaid-refunded-complete',
|
||||
updated_timestamp = CAST(strftime('%s', 'now') AS INTEGER) * 1000
|
||||
WHERE
|
||||
id = 'a7d015e7-9adf-11f0-a73e-02dfe05770ee'
|
||||
AND status = 'underpaid-refunded'
|
||||
AND refund_tx_hash IS NULL;
|
||||
|
||||
-- Verify the update
|
||||
SELECT
|
||||
id,
|
||||
status,
|
||||
refund_tx_hash,
|
||||
refund_amount,
|
||||
refund_reason,
|
||||
datetime(updated_timestamp/1000, 'unixepoch', 'localtime') as updated_at
|
||||
FROM mps_crypto_payment
|
||||
WHERE id = 'a7d015e7-9adf-11f0-a73e-02dfe05770ee';
|
||||
|
|
@ -69,6 +69,71 @@ def _format_scan_semaphore(coin_type, position_value):
|
|||
return None
|
||||
|
||||
|
||||
def _create_duplicate_payment(original_payment, tx, coin_type):
|
||||
"""
|
||||
Create a new payment object for a duplicate transaction.
|
||||
|
||||
This allows tracking each duplicate payment separately with its own
|
||||
refund transaction and status transitions.
|
||||
"""
|
||||
import uuid
|
||||
import time
|
||||
from ..models.crypto_payment import CryptoPayment
|
||||
from ..models.meta import get_coin_config
|
||||
|
||||
# Get coin configuration for amount conversion
|
||||
coin_config = get_coin_config(coin_type)
|
||||
atomic_units = coin_config["atomic_units"]
|
||||
|
||||
# Convert transaction amount to atomic units
|
||||
if coin_type == "XMR":
|
||||
tx_amount = int(tx.get("amount", 0))
|
||||
else:
|
||||
# DOGE/BTC amount is already in atomic units
|
||||
tx_amount = int(float(tx.get("amount", 0)) * 1e8)
|
||||
|
||||
# Create new payment object for this duplicate
|
||||
duplicate_payment = CryptoPayment()
|
||||
duplicate_payment.id = uuid.uuid1()
|
||||
|
||||
# Copy key details from original payment
|
||||
duplicate_payment.invoice_id = None # No invoice - this is a duplicate
|
||||
duplicate_payment.user_id = original_payment.user_id
|
||||
duplicate_payment.shop_id = original_payment.shop_id
|
||||
duplicate_payment.shop_location_id = original_payment.shop_location_id
|
||||
|
||||
# Copy crypto details
|
||||
duplicate_payment.coin_type = original_payment.coin_type
|
||||
duplicate_payment.account_index = original_payment.account_index
|
||||
duplicate_payment.subaddress_index = original_payment.subaddress_index
|
||||
duplicate_payment.address = original_payment.address
|
||||
|
||||
# Set amounts - this duplicate payment is the exact amount received
|
||||
duplicate_payment.expected_amount = (
|
||||
tx_amount # What we "expected" (the duplicate amount)
|
||||
)
|
||||
duplicate_payment.received_amount = tx_amount # What we received
|
||||
|
||||
# Set status and confirmations
|
||||
duplicate_payment.status = CryptoPayment.STATUS_DOUBLEPAY_REFUND
|
||||
duplicate_payment.current_confirmations = tx.get("confirmations", 0)
|
||||
duplicate_payment.confirmations_required = original_payment.confirmations_required
|
||||
|
||||
# Set refund address if original has one
|
||||
duplicate_payment.refund_address = original_payment.refund_address
|
||||
duplicate_payment.shop_sweep_to_address = original_payment.shop_sweep_to_address
|
||||
|
||||
# Set timestamps
|
||||
now_ms = int(time.time() * 1000)
|
||||
duplicate_payment.created_timestamp = now_ms
|
||||
duplicate_payment.updated_timestamp = now_ms
|
||||
|
||||
# Set expiry (copy from original but mark as immediate for refund processing)
|
||||
duplicate_payment.expires_at = now_ms # Already "expired" since it's a duplicate
|
||||
|
||||
return duplicate_payment
|
||||
|
||||
|
||||
def _should_process_late_payment(payment, tx):
|
||||
"""Check if a payment should be processed as a late/edge case payment.
|
||||
|
||||
|
|
@ -97,30 +162,35 @@ def _should_process_late_payment(payment, tx):
|
|||
# DOGE/BTC amount is already in atomic units
|
||||
tx_amount = int(float(tx.get("amount", 0)) * 1e8)
|
||||
|
||||
# Edge Case 1 & 2: Late payments to expired/cancelled quotes
|
||||
if payment.status in [CryptoPayment.STATUS_EXPIRED, CryptoPayment.STATUS_CANCELLED]:
|
||||
return True
|
||||
|
||||
# Edge Case 3 & 4: Double payment - payment already confirmed but more funds arrived
|
||||
if payment.status in [
|
||||
CryptoPayment.STATUS_CONFIRMED,
|
||||
CryptoPayment.STATUS_CONFIRMED_OVERPAID,
|
||||
CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED
|
||||
] and payment.received_amount > 0:
|
||||
# Edge Case: Any payment with non-pending status means invalid multiple payment
|
||||
# ALL multiple payments are invalid - no legitimate double payments exist
|
||||
if payment.status != CryptoPayment.STATUS_PENDING and tx_amount > 0:
|
||||
current_total = payment.received_amount + tx_amount
|
||||
logger.info(f"Double payment detected for {payment.status} payment {payment.id}: received {payment.received_amount}, new {tx_amount}, total {current_total}")
|
||||
if current_total > payment.expected_amount:
|
||||
logger.info(
|
||||
f"INVALID multiple payment detected for {payment.status} payment {payment.id}: received {payment.received_amount}, new {tx_amount}, total {current_total}"
|
||||
)
|
||||
|
||||
# Log specific scenarios for clarity
|
||||
if payment.status in [
|
||||
CryptoPayment.STATUS_EXPIRED,
|
||||
CryptoPayment.STATUS_CANCELLED,
|
||||
]:
|
||||
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,
|
||||
]:
|
||||
logger.info(f"Double payment to completed order {payment.id}")
|
||||
elif current_total > payment.expected_amount:
|
||||
logger.info(
|
||||
f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.info(
|
||||
f"Invalid multiple payment to {payment.status} payment {payment.id}"
|
||||
)
|
||||
|
||||
# Edge Case 6: Underpayment top-up (partially paid, now getting more)
|
||||
if payment.status == CryptoPayment.STATUS_UNDERPAID or (
|
||||
payment.received_amount > 0
|
||||
and payment.received_amount < payment.expected_amount
|
||||
):
|
||||
logger.info(f"Underpayment top-up detected for payment {payment.id}")
|
||||
return True
|
||||
|
||||
# Edge Case: First payment to a fresh quote (normal processing should handle this, but just in case)
|
||||
|
|
@ -809,8 +879,9 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment, send_emails=Tru
|
|||
delete_invoice_for_terminal_state(env_request.dbsession, crypto_payment)
|
||||
|
||||
# Send notification email about the refund
|
||||
if invoice.user.email:
|
||||
if invoice.user.email and not crypto_payment.refund_email_sent:
|
||||
# TODO: Send out of stock refund notification email
|
||||
# When implemented, set: crypto_payment.refund_email_sent = True
|
||||
pass
|
||||
|
||||
return False # Invoice not finalized
|
||||
|
|
@ -862,18 +933,25 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment, send_emails=Tru
|
|||
# Create a request wrapper with shop's domain context for emails
|
||||
email_request = create_shop_context_request(env_request, crypto_payment)
|
||||
|
||||
send_purchase_email(
|
||||
email_request,
|
||||
invoice.user.email,
|
||||
[item.product for item in invoice.line_items],
|
||||
invoice.total,
|
||||
)
|
||||
send_sale_email(
|
||||
email_request,
|
||||
invoice.shop,
|
||||
[item.product for item in invoice.line_items],
|
||||
invoice.total,
|
||||
)
|
||||
# Check if purchase email has already been sent
|
||||
if not crypto_payment.purchase_email_sent:
|
||||
send_purchase_email(
|
||||
email_request,
|
||||
invoice.user.email,
|
||||
[item.product for item in invoice.line_items],
|
||||
invoice.total,
|
||||
)
|
||||
crypto_payment.purchase_email_sent = True
|
||||
|
||||
# Check if sales email has already been sent
|
||||
if not crypto_payment.sales_email_sent:
|
||||
send_sale_email(
|
||||
email_request,
|
||||
invoice.shop,
|
||||
[item.product for item in invoice.line_items],
|
||||
invoice.total,
|
||||
)
|
||||
crypto_payment.sales_email_sent = True
|
||||
|
||||
# Deduct inventory for physical products if a shop location is known
|
||||
if crypto_payment.shop_location:
|
||||
|
|
@ -903,11 +981,79 @@ def process_payment(
|
|||
# Initialize payment rescue if client is available
|
||||
payment_rescue = PaymentRescue(env_request.dbsession, client) if client else None
|
||||
|
||||
# Handle expiry
|
||||
# Handle duplicate payment refunds (special case - always refund with 9% fee)
|
||||
if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND:
|
||||
if incoming_transfers and payment_rescue and crypto_payment.refund_address:
|
||||
total_recv, _, _ = summarize_txs(incoming_transfers)
|
||||
if total_recv > 0:
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
received_crypto = Decimal(total_recv) / coin_config["atomic_units"]
|
||||
|
||||
# Use the user from original payment (find by subaddress)
|
||||
original_payment = (
|
||||
env_request.dbsession.query(CryptoPayment)
|
||||
.filter(
|
||||
CryptoPayment.coin_type == crypto_payment.coin_type,
|
||||
CryptoPayment.account_index == crypto_payment.account_index,
|
||||
CryptoPayment.subaddress_index
|
||||
== crypto_payment.subaddress_index,
|
||||
CryptoPayment.id != crypto_payment.id, # Not this duplicate
|
||||
CryptoPayment.received_amount > 0, # Has received payment
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if (
|
||||
original_payment
|
||||
and original_payment.invoice
|
||||
and original_payment.invoice.user
|
||||
):
|
||||
user = original_payment.invoice.user
|
||||
refund_details = payment_rescue.handle_expired_payment(
|
||||
crypto_payment, received_crypto, user
|
||||
)
|
||||
|
||||
if refund_details:
|
||||
# Update refund reason to be more specific
|
||||
refund_details["reason"] = (
|
||||
f"Duplicate payment: received {received_crypto} {crypto_payment.coin_type} to already-paid quote"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Duplicate payment {crypto_payment.id} eligible for refund: {refund_details}"
|
||||
)
|
||||
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, crypto_payment
|
||||
)
|
||||
if result["success"]:
|
||||
logger.info(
|
||||
f"Refund executed for duplicate payment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
)
|
||||
# Track the refund details
|
||||
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"]
|
||||
else:
|
||||
logger.error(
|
||||
f"Refund failed for duplicate payment {crypto_payment.id}: {result['error']}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Could not find original payment or user for duplicate {crypto_payment.id}"
|
||||
)
|
||||
crypto_payment.status = CryptoPayment.STATUS_NO_REFUND
|
||||
crypto_payment.refund_reason = (
|
||||
"Duplicate payment - could not find original user"
|
||||
)
|
||||
return # Early return - duplicate payments don't need further processing
|
||||
|
||||
# Handle expiry - early detection for refund scenario
|
||||
if crypto_payment.is_expired:
|
||||
crypto_payment.status = CryptoPayment.STATUS_EXPIRED
|
||||
crypto_payment.updated_timestamp = now_ms
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
|
||||
# Check if we received funds after expiry and can refund
|
||||
if (
|
||||
|
|
@ -917,6 +1063,48 @@ def process_payment(
|
|||
):
|
||||
total_recv, _, _ = summarize_txs(incoming_transfers)
|
||||
if total_recv > 0:
|
||||
# EARLY DETECTION: Mark for refund immediately (even in mempool)
|
||||
logger.info(
|
||||
f"EARLY DETECTION - Late payment to expired quote {crypto_payment.id}: "
|
||||
f"received {total_recv} atomic units - determining refund scenario"
|
||||
)
|
||||
|
||||
# Check if refund address exists to determine final status immediately
|
||||
if crypto_payment.refund_address:
|
||||
crypto_payment.status = CryptoPayment.STATUS_EXPIRED_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.refund_reason = (
|
||||
"Late payment - no refund address configured"
|
||||
)
|
||||
# Delete invoice for terminal state
|
||||
delete_invoice_for_terminal_state(
|
||||
env_request.dbsession, crypto_payment
|
||||
)
|
||||
logger.info(
|
||||
f"Expired payment {crypto_payment.id} marked as no-refund (no refund address)"
|
||||
)
|
||||
else:
|
||||
# No funds received - just mark as expired
|
||||
crypto_payment.status = CryptoPayment.STATUS_EXPIRED
|
||||
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
|
||||
# Execute refund logic if we have enough confirmations
|
||||
if (
|
||||
incoming_transfers
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
and crypto_payment.status == CryptoPayment.STATUS_EXPIRED_REFUNDED
|
||||
):
|
||||
total_recv, _, early_min_confs = summarize_txs(incoming_transfers)
|
||||
if (
|
||||
early_min_confs >= int(crypto_payment.confirmations_required)
|
||||
and 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
|
||||
|
|
@ -1305,77 +1493,69 @@ def process_payment(
|
|||
f"Auto-sweep failed for confirmed payment {crypto_payment.id}: {e}"
|
||||
)
|
||||
else:
|
||||
if (
|
||||
crypto_payment.received_amount > 0
|
||||
and crypto_payment.status != CryptoPayment.STATUS_RECEIVED
|
||||
):
|
||||
crypto_payment.status = CryptoPayment.STATUS_RECEIVED
|
||||
# Early detection - as soon as we receive ANY funds, determine the refund scenario
|
||||
# This gets payments out of the active processing queue immediately
|
||||
if crypto_payment.received_amount > 0:
|
||||
# Calculate confirmations for early detection
|
||||
if incoming_transfers:
|
||||
_, _, early_min_confs = summarize_txs(incoming_transfers)
|
||||
else:
|
||||
early_min_confs = 0
|
||||
|
||||
# Check for underpayment detection and processing
|
||||
if (
|
||||
payment_rescue
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
and crypto_payment.received_amount < crypto_payment.expected_amount
|
||||
):
|
||||
# Underpayment detected - handle immediately if we have enough confirmations,
|
||||
# or mark for future processing if still in mempool
|
||||
if min_confs >= int(crypto_payment.confirmations_required):
|
||||
# Check for underpayment first (most common case)
|
||||
if (
|
||||
payment_rescue
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
and crypto_payment.received_amount < crypto_payment.expected_amount
|
||||
and crypto_payment.status != CryptoPayment.STATUS_UNDERPAID_REFUNDED
|
||||
):
|
||||
# Underpayment detected - immediately mark for refund (even in mempool)
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
atomic_units = coin_config["atomic_units"]
|
||||
received_xmr = Decimal(crypto_payment.received_amount) / atomic_units
|
||||
expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units
|
||||
|
||||
logger.info(
|
||||
f"EARLY DETECTION - Underpayment for payment {crypto_payment.id}: "
|
||||
f"received {received_xmr} {crypto_payment.coin_type}, expected {expected_xmr} {crypto_payment.coin_type} "
|
||||
f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - marking for refund"
|
||||
)
|
||||
|
||||
# Check if refund address is available
|
||||
if crypto_payment.refund_address:
|
||||
refund_details = payment_rescue.handle_underpayment(
|
||||
crypto_payment,
|
||||
expected_xmr,
|
||||
received_xmr,
|
||||
crypto_payment.invoice.user,
|
||||
)
|
||||
# Set status immediately to get out of active queue
|
||||
crypto_payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED
|
||||
crypto_payment.refund_reason = f"Underpayment: received {received_xmr} but expected {expected_xmr}"
|
||||
|
||||
if refund_details:
|
||||
logger.info(
|
||||
f"Underpayment {crypto_payment.id} eligible for refund: {refund_details}"
|
||||
# Try refund if we have enough confirmations, otherwise wait
|
||||
if early_min_confs >= int(crypto_payment.confirmations_required):
|
||||
refund_details = payment_rescue.handle_underpayment(
|
||||
crypto_payment,
|
||||
expected_xmr,
|
||||
received_xmr,
|
||||
crypto_payment.invoice.user,
|
||||
)
|
||||
|
||||
# Set status to underpaid-refunded immediately when refund is determined
|
||||
crypto_payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED
|
||||
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, crypto_payment
|
||||
)
|
||||
if result["success"]:
|
||||
logger.info(
|
||||
f"Refund executed for underpayment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
if refund_details:
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, crypto_payment
|
||||
)
|
||||
# Status already set above
|
||||
# Track the refund details
|
||||
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"]
|
||||
else:
|
||||
logger.error(
|
||||
f"Refund failed for underpayment {crypto_payment.id}: {result['error']}"
|
||||
)
|
||||
# Status remains underpaid-refunded, refund will be retried later
|
||||
else:
|
||||
# Refund address exists but refund not possible for other reasons
|
||||
logger.warning(
|
||||
f"Underpayment {crypto_payment.id} has refund address but refund not possible"
|
||||
)
|
||||
crypto_payment.status = CryptoPayment.STATUS_NO_REFUND
|
||||
crypto_payment.refund_reason = (
|
||||
"Underpayment - refund conditions not met"
|
||||
)
|
||||
# Delete invoice for terminal state
|
||||
delete_invoice_for_terminal_state(
|
||||
env_request.dbsession, crypto_payment
|
||||
)
|
||||
if result["success"]:
|
||||
logger.info(
|
||||
f"Refund executed for underpayment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
)
|
||||
# Track the refund details
|
||||
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"]
|
||||
else:
|
||||
logger.error(
|
||||
f"Refund failed for underpayment {crypto_payment.id}: {result['error']}"
|
||||
)
|
||||
else:
|
||||
# No refund possible - no refund address configured
|
||||
logger.warning(
|
||||
|
|
@ -1389,17 +1569,39 @@ def process_payment(
|
|||
delete_invoice_for_terminal_state(
|
||||
env_request.dbsession, crypto_payment
|
||||
)
|
||||
else:
|
||||
# Underpayment detected but not enough confirmations yet - log and wait
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
atomic_units = coin_config["atomic_units"]
|
||||
received_xmr = Decimal(crypto_payment.received_amount) / atomic_units
|
||||
expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units
|
||||
logger.info(
|
||||
f"Underpayment detected for payment {crypto_payment.id}: "
|
||||
f"received {received_xmr} {crypto_payment.coin_type}, expected {expected_xmr} {crypto_payment.coin_type} "
|
||||
f"({min_confs}/{crypto_payment.confirmations_required} confirmations) - waiting for more confirmations"
|
||||
)
|
||||
|
||||
# Check for overpayment (exact match or overpaid)
|
||||
elif (
|
||||
crypto_payment.received_amount >= crypto_payment.expected_amount
|
||||
and crypto_payment.status != CryptoPayment.STATUS_RECEIVED
|
||||
):
|
||||
crypto_payment.status = CryptoPayment.STATUS_RECEIVED
|
||||
|
||||
# If this will become confirmed, check for overpayment scenario immediately
|
||||
if early_min_confs >= int(crypto_payment.confirmations_required):
|
||||
# Already confirmed logic will handle this below
|
||||
pass
|
||||
else:
|
||||
# Just received, check if it's an overpayment for early detection
|
||||
if crypto_payment.received_amount > crypto_payment.expected_amount:
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
atomic_units = coin_config["atomic_units"]
|
||||
received_xmr = (
|
||||
Decimal(crypto_payment.received_amount) / atomic_units
|
||||
)
|
||||
expected_xmr = (
|
||||
Decimal(crypto_payment.expected_amount) / atomic_units
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"EARLY DETECTION - Overpayment for payment {crypto_payment.id}: "
|
||||
f"received {received_xmr} {crypto_payment.coin_type}, expected {expected_xmr} {crypto_payment.coin_type} "
|
||||
f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - will refund excess when confirmed"
|
||||
)
|
||||
|
||||
# Legacy case - payment already marked as received
|
||||
elif crypto_payment.status != CryptoPayment.STATUS_RECEIVED:
|
||||
crypto_payment.status = CryptoPayment.STATUS_RECEIVED
|
||||
|
||||
# Note: Auto-sweep is now handled immediately when payment is confirmed (based on RPC confirmation data)
|
||||
|
||||
|
|
@ -1568,6 +1770,12 @@ 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
|
||||
# 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)"
|
||||
)
|
||||
else:
|
||||
# Fallback for any other status
|
||||
logger.info(
|
||||
|
|
@ -1758,14 +1966,40 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
|
||||
if payment and _should_process_late_payment(payment, tx):
|
||||
late_payments_found += 1
|
||||
logger.info(
|
||||
f"Found late payment to {payment.status} quote {payment.id}: "
|
||||
f"{tx.get('amount', 0) / 1e12:.12f} XMR, "
|
||||
f"{tx.get('confirmations', 0)} confirmations, height {tx.get('height', 0)}"
|
||||
)
|
||||
# Process this late payment
|
||||
incoming = [tx] # Process just this transfer
|
||||
process_payment(request, payment, incoming, client=client)
|
||||
|
||||
# Check if this is a duplicate payment (original already has funds)
|
||||
if payment.received_amount > 0:
|
||||
logger.info(
|
||||
f"DUPLICATE payment detected for {payment.status} quote {payment.id}: "
|
||||
f"{tx.get('amount', 0) / 1e12:.12f} XMR, "
|
||||
f"{tx.get('confirmations', 0)} confirmations, height {tx.get('height', 0)}"
|
||||
)
|
||||
|
||||
# Create new payment object for this duplicate
|
||||
duplicate_payment = _create_duplicate_payment(
|
||||
payment, tx, coin_type
|
||||
)
|
||||
db.add(duplicate_payment)
|
||||
db.flush() # Get the ID assigned
|
||||
|
||||
logger.info(
|
||||
f"Created duplicate payment {duplicate_payment.id} for refund processing"
|
||||
)
|
||||
|
||||
# Process the duplicate payment through refund pipeline
|
||||
incoming = [tx]
|
||||
process_payment(
|
||||
request, duplicate_payment, incoming, client=client
|
||||
)
|
||||
else:
|
||||
# First payment to this address - process normally
|
||||
logger.info(
|
||||
f"Found late payment to {payment.status} quote {payment.id}: "
|
||||
f"{tx.get('amount', 0) / 1e12:.12f} XMR, "
|
||||
f"{tx.get('confirmations', 0)} confirmations, height {tx.get('height', 0)}"
|
||||
)
|
||||
incoming = [tx]
|
||||
process_payment(request, payment, incoming, client=client)
|
||||
|
||||
# Update scan positions for all processors of this coin type
|
||||
if max_height > 0: # Only update if we found confirmed transfers
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlalchemy import (
|
|||
Numeric,
|
||||
UnicodeText,
|
||||
Unicode,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
|
@ -37,6 +38,8 @@ class CryptoPayment(RBase, Base):
|
|||
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"
|
||||
|
||||
# Active statuses that should be processed by the watcher
|
||||
ACTIVE_STATUSES = [
|
||||
|
|
@ -52,6 +55,7 @@ class CryptoPayment(RBase, Base):
|
|||
STATUS_EXPIRED_REFUNDED, # Refund sent, but may need more incoming confirmations
|
||||
STATUS_UNDERPAID_REFUNDED, # Refund sent, but may need more incoming confirmations
|
||||
STATUS_OUT_OF_STOCK_REFUNDED, # Refund sent, but may need more incoming confirmations
|
||||
STATUS_DOUBLEPAY_REFUND, # Double payment refund sent, needs confirmation
|
||||
]
|
||||
|
||||
# Terminal statuses that should not be processed
|
||||
|
|
@ -64,6 +68,7 @@ class CryptoPayment(RBase, Base):
|
|||
STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE,
|
||||
STATUS_NO_REFUND,
|
||||
STATUS_NO_REFUND_COMPLETE,
|
||||
STATUS_DOUBLEPAY_REFUND_COMPLETE,
|
||||
]
|
||||
|
||||
# Statuses that trigger redirect to crypto quotes history (refund/no-refund scenarios)
|
||||
|
|
@ -76,6 +81,8 @@ class CryptoPayment(RBase, Base):
|
|||
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
|
||||
]
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
|
|
@ -154,6 +161,17 @@ class CryptoPayment(RBase, Base):
|
|||
Integer, nullable=False, default=0
|
||||
) # Current number of confirmations
|
||||
|
||||
# Email tracking to prevent duplicates
|
||||
sales_email_sent = Column(
|
||||
Boolean, nullable=False, default=False
|
||||
) # Boolean: whether sale confirmation email has been sent
|
||||
purchase_email_sent = Column(
|
||||
Boolean, nullable=False, default=False
|
||||
) # Boolean: whether purchase confirmation email has been sent
|
||||
refund_email_sent = Column(
|
||||
Boolean, nullable=False, default=False
|
||||
) # Boolean: whether refund notification email has been sent
|
||||
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
"""add_email_tracking_columns_to_crypto_payment
|
||||
|
||||
Revision ID: 07908c8c840d
|
||||
Revises: 0f59018f6537
|
||||
Create Date: 2025-09-26 13:35:10.443559
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "07908c8c840d"
|
||||
down_revision = "0f59018f6537"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add email tracking columns to prevent duplicate emails
|
||||
op.add_column(
|
||||
"mps_crypto_payment",
|
||||
sa.Column("sales_email_sent", sa.Boolean(), nullable=False, default=False),
|
||||
)
|
||||
op.add_column(
|
||||
"mps_crypto_payment",
|
||||
sa.Column("purchase_email_sent", sa.Boolean(), nullable=False, default=False),
|
||||
)
|
||||
op.add_column(
|
||||
"mps_crypto_payment",
|
||||
sa.Column("refund_email_sent", sa.Boolean(), nullable=False, default=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove email tracking columns
|
||||
op.drop_column("mps_crypto_payment", "refund_email_sent")
|
||||
op.drop_column("mps_crypto_payment", "purchase_email_sent")
|
||||
op.drop_column("mps_crypto_payment", "sales_email_sent")
|
||||
|
|
@ -1109,6 +1109,7 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
mock_payment.id = "payment_123"
|
||||
mock_payment.status = CryptoPayment.STATUS_EXPIRED
|
||||
mock_payment.coin_type = "XMR"
|
||||
mock_payment.received_amount = 0 # No previous funds received
|
||||
|
||||
# Configure query chain - need to handle both CryptoProcessor and CryptoPayment queries
|
||||
def query_side_effect(model):
|
||||
|
|
@ -1237,6 +1238,7 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
mock_payment.id = "payment_456"
|
||||
mock_payment.status = CryptoPayment.STATUS_CANCELLED
|
||||
mock_payment.coin_type = "XMR"
|
||||
mock_payment.received_amount = 0 # No previous funds received
|
||||
|
||||
# Configure query chain
|
||||
def query_side_effect(model):
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ class TestInvoiceDeletion(unittest.TestCase):
|
|||
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE,
|
||||
CryptoPayment.STATUS_NO_REFUND,
|
||||
CryptoPayment.STATUS_NO_REFUND_COMPLETE,
|
||||
CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE,
|
||||
}
|
||||
|
||||
self.assertEqual(terminal_states_that_should_delete, expected_delete_states)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue