diff --git a/Makefile b/Makefile index b1e6137..413de71 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,7 @@ help: @echo "WALLET MANAGEMENT:" @echo " make sweep-check - Check hot wallet balances (dry run)" @echo " make sweep - Sweep funds to cold storage" + @echo " make monero-transactions - View recent wallet transactions" @echo "" @echo "CLEANUP:" @echo " make clean - Remove virtual environment" @@ -197,6 +198,14 @@ sweep: venv config @echo "IMPORTANT: Set COLD_WALLET_ADDRESS environment variable first!" $(VENV_DIR)/bin/sweep_to_cold $(DATA_DIR)/$(CONFIG_FILE) $${COLD_WALLET_ADDRESS:-ADDRESS_NOT_SET} +# View recent wallet transactions (requires wallet RPC running) +monero-transactions: + @echo "Recent Monero wallet transactions:" + @curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"0","method":"get_transfers","params":{"in":true,"out":true}}' | \ + python3 -c "import sys, json; data = json.load(sys.stdin); print(json.dumps(data, indent=2))" + # ----------------------------------------------------------------------------- # Monero Infrastructure Targets # ----------------------------------------------------------------------------- diff --git a/development.ini b/development.ini index 8ff1aa7..68c182c 100644 --- a/development.ini +++ b/development.ini @@ -72,8 +72,8 @@ app.payments.dogecoin.enabled = ${MPS_PAYMENTS_DOGECOIN_ENABLED:-False} # Monero RPC Configuration # RPC endpoint of monero-wallet-rpc (recommend binding to localhost only) monero.rpc_url = ${MPS_MONERO_RPC_URL:-http://127.0.0.1:18083/json_rpc} -monero.rpc_user = ${MPS_MONERO_RPC_USER:-} -monero.rpc_pass = ${MPS_MONERO_RPC_PASS:-} +monero.rpc_user = ${MPS_MONERO_RPC_USER:-test_user} +monero.rpc_pass = ${MPS_MONERO_RPC_PASS:-test_pass} monero.account_index = ${MPS_MONERO_ACCOUNT_INDEX:-0} # Monero confirmation requirements by amount tier # Note: Payment thresholds are now per-shop settings (default $10 and $100) diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 9863ea1..a3fab58 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -1190,12 +1190,10 @@ def process_refund_confirmations(request, settings): db = request.dbsession - # Query payments that need refund confirmation monitoring + # Query payments that need refund confirmation monitoring OR retry refund_queue = ( db.query(CryptoPayment) .filter( - CryptoPayment.refund_tx_hash.isnot(None), - CryptoPayment.refund_confirmations < 10, CryptoPayment.status.in_( [ CryptoPayment.STATUS_EXPIRED_REFUNDED, @@ -1274,6 +1272,7 @@ def process_refund_confirmations(request, settings): refund_details["refund_amount"] * Decimal("1e12") ) + db.add(payment) # Mark for database commit logger.info( f"Refund retry successful for payment {payment.id}: {result['tx_hash']}" ) @@ -1319,8 +1318,27 @@ def process_refund_confirmations(request, settings): logger.info( f"Overpayment refund confirmed for payment {payment.id}: {old_status} → {payment.status}" ) + elif payment.status == CryptoPayment.STATUS_EXPIRED_REFUNDED: + payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED_COMPLETE + logger.info( + f"Expired refund fully confirmed for payment {payment.id}: {old_status} → {payment.status}" + ) + elif payment.status == CryptoPayment.STATUS_UNDERPAID_REFUNDED: + payment.status = ( + CryptoPayment.STATUS_UNDERPAID_REFUNDED_COMPLETE + ) + logger.info( + f"Underpaid refund fully confirmed for payment {payment.id}: {old_status} → {payment.status}" + ) + elif payment.status == CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED: + payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE + ) + logger.info( + f"Out-of-stock refund fully confirmed for payment {payment.id}: {old_status} → {payment.status}" + ) else: - # Other refund types are already in final status, just log confirmation + # Fallback for any other status logger.info( f"Refund fully confirmed for payment {payment.id} (status: {payment.status})" ) diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index b2ec911..2377178 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -29,9 +29,12 @@ class CryptoPayment(RBase, Base): STATUS_CONFIRMED_OVERPAID_REFUNDED = "confirmed-overpaid-refunded" STATUS_EXPIRED = "expired" STATUS_EXPIRED_REFUNDED = "expired-refunded" + STATUS_EXPIRED_REFUNDED_COMPLETE = "expired-refunded-complete" STATUS_UNDERPAID_REFUNDED = "underpaid-refunded" + STATUS_UNDERPAID_REFUNDED_COMPLETE = "underpaid-refunded-complete" STATUS_CANCELLED = "cancelled" STATUS_OUT_OF_STOCK_REFUNDED = "out-of-stock-refunded" + STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE = "out-of-stock-refunded-complete" STATUS_NO_REFUND = "no-refund" # Active statuses that should be processed by the watcher @@ -52,20 +55,23 @@ class CryptoPayment(RBase, Base): # Terminal statuses that should not be processed TERMINAL_STATUSES = [ STATUS_EXPIRED, - STATUS_EXPIRED_REFUNDED, - STATUS_UNDERPAID_REFUNDED, + STATUS_EXPIRED_REFUNDED_COMPLETE, + STATUS_UNDERPAID_REFUNDED_COMPLETE, STATUS_CONFIRMED_OVERPAID_REFUNDED, STATUS_CANCELLED, - STATUS_OUT_OF_STOCK_REFUNDED, + STATUS_OUT_OF_STOCK_REFUNDED_COMPLETE, STATUS_NO_REFUND, ] # Statuses that trigger redirect to crypto quotes history (refund/no-refund scenarios) REFUND_REDIRECT_STATUSES = [ STATUS_EXPIRED_REFUNDED, # Late payment refunded with 9% fee + STATUS_EXPIRED_REFUNDED_COMPLETE, # Late payment refund confirmed STATUS_UNDERPAID_REFUNDED, # Partial payment refunded with 9% fee + STATUS_UNDERPAID_REFUNDED_COMPLETE, # Partial payment refund confirmed STATUS_CONFIRMED_OVERPAID_REFUNDED, # Overpayment excess refunded with 9% fee STATUS_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) ] diff --git a/make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py b/make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py new file mode 100644 index 0000000..f41b0d6 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py @@ -0,0 +1,71 @@ +"""Make crypto_payment.invoice_id nullable for cancelled payments + +Revision ID: dd7466bfc690 +Revises: 5bbb5df5bf1b +Create Date: 2025-09-24 18:44:52.643251 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "dd7466bfc690" +down_revision = "5bbb5df5bf1b" +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +def upgrade(): + # Drop and recreate crypto_payment table with nullable invoice_id + # Safe to drop since no real customer data exists yet + op.drop_table("mps_crypto_payment") + + # Recreate table with complete schema and nullable invoice_id + op.create_table( + "mps_crypto_payment", + sa.Column("id", UUIDType, primary_key=True, index=True), + sa.Column( + "invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=True + ), + sa.Column( + "shop_location_id", + UUIDType, + sa.ForeignKey("mps_shop_location.id"), + nullable=True, + ), + sa.Column("address", sa.String(128), nullable=False), + sa.Column("account_index", sa.Integer, nullable=False), + sa.Column("subaddress_index", sa.Integer, nullable=False), + sa.Column("coin_type", sa.String(10), nullable=False), + sa.Column("expected_amount", sa.BigInteger, nullable=False), + sa.Column("received_amount", sa.BigInteger, nullable=False, default=0), + sa.Column("received_network_fee", sa.BigInteger, nullable=True), + sa.Column("rate_locked_usd_per_coin", sa.Numeric(18, 8), nullable=False), + sa.Column("quote_expires_at", sa.BigInteger, nullable=False), + sa.Column("confirmations_required", sa.Integer, nullable=False), + sa.Column("status", sa.String(32), nullable=False, default="pending"), + sa.Column("tx_hashes", sa.UnicodeText, nullable=False), + sa.Column("shop_sweep_to_address", sa.String(256), nullable=True), + sa.Column("refund_address", sa.String(256), nullable=True), + sa.Column("swept_amount", sa.BigInteger, nullable=True), + sa.Column("swept_tx_hash", sa.String(128), nullable=True), + sa.Column("swept_timestamp", sa.BigInteger, nullable=True), + sa.Column("swept_network_fee", sa.BigInteger, nullable=True), + sa.Column("current_confirmations", sa.Integer, nullable=False, default=0), + sa.Column("created_timestamp", sa.BigInteger, nullable=False), + sa.Column("updated_timestamp", sa.BigInteger, nullable=False), + sa.Column("refund_reason", sa.UnicodeText, nullable=True), + sa.Column("refund_tx_hash", sa.String(128), nullable=True), + sa.Column("refund_amount", sa.BigInteger, nullable=True), + ) + + +def downgrade(): + # Revert back to NOT NULL (but this could fail if there are NULL values) + op.alter_column( + "mps_crypto_payment", "invoice_id", existing_type=UUIDType, nullable=False + ) diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2 index 7d69860..206243f 100644 --- a/make_post_sell/templates/crypto_checkout.j2 +++ b/make_post_sell/templates/crypto_checkout.j2 @@ -18,11 +18,6 @@

