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:
Russell Ballestrini 2025-09-26 13:48:36 -04:00
parent aa477b129f
commit f58bd3e628
7 changed files with 444 additions and 113 deletions

View file

@ -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
View 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';

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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):

View file

@ -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)