Send exactly {{ amount_fmt }} {{ coin_symbol }} to this address:

{{ address }}
- -

Status: {{ status }}

-

- Confirmations: {{ current_confirmations or 0 }} / {{ confirmations_required or 0 }} -

{% if expires_at %} Expires in: --:-- @@ -49,6 +44,10 @@

Cart Total: ${{ '%.2f' % usd_total }}

+

Status: {{ status }}

+

+ Confirmations: {{ current_confirmations or 0 }} / {{ confirmations_required or 0 }} +

Conversion Rate: ${{ '%.2f' % usd_per_crypto }} USD per {{ coin_symbol }}

{% if coin_symbol == 'XMR' %}

Base Amount: {{ '%.12f' % amount_crypto_base }} {{ coin_symbol }}

diff --git a/make_post_sell/templates/crypto_quotes_history.j2 b/make_post_sell/templates/crypto_quotes_history.j2 index 547ed2d..6b03460 100644 --- a/make_post_sell/templates/crypto_quotes_history.j2 +++ b/make_post_sell/templates/crypto_quotes_history.j2 @@ -68,6 +68,10 @@ View Quote + {% elif payment.status.endswith('-refunded') or payment.status.endswith('-refunded-complete') or payment.status == 'cancelled' %} + + View Quote + {% else %} @@ -92,14 +96,14 @@ {% if payment.refund_tx_hash %} 💸 This payment was automatically refunded. - {% if payment.status == 'out-of-stock-refunded' %} + {% if payment.status == 'out-of-stock-refunded' or payment.status == 'out-of-stock-refunded-complete' %} The item became unavailable after your payment - full refund issued. {% else %} A 9% restocking fee was deducted to cover network costs. {% endif %} {% else %} ⏳ Refund pending - waiting for your payment to reach 10 confirmations before processing the refund. - {% if payment.status == 'out-of-stock-refunded' %} + {% if payment.status == 'out-of-stock-refunded' or payment.status == 'out-of-stock-refunded-complete' %} The item became unavailable after your payment - full refund will be issued. {% else %} A 9% restocking fee will be deducted to cover network costs. diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index 458e6d3..705dd48 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -522,17 +522,16 @@ def crypto_quote(request): return HTTPFound("/cart") # Access control: allow purchaser or shop owners/editors - invoice = crypto_payment.invoice user_can_access = False if request.user: - # Allow the purchaser (invoice owner) - if invoice.user_id == request.user.id: + # Allow the purchaser + if crypto_payment.user_id == request.user.id: user_can_access = True # Allow shop owners/editors - elif invoice.shop and ( - request.user.can_edit_shop(invoice.shop) - or request.user.can_own_shop(invoice.shop) + elif crypto_payment.shop and ( + request.user.can_edit_shop(crypto_payment.shop) + or request.user.can_own_shop(crypto_payment.shop) ): user_can_access = True @@ -557,7 +556,18 @@ def crypto_quote(request): user_refund_address = user_refund_addr_obj.address if user_refund_addr_obj else None # Calculate amounts for display - usd_total = float(crypto_payment.invoice.total) + # For cancelled payments, invoice is None, so calculate USD total from locked rate + if crypto_payment.invoice: + usd_total = float(crypto_payment.invoice.total) + else: + # Calculate USD total from crypto amount and locked rate + amount_crypto_with_fee = ( + crypto_payment.expected_amount / coin_info["smallest_unit_divisor"] + ) + usd_total = float( + amount_crypto_with_fee * float(crypto_payment.rate_locked_usd_per_coin) + ) + amount_crypto_with_fee = ( crypto_payment.expected_amount / coin_info["smallest_unit_divisor"] ) @@ -568,9 +578,7 @@ def crypto_quote(request): # Calculate the fee the same way it was calculated during payment creation if coin_type == "XMR" and crypto_payment.shop_sweep_to_address: # Calculate base amount from the total - base_amount_crypto = float(crypto_payment.invoice.total) / float( - crypto_payment.rate_locked_usd_per_coin - ) + base_amount_crypto = usd_total / float(crypto_payment.rate_locked_usd_per_coin) base_amount_piconero = int( base_amount_crypto * coin_info["smallest_unit_divisor"] ) @@ -955,7 +963,15 @@ def get_payment_status_info(status): "confirmed-overpaid": {"label": "Confirmed (Overpaid) ✓", "color": "#28a745"}, "expired": {"label": "Expired", "color": "#6c757d"}, "expired-refunded": {"label": "Expired - Refunded", "color": "#fd7e14"}, + "expired-refunded-complete": { + "label": "Expired - Refunded ✓", + "color": "#fd7e14", + }, "underpaid-refunded": {"label": "Underpaid - Refunded", "color": "#fd7e14"}, + "underpaid-refunded-complete": { + "label": "Underpaid - Refunded ✓", + "color": "#fd7e14", + }, "confirmed-overpaid-refunded": { "label": "Overpaid - Refunded", "color": "#fd7e14", @@ -965,6 +981,10 @@ def get_payment_status_info(status): "label": "Out of Stock - Refunded", "color": "#fd7e14", }, + "out-of-stock-refunded-complete": { + "label": "Out of Stock - Refunded ✓", + "color": "#fd7e14", + }, "no-refund": {"label": "No Refund Possible", "color": "#dc3545"}, } return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"})