Add Monero (XMR) payment support

This commit is contained in:
Russell Ballestrini 2025-09-22 01:45:54 +00:00
parent 0c8e1b4f89
commit f3cd1e7116
45 changed files with 7200 additions and 759 deletions

4
.gitignore vendored
View file

@ -30,3 +30,7 @@ test
*.dkim.key
caddy
monero-wallet-cli.log
monero-wallet-rpc.log

480
CRYPTO.rst Normal file
View file

@ -0,0 +1,480 @@
Cryptocurrency Payment System Documentation
===========================================
This document provides detailed information about the cryptocurrency payment system in Make Post Sell, including wallet management, security considerations, and operational procedures.
Introduction
------------
The cryptocurrency payment system in Make Post Sell works like a digital cash register that can accept Monero (and eventually other cryptocurrencies). Here's the key concepts:
**Why You Can't Use External Wallets (like Cake Wallet):**
The system needs to generate a unique address for each customer payment (like giving each customer a different invoice number). Only the wallet owner can create these subaddresses, and external wallets can't provide this functionality via API.
**How Money Flows:**
1. Customer pays → Your hot wallet receives it
2. Payment is detected and order is fulfilled automatically
3. You manually move funds to cold storage periodically
**Security Reality:**
- Your server holds the wallet keys (like leaving cash register unlocked)
- If server is hacked = funds at risk
- Best practice: Keep minimal funds, move to cold storage often
**The Architecture:**
- **monero-wallet-rpc**: The "cash register" software
- **crypto_watcher**: The "cashier" watching for payments
- **CryptoPayment**: The "receipt book" tracking who paid what
Think of it like running a physical store - you need your own cash register (can't use someone else's), you periodically empty it to a safe (cold wallet), and you keep receipts (database records) of all transactions.
Overview
--------
Make Post Sell supports cryptocurrency payments through a modular system designed to handle multiple coins. Currently, Monero (XMR) is implemented, with the architecture ready for Bitcoin, Litecoin, and other cryptocurrencies.
Architecture
------------
The crypto payment system consists of several components:
1. **CryptoPayment Model** - Database model tracking payment details
2. **Crypto Client Library** - RPC client for communicating with wallet daemons
3. **Crypto Views** - Web endpoints for payment flow
4. **Crypto Watcher** - Background service monitoring blockchain for payments
5. **Auto-Sweep** - Automatically transfers funds to cold storage after each payment
How Payments Work
-----------------
1. Customer selects "Pay with Crypto" at checkout
2. System generates a unique subaddress for the payment
3. Current exchange rate is locked for the quote duration
4. Customer sends payment to the generated address
5. Crypto watcher monitors for incoming transfers
6. Order is fulfilled when payment is confirmed
7. **Funds are immediately swept to shop's cold wallet** (keeps only 0.001 XMR for fees)
Per-Shop Wallet Configuration
-----------------------------
Each shop can have its own hot wallet account and cold storage address:
1. **Hot Wallet Account**: Automatically assigned when shop configures a cold wallet
2. **Cold Wallet Address**: Shop owner's secure wallet for receiving swept funds
To enable Monero payments for a shop:
1. Go to Shop Settings
2. Enter your cold wallet address in the Monero section
3. System automatically assigns a unique account index (one-time)
4. Shop is now ready to accept Monero payments
**Changing Cold Wallet Address**:
- You can update your cold wallet address anytime
- Your account index stays the same (no funds lost)
- Future sweeps will go to the new address
- Existing balance (0.001 XMR reserve) remains available
**Security Design**:
- Account indices are hidden from users to prevent confusion
- Each shop gets one permanent account index (doesn't change with cold wallet updates)
- Each shop's funds are isolated in separate accounts
- **Auto-sweep is mandatory** - funds move to cold storage immediately after confirmation
- Hot wallet never holds more than 0.001 XMR per account
Transaction Lifecycle Example
-----------------------------
Let's follow a complete user journey for a $10 digital product purchase:
**Customer Journey Begins**
**1. Product Discovery**
- Customer visits shop.example.com
- Browses products, finds a $10 digital guide
- Clicks "Add to Cart"
- Cart shows: 1 item, $10.00
**2. Checkout Process**
- Customer clicks "Checkout"
- Enters email: customer@email.com
- Sees payment options: "Pay with Stripe" or "Pay with Monero"
- Chooses "Pay with Monero"
**3. Payment Quote Generation** (T+0 seconds)
- System fetches current XMR/USD rate: 1 XMR = $300 USD
- Calculates price: $10 ÷ $300 = 0.03333333 XMR
- Displays to customer:
```
Amount Due: 0.03333333 XMR
Exchange Rate: $300.00/XMR
Quote Valid For: 15 minutes
```
**4. Payment Address Generation** (T+1 second)
- System assigns subaddress from shop's account (e.g., account 5, subaddress 147)
- Displays QR code and address: 87BqQYkugEzh6Tg3gmfDPD6u7w6rNwJrTZXSdWgMHMfT...
- CryptoPayment record created:
- Shop's cold wallet stored for later sweep
- Expected amount: 0.03333333 XMR
- Quote expiry timestamp set
**5. Customer Sends Payment** (T+2 minutes)
- Customer opens mobile Monero wallet
- Scans QR code or copies address
- Enters amount: 0.03333333 XMR
- Confirms transaction (network fee ~0.00001 XMR)
- Transaction broadcasts to Monero network
**6. Payment Detection** (T+2-3 minutes)
- crypto_watcher polls every 20 seconds
- Detects incoming transaction in mempool
- Status changes: "pending" → "received"
- Customer sees: "Payment received! Awaiting 10 confirmations..."
- Progress bar shows: 0/10 confirmations
**7. Confirmation Progress** (T+2 to T+22 minutes)
- Monero blocks average 2 minutes each
- Customer refreshes page, sees progress:
- 2 confirmations (T+6 min): "2/10 confirmations"
- 5 confirmations (T+12 min): "5/10 confirmations"
- 8 confirmations (T+18 min): "8/10 confirmations"
**8. Payment Fully Confirmed** (T+22 minutes)
- 10th confirmation reached
- Status changes: "received" → "confirmed"
- Customer sees: "Payment confirmed! ✓"
- Invoice finalized:
- Digital download link appears
- Email sent: "Your purchase from Shop Name"
- Shop owner notified: "New sale: $10.00 (0.03333333 XMR)"
**9. Customer Downloads Product** (T+23 minutes)
- Customer clicks download link
- Gets their digital guide PDF
- Happy customer, transaction complete from their perspective
**Behind the Scenes - Shop Gets Paid**
**10. Automatic Sweep Triggers** (T+22 minutes, 5 seconds)
- Immediately after confirmation, auto-sweep initiates
- Balance check: Account 5 has 0.03333333 XMR
- Sweep calculation: 0.03333333 - 0.001 (keep for fees) = 0.03233333 XMR
- Transfer initiated to shop's cold wallet: 4A1s7n9...
**11. Sweep Complete** (T+24 minutes)
- Sweep transaction confirmed
- Hot wallet account 5 balance: 0.001 XMR (ready for next customer)
- Shop's cold wallet receives: 0.03233333 XMR
- At $300/XMR, shop owner has $9.70 (after fee reserve)
**Total Timeline**:
- Customer experience: ~23 minutes (browse to download)
- Full settlement: ~24 minutes (payment to cold storage)
**Financial Summary**:
- Product price: $10.00
- Customer paid: 0.03333333 XMR ($10.00)
- Network fee: ~0.00001 XMR ($0.003)
- Shop received: 0.03233333 XMR ($9.70)
- Fee reserve kept: 0.001 XMR ($0.30)
**Key Points**:
- Customer experience is smooth - they get their product in ~23 minutes
- Shop gets paid automatically with no manual intervention
- Hot wallet exposure: maximum 22 minutes, only 0.033 XMR
- Each shop's funds stay completely isolated
- System handles everything: quote, payment detection, fulfillment, sweep
**What Could Go Wrong**:
1. **Customer Underpays**: Sends 0.03 instead of 0.03333333 XMR
- Payment detected but never confirms
- Manual intervention needed or customer adds missing amount
2. **Quote Expires**: Customer takes 20 minutes to send
- Quote expired after 15 minutes
- Customer needs new quote at potentially different rate
3. **Network Congestion**: Monero network is busy
- Confirmations take 3-4 minutes per block instead of 2
- Customer waits longer but system handles it
4. **Wrong Copy/Paste**: Customer sends to wrong address
- Funds lost (no recovery possible)
- Importance of QR codes to avoid this
5. **Shop Misconfiguration**: Shop entered wrong cold wallet
- Sweep succeeds but funds go to wrong wallet
- Unrecoverable - emphasizes importance of testing
Wallet Configuration
--------------------
**Creating a Wallet**
For production use, create a dedicated wallet::
# Create new wallet
monero-wallet-cli --generate-new-wallet=/path/to/mps-wallet
# IMPORTANT: Save the 25-word mnemonic seed securely!
# This is your only way to recover funds if the wallet file is lost
**Running the Wallet RPC**
The wallet must be accessible via RPC. You have two options:
**Option 1: Production Setup (Run Your Own Node)**
This is the most secure and reliable setup::
# Terminal 1: Start your own Monero node (daemon)
# This downloads ~150GB blockchain and uses P2P to stay synced
monerod --data-dir=/path/to/blockchain \
--rpc-bind-ip=127.0.0.1 \
--rpc-bind-port=18081 \
--confirm-external-bind
# Terminal 2: Start wallet RPC (after daemon syncs)
monero-wallet-rpc \
--rpc-bind-ip=127.0.0.1 \
--rpc-bind-port=18083 \
--disable-rpc-login \
--wallet-file=/path/to/mps-wallet \
--daemon-address=127.0.0.1:18081 \
--trusted-daemon
**Option 2: Development Setup (Use Remote Node)**
Quicker to start but less private and relies on external service::
monero-wallet-rpc \
--rpc-bind-ip=127.0.0.1 \
--rpc-bind-port=18083 \
--disable-rpc-login \
--wallet-file=/path/to/mps-wallet \
--daemon-address=node.moneroworld.com:18089 \
--trusted-daemon
**Using the Makefile**
The included Makefile provides convenient targets::
# First time setup
make monero-wallet-create # Create a new wallet
# Production (your own node)
make monero-node # Terminal 1: Start blockchain node
make monero-wallet # Terminal 2: Start wallet RPC
# Development (remote node)
make monero-wallet-remote # Start wallet with public node
# View all options
make monero-full-stack # Shows complete setup instructions
Security notes:
- ``--disable-rpc-login`` is safe only when binding to localhost
- For remote access, use ``--rpc-login username:password``
- Running your own node ensures privacy and reliability
- The daemon automatically uses P2P to find peers and stay synchronized
**Account Structure**
Monero wallets contain multiple accounts, each with many subaddresses:
- **Account 0** (default): Main receiving account
- **Account 1+**: Can be used for organization (optional)
- **Subaddresses**: Unique addresses generated per payment
The ``MPS_MONERO_ACCOUNT_INDEX`` environment variable (default: 0) specifies which account to use.
Wallet Management
-----------------
**Checking Balance**
View wallet balance and incoming payments::
# Via CLI (stop RPC first)
monero-wallet-cli --wallet-file=/path/to/mps-wallet
[wallet]: balance
[wallet]: show_transfers
# Via RPC
curl -X POST http://127.0.0.1:18083/json_rpc -d '
{
"jsonrpc":"2.0",
"id":"0",
"method":"get_balance",
"params":{"account_index":0}
}'
**Sending Funds (Hot Wallet Management)**
The hot wallet runs as a service with mandatory auto-sweep:
1. **Automatic Post-Payment Sweep**:
Every confirmed payment triggers an immediate sweep to the shop's cold wallet.
Only 0.001 XMR remains for future transaction fees.
2. **Manual Sweep Commands** (for maintenance/recovery):
Make Post Sell includes a ``sweep_to_cold`` command for automated cold storage transfers:
**Per-Shop Sweeping**::
# Sweep a specific shop's wallet (uses shop's configured cold wallet)
sweep_to_cold development.ini --shop-id SHOP_UUID --dry-run
sweep_to_cold development.ini --shop-id SHOP_UUID
# Sweep all shops with configured wallets
sweep_to_cold development.ini --all-shops --dry-run
sweep_to_cold development.ini --all-shops
# Aggressive sweep all shops
sweep_to_cold development.ini --all-shops --sweep-all
**Command Options**:
- ``config_uri``: Path to your configuration file (required)
- ``--shop-id``: Sweep specific shop's wallet using its configured cold address
- ``--all-shops``: Sweep all shops with configured wallets
- ``--sweep-all``: Aggressive mode, keep only 0.001 XMR (~7-8 transactions)
- ``--account-index``: Override wallet account to sweep from (default: shop's configured account)
- ``--priority``: Transaction priority 0-3, affects fee (default: 1)
- ``--dry-run``: Check balance without sending funds
**Security Notes**:
- The cold wallet address should be from a hardware wallet or air-gapped system
- Test with ``--dry-run`` first to verify balance calculations
- Monitor the sweep log file for any errors
- Consider setting up alerts for failed sweeps
**Cold Storage Best Practices**
1. **Minimize Hot Wallet Exposure**:
- Keep only enough XMR for daily operations
- Sweep excess funds to cold storage regularly
2. **Hardware Wallet Integration**:
- Use a hardware wallet (Ledger/Trezor) for cold storage
- Generate cold storage address offline
3. **Multi-signature Setup** (Advanced):
- Use 2-of-3 multisig for large amounts
- Requires multiple parties to authorize transfers
Security Considerations
-----------------------
**Hot Wallet Risks**
The wallet on the server is a "hot wallet" with inherent risks:
- Server compromise = potential fund loss
- No way to use external wallets (Cake Wallet, etc.) due to subaddress generation requirement
- Must trust the server environment
**Mitigation Strategies**
1. **Access Controls**:
- Restrict RPC to localhost only
- Use strong authentication if remote access needed
- Monitor access logs
2. **Operational Security**:
- Regular security updates
- Encrypted wallet file storage
- Secure backup procedures
- Audit trail for all transfers
3. **Monitoring**:
- Set up alerts for large incoming payments
- Monitor wallet balance changes
- Track unusual access patterns
Backup and Recovery
-------------------
**Backup Requirements**
1. **Mnemonic Seed** (Critical):
- 25-word recovery phrase
- Store offline in multiple secure locations
- Never store digitally on the server
2. **Wallet Files**:
- Regular backups of wallet file
- Include wallet.keys file
- Encrypt backups
**Recovery Procedures**
From mnemonic seed::
monero-wallet-cli --restore-deterministic-wallet
# Enter 25-word mnemonic
# Specify restore height to speed up sync
From wallet file backup::
# Copy wallet and wallet.keys files
cp backup/mps-wallet* /path/to/
# Start normally
Troubleshooting
---------------
**Common Issues**
1. **RPC Connection Failed**:
- Check monero-wallet-rpc is running
- Verify firewall allows localhost:18083
- Check RPC URL in configuration
2. **Payments Not Detected**:
- Ensure crypto_watcher is running
- Check wallet sync status
- Verify confirmations requirement
3. **Subaddress Generation Fails**:
- Check wallet is not locked
- Verify account index exists
- Ensure RPC has wallet access
**Debug Commands**
Check wallet sync status::
curl -X POST http://127.0.0.1:18083/json_rpc -d '
{"jsonrpc":"2.0","id":"0","method":"get_height"}'
List recent transfers::
curl -X POST http://127.0.0.1:18083/json_rpc -d '
{
"jsonrpc":"2.0","id":"0","method":"get_transfers",
"params":{"in":true,"out":true,"pending":true,"failed":true}
}'
References
----------
- Monero Documentation: https://www.getmonero.org/resources/
- Monero RPC Documentation: https://www.getmonero.org/resources/developer-guides/wallet-rpc.html
- Monero Security Best Practices: https://www.getmonero.org/resources/user-guides/securely_purchase.html

313
CRYPTO_PROBLEMS.rst Normal file
View file

@ -0,0 +1,313 @@
Cryptocurrency Payment Problems and Solutions
=============================================
This document outlines the various failure modes in cryptocurrency payment processing and proposed solutions for each.
Transaction Failure Scenarios
-----------------------------
1. Underpayment
~~~~~~~~~~~~~~~
**Problem**: Customer sends less than the required amount (e.g., $9.50 instead of $10)
**Current Behavior**:
- Order not fulfilled (remains 'pending' status)
- Automatic refund minus 9% restocking fee (if refund address configured) (✓ RESOLVED)
- Remaining funds auto-swept to cold wallet after refund (✓ RESOLVED)
**Implemented Solutions**:
- ✓ PaymentRescue.handle_underpayment() processes partial payments
- ✓ Automatic refund minus 9% restocking fee (covers network costs)
- ✓ Auto-sweep ensures partial payments don't accumulate in hot wallet
- ✓ Minimal reserve (0.001 XMR) maintained for operations
**Remaining Solutions Needed**:
- **WANT**: Implement configurable underpayment tolerance (e.g., accept if within 1%)
- **WANT**: Add manual order completion option in shop admin panel
- **WANT**: Track all payment attempts for accounting/recovery
- Allow grace period for customer to send remaining amount
2. Overpayment
~~~~~~~~~~~~~~
**Problem**: Customer sends more than required amount (e.g., $12 instead of $10)
**Current Behavior**:
- If payment >= expected, order completes
- Automatic refund of excess minus 9% restocking fee (if refund address configured) (✓ RESOLVED)
- Remaining funds auto-swept to cold wallet after refund (✓ RESOLVED)
**Implemented Solutions**:
- ✓ Optional "refund address" field at checkout (stored in crypto_payment.refund_address)
- ✓ Automatic overpayment refunds minus 9% restocking fee (prevents abuse)
- ✓ 5% overpayment tolerance before triggering refund
- ✓ Auto-sweep prevents excess funds from accumulating in hot wallet
- ✓ PaymentRescue class handles all refund logic
**Remaining Solutions Needed**:
- Credit overpayment to customer's account for future purchases (alternative to refund)
- Allow shop to set custom overpayment handling policy
- Track overpayments separately for accounting
3. Payment After Quote Expiry
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: Customer sends correct amount after 15-minute pricing window
**Current Behavior**:
- Quote marked as 'expired' after time window
- Automatic refund minus 9% restocking fee (if refund address configured) (✓ RESOLVED)
- NO grace period - strict expiry enforced as shown in template (✓ RESOLVED)
- Remaining funds auto-swept to cold wallet after refund (✓ RESOLVED)
**Implemented Solutions**:
- ✓ PaymentRescue.handle_expired_payment() processes late payments
- ✓ Automatic refund minus 9% restocking fee
- ✓ 15-minute quote window configurable via monero.quote_expiry_seconds
- ✓ Clear warning in checkout UI about strict expiry time
- ✓ Auto-sweep ensures expired payment funds don't accumulate
**Remaining Solutions Needed**:
- **WANT**: Send email notification to customer about expired payment
- **WANT**: Allow manual order completion by shop owner (override)
- **WANT**: Track expired payments for analytics
4. Wrong Address
~~~~~~~~~~~~~~~~
**Problem**: Customer sends to incorrect address (typo or old address)
**Current Behavior**:
- Funds permanently lost (if invalid address)
- Funds go to wrong recipient (if valid but wrong address)
**Solutions**:
- Display address as QR code to reduce typing errors
- Implement address verification/checksum display
- Warn users that addresses are single-use
- Add copy-to-clipboard functionality
- Use payment URIs with amount included
5. Double Payment
~~~~~~~~~~~~~~~~~
**Problem**: Customer accidentally pays twice for same order
**Current Behavior**:
- First payment completes order
- Second payment automatically swept to shop's cold wallet (✓ RESOLVED)
**Implemented Solutions**:
- ✓ Refund address collected at checkout for potential refunds
- ✓ Auto-sweep prevents duplicate payments from accumulating
**Remaining Solutions Needed**:
- Detect and flag duplicate payments within time window
- Auto-refund second payment if refund address known
- **WANT**: Email notification about duplicate payment
- Add "payment already received" status check
6. Network/Mempool Congestion
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: Transaction stuck in mempool, confirmations delayed
**Current Behavior**:
- Order remains pending
- May arrive after quote expiry
**Solutions**:
- Implement 0-conf acceptance for trusted customers
- Dynamic confirmation requirements based on network conditions
- Accept transaction once seen in mempool for small amounts
- Email updates about confirmation progress
- Allow RBF (Replace-By-Fee) for stuck transactions
7. Insufficient Confirmations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: Payment received but not enough confirmations before timeout
**Current Behavior**:
- System uses risk-based confirmation requirements (✓ RESOLVED)
- Order waits for required confirmations based on amount/product type
**Implemented Solutions**:
- ✓ Risk-based confirmation requirements (petty/mid/high tiers)
- ✓ Fewer confirmations for digital goods (2/10/20 defaults)
- ✓ Physical products always use maximum confirmations
- ✓ Per-shop configurable risk thresholds
**Remaining Solutions Needed**:
- Implement confirmation timeout extension
- Progressive order fulfillment (partial access)
- Customer notification of confirmation progress
8. Exchange Rate Fluctuation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: Crypto value changes significantly during payment window
**Current Behavior**:
- Customer may pay correct crypto amount but USD value differs
- Treated as under/overpayment
**Solutions**:
- Implement rate-lock tolerance (e.g., ±5%)
- Use shorter quote windows during volatile periods
- Offer "pay extra" option if rate moves against customer
- Multiple rate sources for better accuracy
- Allow payment in stablecoins
9. Invalid Cold Wallet Configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: Shop owner enters invalid sweep destination address
**Current Behavior**:
- Sweep operation fails but order still completes
- Failed sweeps are logged but don't block fulfillment
**Implemented Solutions**:
- ✓ Auto-sweep maintains minimal reserve (0.001 XMR) to prevent total accumulation
- ✓ All sweep attempts are logged for debugging
- ✓ Failed sweeps don't block order completion
**Remaining Solutions Needed**:
- **NEED**: Validate cold wallet address format on entry
- **NEED**: Alert shop owner of failed sweeps via email/admin panel
- **NEED**: Implement sweep retry logic with exponential backoff
- **WANT**: Test sweep with minimal amount on configuration
10. RPC Node Failure
~~~~~~~~~~~~~~~~~~~~
**Problem**: Cannot communicate with blockchain node
**Current Behavior**:
- Cannot detect incoming payments
- Cannot create new addresses
- Cannot perform sweeps
**Remaining Solutions Needed**:
- **NEED**: Configure multiple backup nodes
- **NEED**: Automatic failover to backup nodes
- **WANT**: Node health monitoring dashboard
- Cache recent blockchain data
- Manual payment verification fallback
11. Blockchain Reorganization
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: Confirmed transaction gets reversed in chain reorg
**Current Behavior**:
- Product already delivered
- Payment no longer valid
**Implemented Solutions**:
- ✓ Higher confirmation requirements for high-value orders (20 confirms)
- ✓ Physical products always use maximum confirmations
**Note**: Further reorg protection marked as TRASH (over-engineering for extremely rare edge case)
General Solutions Architecture
------------------------------
1. **Payment Tracking System**
- Record ALL incoming transactions
- Track payment attempts, not just successful payments
- Link payments to addresses, quotes, and customers
2. **Refund Infrastructure**
- Optional refund address collection at checkout
- Manual refund initiation by shop admin
- Automated refund for specific scenarios
- Refund policy configuration per shop
3. **Communication Layer**
- Email notifications for payment issues
- In-app notifications for customers
- Admin alerts for shop owners
- Status page for payment processing
4. **Policy Framework**
- Configurable tolerance levels per shop
- Clear Terms of Service templates
- Automated policy enforcement
- Audit trail for all decisions
5. **Recovery Mechanisms**
- Manual payment verification tools
- Order completion override for admins
- Payment matching algorithms
- Orphaned fund recovery process
6. **Implemented Auto-Sweep Configuration**
The system now implements automatic sweeping of confirmed payments to minimize hot wallet exposure:
**Risk-Based Confirmation Requirements**:
- Confirmation thresholds are configured in INI files per coin type
- Three tiers based on payment amount and product type:
* **Petty Tier** (default: 2 confirmations for XMR):
- Amounts below shop's mid-tier threshold (default $10)
- Digital products only
- Configurable via: `monero.confirmations.petty`
* **Mid Tier** (default: 10 confirmations for XMR):
- Amounts between mid and high thresholds ($10-$100 default)
- Digital products only
- Configurable via: `monero.confirmations.mid`
* **High Tier** (default: 20 confirmations for XMR):
- Amounts above high threshold ($100+)
- ALL physical products regardless of amount
- Configurable via: `monero.confirmations.high`
**Auto-Sweep Process**:
- Executes immediately after required confirmations are reached
- Uses "sweep_all" to transfer entire account balance to cold wallet
- Each shop has dedicated Monero account (prevents cross-shop fund mixing)
- Account isolation: Shop A uses account 0, Shop B uses account 1, etc.
- Multiple payments to same shop pool in their account until swept
- Not 1:1 payment-to-sweep mapping (more efficient, fewer transactions)
- Zero balance handling: If account already swept, payment marked as "pooled_sweep"
- Failed sweeps are logged but don't block order fulfillment
**Per-Shop Risk Thresholds**:
- `payment_risk_threshold_mid_cents`: Boundary for petty/mid tiers
- `payment_risk_threshold_high_cents`: Boundary for mid/high tiers
- Shops can customize based on their risk tolerance
- Physical products always use maximum confirmations
Implementation Status & Priority
--------------------------------
**Completed Features**:
- ✓ Auto-sweep with minimal hot wallet exposure
- ✓ Risk-based confirmation tiers (petty/mid/high)
- ✓ Refund address collection at checkout
- ✓ Per-shop configurable risk thresholds
- ✓ Sweep failure logging and non-blocking order completion
- ✓ Configurable quote expiry windows
- ✓ Automated refund system with 9% restocking fee:
- ✓ Underpayment refunds (minus fee)
- ✓ Overpayment refunds (excess minus fee, 5% tolerance)
- ✓ Expired payment refunds (minus fee)
- ✓ PaymentRescue class handles all refund scenarios
- ✓ Clear payment rules displayed in checkout UI
**NEED TO HAVE** (Security/Operational Critical):
- **Invoice payment status tracking**: ✓ RESOLVED - Added derived properties to Invoice model (payment_status, payment_method, is_paid) that read from related CryptoPayment when present
- **Monero account creation**: ✓ RESOLVED - Now creates dedicated Monero accounts via RPC when shops configure crypto processors
- Cold wallet address validation on entry (prevent fund loss)
- Sweep failure alerts to shop owners (prevent hot wallet accumulation)
- Multiple/fallback RPC node support (prevent downtime)
- Sweep retry logic with exponential backoff (reliability)
**WANT TO HAVE** (Good UX/Business Value):
- Failed payment email notifications to customers
- Manual order completion tools in admin panel
- Payment attempt tracking for ALL transactions (analytics)
- Underpayment tolerance configuration (e.g., accept if within 1%)
- Customer crypto payment history tracking
**TRASH** (Over-engineering/Low ROI):
- 0-conf acceptance for trusted customers (high risk, complex trust system)
- Progressive order fulfillment for digital goods (unnecessarily complex)
- Blockchain reorg detection and protection (rare edge case)
- Payment status webhooks/callbacks (polling works fine)
- Credit system for overpayments (refunds work, adds account complexity)
- Custom refund policies per shop (9% standard works for all)

226
Makefile
View file

@ -13,8 +13,51 @@ PSERVE = $(VENV_DIR)/bin/pserve
ALEMBIC = $(VENV_DIR)/bin/alembic
MPS_INIT = $(VENV_DIR)/bin/initialize_make_post_sell_db
# Default target: install from PyPI and then start the server.
all: install-from-pypi serve
# Default target: show help
.DEFAULT_GOAL := help
# Help target - shows all available commands
help:
@echo "Make Post Sell - Available Commands"
@echo "==================================="
@echo ""
@echo "SETUP & INSTALLATION:"
@echo " make venv - Create Python virtual environment"
@echo " make config - Download configuration file"
@echo " make install-from-pypi - Complete setup using PyPI packages"
@echo " make install-from-source - Complete setup from source (dev mode)"
@echo " make install-from-source-prod - Production install from source"
@echo ""
@echo "DATABASE:"
@echo " make init-db - Initialize the database"
@echo ""
@echo "DEVELOPMENT:"
@echo " make serve - Start development server with auto-reload"
@echo " make test - Run test suite"
@echo " make test-coverage - Run tests with coverage report"
@echo " make http - Start simple HTTP server on port 8000"
@echo " make activate - Show how to activate virtual environment"
@echo ""
@echo "CRYPTOCURRENCY (MONERO):"
@echo " make check-monero - Check if Monero tools are installed"
@echo " make install-monero - Install Monero tools automatically"
@echo " make monero-wallet-create - Create new Monero wallet (first time)"
@echo " make monero-node - Start local Monero node (150GB required)"
@echo " make monero-wallet - Start wallet RPC with local node"
@echo " make monero-wallet-remote - Start wallet RPC with remote node (dev)"
@echo " make monero-full-stack - Show instructions for complete setup"
@echo " make crypto-watcher - Start payment monitoring service"
@echo " make crypto-watcher-once - Run payment check once (testing)"
@echo ""
@echo "WALLET MANAGEMENT:"
@echo " make sweep-check - Check hot wallet balances (dry run)"
@echo " make sweep - Sweep funds to cold storage"
@echo ""
@echo "CLEANUP:"
@echo " make clean - Remove virtual environment"
@echo ""
@echo "For more info, see README.md and CRYPTO.rst"
# -----------------------------------------------------------------------------
# Environment Setup Targets
@ -123,6 +166,185 @@ http: venv
@echo "Starting simple HTTP server on port 8000..."
$(PYTHON) -m http.server 8000
# Run the crypto watcher service for monitoring Monero payments.
crypto-watcher: venv config
@echo "Starting crypto payment watcher..."
$(VENV_DIR)/bin/crypto_watcher $(DATA_DIR)/$(CONFIG_FILE)
# Run the crypto watcher once (for testing or manual processing).
crypto-watcher-once: venv config
@echo "Running crypto payment watcher once..."
$(VENV_DIR)/bin/crypto_watcher $(DATA_DIR)/$(CONFIG_FILE) --once
# Check hot wallet balance (dry run).
sweep-check: venv config
@echo "Checking hot wallet balance..."
@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} --dry-run
# Sweep excess funds to cold storage.
sweep: venv config
@echo "Sweeping excess funds to cold storage..."
@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}
# -----------------------------------------------------------------------------
# Monero Infrastructure Targets
# -----------------------------------------------------------------------------
# Start the Monero daemon (blockchain node) - requires ~150GB disk space
monero-node: venv config check-monero
@echo "Starting Monero daemon (monerod)..."
@echo "This will download ~150GB blockchain data and may take 1-2 days to sync"
@mkdir -p $(DATA_DIR)/monero-blockchain
@echo "Checking available disk space..."
@available=$$(df -BG $(DATA_DIR) | tail -1 | awk '{print $$4}' | sed 's/G//'); \
if [ $$available -lt 200 ]; then \
echo "ERROR: Insufficient disk space!"; \
echo "Available: $${available}GB"; \
echo "Required: 200GB+ (150GB blockchain + growth)"; \
exit 1; \
else \
echo "Disk space OK: $${available}GB available"; \
fi
@echo "Check sync status at: http://127.0.0.1:18081/get_info"
@echo "Press Ctrl+C to stop"
monerod --data-dir=$(DATA_DIR)/monero-blockchain \
--rpc-bind-ip=127.0.0.1 \
--rpc-bind-port=18081 \
--confirm-external-bind \
--log-level=1
# Start the Monero wallet RPC (requires monerod or remote node)
monero-wallet: venv config check-monero
@echo "Starting Monero wallet RPC..."
@echo "Make sure monerod is running and synced first!"
@echo "Wallet file: $(DATA_DIR)/mps-wallet"
@echo "RPC will be available at: http://127.0.0.1:18083"
monero-wallet-rpc \
--wallet-file=$(DATA_DIR)/mps-wallet \
--password-file=$(DATA_DIR)/wallet-password.txt \
--rpc-bind-ip=127.0.0.1 \
--rpc-bind-port=18083 \
--disable-rpc-login \
--daemon-address=127.0.0.1:18081 \
--trusted-daemon \
--log-level=1
# Development mode - use remote node (no blockchain download needed)
monero-wallet-remote: venv config check-monero
@echo "Starting wallet with REMOTE node (development/testing only)..."
@echo "Using public node - less private but no blockchain download"
@echo "Wallet file: $(DATA_DIR)/mps-wallet"
@echo "RPC will be available at: http://127.0.0.1:18083"
@echo ""
@echo "Trying primary node: opennode.xmr-tw.org:18089"
monero-wallet-rpc \
--wallet-file=$(DATA_DIR)/mps-wallet \
--password-file=$(DATA_DIR)/wallet-password.txt \
--rpc-bind-ip=127.0.0.1 \
--rpc-bind-port=18083 \
--disable-rpc-login \
--daemon-address=opennode.xmr-tw.org:18089 \
--trusted-daemon \
--log-level=1
# Install Monero tools automatically
install-monero:
@echo "Installing Monero tools..."
@if [ "$$(uname)" = "Linux" ]; then \
mkdir -p $(HOME)/.local/bin && \
cd /tmp && \
wget -q --show-progress https://downloads.getmonero.org/cli/linux64 && \
tar -xf linux64 && \
cp monero-x*/monero* $(HOME)/.local/bin/ && \
rm -rf monero-x* linux64 && \
echo "✓ Monero tools installed to $(HOME)/.local/bin/" && \
echo "" && \
echo "Add to your PATH by running:" && \
echo " export PATH=\"$(HOME)/.local/bin:\$$PATH\"" && \
echo "Or add that line to your ~/.bashrc or ~/.zshrc" && \
echo "" && \
echo "Then run 'make monero-wallet-create' to create a wallet"; \
elif [ "$$(uname)" = "Darwin" ]; then \
if command -v brew >/dev/null 2>&1; then \
brew install monero; \
else \
echo "Please install Homebrew first: https://brew.sh"; \
exit 1; \
fi; \
else \
echo "Unsupported OS. Please download manually from:"; \
echo "https://www.getmonero.org/downloads/"; \
exit 1; \
fi
# Check if Monero tools are installed and provide install instructions
check-monero:
@if command -v monero-wallet-rpc >/dev/null 2>&1; then \
echo "✓ Monero tools found: $$(monero-wallet-rpc --version | head -1)"; \
else \
echo "❌ Monero tools not found!"; \
echo ""; \
if [ "$$(uname)" = "Linux" ]; then \
echo "Install on Linux:"; \
echo "Download latest official release (recommended):"; \
echo " wget https://downloads.getmonero.org/cli/linux64"; \
echo " tar -xf linux64 && sudo cp monero-x*/monero* /usr/local/bin/"; \
echo ""; \
echo "Or install to user directory (no sudo):"; \
echo " mkdir -p $$HOME/.local/bin"; \
echo " wget https://downloads.getmonero.org/cli/linux64"; \
echo " tar -xf linux64 && cp monero-x*/monero* $$HOME/.local/bin/"; \
echo " export PATH=\"$$HOME/.local/bin:$$PATH\""; \
elif [ "$$(uname)" = "Darwin" ]; then \
echo "Install on macOS:"; \
echo " brew install monero"; \
echo ""; \
echo "Or download latest official release:"; \
echo " wget https://downloads.getmonero.org/cli/mac64"; \
echo " tar -xf mac64 && sudo cp monero-x*/monero* /usr/local/bin/"; \
else \
echo "Download latest official Monero CLI tools:"; \
echo " https://www.getmonero.org/downloads/"; \
echo " Extract and copy monero-* binaries to /usr/local/bin/"; \
fi; \
echo ""; \
echo "Package managers may have older versions. Official downloads are recommended."; \
echo "After installing, run this command again."; \
exit 1; \
fi
# Create a new Monero wallet for the shop
monero-wallet-create: check-monero
@echo "Creating new Monero wallet..."
@echo "IMPORTANT: Save the 25-word mnemonic seed that will be displayed!"
@echo "Enter a password for the wallet (or leave empty):"
@read -s password; echo $$password > $(DATA_DIR)/wallet-password.txt
monero-wallet-cli --generate-new-wallet=$(DATA_DIR)/mps-wallet \
--password-file=$(DATA_DIR)/wallet-password.txt
@echo "Wallet created at: $(DATA_DIR)/mps-wallet"
@echo "Password saved in: $(DATA_DIR)/wallet-password.txt"
@echo "Now run 'make monero-wallet' or 'make monero-wallet-remote' to start the RPC server"
# Instructions for running the full Monero stack
monero-full-stack:
@echo "=== Running Full Monero Payment Stack ==="
@echo ""
@echo "For PRODUCTION (most secure, requires ~150GB):"
@echo " Terminal 1: make monero-node # Start blockchain node"
@echo " Terminal 2: make monero-wallet # Start wallet RPC (after node syncs)"
@echo " Terminal 3: make crypto-watcher # Start payment watcher"
@echo " Terminal 4: make serve # Start web application"
@echo ""
@echo "For DEVELOPMENT (quick start, uses public node):"
@echo " Terminal 1: make monero-wallet-remote # Start wallet with remote node"
@echo " Terminal 2: make crypto-watcher # Start payment watcher"
@echo " Terminal 3: make serve # Start web application"
@echo ""
@echo "First time? Run 'make monero-wallet-create' to create a wallet"
# -----------------------------------------------------------------------------
# Cleanup Target
# -----------------------------------------------------------------------------

View file

@ -54,12 +54,20 @@ This Makefile-based workflow lets you choose between installing ``make_post_sell
export MPS_APP_SECURE_UPLOADS_SECRET_KEY="removed"
# stripe keys for collecting credit cards & crypto.
# NOTE: These are used by tests, shops configure their own keys in the UI
export MPS_TEST_STRIPE_PUBLIC_API_KEY="pk_test_removed"
export MPS_TEST_STRIPE_SECRET_API_KEY="sk_test_removed"
# the root domain acts as a SaaS for many shop domains!
export MAKE_POST_SELL_ROOT_DOMAIN="example.com"
export MAKE_POST_SELL_ROOT_URL="http://example.com:6501"
# optional: email for the root domain owner
export MAKE_POST_SELL_DOMAIN_OWNER_EMAIL="admin@example.com"
# optional: DKIM email signing (commented out by default)
# export MPS_APP_DKIM_PRIVATE_KEY_PATH="/path/to/dkim/private.key"
# export MPS_APP_DKIM_SELECTOR="selector"
With the virtual environment active, start the server::
@ -69,6 +77,77 @@ This Makefile-based workflow lets you choose between installing ``make_post_sell
Then browse to `http://127.0.0.1:6501/ <http://127.0.0.1:6501/>`_ to view the app.
Monero (XMR) Payment Support (Optional)
---------------------------------------
Make Post Sell now supports Monero (XMR) cryptocurrency payments alongside traditional Stripe payments. This feature is optional and can be enabled/disabled in the configuration.
**Configuration in development.ini:**
.. code-block:: ini
# Payment method toggles
app.payments.stripe.enabled = True
app.payments.monero.enabled = False # Set to True to enable Monero
# Monero RPC Configuration (if enabled)
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.account_index = ${MPS_MONERO_ACCOUNT_INDEX:-0}
monero.confirmations_required = ${MPS_MONERO_CONFIRMATIONS_REQUIRED:-10}
monero.quote_expiry_seconds = ${MPS_MONERO_QUOTE_EXPIRY_SECONDS:-900}
monero.rate_source_url = ${MPS_MONERO_RATE_SOURCE_URL:-https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd}
**Setting up Monero:**
1. **Run a Monero Wallet RPC:** You'll need to run ``monero-wallet-rpc`` with a wallet that can receive payments::
monero-wallet-rpc --rpc-bind-ip=127.0.0.1 --rpc-bind-port=18083 \
--disable-rpc-login --wallet-file=/path/to/wallet
2. **Configure environment variables** in your ``vars.sh``::
export MPS_MONERO_RPC_URL="http://127.0.0.1:18083/json_rpc"
# Optional: if RPC requires authentication
export MPS_MONERO_RPC_USER="username"
export MPS_MONERO_RPC_PASS="password"
# Optional: confirmation requirements by amount
export MPS_MONERO_CONFIRMATIONS_PETTY="2" # For amounts < $10
export MPS_MONERO_CONFIRMATIONS_MID="10" # For amounts < $100
export MPS_MONERO_CONFIRMATIONS_HIGH="20" # For amounts >= $100
export MPS_MONERO_THRESHOLD_MID="10.00" # USD threshold for mid tier
export MPS_MONERO_THRESHOLD_HIGH="100.00" # USD threshold for high tier
3. **Run the crypto watcher:** This background service monitors incoming Monero payments and confirms orders.
Using Make targets::
make crypto-watcher # Run continuously
make crypto-watcher-once # Run once for testing
Or run directly::
crypto_watcher development.ini # Run continuously
crypto_watcher development.ini --once # Run once
**Shop Readiness:**
Shops are considered "ready" based on enabled payment methods:
- If only Stripe is enabled: Shop needs Stripe API keys
- If only Monero is enabled: Shop just needs the Monero RPC to be available
- If both are enabled: Shop needs either Stripe API keys OR Monero RPC available
**How it works:**
1. Customers can choose "Pay with Monero" at checkout (for single-shop carts)
2. A unique subaddress is generated for each payment
3. The system monitors the blockchain for incoming payments
4. Orders are automatically fulfilled when payment is confirmed
Running Tests
-------------

View file

@ -32,7 +32,8 @@ session.timeout = 31104000
session.max_age = 31104000
session.reissue_time = 15552000
session.secure = false
session.domain = localhost.localhost
#session.domain = localhost.localhost
session.domain = localhost
session.samesite = Lax
###
@ -63,6 +64,24 @@ app.bucket.secure_uploads.secret_key = ${MPS_APP_SECURE_UPLOADS_SECRET_KEY}
# stripe test mode is enabled for development & disabled by default.
app.stripe.test_mode = True
# Payment method toggles
app.payments.stripe.enabled = True
app.payments.monero.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.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)
monero.confirmations.petty = ${MPS_MONERO_CONFIRMATIONS_PETTY:-2}
monero.confirmations.mid = ${MPS_MONERO_CONFIRMATIONS_MID:-10}
monero.confirmations.high = ${MPS_MONERO_CONFIRMATIONS_HIGH:-20}
monero.quote_expiry_seconds = ${MPS_MONERO_QUOTE_EXPIRY_SECONDS:-900}
monero.rate_source_url = ${MPS_MONERO_RATE_SOURCE_URL:-https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd}
# set this to the path of your private DKIM key.
# reference: https://russell.ballestrini.net/quickstart-to-dkim-signed-email-with-python/

View file

@ -48,18 +48,50 @@ def get_int_or_bool_or_none_or_str(value):
Given a string value pulled from a configuration file,
this function attempts to return the value with the proper type.
"""
# Handle non-string values
if not isinstance(value, str):
return value
# Handle string values
try:
return int(value)
except ValueError:
if value.lower() in {"yes", "y", "true", "y"}:
value_lower = value.lower()
if value_lower in {"yes", "y", "true", "t", "1"}:
return True
elif value.lower() in {"no", "n", "false", "f"}:
elif value_lower in {"no", "n", "false", "f", "0"}:
return False
elif value.lower() == "none":
elif value_lower in {"none", "null"}:
return None
return str(value)
def expand_env_vars(value):
"""Expand environment variables including ${VAR:-default} syntax."""
if not isinstance(value, str):
return value
import os
import re
# First try os.path.expandvars for simple cases
value = os.path.expandvars(value)
# Then handle ${VAR:-default} syntax
# Use a non-greedy match to stop at the first closing brace
pattern = r"\$\{([^:}]*)(?::-([^}]*?))?\}"
def replacer(match):
var_name = match.group(1)
# Handle empty variable name case ${:-default}
if not var_name:
return match.group(2) if match.group(2) is not None else match.group(0)
default_value = match.group(2) if match.group(2) is not None else ""
return os.environ.get(var_name, default_value)
return re.sub(pattern, replacer, value)
def get_children_settings(settings, parent_key):
"""
Accept a settings dict and parent key, return dict of children
@ -78,24 +110,25 @@ def get_children_settings(settings, parent_key):
{'hashalg': 'md5'}
"""
# needed to support expanding ENV vars from ini.
from os.path import expandvars
# the +1 is the . between parent and child settings.
parent_len = len(parent_key) + 1
children = {}
for key, value in settings.items():
if parent_key in key:
# expandvars replaces template with ENV vars.
children[key[parent_len:]] = get_int_or_bool_or_none_or_str(
expandvars(value)
)
# Expand environment variables with support for defaults
expanded_value = expand_env_vars(value)
children[key[parent_len:]] = get_int_or_bool_or_none_or_str(expanded_value)
return children
def main(global_config, **settings):
"""This function returns a Pyramid WSGI application."""
# Expand environment variables in all settings using our custom function
for key, value in list(settings.items()):
if isinstance(value, str):
settings[key] = expand_env_vars(value)
# Setup session factory signed cookies prevent tampering, not encrypted.
session_settings = get_children_settings(settings, "session")

View file

@ -0,0 +1,332 @@
from typing import Any, Dict, List, Optional, Tuple
import json
import time
import urllib.request
import urllib.error
import base64
class MoneroClient:
"""
Minimal JSON-RPC client for monero-wallet-rpc.
Uses stdlib only to avoid new dependencies.
"""
def __init__(
self,
rpc_url: str,
rpc_user: Optional[str] = None,
rpc_pass: Optional[str] = None,
timeout: int = 15,
):
self.rpc_url = rpc_url.rstrip("/")
self.rpc_user = rpc_user
self.rpc_pass = rpc_pass
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.rpc_user and self.rpc_pass:
auth = f"{self.rpc_user}:{self.rpc_pass}".encode()
headers["Authorization"] = "Basic " + base64.b64encode(auth).decode()
return headers
def _call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any:
payload = {
"jsonrpc": "2.0",
"id": int(time.time() * 1000),
"method": method,
}
if params is not None:
payload["params"] = params
data = json.dumps(payload).encode()
req = urllib.request.Request(self.rpc_url, data=data, headers=self._headers())
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read()
obj = json.loads(body)
if "error" in obj and obj["error"]:
raise RuntimeError(obj["error"]) # bubble up rpc error
return obj.get("result")
except urllib.error.URLError as e:
raise RuntimeError(f"Monero RPC connection error: {e}")
# High-level helpers
def create_subaddress(
self, account_index: int = 0, label: Optional[str] = None
) -> Tuple[str, int]:
params: Dict[str, Any] = {"account_index": account_index}
if label:
params["label"] = label
res = self._call("create_address", params)
return res["address"], res["address_index"]
def get_transfers_for_subaddr(
self, account_index: int, subaddr_indices: List[int]
) -> Dict[str, Any]:
params = {
"in": True,
"out": False,
"pending": True,
"failed": False,
"pool": True,
"filter_by_height": False,
"subaddr_indices": subaddr_indices,
"account_index": account_index,
}
return self._call("get_transfers", params) or {}
def get_height(self) -> int:
res = self._call("get_height")
return int(res.get("height", 0))
class MockMoneroClient:
"""
Minimal mock client for development. Reads a JSON file mapping subaddress_index
to a list of inbound transfers for testing the watcher without a live RPC.
settings:
- monero.mock_transfers_file: path to JSON file
JSON format example:
{
"height": 100000,
"transfers": {
"0": [{"amount": 123000000000, "confirmations": 12, "txid": "tx1"}],
"5": [{"amount": 999, "confirmations": 0, "txid": "tx2"}]
}
}
"""
def __init__(self, path: str):
self.path = path
self._data = None
self._load()
def _load(self) -> None:
try:
with open(self.path, "r") as f:
self._data = json.load(f)
except FileNotFoundError:
self._data = {"height": 0, "transfers": {}}
def create_subaddress(
self, account_index: int = 0, label: Optional[str] = None
) -> Tuple[str, int]:
# Not used by watcher; provided for completeness
raise RuntimeError(
"MockMoneroClient does not support create_subaddress in this context"
)
def get_transfers_for_subaddr(
self, account_index: int, subaddr_indices: List[int]
) -> Dict[str, Any]:
transfers: Dict[str, List[Dict[str, Any]]] = {}
for idx in subaddr_indices:
key = str(idx)
arr = self._data.get("transfers", {}).get(key, [])
if arr:
transfers.setdefault("in", []).extend(arr)
return transfers
def get_height(self) -> int:
return int(self._data.get("height", 0))
class DogecoinClient:
"""
Minimal JSON-RPC client for dogecoind.
Uses stdlib only to avoid new dependencies.
Do Only Good Everyday 🐕
"""
def __init__(
self,
rpc_url: str,
rpc_user: Optional[str] = None,
rpc_pass: Optional[str] = None,
timeout: int = 15,
):
self.rpc_url = rpc_url.rstrip("/")
self.rpc_user = rpc_user
self.rpc_pass = rpc_pass
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.rpc_user and self.rpc_pass:
auth = f"{self.rpc_user}:{self.rpc_pass}".encode()
headers["Authorization"] = "Basic " + base64.b64encode(auth).decode()
return headers
def _call(self, method: str, params: Optional[List[Any]] = None) -> Any:
"""Note: Dogecoin RPC expects params as array, not dict"""
payload = {
"jsonrpc": "2.0",
"id": int(time.time() * 1000),
"method": method,
}
if params is not None:
payload["params"] = params
data = json.dumps(payload).encode()
req = urllib.request.Request(self.rpc_url, data=data, headers=self._headers())
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read()
obj = json.loads(body)
if "error" in obj and obj["error"]:
raise RuntimeError(f"RPC error: {obj['error']}")
return obj.get("result")
except urllib.error.HTTPError as e:
raise RuntimeError(f"HTTP error {e.code}: {e.reason}")
except Exception as e:
raise RuntimeError(f"RPC call failed: {e}")
# Wallet Management
def getnewaddress(self, label: str = "") -> str:
"""Generate a new Dogecoin address with optional label."""
return self._call("getnewaddress", [label])
def getaddressesbylabel(self, label: str) -> Dict[str, Any]:
"""Get all addresses with a specific label."""
return self._call("getaddressesbylabel", [label])
def validateaddress(self, address: str) -> Dict[str, Any]:
"""Validate a Dogecoin address."""
return self._call("validateaddress", [address])
# Balance and Transaction Info
def getbalance(self) -> float:
"""Get total wallet balance."""
return self._call("getbalance")
def getreceivedbyaddress(self, address: str, minconf: int = 1) -> float:
"""Get total received by specific address."""
return self._call("getreceivedbyaddress", [address, minconf])
def listtransactions(
self, label: str = "*", count: int = 10, skip: int = 0
) -> List[Dict[str, Any]]:
"""List transactions for a label or all (*)."""
return self._call("listtransactions", [label, count, skip])
def gettransaction(self, txid: str) -> Dict[str, Any]:
"""Get detailed information about a specific transaction."""
return self._call("gettransaction", [txid])
# Sending Funds
def sendtoaddress(self, address: str, amount: float, comment: str = "") -> str:
"""Send Dogecoin to an address. Returns transaction ID."""
return self._call("sendtoaddress", [address, amount, comment])
def sendmany(self, from_label: str, addresses_amounts: Dict[str, float]) -> str:
"""Send to multiple addresses at once. More efficient for sweeping."""
return self._call("sendmany", [from_label, addresses_amounts])
# Blockchain Info
def getblockcount(self) -> int:
"""Get current block height."""
return self._call("getblockcount")
def getnetworkinfo(self) -> Dict[str, Any]:
"""Get network status information."""
return self._call("getnetworkinfo")
class MockDogecoinClient:
"""Mock client for testing without a real Dogecoin node."""
def __init__(self, *args, **kwargs):
self.addresses = {}
self.next_address_num = 1
self.balance = 100.0 # Start with 100 DOGE for testing
def getnewaddress(self, label: str = "") -> str:
address = f"DTest{self.next_address_num:04d}Address{label[:8]}"
self.addresses[address] = {"label": label, "balance": 0.0}
self.next_address_num += 1
return address
def getaddressesbylabel(self, label: str) -> Dict[str, Any]:
return {
addr: {"purpose": "receive"}
for addr, info in self.addresses.items()
if info["label"] == label
}
def validateaddress(self, address: str) -> Dict[str, Any]:
is_valid = address.startswith("D") and len(address) == 34
return {
"isvalid": is_valid,
"address": address if is_valid else "",
"ismine": address in self.addresses,
}
def getbalance(self) -> float:
return self.balance
def getreceivedbyaddress(self, address: str, minconf: int = 1) -> float:
return self.addresses.get(address, {}).get("balance", 0.0)
def listtransactions(
self, label: str = "*", count: int = 10, skip: int = 0
) -> List[Dict[str, Any]]:
# Return mock transactions
return (
[
{
"address": next(iter(self.addresses)),
"category": "receive",
"amount": 10.0,
"confirmations": 6,
"txid": "mocktxid123",
"time": int(time.time()),
}
]
if self.addresses
else []
)
def sendtoaddress(self, address: str, amount: float, comment: str = "") -> str:
if self.balance >= amount:
self.balance -= amount
return f"mocktxid{int(time.time())}"
raise RuntimeError("Insufficient funds")
def getblockcount(self) -> int:
return 5500000 # Mock block height
def getnetworkinfo(self) -> Dict[str, Any]:
return {
"version": 1140200,
"subversion": "/Shibetoshi:1.14.2/",
"protocolversion": 70015,
"connections": 8,
}
def get_client_from_settings(settings) -> MoneroClient:
"""
Helper to construct a client from Pyramid settings.
Expects keys:
monero.rpc_url, monero.rpc_user, monero.rpc_pass
"""
if str(settings.get("monero.mock", "false")).lower() in ("1", "true", "yes"):
path = settings.get("monero.mock_transfers_file") or "mock_transfers.json"
return MockMoneroClient(path)
rpc_url = settings.get("monero.rpc_url")
if not rpc_url:
raise RuntimeError("monero.rpc_url not configured")
return MoneroClient(
rpc_url=rpc_url,
rpc_user=settings.get("monero.rpc_user"),
rpc_pass=settings.get("monero.rpc_pass"),
)

View file

@ -0,0 +1,183 @@
"""
Payment rescue module for handling crypto payment errors with automatic refunds.
Handles:
- Underpayments: Refund partial payments minus 9% restocking fee
- Overpayments: Refund excess amount minus 9% restocking fee
- Expired quotes: Refund late payments minus 9% restocking fee
"""
from decimal import Decimal
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
RESTOCKING_FEE_PERCENT = Decimal("0.09") # 9% restocking fee
OVERPAYMENT_THRESHOLD_PERCENT = Decimal("0.05") # 5% overpayment allowed before refund
def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT):
"""Calculate refund amount after deducting restocking fee."""
fee = amount * fee_percent
refund = amount - fee
# Ensure refund is not negative
return max(refund, Decimal("0"))
class PaymentRescue:
"""Handle crypto payment errors and trigger refunds when appropriate."""
def __init__(self, dbsession, crypto_client):
self.dbsession = dbsession
self.crypto_client = crypto_client
def get_user_refund_address(self, user, crypto_type):
"""Get user's configured refund address for the crypto type."""
if not user:
return None
refund_record = get_user_crypto_refund_address(
self.dbsession, user, crypto_type
)
return refund_record.address if refund_record else None
def handle_underpayment(self, payment, expected_amount, received_amount, user):
"""
Handle underpayment scenario.
Args:
payment: CryptoPayment object
expected_amount: Expected amount in crypto units
received_amount: Actually received amount in crypto units
user: User object who made the payment
Returns:
dict with refund details or None if no refund possible
"""
refund_address = self.get_user_refund_address(user, payment.crypto_type)
if not refund_address:
return None
# Calculate refund amount (received minus fee)
refund_amount = calculate_refund_amount(received_amount)
if refund_amount <= 0:
return None
return {
"type": "underpayment",
"payment_id": payment.id,
"refund_address": refund_address,
"received_amount": received_amount,
"expected_amount": expected_amount,
"refund_amount": refund_amount,
"fee_amount": received_amount - refund_amount,
"reason": f"Underpayment: received {received_amount} but expected {expected_amount}",
}
def handle_overpayment(self, payment, expected_amount, received_amount, user):
"""
Handle overpayment scenario.
Args:
payment: CryptoPayment object
expected_amount: Expected amount in crypto units
received_amount: Actually received amount in crypto units
user: User object who made the payment
Returns:
dict with refund details or None if no refund possible
"""
# Check if overpayment exceeds threshold
overpayment_ratio = (received_amount - expected_amount) / expected_amount
if overpayment_ratio <= OVERPAYMENT_THRESHOLD_PERCENT:
# Within acceptable threshold, no refund needed
return None
refund_address = self.get_user_refund_address(user, payment.crypto_type)
if not refund_address:
return None
# Calculate excess amount
excess_amount = received_amount - expected_amount
# Calculate refund on the excess (minus fee)
refund_amount = calculate_refund_amount(excess_amount)
if refund_amount <= 0:
return None
return {
"type": "overpayment",
"payment_id": payment.id,
"refund_address": refund_address,
"received_amount": received_amount,
"expected_amount": expected_amount,
"excess_amount": excess_amount,
"refund_amount": refund_amount,
"fee_amount": excess_amount - refund_amount,
"reason": f"Overpayment exceeds {int(OVERPAYMENT_THRESHOLD_PERCENT * 100)}% threshold: received {received_amount} but expected {expected_amount}",
}
def handle_expired_payment(self, payment, received_amount, user):
"""
Handle payment received after quote expiration.
Args:
payment: CryptoPayment object
received_amount: Actually received amount in crypto units
user: User object who made the payment
Returns:
dict with refund details or None if no refund possible
"""
refund_address = self.get_user_refund_address(user, payment.crypto_type)
if not refund_address:
return None
# Calculate refund amount (received minus fee)
refund_amount = calculate_refund_amount(received_amount)
if refund_amount <= 0:
return None
return {
"type": "expired",
"payment_id": payment.id,
"refund_address": refund_address,
"received_amount": received_amount,
"refund_amount": refund_amount,
"fee_amount": received_amount - refund_amount,
"reason": "Payment received after quote expiration",
}
def execute_refund(self, refund_details):
"""
Execute the actual refund transaction.
Args:
refund_details: dict with refund information
Returns:
dict with transaction details or raises exception
"""
try:
# Create the refund transaction
tx_result = self.crypto_client.transfer(
destinations=[
{
"address": refund_details["refund_address"],
"amount": int(
refund_details["refund_amount"] * 1e12
), # Convert to piconero
}
]
)
return {
"success": True,
"tx_hash": tx_result.get("tx_hash"),
"refund_details": refund_details,
"fee_charged": refund_details["fee_amount"],
}
except Exception as e:
return {"success": False, "error": str(e), "refund_details": refund_details}

View file

@ -0,0 +1,456 @@
import argparse
import json
import sys
import time
import logging
from typing import List
from decimal import Decimal
from pyramid.paster import bootstrap, setup_logging
from .crypto_clients import get_client_from_settings
from ..models.crypto_payment import CryptoPayment
from ..models.invoice import Invoice
from .mail import send_purchase_email, send_sale_email
from ..models.inventory import get_inventory_by_product_and_shop_location
from .crypto_payment_rescue import PaymentRescue
from ..models.meta import now_timestamp
logger = logging.getLogger(__name__)
# Constants
ATOMIC_UNITS = Decimal("1000000000000") # 1 XMR = 10^12 atomic units
MIN_SWEEP_BALANCE = Decimal("0.001") # Keep minimal 0.001 XMR for ~7-8 transaction fees
def auto_sweep_payment(client, crypto_payment: CryptoPayment):
"""Auto-sweep funds from a confirmed payment to the shop's cold wallet."""
logger.info(f"Starting auto-sweep check for payment {crypto_payment.id}")
if not crypto_payment.shop_sweep_to_address:
logger.info(f"Payment {crypto_payment.id} has no sweep address configured")
return False
if crypto_payment.is_swept:
logger.info(f"Payment {crypto_payment.id} already swept")
return True
logger.info(
f"Payment {crypto_payment.id} needs sweep to {crypto_payment.shop_sweep_to_address}"
)
try:
# Get balance for the account
result = client._call(
"get_balance", {"account_index": crypto_payment.account_index}
)
unlocked_balance = Decimal(result.get("unlocked_balance", 0)) / ATOMIC_UNITS
logger.info(
f"Account {crypto_payment.account_index} unlocked balance: {unlocked_balance} XMR"
)
# Calculate sweep amount for THIS SPECIFIC payment only
payment_amount_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
# If no balance, mark as already swept (another payment swept it)
if unlocked_balance == 0:
logger.info(
f"No balance to sweep for payment {crypto_payment.id} - marking as swept"
)
crypto_payment.swept_amount = crypto_payment.received_amount
crypto_payment.swept_tx_hash = "pooled_sweep"
crypto_payment.swept_timestamp = now_timestamp()
return True
# Sweep all available balance minus a small buffer for fees
# Use "sweep_all" instead of exact amount to handle fees automatically
logger.info(
f"Sweeping all funds from account {crypto_payment.account_index} for payment {crypto_payment.id} to {crypto_payment.shop_sweep_to_address}"
)
result = client._call(
"sweep_all",
{
"address": crypto_payment.shop_sweep_to_address,
"account_index": crypto_payment.account_index,
"priority": 1,
"get_tx_hex": False,
},
)
tx_hash = result.get("tx_hash")
fee = result.get("fee", 0) # Network fee in atomic units
if tx_hash:
# Mark this payment as swept
# For sweep_all, the amount_list contains the actual amounts swept
amount_list = result.get("amount_list", [])
total_swept = (
sum(amount_list) if amount_list else unlocked_balance * ATOMIC_UNITS
)
crypto_payment.swept_amount = int(total_swept)
crypto_payment.swept_tx_hash = tx_hash
crypto_payment.swept_timestamp = now_timestamp()
crypto_payment.swept_network_fee = fee
logger.info(
f"Auto-sweep successful for payment {crypto_payment.id}! TX: {tx_hash}, Swept: {total_swept} atomic units, Fee: {fee} atomic units"
)
return True
else:
logger.error(f"Sweep failed for payment {crypto_payment.id}: {result}")
return False
except Exception as e:
logger.error(f"Auto-sweep error for payment {crypto_payment.id}: {e}")
return False
def parse_args(argv):
p = argparse.ArgumentParser(
description="Crypto watcher: confirm payments and finalize invoices"
)
p.add_argument("config_uri", help="Pyramid config file, e.g. development.ini")
p.add_argument(
"--interval", type=int, default=20, help="Polling interval seconds (default 20)"
)
p.add_argument("--once", action="store_true", help="Run a single pass then exit")
return p.parse_args(argv[1:])
def summarize_txs(transfers: List[dict]):
total = 0
txids = []
confs = []
for t in transfers:
# monero-wallet-rpc returns atomic units in `amount` and integer `confirmations`
amt = int(t.get("amount", 0) or 0)
total += amt
txid = t.get("txid") or t.get("transaction_id")
if txid:
txids.append(txid)
c = t.get("confirmations")
if isinstance(c, int):
confs.append(c)
min_conf = min(confs) if confs else 0
return total, list(dict.fromkeys(txids)), min_conf
# Removed mark_payment_voided - no longer needed without Payment model
def finalize_invoice(env_request, crypto_payment: CryptoPayment):
invoice: Invoice = crypto_payment.invoice
# Unlock products for the purchasing user and notify via email (mirrors Stripe flow)
for line_item in invoice.line_items:
line_item.product.unlock_for_user(invoice.user)
env_request.dbsession.add(line_item.product)
# Emails (configurable)
email_enabled = True
try:
settings = getattr(getattr(env_request, "registry", None), "settings", {}) or {}
val = settings.get("app.email.enabled")
if isinstance(val, str):
email_enabled = val.strip().lower() in ("1", "true", "yes", "on")
elif isinstance(val, bool):
email_enabled = val
except Exception:
# Leave default True if anything goes wrong
pass
if email_enabled:
send_purchase_email(
env_request,
invoice.user.email,
[item.product for item in invoice.line_items],
invoice.total,
)
send_sale_email(
env_request,
invoice.shop,
[item.product for item in invoice.line_items],
invoice.total,
)
# Deduct inventory for physical products if a shop location is known
if crypto_payment.shop_location:
for item in invoice.line_items:
product = item.product
if getattr(product, "is_physical", False):
inv = get_inventory_by_product_and_shop_location(
env_request.dbsession, product.id, crypto_payment.shop_location.id
)
if inv:
inv.quantity = max(0, int(inv.quantity) - int(item.quantity))
env_request.dbsession.add(inv)
def process_payment(
env_request,
crypto_payment: CryptoPayment,
incoming_transfers: List[dict],
client=None,
):
"""Process a single CryptoPayment given already-fetched incoming transfers.
Mirrors the status and finalization logic for easier unit testing.
"""
now_ms = int(time.time() * 1000)
# Initialize payment rescue if client is available
payment_rescue = PaymentRescue(env_request.dbsession, client) if client else None
# Handle expiry
if crypto_payment.is_expired:
crypto_payment.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 (
incoming_transfers
and payment_rescue
and crypto_payment.invoice
and crypto_payment.invoice.user
):
total_recv, _, _ = summarize_txs(incoming_transfers)
if total_recv > 0:
received_xmr = Decimal(total_recv) / ATOMIC_UNITS
refund_details = payment_rescue.handle_expired_payment(
crypto_payment, received_xmr, crypto_payment.invoice.user
)
if refund_details:
logger.info(
f"Expired payment {crypto_payment.id} eligible for refund: {refund_details}"
)
result = payment_rescue.execute_refund(refund_details)
if result["success"]:
logger.info(
f"Refund executed for expired payment {crypto_payment.id}: TX {result['tx_hash']}"
)
else:
logger.error(
f"Refund failed for expired payment {crypto_payment.id}: {result['error']}"
)
return
# No funds seen yet
if not incoming_transfers:
crypto_payment.updated_timestamp = now_ms
env_request.dbsession.add(crypto_payment)
return
total_recv, txids, min_confs = summarize_txs(incoming_transfers)
# Existing seen txids
try:
existing = json.loads(crypto_payment.tx_hashes or "[]")
except Exception:
existing = []
# Only count new amounts for txids we haven't seen yet to remain idempotent
new_sum = 0
seen = set(existing)
total_fee = 0
for t in incoming_transfers:
txid = t.get("txid") or t.get("transaction_id")
if txid and txid not in seen:
new_sum += int(t.get("amount", 0) or 0)
# Capture fee if available (some coins provide this, others don't)
fee = t.get("fee", 0)
if fee:
total_fee += int(fee)
crypto_payment.received_amount = int(crypto_payment.received_amount or 0) + int(
new_sum
)
# Track customer's network fee if available
if total_fee > 0:
crypto_payment.received_network_fee = total_fee
# Merge and dedupe txids
merged = list(dict.fromkeys(list(existing) + txids))
crypto_payment.tx_hashes = json.dumps(merged)
# Update current confirmation count
crypto_payment.current_confirmations = min_confs
crypto_payment.updated_timestamp = now_ms
# Status logic
if (
crypto_payment.received_amount >= crypto_payment.expected_amount
and min_confs >= int(crypto_payment.confirmations_required)
):
if crypto_payment.status != "confirmed":
crypto_payment.status = "confirmed"
# Finalize the invoice: unlock products, notify, and update inventory
finalize_invoice(env_request, crypto_payment)
# Check for overpayment refund AFTER confirming the order
if (
payment_rescue
and crypto_payment.received_amount > crypto_payment.expected_amount
and crypto_payment.invoice
and crypto_payment.invoice.user
):
received_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
expected_xmr = Decimal(crypto_payment.expected_amount) / ATOMIC_UNITS
refund_details = payment_rescue.handle_overpayment(
crypto_payment,
expected_xmr,
received_xmr,
crypto_payment.invoice.user,
)
if refund_details:
logger.info(
f"Overpayment detected for {crypto_payment.id}: {refund_details}"
)
result = payment_rescue.execute_refund(refund_details)
if result["success"]:
logger.info(
f"Excess refunded for payment {crypto_payment.id}: TX {result['tx_hash']}"
)
# Mark as confirmed with overpayment refunded
crypto_payment.status = "confirmed_overpaid"
else:
logger.error(
f"Excess refund failed for payment {crypto_payment.id}: {result['error']}"
)
# Note: Auto-sweep is handled separately based on unlocked balance, not confirmation status
else:
if crypto_payment.received_amount > 0 and crypto_payment.status != "received":
crypto_payment.status = "received"
# Check for underpayment rescue when we have enough confirmations
if (
payment_rescue
and min_confs >= int(crypto_payment.confirmations_required)
and crypto_payment.invoice
and crypto_payment.invoice.user
):
# Only handle underpayment here (overpayment is handled after confirmation)
if crypto_payment.received_amount < crypto_payment.expected_amount:
received_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
expected_xmr = Decimal(crypto_payment.expected_amount) / ATOMIC_UNITS
refund_details = payment_rescue.handle_underpayment(
crypto_payment,
expected_xmr,
received_xmr,
crypto_payment.invoice.user,
)
if refund_details:
logger.info(
f"Underpayment {crypto_payment.id} eligible for refund: {refund_details}"
)
result = payment_rescue.execute_refund(refund_details)
if result["success"]:
logger.info(
f"Refund executed for underpayment {crypto_payment.id}: TX {result['tx_hash']}"
)
crypto_payment.status = "underpaid_refunded"
else:
logger.error(
f"Refund failed for underpayment {crypto_payment.id}: {result['error']}"
)
# Auto-sweep based on unlocked balance availability, not just confirmation status
if (
crypto_payment.status in ["confirmed", "confirmed_overpaid"]
and client
and crypto_payment.shop_sweep_to_address
and not crypto_payment.is_swept
):
logger.info(f"Checking if funds are unlocked for payment {crypto_payment.id}")
try:
# Check if funds are actually unlocked before attempting sweep
result = client._call(
"get_balance", {"account_index": crypto_payment.account_index}
)
unlocked_balance = result.get("unlocked_balance", 0)
if unlocked_balance > 0:
logger.info(
f"Funds unlocked ({unlocked_balance} atomic units) - attempting sweep for payment {crypto_payment.id}"
)
auto_sweep_payment(client, crypto_payment)
else:
logger.debug(
f"Payment {crypto_payment.id} confirmed but funds not yet unlocked"
)
except Exception as e:
logger.error(
f"Auto-sweep check/attempt failed for payment {crypto_payment.id}: {e}"
)
env_request.dbsession.add(crypto_payment)
def run_once(env, interval):
request = env["request"]
settings = request.registry.settings
client = get_client_from_settings(settings)
logger.info("Crypto watcher starting payment processing cycle")
with request.tm:
db = request.dbsession
q = db.query(CryptoPayment).filter(
CryptoPayment.status.in_(
["pending", "received", "confirmed", "confirmed_overpaid"]
)
)
payments = q.all()
logger.info(
f"Found {len(payments)} payments to process: {[p.status for p in payments]}"
)
for crypto_payment in payments:
logger.info(
f"Processing payment {crypto_payment.id} (status: {crypto_payment.status})"
)
# Query transfers for subaddress
res = (
client.get_transfers_for_subaddr(
crypto_payment.account_index, [crypto_payment.subaddress_index]
)
or {}
)
incoming = res.get("in", []) or []
process_payment(request, crypto_payment, incoming, client)
def main(argv=sys.argv):
args = parse_args(argv)
setup_logging(args.config_uri)
env = bootstrap(args.config_uri)
logger.info(
f"Crypto watcher started with interval {args.interval}s, once={args.once}"
)
try:
while True:
run_once(env, args.interval)
if args.once:
logger.info("Crypto watcher finished single run")
break
logger.info(f"Crypto watcher sleeping for {args.interval} seconds")
time.sleep(args.interval)
finally:
logger.info("Crypto watcher shutting down")
env["closer"]()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,96 @@
"""
Monero address validation utilities.
This module provides functions to validate Monero addresses including
checksum validation to catch typos.
"""
import struct
from typing import Tuple, Optional
# Base58 alphabet used by Monero
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def decode_base58(address: str) -> Optional[bytes]:
"""Decode a base58 string to bytes."""
decoded = 0
for char in address:
try:
decoded = decoded * 58 + BASE58_ALPHABET.index(char)
except ValueError:
return None
# Convert to bytes
hex_str = hex(decoded)[2:]
if len(hex_str) % 2:
hex_str = "0" + hex_str
return bytes.fromhex(hex_str)
def keccak_256(data: bytes) -> bytes:
"""Compute Keccak-256 hash (not SHA3-256)."""
try:
from Crypto.Hash import keccak
k = keccak.new(digest_bits=256)
k.update(data)
return k.digest()
except ImportError:
# If pycryptodome not available, we can't validate
return b""
def validate_monero_address(address: str) -> Tuple[bool, str]:
"""
Validate a Monero address.
Returns:
(is_valid, error_message)
"""
# Basic length check
if len(address) not in [95, 106]:
return False, "Invalid length - Monero addresses are 95 or 106 characters"
# Network byte check - Monero mainnet addresses can start with 4, 8, or 5
# 4 = standard address, 8 = integrated address, 5 = subaddress
if not address[0] in ["4", "8", "5"]:
return False, "Invalid network - mainnet addresses start with '4', '8', or '5'"
# Try to decode base58
try:
decoded = decode_base58(address)
if not decoded:
return False, "Invalid base58 encoding"
# Skip cryptographic validation if we can't import keccak
if not keccak_256(b"test"):
# Can't do full validation but format is OK
return True, ""
# Validate checksum (last 4 bytes)
if len(decoded) < 69: # Minimum size for address
return False, "Decoded address too short"
payload = decoded[:-4]
checksum = decoded[-4:]
# Calculate expected checksum
hash_result = keccak_256(payload)
expected_checksum = hash_result[:4]
if checksum != expected_checksum:
return False, "Invalid checksum - possible typo in address"
return True, ""
except Exception as e:
return False, f"Validation error: {str(e)}"
def is_valid_monero_address(address: str) -> bool:
"""Simple boolean check for Monero address validity."""
valid, _ = validate_monero_address(address)
return valid

View file

@ -0,0 +1,266 @@
#!/usr/bin/env python
"""
Sweep excess funds from hot wallet to cold storage.
This script checks the Monero hot wallet balance and automatically
transfers any funds above a configured threshold to a cold wallet address.
"""
import argparse
import json
import logging
import sys
from decimal import Decimal
from typing import Optional, Dict, Any
import requests
from pyramid.paster import bootstrap
# Set up logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Constants
ATOMIC_UNITS = Decimal("1000000000000") # 1 XMR = 10^12 atomic units
class WalletSweeper:
"""Handle sweeping excess funds to cold storage."""
def __init__(
self,
rpc_url: str,
rpc_user: Optional[str] = None,
rpc_pass: Optional[str] = None,
):
self.rpc_url = rpc_url
self.session = requests.Session()
if rpc_user and rpc_pass:
self.session.auth = (rpc_user, rpc_pass)
def _rpc_call(
self, method: str, params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Make RPC call to wallet."""
payload = {
"jsonrpc": "2.0",
"id": "0",
"method": method,
"params": params or {},
}
try:
response = self.session.post(self.rpc_url, json=payload)
response.raise_for_status()
result = response.json()
if "error" in result:
raise Exception(f"RPC error: {result['error']}")
return result.get("result", {})
except Exception as e:
logger.error(f"RPC call failed: {e}")
raise
def get_balance(self, account_index: int = 0) -> Dict[str, Decimal]:
"""Get wallet balance in XMR."""
result = self._rpc_call("get_balance", {"account_index": account_index})
return {
"balance": Decimal(result["balance"]) / ATOMIC_UNITS,
"unlocked_balance": Decimal(result["unlocked_balance"]) / ATOMIC_UNITS,
}
def sweep_to_address(
self,
cold_wallet: str,
account_index: int = 0,
priority: int = 1,
keep_amount: Decimal = Decimal("0.001"),
) -> Optional[str]:
"""Sweep funds to cold wallet address, keeping minimal reserve."""
# Get current balance
balances = self.get_balance(account_index)
unlocked = balances["unlocked_balance"]
logger.info(f"Current unlocked balance: {unlocked} XMR")
# Calculate sweep amount (keep minimal for fees)
sweep_amount = unlocked - keep_amount
if sweep_amount <= 0:
logger.info(f"Balance too low to sweep (need > {keep_amount} XMR)")
return None
logger.info(f"Sweeping {sweep_amount} XMR to cold storage")
# Convert to atomic units
sweep_atomic = int(sweep_amount * ATOMIC_UNITS)
# Send transfer
try:
result = self._rpc_call(
"transfer",
{
"destinations": [{"amount": sweep_atomic, "address": cold_wallet}],
"account_index": account_index,
"priority": priority,
"get_tx_hex": False,
},
)
tx_hash = result.get("tx_hash")
fee = Decimal(result.get("fee", 0)) / ATOMIC_UNITS
logger.info(f"Sweep successful! TX: {tx_hash}, Fee: {fee} XMR")
return tx_hash
except Exception as e:
logger.error(f"Sweep failed: {e}")
raise
def main():
"""Main entry point for sweep script."""
parser = argparse.ArgumentParser(description="Sweep excess XMR to cold storage")
parser.add_argument("config_uri", help="Configuration file (e.g., development.ini)")
parser.add_argument(
"--account-index",
type=int,
default=0,
help="Account index to sweep from (default: 0)",
)
parser.add_argument(
"--priority", type=int, default=1, help="Transaction priority 0-3 (default: 1)"
)
parser.add_argument(
"--dry-run", action="store_true", help="Check balance without sending funds"
)
parser.add_argument(
"--sweep-all",
action="store_true",
help="Aggressive sweep: keep only 0.001 XMR (~7-8 transactions)",
)
parser.add_argument(
"--shop-id",
help="Sweep a specific shop's wallet (uses shop's configured cold wallet)",
)
parser.add_argument(
"--all-shops",
action="store_true",
help="Sweep all shops with configured wallets",
)
args = parser.parse_args()
# Bootstrap Pyramid app to get settings
env = bootstrap(args.config_uri)
settings = env["registry"].settings
request = env["request"]
# Get RPC settings
rpc_url = settings.get("monero.rpc_url", "http://127.0.0.1:18083/json_rpc")
rpc_user = settings.get("monero.rpc_user")
rpc_pass = settings.get("monero.rpc_pass")
# Create sweeper
sweeper = WalletSweeper(rpc_url, rpc_user, rpc_pass)
# Always sweep to shop-configured addresses
from ..models.shop import Shop, get_shop_by_id
from ..models.crypto_processor import CryptoProcessor
if args.shop_id:
# Sweep specific shop
shop = get_shop_by_id(request.dbsession, args.shop_id)
if not shop:
logger.error(f"Shop {args.shop_id} not found")
sys.exit(1)
# Check if shop has XMR processor configured
xmr_processor = (
request.dbsession.query(CryptoProcessor)
.filter_by(shop_id=shop.id, coin_type="XMR", enabled=True)
.first()
)
if not xmr_processor or not xmr_processor.sweep_to_address:
logger.error(f"Shop {shop.name} has no Monero processor with cold wallet configured")
sys.exit(1)
shops_to_sweep = [shop]
elif args.all_shops:
# Sweep all shops with configured Monero processors
shops_with_xmr = (
request.dbsession.query(Shop)
.join(CryptoProcessor)
.filter(
CryptoProcessor.coin_type == "XMR",
CryptoProcessor.enabled == True,
CryptoProcessor.sweep_to_address.isnot(None),
)
.all()
)
shops_to_sweep = shops_with_xmr
if not shops_to_sweep:
logger.info("No shops with configured Monero processors found")
sys.exit(0)
else:
logger.error("Must specify --shop-id or --all-shops")
sys.exit(1)
# Determine how much to keep based on sweep mode
keep_amount = Decimal("0.001") if args.sweep_all else Decimal("0.005")
if args.sweep_all:
logger.info("Sweep-all mode: keeping only 0.001 XMR for fees")
# Process each shop
for shop in shops_to_sweep:
# Get the XMR processor for this shop
xmr_processor = (
request.dbsession.query(CryptoProcessor)
.filter_by(shop_id=shop.id, coin_type="XMR", enabled=True)
.first()
)
if not xmr_processor:
logger.warning(f"Shop {shop.name} has no enabled XMR processor, skipping")
continue
print(f"\n=== Sweeping shop: {shop.name} ===")
print(f"Account index: {xmr_processor.wallet_label}")
print(f"Cold wallet: {xmr_processor.sweep_to_address}")
account_index = int(xmr_processor.wallet_label) # wallet_label stores the account index
try:
if args.dry_run:
balances = sweeper.get_balance(account_index)
print(f"Current balance: {balances['balance']} XMR")
print(f"Unlocked balance: {balances['unlocked_balance']} XMR")
sweep_amount = balances["unlocked_balance"] - keep_amount
if sweep_amount > 0:
print(f"Would sweep: {sweep_amount} XMR")
else:
print("No sweep needed")
else:
tx_hash = sweeper.sweep_to_address(
cold_wallet=xmr_processor.sweep_to_address,
account_index=account_index,
priority=args.priority,
keep_amount=keep_amount,
)
if tx_hash:
print(f"Sweep successful! Transaction: {tx_hash}")
else:
print("No funds to sweep")
except Exception as e:
logger.error(f"Failed to sweep shop {shop.name}: {e}")
# Continue with other shops
env["closer"]()
if __name__ == "__main__":
main()

View file

@ -20,6 +20,9 @@ from .coupon import *
from .coupon_redemption import *
from .invoice import *
from .inventory import *
from .crypto_payment import *
from .crypto_processor import *
from .user_crypto_refund_address import *
from .stripe_user_shop import *

View file

@ -0,0 +1,139 @@
import uuid
from decimal import Decimal
from sqlalchemy import (
Column,
Integer,
BigInteger,
String,
Numeric,
UnicodeText,
Unicode,
)
from sqlalchemy.orm import relationship
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class CryptoPayment(RBase, Base):
"""
Represents a cryptocurrency payment intent tied to a single Invoice.
Amounts are stored in the smallest unit (e.g., satoshis for BTC, piconero for XMR).
"""
id = Column(UUIDType, primary_key=True, index=True)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
# optional: the shop location used for fulfillment (for physical products)
shop_location_id = Column(
UUIDType, foreign_key("ShopLocation", "id"), nullable=True
)
address = Column(String(128), nullable=False)
account_index = Column(Integer, nullable=False, default=0)
subaddress_index = Column(Integer, nullable=False)
# Coin type (e.g., 'XMR', 'BTC', 'LTC', 'DOGE')
coin_type = Column(String(10), nullable=False)
# expected and observed amounts in atomic units (satoshis, piconero, etc.)
expected_amount = Column(BigInteger, nullable=False)
received_amount = Column(BigInteger, nullable=False, default=0)
received_network_fee = Column(
BigInteger, nullable=True
) # Customer's tx fee (when available from RPC)
# locked rate at the time of creating the payment (USD per coin)
rate_locked_usd_per_coin = Column(Numeric(18, 8), nullable=False)
quote_expires_at = Column(BigInteger, nullable=False)
confirmations_required = Column(Integer, nullable=False, default=10)
status = Column(String(32), nullable=False, default="pending")
# store as JSON string for DB portability
tx_hashes = Column(UnicodeText, nullable=False)
# Shop's wallet configuration at time of payment (for recovery/sweeping)
shop_sweep_to_address = Column(
Unicode(256), nullable=True
) # Where to sweep funds for this payment
# Customer's refund address (optional, provided at checkout)
refund_address = Column(
Unicode(256), nullable=True
) # Where to send refunds if payment fails/expires
# Sweep tracking
swept_amount = Column(BigInteger, nullable=True) # Amount swept in atomic units
swept_tx_hash = Column(Unicode(128), nullable=True) # Transaction hash of the sweep
swept_timestamp = Column(BigInteger, nullable=True) # When the sweep occurred
swept_network_fee = Column(
BigInteger, nullable=True
) # Network fee paid (in atomic units, from node)
# Current confirmation count
current_confirmations = Column(
Integer, nullable=False, default=0
) # Current number of confirmations
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
invoice = relationship("Invoice", backref="crypto_payment")
shop_location = relationship("ShopLocation", uselist=False)
def __init__(
self,
invoice,
address,
account_index,
subaddress_index,
coin_type,
expected_amount,
rate_locked_usd_per_coin: Decimal,
quote_expires_at_ms: int,
confirmations_required: int = 10,
shop_location=None,
shop_sweep_to_address=None,
refund_address=None,
):
self.id = uuid.uuid1()
self.invoice = invoice
self.address = address
self.account_index = account_index
self.subaddress_index = subaddress_index
self.coin_type = coin_type
self.expected_amount = int(expected_amount)
self.received_amount = 0
self.rate_locked_usd_per_coin = rate_locked_usd_per_coin
self.quote_expires_at = int(quote_expires_at_ms)
self.confirmations_required = confirmations_required
self.status = "pending"
self.tx_hashes = "[]"
self.shop_location = shop_location
self.shop_sweep_to_address = shop_sweep_to_address
self.refund_address = refund_address
now = now_timestamp()
self.created_timestamp = now
self.updated_timestamp = now
@property
def is_expired(self) -> bool:
return now_timestamp() > self.quote_expires_at and self.received_amount == 0
@property
def due_amount(self) -> int:
return max(0, self.expected_amount - self.received_amount)
@property
def is_swept(self) -> bool:
"""Check if this payment has been swept to cold storage."""
return self.swept_tx_hash is not None
@property
def available_to_sweep(self) -> int:
"""Calculate amount available to sweep for this specific payment."""
if self.is_swept or self.received_amount == 0:
return 0
# Return the received amount for this specific payment
return self.received_amount

View file

@ -0,0 +1,48 @@
import uuid
from sqlalchemy import Column, BigInteger, Unicode, Boolean, Index, UniqueConstraint
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
class CryptoProcessor(RBase, Base):
"""Configuration for cryptocurrency payment processing per shop."""
id = Column(UUIDType, primary_key=True, index=True)
# Shop this processor belongs to
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True)
# Cryptocurrency type (XMR, DOGE, BTC, LTC, etc)
coin_type = Column(Unicode(32), nullable=False, index=True)
# Is this processor active?
enabled = Column(Boolean, default=True, nullable=False)
# Remote cold wallet address to sweep funds to
sweep_to_address = Column(Unicode(256), nullable=False)
# Wallet identifier - account index for Monero, label for Bitcoin-like
wallet_label = Column(Unicode(128), nullable=False)
# Timestamps
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
def __init__(self, shop_id, coin_type, sweep_to_address):
self.id = uuid.uuid1()
self.shop_id = shop_id
self.coin_type = coin_type.upper()
self.sweep_to_address = sweep_to_address
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
# Create indexes and constraints
Index(
"ix_crypto_processor_shop_coin", CryptoProcessor.shop_id, CryptoProcessor.coin_type
)
UniqueConstraint(
CryptoProcessor.shop_id,
CryptoProcessor.coin_type,
name="uq_crypto_processor_shop_coin",
)
UniqueConstraint(CryptoProcessor.wallet_label, name="uq_crypto_processor_wallet_label")

View file

@ -204,13 +204,13 @@ class Invoice(RBase, Base):
return sum(
item.price.price_in_cents * item.quantity for item in self.line_items
)
@property
def discount_amount_in_cents(self):
"""Calculate total discount amount from coupon redemptions."""
discount = 0
subtotal = self.subtotal_in_cents
for redemption in self.coupon_redemptions:
coupon = redemption.coupon
if coupon.is_valid:
@ -220,7 +220,7 @@ class Invoice(RBase, Base):
discount += subtotal - discounted_subtotal
# Update subtotal for next coupon (if multiple coupons allowed)
subtotal = discounted_subtotal
return discount
@property
@ -229,7 +229,7 @@ class Invoice(RBase, Base):
subtotal = self.subtotal_in_cents
discount = self.discount_amount_in_cents
handling = self.handling_cost_in_cents or 0
total = subtotal - discount + handling
# Never go negative
return max(0, total)
@ -258,6 +258,35 @@ class Invoice(RBase, Base):
"%Y-%m-%d %H:%M:%S"
)
@property
def payment_status(self):
"""Get payment status from crypto_payment or assume paid for Stripe."""
if hasattr(self, "crypto_payment") and self.crypto_payment:
return self.crypto_payment.status
else:
# If invoice exists without crypto_payment, it's a successful Stripe payment
return "paid"
@property
def payment_method(self):
"""Get payment method from crypto_payment or return 'stripe' for card payments."""
try:
if hasattr(self, "crypto_payment") and self.crypto_payment:
# crypto_payment is a collection, get the first one
if hasattr(self.crypto_payment, '__len__') and len(self.crypto_payment) > 0:
return self.crypto_payment[0].coin_type.lower()
elif hasattr(self.crypto_payment, 'coin_type'):
return self.crypto_payment.coin_type.lower()
except Exception:
# Fall back to stripe if there's any issue accessing crypto_payment
pass
return "stripe"
@property
def is_paid(self):
"""Check if this invoice has been successfully paid."""
return self.payment_status in ["confirmed", "confirmed_overpaid", "paid"]
def get_invoice_by_id(dbsession, invoice_id):
"""Try to get Invoice object by id or return None."""

View file

@ -3,7 +3,7 @@ import base64
from sqlalchemy import ForeignKey
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import declarative_base, declared_attr
from sqlalchemy.schema import MetaData
@ -37,6 +37,9 @@ CLASS_TO_TABLE = {
"StripeUserShop": "mps_stripe_user_shop",
"Market": "mps_market",
"Comment": "mps_comment",
"CryptoPayment": "mps_crypto_payment",
"CryptoProcessor": "mps_crypto_processor",
"UserCryptoRefundAddress": "mps_user_crypto_refund_address",
}

View file

@ -83,12 +83,25 @@ class Shop(RBase, Base):
maint_mode = Column(Boolean, default=False)
favicon = Column(Boolean, default=False)
logo_banner = Column(Boolean, default=False)
# Comment/Review system settings
comments_enabled = Column(Boolean, default=True)
comments_require_purchase = Column(Boolean, default=False)
comments_require_approval = Column(Boolean, default=False)
# Payment risk thresholds (in cents)
payment_risk_threshold_mid_cents = Column(
BigInteger, nullable=False, default=1000
) # Risk threshold between petty and mid tier (default $10)
payment_risk_threshold_high_cents = Column(
BigInteger, nullable=False, default=10000
) # Risk threshold between mid and high tier (default $100)
# Cryptocurrency quote expiry time in seconds
crypto_quote_expiry_seconds = Column(
BigInteger, nullable=False, default=3600
) # Default 60 minutes
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
@ -200,15 +213,41 @@ class Shop(RBase, Base):
return slugify(self.name)
@property
def is_ready(self):
"""is the shop ready to accept charges?"""
def is_stripe_ready(self):
"""Check if shop has Stripe API keys configured."""
if self.stripe_secret_api_key and self.stripe_public_api_key:
return True
return False
@property
def is_not_ready(self):
return not self.is_ready
def is_stripe_not_ready(self):
return not self.is_stripe_ready
def is_ready_for_payment(self, request):
"""Check if shop is ready based on enabled payment methods."""
# If Stripe is enabled, shop needs Stripe API keys
if request.stripe_enabled:
if self.is_stripe_ready:
return True
# If Monero is enabled, check if shop has configured processor and RPC is available
if request.monero_enabled and request.monero_rpc_available:
from .crypto_processor import CryptoProcessor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == self.id,
CryptoProcessor.coin_type == "XMR",
CryptoProcessor.enabled == True,
)
.first()
)
if processor:
return True
# No payment methods available or properly configured
return False
@property
def stripe(self):

View file

@ -64,7 +64,27 @@ class StripeUserShop(RBase, Base):
@property
def active_card(self):
return self.get_card_by_id(self.active_card_id)
# If we have an active_card_id, try to get that card
if self.active_card_id:
card = self.get_card_by_id(self.active_card_id)
if card:
return card
# If no active card or the active card doesn't exist anymore,
# automatically set the first available card as active
available_cards = self.stripe_cards
if available_cards:
# Set the first card as active
self.active_card_id = available_cards[0].id
# Save to database using object_session
from sqlalchemy.orm.session import object_session
session = object_session(self)
if session:
session.add(self)
session.flush()
return available_cards[0]
return None
def get_all_stripe_user_shop_objects(dbsession):

View file

@ -0,0 +1,60 @@
import uuid
from sqlalchemy import Column, BigInteger, Unicode, Index, UniqueConstraint
from sqlalchemy.orm import relationship
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class UserCryptoRefundAddress(RBase, Base):
"""
Stores cryptocurrency refund addresses for users.
One user can have one address per coin type.
"""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
# Coin type (e.g., 'XMR', 'BTC', 'LTC', 'DOGE', 'BCH')
coin_type = Column(Unicode(32), nullable=False)
# The refund address for this coin type
address = Column(Unicode(256), nullable=False)
# Optional label/description
label = Column(Unicode(128), nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
# Relationships
user = relationship("User", backref="crypto_refund_addresses")
def __init__(self, user, coin_type, address, label=None):
self.id = uuid.uuid1()
self.user = user
self.coin_type = coin_type.upper()
self.address = address
self.label = label
now = now_timestamp()
self.created_timestamp = now
self.updated_timestamp = now
# Create unique constraint for user_id + coin_type
UniqueConstraint(
UserCryptoRefundAddress.user_id,
UserCryptoRefundAddress.coin_type,
name="uq_user_crypto_refund_address_user_coin",
)
def get_user_crypto_refund_address(dbsession, user, coin_type):
"""Get the refund address for a user and coin type."""
return (
dbsession.query(UserCryptoRefundAddress)
.filter(
UserCryptoRefundAddress.user_id == user.id,
UserCryptoRefundAddress.coin_type == coin_type.upper(),
)
.first()
)

View file

@ -168,6 +168,45 @@ def includeme(config):
return request.domain.endswith(root_domain)
return False
def add_stripe_enabled(request):
"""Check if Stripe payments are enabled globally."""
try:
val = request.app.get("payments.stripe.enabled")
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
elif isinstance(val, bool):
return val
except Exception as e:
pass
return True # Default to enabled for backwards compatibility
def add_monero_enabled(request):
"""Check if Monero payments are enabled globally."""
try:
val = request.app.get("payments.monero.enabled")
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
elif isinstance(val, bool):
return val
except Exception:
pass
return False # Default to disabled
def add_monero_rpc_available(request):
"""Check if Monero RPC is available and responding."""
if not request.monero_enabled:
return False
try:
from ..lib.crypto_clients import get_client_from_settings
client = get_client_from_settings(request.registry.settings)
# Try to get blockchain height as a simple health check
height = client.get_height()
return height > 0
except Exception:
return False
# Register functions to app config as request methods.
# To prevent multiple DB lookups, cache result with `reify=True`.
config.add_request_method(add_debug_mode, "debug_mode", reify=True)
@ -196,3 +235,26 @@ def includeme(config):
config.add_request_method(
add_secure_uploads_client, "secure_uploads_client", reify=True
)
# Payment method checks
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
config.add_request_method(add_monero_enabled, "monero_enabled", reify=True)
config.add_request_method(
add_monero_rpc_available, "monero_rpc_available", reify=True
)
def add_has_xmr_refund_address(request):
"""Check if the current user has an XMR refund address configured."""
if not request.user:
return False
from .models.user_crypto_refund_address import get_user_crypto_refund_address
refund_address = get_user_crypto_refund_address(
request.dbsession, request.user, "XMR"
)
return refund_address is not None and refund_address.address is not None
# Refund address checks
config.add_request_method(
add_has_xmr_refund_address, "has_xmr_refund_address", reify=True
)

View file

@ -27,6 +27,8 @@ def includeme(config):
# user routes.
config.add_route("user_settings", "/u/settings")
config.add_route("user_crypto_settings", "/u/settings/crypto")
config.add_route("user_crypto_settings_update", "/u/settings/crypto/{coin_type}")
config.add_route("user_purchases", "/u/purchases")
config.add_route("user_addresses", "/u/addresses")
config.add_route("user_address_save", "/u/addresses/save")
@ -92,6 +94,7 @@ def includeme(config):
config.add_route("shop_sales", "/s/{shop_id}/sales")
config.add_route("shop_settings", "/s/{shop_id}/settings")
config.add_route("crypto_processor_settings", "/s/{shop_id}/crypto-processor/{coin_type}")
config.add_route("shop_users", "/s/{shop_id}/users")
config.add_route("shop_user_remove", "/s/{shop_id}/remove-user")
@ -141,3 +144,8 @@ def includeme(config):
config.add_route("comment_undelete", "/comments/{comment_id}/undelete")
config.add_route("comment_approve", "/comments/{comment_id}/approve")
config.add_route("comment_unapprove", "/comments/{comment_id}/unapprove")
# cryptocurrency payment routes.
config.add_route("crypto_xmr_start", "/crypto/xmr/start")
config.add_route("crypto_xmr_status", "/crypto/xmr/status/{payment_id}")
config.add_route("crypto_quote", "/crypto/quote/{payment_id}")

View file

@ -0,0 +1,57 @@
"""Add crypto_quote_expiry_seconds to shop
Revision ID: 0f59018f6537
Revises: 193438acaa95
Create Date: 2025-09-21 07:39:30.019212
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0f59018f6537"
down_revision = "193438acaa95"
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# Add crypto quote expiry seconds column
op.add_column(
"mps_shop",
sa.Column(
"crypto_quote_expiry_seconds",
sa.BigInteger(),
nullable=False,
server_default="3600",
),
)
# Add payment risk threshold columns with new names
op.add_column(
"mps_shop",
sa.Column(
"payment_risk_threshold_mid_cents",
sa.BigInteger(),
nullable=False,
server_default="1000",
),
)
op.add_column(
"mps_shop",
sa.Column(
"payment_risk_threshold_high_cents",
sa.BigInteger(),
nullable=False,
server_default="10000",
),
)
def downgrade():
op.drop_column("mps_shop", "payment_risk_threshold_high_cents")
op.drop_column("mps_shop", "payment_risk_threshold_mid_cents")
op.drop_column("mps_shop", "crypto_quote_expiry_seconds")

View file

@ -0,0 +1,27 @@
"""Add crypto_quote_expiry_seconds to shop
Revision ID: 193438acaa95
Revises: 81d65d8605c2
Create Date: 2025-09-20 21:31:26.257478
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '193438acaa95'
down_revision = '81d65d8605c2'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# Add crypto quote expiry time column with default of 3600 seconds (60 minutes)
op.add_column('mps_shop', sa.Column('crypto_quote_expiry_seconds', sa.BigInteger(), nullable=False, server_default='3600'))
def downgrade():
op.drop_column('mps_shop', 'crypto_quote_expiry_seconds')

View file

@ -911,4 +911,9 @@ div.message-ribbon {
#toggle:checked ~ .hidden-control {
display: block;
}
/* Stripe toggle */
#toggle-stripe:checked ~ .hidden-control {
display: block;
}
/* hidden control area */

View file

@ -7,19 +7,25 @@
<section class="checkout-left well">
{% if active_card %}
<h2><b>Active Card</b></h2>
{% if stripe_enabled %}
{% if active_card %}
<h2><b>Active Card</b></h2>
{{ stripe.display_card(active_card, actions=False) }}
{{ stripe.display_card(active_card, actions=False) }}
<br>
<br>
{% if request.shop and request.shop.is_ready %}
<a href="/billing" class="product-edit-button mps-button">Use a different card</a>
{% endif %}
{% if request.shop and request.shop.is_ready_for_payment(request) %}
<a href="/billing" class="product-edit-button mps-button">Use a different card</a>
{% endif %}
{% else %}
<h2><b>Payment</b></h2>
<p>No active payment method configured. <a href="/billing">Add a payment method</a>.</p>
<br>
{% endif %}
{% else %}
<h2><b>Payment</b></h2>
<p>No payment required for this order.</p>
<p>Card payments are disabled by configuration.</p>
<br>
{% endif %}
@ -36,20 +42,49 @@
<h2>Cart Total: ${{ '{:,.2f}'.format(cart.total) }}</h2>
<br/>
Are you sure you want to charge <br/>
<b>${{ '{:,.2f}'.format(cart.total) }}</b> to your active credit card?
{% if stripe_enabled %}
Are you sure you want to charge <br/>
<b>${{ '{:,.2f}'.format(cart.total) }}</b> to your active credit card?
{% else %}
{% if cart.requires_payment %}
Choose a payment method below.
{% endif %}
{% endif %}
<br/>
<br/>
<br/>
<br/>
<form method="POST" action="{{ request.route_url('user_cart_complete_checkout', cart_id=cart.id) }}">
{% include "snippets/csrf.j2" %}
<button type="submit" class="cart-checkout-button mps-button">Yes, Complete Checkout</button>
</form>
{% if stripe_enabled %}
<form method="POST" action="{{ request.route_url('user_cart_complete_checkout', cart_id=cart.id) }}">
{% include "snippets/csrf.j2" %}
<button type="submit" class="cart-checkout-button mps-button">Yes, Complete Checkout</button>
</form>
{% endif %}
{# Offer Monero if globally enabled, shop has enabled processor, cart requires payment and is single-shop #}
{% if monero_enabled and xmr_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
<br/>
<form method="POST" action="{{ request.route_url('crypto_xmr_start') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
<button type="submit" class="mps-button" style="background:#4c4;">Pay with Monero (XMR)</button>
</form>
{% if not request.has_xmr_refund_address %}
<a href="/u/settings/crypto" target="_blank" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
⚠️ Configure an XMR refund address to enable automatic refunds during payment errors.
</a>
{% endif %}
{% endif %}
{% if not stripe_enabled and not (monero_enabled and xmr_processor_enabled) %}
<br/>
<p>No payment methods are enabled. Please contact the shop owner.</p>
{% endif %}
</section>
</section>

View file

@ -0,0 +1,258 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<h2>{{ coin_name }} ({{ coin_symbol }}) Checkout</h2>
{% if address and amount_crypto %}
{% if coin_symbol == 'XMR' %}
{% set amount_fmt = '%.12f' % amount_crypto %}
{% else %}
{% set amount_fmt = '%.8f' % amount_crypto %}
{% endif %}
<p>Send exactly <b id="crypto-amount">{{ amount_fmt }}</b> {{ coin_symbol }} to this address:</p>
<pre id="crypto-address" style="white-space:pre-wrap;word-wrap:break-word;">{{ address }}</pre>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin: 8px 0;">
<button class="mps-button" id="copy-amt" style="background:#4c4;">Copy Amount</button>
<button class="mps-button" id="copy-addr" style="background:#4c4;">Copy Address</button>
<a class="mps-button" href="{{ wallet_uri_scheme }}:{{ address }}?{% if coin_symbol == 'XMR' %}tx_amount{% else %}amount{% endif %}={{ amount_fmt }}" target="_blank" rel="noopener" style="background:#4c4;">Open in Wallet</a>
</div>
<p>Status: <span id="status">{{ status }}</span></p>
<p id="confirmations" style="display:none;">
Confirmations: <span id="current-confirmations">0</span> / <span id="required-confirmations">0</span>
</p>
<p>
{% if expires_at %}
Expires in: <span id="countdown" data-expires="{{ expires_at }}">--:--</span>
{% endif %}
</p>
<p>
<small>Payment ID: <code id="payment-id">{{ payment_id }}</code></small>
</p>
<h3>💰 Quote Details</h3>
<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 15px; margin: 10px 0;">
<p><strong>Cart Total:</strong> ${{ '%.2f' % usd_total }}</p>
<p><strong>Conversion Rate:</strong> ${{ '%.2f' % usd_per_crypto }} USD per {{ coin_symbol }}</p>
{% if coin_symbol == 'XMR' %}
<p><strong>Base Amount:</strong> {{ '%.12f' % amount_crypto_base }} {{ coin_symbol }}</p>
<p><strong>Transaction Fee Buffer:</strong> +{{ '%.12f' % fee_buffer_crypto }} {{ coin_symbol }}</p>
<p><strong>Total Amount:</strong> {{ '%.12f' % amount_crypto }} {{ coin_symbol }}</p>
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
{% else %}
<p><strong>Base Amount:</strong> {{ '%.8f' % amount_crypto_base }} {{ coin_symbol }}</p>
<p><strong>Transaction Fee Buffer:</strong> +{{ '%.8f' % fee_buffer_crypto }} {{ coin_symbol }}</p>
<p><strong>Total Amount:</strong> {{ '%.8f' % amount_crypto }} {{ coin_symbol }}</p>
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
{% endif %}
</div>
{% if not has_refund_address %}
<small style="color: #666; display: block; margin: 5px 0;">No refund address configured. <a href="/u/settings/crypto">Set one up</a> for automatic refunds in rare error cases.</small>
{% endif %}
<div id="received" style="display:none;">
<small>Received: <span id="recv-piconero"></span> / <span id="exp-piconero"></span> piconero</small>
</div>
<script>
(function(){
const paymentId = document.getElementById('payment-id')?.textContent;
const statusEl = document.getElementById('status');
const recvEl = document.getElementById('recv-piconero');
const expEl = document.getElementById('exp-piconero');
const recvWrap = document.getElementById('received');
const expiresEl = document.getElementById('countdown');
const confirmationsEl = document.getElementById('confirmations');
const currentConfirmationsEl = document.getElementById('current-confirmations');
const requiredConfirmationsEl = document.getElementById('required-confirmations');
function fmtPico(n){ try { return Number(n).toLocaleString(); } catch(e){ return n; } }
// Countdown timer
let countdownInterval;
let expiredWarningShown = false;
function disableQuoteButtons() {
// Disable payment buttons when quote expires
const copyAddrBtn = document.getElementById('copy-addr');
const copyAmtBtn = document.getElementById('copy-amt');
const walletLink = document.querySelector('a[href^="monero:"], a[href^="bitcoin:"], a[href^="litecoin:"], a[href^="dogecoin:"]');
[copyAddrBtn, copyAmtBtn].forEach(btn => {
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
btn.style.background = '#ccc';
}
});
if (walletLink) {
walletLink.style.opacity = '0.5';
walletLink.style.pointerEvents = 'none';
walletLink.style.background = '#ccc';
}
}
// Add warning message for expired payments (only once)
function addExpiredWarning() {
if (!expiredWarningShown) {
const expiredWarning = document.createElement('p');
expiredWarning.style.color = '#dc3545';
expiredWarning.style.fontWeight = 'bold';
expiredWarning.style.marginTop = '10px';
expiredWarning.innerHTML = '⚠️ This quote has expired. Please return to cart to generate a new quote.';
(statusEl || expiresEl).parentNode.appendChild(expiredWarning);
expiredWarningShown = true;
}
}
// Check if payment is already expired even without countdown element
if (statusEl && statusEl.textContent.trim() === 'expired') {
disableQuoteButtons();
addExpiredWarning();
// Hide countdown since status already shows expired
if (expiresEl) {
expiresEl.parentNode.style.display = 'none';
}
}
if (expiresEl) {
const expiresAt = parseInt(expiresEl.getAttribute('data-expires'), 10);
// Check if already expired on page load
const now = Date.now();
if ((now >= expiresAt && statusEl && statusEl.textContent.trim() === 'pending') ||
(statusEl && statusEl.textContent.trim() === 'expired')) {
disableQuoteButtons();
expiresEl.style.color = '#dc3545';
expiresEl.textContent = 'EXPIRED';
addExpiredWarning();
return; // Don't start countdown if already expired
}
const tick = () => {
// Stop countdown if payment status is no longer pending
if (statusEl && statusEl.textContent.trim() !== 'pending') {
expiresEl.textContent = '--:--';
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
return;
}
const now = Date.now();
const ms = Math.max(0, expiresAt - now);
// Check if expired first
if (ms === 0 && statusEl && statusEl.textContent.trim() === 'pending' && !expiredWarningShown) {
disableQuoteButtons();
expiresEl.style.color = '#dc3545'; // Red color to indicate expiry
expiresEl.textContent = 'EXPIRED';
addExpiredWarning();
// Stop the countdown timer since quote is expired
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
} else if (!expiredWarningShown) {
// Only update countdown if not expired
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
expiresEl.textContent = `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
}
};
tick();
countdownInterval = setInterval(tick, 1000);
}
// Copy buttons
const addrBtn = document.getElementById('copy-addr');
const amtBtn = document.getElementById('copy-amt');
addrBtn?.addEventListener('click', () => {
const addr = document.getElementById('crypto-address').textContent;
navigator.clipboard.writeText(addr);
});
amtBtn?.addEventListener('click', () => {
const amt = document.getElementById('crypto-amount').textContent;
navigator.clipboard.writeText(amt);
});
// Poll status endpoint
async function poll(){
if (!paymentId) return;
try {
const res = await fetch(`/crypto/{{ coin_symbol.lower() }}/status/${paymentId}`, { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data.status) statusEl.textContent = data.status;
// Show confirmations if payment has been received
if (typeof data.current_confirmations === 'number' && typeof data.confirmations_required === 'number') {
currentConfirmationsEl.textContent = data.current_confirmations;
requiredConfirmationsEl.textContent = data.confirmations_required;
// Show confirmations section if payment is being processed
if (data.status !== 'pending' && data.status !== 'expired') {
confirmationsEl.style.display = 'block';
}
}
const receivedKey = 'received_{{ smallest_unit_name.lower() }}';
const expectedKey = 'expected_{{ smallest_unit_name.lower() }}';
if (typeof data[receivedKey] === 'number' && typeof data[expectedKey] === 'number'){
recvEl.textContent = fmtPico(data[receivedKey]);
expEl.textContent = fmtPico(data[expectedKey]);
recvWrap.style.display = 'block';
}
// Optional: redirect when confirmed
if (data.status === 'confirmed') {
// Redirect to purchases page (matches Stripe flow)
window.location.href = '/u/purchases';
}
} catch(e) {
// ignore transient errors
}
}
poll();
setInterval(poll, 5000);
})();
</script>
{% else %}
<p>Monero checkout is initializing. If this message persists, the RPC may not be configured.</p>
{% endif %}
<h3>⚠️ Payment Information</h3>
{% if has_refund_address %}
<div style="background: #d1fae5; border: 1px solid #10b981; border-radius: 4px; padding: 15px; margin: 20px 0;">
<p><strong>✓ Refund address configured</strong></p>
<p style="font-family: monospace; background: #f0f0f0; padding: 8px; border-radius: 3px; word-break: break-all;">{{ refund_address }}</p>
<p>If you make a payment error, you'll receive an automatic refund minus a 9% restocking fee that covers network costs and prevents abuse.</p>
<ul style="margin: 10px 0;">
<li><strong>Overpayment:</strong> Fulfilled & excess refunded minus fee</li>
<li><strong>Underpayment:</strong> Refunded minus fee</li>
<li><strong>Late payment:</strong> Refunded minus fee if sent after the minute expiry</li>
<li><strong>Wrong address:</strong> Cannot be recovered</li>
</ul>
</div>
{% else %}
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 4px; padding: 15px; margin: 20px 0;">
<p><strong>⚠️ No refund address configured</strong></p>
<p>Without a refund address, all payment errors result in lost funds:</p>
<ul style="margin: 10px 0;">
<li>Underpayments are kept</li>
<li>Overpayments are kept</li>
<li>Late payments after <strong>{{ ((expires_at - now) // 60000) if expires_at and (expires_at - now) > 0 else 'expiry' }} minutes</strong> are kept</li>
<li>Wrong address payments cannot be recovered</li>
</ul>
<p style="margin-top: 10px;">Consider configuring a refund address for future purchases.</p>
</div>
{% endif %}
</section>
{%- endblock %}

View file

@ -4,6 +4,36 @@
<h1>Invoice Identifier<br>{{ invoice.id }}</h1>
<p><strong>Customer Name:</strong> {{ invoice.user.name }}</p>
<p><strong>Date:</strong> {{ invoice.human_created_timestamp }}</p>
<p><strong>Payment Method:</strong> {{ invoice.payment_method.upper() }}</p>
<p><strong>Payment Status:</strong> {{ invoice.payment_status }}</p>
{% if invoice.crypto_payment and invoice.crypto_payment|length > 0 %}
{% set crypto_pay = invoice.crypto_payment[0] %}
<!-- Crypto Payment Details -->
<div style="background-color: #f0f8ff; padding: 15px; border: 1px solid #4169e1; border-radius: 5px; margin: 15px 0;">
<h3>Crypto Payment Information</h3>
<p><strong>Currency:</strong> {{ crypto_pay.coin_type }}</p>
<p><strong>Payment Address:</strong> <code style="font-family: monospace; background-color: #e9e9e9; padding: 2px 5px; border-radius: 3px;">{{ crypto_pay.address }}</code></p>
<p><strong>Expected Amount:</strong> {{ '%.12f'|format(crypto_pay.expected_amount / (10**12 if crypto_pay.coin_type == 'XMR' else 10**8)) }} {{ crypto_pay.coin_type }}</p>
<p><strong>Received Amount:</strong> {{ '%.12f'|format(crypto_pay.received_amount / (10**12 if crypto_pay.coin_type == 'XMR' else 10**8)) }} {{ crypto_pay.coin_type }}</p>
{% if crypto_pay.status in ['confirmed', 'confirmed_overpaid'] %}
<p style="color: #28a745;"><strong>✓ Payment Confirmed</strong></p>
{% elif crypto_pay.status == 'received' %}
<p style="color: #ffc107;"><strong>⏳ Awaiting Confirmations ({{ crypto_pay.current_confirmations }}/{{ crypto_pay.confirmations_required }})</strong></p>
{% elif crypto_pay.status == 'pending' %}
<p style="color: #6c757d;"><strong>⏳ Waiting for Payment</strong></p>
{% elif crypto_pay.status == 'expired' %}
<p style="color: #dc3545;"><strong>❌ Payment Expired</strong></p>
{% elif crypto_pay.status == 'underpaid_refunded' %}
<p style="color: #dc3545;"><strong>❌ Underpaid - Refunded</strong></p>
{% endif %}
{% if crypto_pay.tx_hashes and crypto_pay.tx_hashes != '[]' %}
<p><strong>Transaction(s):</strong> <code style="font-size: 0.9em; font-family: monospace;">{{ crypto_pay.tx_hashes }}</code></p>
{% endif %}
</div>
{% endif %}
<!-- Pricing Breakdown -->
<div style="background-color: #f8f9fa; padding: 15px; border: 1px solid #dee2e6; border-radius: 5px; margin: 15px 0;">

View file

@ -8,6 +8,7 @@
<h3>Shop Settings</h3>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="shop-settings" />
<label for="name_input">Shop Name</label>
<input
@ -77,6 +78,7 @@
<h3>Integration Settings</h3>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="integration-settings" />
<label for="domain_name_input">Custom Shop Domain Name</label>
<input
@ -91,23 +93,6 @@
<br />
<br />
<!--
<br />
<br />
<label for="google_analytics_id_input">Google Analytics Tracking Id (optional)</label>
<input
name = "google_analytics_id"
type = "text"
id = "google_analytics_id"
class = "mps-shop-name"
value = "{% if google_analytics_id %}{{ google_analytics_id }}{% endif %}"
placeholder = "Google Analytics Id"
/>
-->
<label for="plausible_domain_name_input">Plausible Analytics Domain Name (optional)</label>
<input
name = "plausible_domain_name"
@ -120,14 +105,35 @@
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />
<br />
</form>
</section>
</section>
<br />
<br />
{% if request.stripe_enabled %}
<section class="one-column">
<section class="shop-settings well">
<h3>Stripe Settings 💳</h3>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="stripe-settings" />
<label for="stripe_public_api_key_input">Stripe API Keys</label>
<br />
<input type="checkbox" id="toggle" style="">
<label for="toggle" style="display: inline;" >show values</label>
<input type="checkbox" id="toggle-stripe" style="">
<label for="toggle-stripe" style="display: inline;" >show values</label>
<div class="hidden-control">
@ -159,17 +165,163 @@
<br />
<br />
</div>
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<input type="submit" name="submit" class="mps-submit" value="Save Stripe Settings" />
<br />
<br />
</form>
</section>
</section>
<br />
<br />
{% endif %}
{% if request.monero_enabled %}
<section class="one-column">
<section class="shop-settings well">
<h3>Crypto Settings 🪙</h3>
<h4>Payment Risk Thresholds</h4>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="crypto-settings" />
<label for="payment_risk_threshold_mid_dollars_input">Medium Risk Threshold ($)</label>
<input
name = "payment_risk_threshold_mid_dollars"
type = "number"
id = "payment_risk_threshold_mid_dollars_input"
class = "mps-threshold"
value = "{{ payment_risk_threshold_mid_dollars }}"
placeholder = "10.00"
min = "1"
max = "1000"
step = "0.01"
/>
<br />
<small>Orders below this amount require minimal confirmations. Default: $10.00</small>
<br />
<br />
<label for="payment_risk_threshold_high_dollars_input">High Risk Threshold ($)</label>
<input
name = "payment_risk_threshold_high_dollars"
type = "number"
id = "payment_risk_threshold_high_dollars_input"
class = "mps-threshold"
value = "{{ payment_risk_threshold_high_dollars }}"
placeholder = "100.00"
min = "10"
max = "10000"
step = "0.01"
/>
<br />
<small>Orders above this amount require maximum confirmations. Default: $100.00</small>
<br />
<br />
<label for="crypto_quote_expiry_seconds_input">Cryptocurrency Quote Expiry Time (seconds)</label>
<input
name = "crypto_quote_expiry_seconds"
type = "number"
id = "crypto_quote_expiry_seconds_input"
class = "mps-crypto-quote-expiry"
value = "{{ crypto_quote_expiry_seconds }}"
placeholder = "3600"
min = "300"
max = "7200"
step = "60"
/>
<br />
<small>How long cryptocurrency payment quotes are valid (300-7200 seconds, default 3600 = 60 minutes)</small>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Crypto Settings" />
<br />
<br />
</form>
<hr />
<h4>Monero (XMR) Configuration</h4>
<form method="post" action="/s/{{ request.shop.id }}/crypto-processor/XMR" onsubmit="submit.disabled = true; return true;">
<label for="xmr_sweep_to_address_input">Cold Wallet Address (Required to accept Monero)</label>
<input
name = "sweep_to_address"
type = "text"
id = "xmr_sweep_to_address_input"
class = "mps-sweep-address"
value = "{% if xmr_processor %}{{ xmr_processor.sweep_to_address }}{% endif %}"
placeholder = "Monero address to sweep funds to"
style = "width: 100%;{% if xmr_processor and not xmr_processor.enabled %} opacity: 0.5; background-color: #f5f5f5;{% endif %}"
{% if xmr_processor and not xmr_processor.enabled %}readonly{% endif %}
/>
<br />
<small>Configure your cold wallet address where this shop's Monero funds will be swept. This is required to accept Monero payments.</small>
{% if xmr_processor %}
{% if xmr_processor.enabled %}
<br />
<br />
<small style="color: green;">✓ Monero wallet configured and ready to accept payments</small>
<br />
<small style="color: #666;">
Note: We cannot verify you control this address. Please double-check it's correct!
Consider sending a small test amount from another wallet to verify.
</small>
{% else %}
<br />
<br />
<small style="color: #d44;">✗ Monero payments are currently disabled</small>
<br />
<small style="color: #666;">
Your cold wallet address is preserved but customers cannot select Monero as a payment method.
</small>
{% endif %}
{% endif %}
<br />
<br />
{% if xmr_processor %}
{% if xmr_processor.enabled %}
<input type="submit" name="submit" class="mps-submit" value="Update Monero Settings" />
<input type="submit" name="disable" class="mps-submit" value="Disable Monero" style="background: #d44; color: white;" />
{% else %}
<input type="submit" name="submit" class="mps-submit" value="Re-enable Monero" style="background: #4a4;" />
<small style="color: #666;">Enter your cold wallet address to re-enable Monero payments</small>
{% endif %}
{% else %}
<input type="submit" name="submit" class="mps-submit" value="Enable Monero Payments" />
{% endif %}
<br />
<br />
</form>
</section>
</section>
<br />
<br />
{% endif %}
<br />
<br />
@ -214,6 +366,7 @@
<section class="shop-settings well">
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="ribbon-settings" />
<h3>Announcement Ribbon Settings 🎀</h3>
@ -280,10 +433,13 @@
<br />
<br />
<section class="one-column">
<section class="shop-settings well">
<form method="post" action="/s/{{ request.shop.id }}/settings">
<input type="hidden" name="form_section" value="maintenance-settings" />
<h3>Maintenance Mode 🏗️</h3>
@ -318,6 +474,7 @@ Existing sales honored for download buy purchasers.
<section class="shop-settings well">
<form method="post" action="/s/{{ request.shop.id }}/settings">
<input type="hidden" name="form_section" value="comment-settings" />
<h3>Comment System Settings 💬</h3>

View file

@ -0,0 +1,71 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h3>Crypto Payment Preferences</h3>
<p>Configure your cryptocurrency refund addresses. These addresses will be used automatically by our system in rare cases where refunds are necessary (e.g., system errors). <strong>Note: This is handled entirely by our automated system with no human support.</strong></p>
<br/>
{% if enabled_coins %}
{% for coin in enabled_coins %}
<form method="post" action="/u/settings/crypto/{{ coin }}" onsubmit="submit.disabled = true; return true;">
{% include "snippets/csrf.j2" %}
<h4>{{ coin }} Refund Address</h4>
<label for="{{ coin.lower() }}_address_input">{{ coin }} Address</label>
<input
name = "address"
type = "text"
id = "{{ coin.lower() }}_address_input"
class = "mps-crypto-address"
value = "{% if addresses.get(coin) %}{{ addresses[coin].address }}{% endif %}"
placeholder = "Your {{ coin }} address for automatic refunds"
style = "width: 100%;"
/>
<br />
<label for="{{ coin.lower() }}_label_input">Label (Optional)</label>
<input
name = "label"
type = "text"
id = "{{ coin.lower() }}_label_input"
class = "mps-crypto-label"
value = "{% if addresses.get(coin) %}{{ addresses[coin].label }}{% endif %}"
placeholder = "e.g., My Hardware Wallet"
style = "width: 100%;"
/>
<br />
<br />
{% if addresses.get(coin) %}
<input type="submit" name="submit" class="mps-submit" value="Update {{ coin }} Address" />
{% else %}
<input type="submit" name="submit" class="mps-submit" value="Save {{ coin }} Address" />
{% endif %}
<br />
<br />
</form>
<hr />
{% endfor %}
{% else %}
<p>No cryptocurrencies are currently enabled on this platform.</p>
{% endif %}
<br />
<a href="/u/settings" class="mps-button">Back to Settings</a>
</section>
</section>
{%- endblock -%}

View file

@ -66,10 +66,12 @@
<a href="/u/shops" class="product-edit-button mps-button">My Shops</a>
{% endif %}
{% if request.shop and request.shop.is_ready %}
{% if request.shop and request.shop.is_ready_for_payment(request) %}
<a href="/u/purchases" class="product-download-button mps-button">My Purchases</a>
<br/>
<a href="/billing" class="product-edit-button mps-button">Payment Preferences</a>
<a href="/billing" class="product-edit-button mps-button">Credit Card Payment Preferences</a>
<br/>
<a href="/u/settings/crypto" class="product-edit-button mps-button">Crypto Payment Preferences</a>
<br/>
<a href="/u/addresses" class="product-edit-button mps-button">Shipping Addresses</a>
{% endif %}

View file

@ -0,0 +1,315 @@
"""Tests for environment variable expansion in configuration."""
import os
import unittest
from unittest import TestCase
from make_post_sell import get_children_settings
class TestEnvironmentVariableExpansion(unittest.TestCase):
"""Test environment variable expansion with ${VAR:-default} syntax."""
def setUp(self):
"""Set up test environment."""
# Store original environment
self.original_env = os.environ.copy()
# Clear any test variables
for key in list(os.environ.keys()):
if key.startswith("TEST_"):
del os.environ[key]
def tearDown(self):
"""Restore original environment."""
os.environ.clear()
os.environ.update(self.original_env)
def test_simple_expansion_with_value(self):
"""Test ${VAR} expansion when variable is set."""
os.environ["TEST_VAR"] = "test_value"
settings = {"app.test.setting": "${TEST_VAR}", "app.other": "static"}
result = get_children_settings(settings, "app")
self.assertEqual(result["test.setting"], "test_value")
self.assertEqual(result["other"], "static")
def test_expansion_with_default_used(self):
"""Test ${VAR:-default} uses default when var is not set."""
# Ensure TEST_MISSING is not in environment
if "TEST_MISSING" in os.environ:
del os.environ["TEST_MISSING"]
settings = {
"app.payment.enabled": "${TEST_MISSING:-true}",
"app.payment.provider": "${TEST_MISSING:-stripe}",
}
result = get_children_settings(settings, "app")
self.assertEqual(
result["payment.enabled"], True
) # get_int_or_bool_or_none_or_str converts
self.assertEqual(result["payment.provider"], "stripe")
def test_expansion_with_default_not_used(self):
"""Test ${VAR:-default} uses env value when var is set."""
os.environ["TEST_PAYMENT_ENABLED"] = "false"
os.environ["TEST_PROVIDER"] = "monero"
settings = {
"app.payment.enabled": "${TEST_PAYMENT_ENABLED:-true}",
"app.payment.provider": "${TEST_PROVIDER:-stripe}",
}
result = get_children_settings(settings, "app")
self.assertEqual(
result["payment.enabled"], False
) # get_int_or_bool_or_none_or_str converts
self.assertEqual(result["payment.provider"], "monero")
def test_empty_default_value(self):
"""Test ${VAR:-} with empty default."""
settings = {
"app.optional.setting": "${TEST_OPTIONAL:-}",
"app.optional.another": "${TEST_ANOTHER:-}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["optional.setting"], "")
self.assertEqual(result["optional.another"], "")
def test_boolean_conversion(self):
"""Test boolean string conversion."""
settings = {
"app.bool.true1": "${TEST_BOOL:-true}",
"app.bool.true2": "${TEST_BOOL:-True}",
"app.bool.true3": "${TEST_BOOL:-1}",
"app.bool.true4": "${TEST_BOOL:-yes}",
"app.bool.false1": "${TEST_BOOL:-false}",
"app.bool.false2": "${TEST_BOOL:-False}",
"app.bool.false3": "${TEST_BOOL:-0}",
"app.bool.false4": "${TEST_BOOL:-no}",
}
result = get_children_settings(settings, "app")
# All true values
self.assertEqual(result["bool.true1"], True)
self.assertEqual(result["bool.true2"], True)
self.assertEqual(result["bool.true3"], True)
self.assertEqual(result["bool.true4"], True)
# All false values
self.assertEqual(result["bool.false1"], False)
self.assertEqual(result["bool.false2"], False)
self.assertEqual(result["bool.false3"], False)
self.assertEqual(result["bool.false4"], False)
def test_integer_conversion(self):
"""Test integer string conversion."""
settings = {
"app.number.int1": "${TEST_NUM:-123}",
"app.number.int2": "${TEST_NUM:-0}",
"app.number.negative": "${TEST_NUM:--456}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["number.int1"], 123)
self.assertEqual(result["number.int2"], 0)
self.assertEqual(result["number.negative"], -456)
def test_none_conversion(self):
"""Test None string conversion."""
settings = {
"app.nullable.none1": "${TEST_NULL:-None}",
"app.nullable.none2": "${TEST_NULL:-none}",
"app.nullable.null": "${TEST_NULL:-null}",
"app.nullable.null2": "${TEST_NULL:-NULL}",
}
result = get_children_settings(settings, "app")
self.assertIsNone(result["nullable.none1"])
self.assertIsNone(result["nullable.none2"])
self.assertIsNone(result["nullable.null"])
self.assertIsNone(result["nullable.null2"])
def test_mixed_syntax(self):
"""Test mixing of different variable syntaxes."""
os.environ["TEST_SIMPLE"] = "simple_value"
os.environ["TEST_BRACES"] = "braces_value"
os.environ["TEST_DEFAULT_OVERRIDE"] = "override_value"
settings = {
"app.var.simple": "$TEST_SIMPLE",
"app.var.braces": "${TEST_BRACES}",
"app.var.default_used": "${TEST_MISSING:-default_value}",
"app.var.default_override": "${TEST_DEFAULT_OVERRIDE:-default_value}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["var.simple"], "simple_value")
self.assertEqual(result["var.braces"], "braces_value")
self.assertEqual(result["var.default_used"], "default_value")
self.assertEqual(result["var.default_override"], "override_value")
def test_complex_default_values(self):
"""Test default values with special characters."""
settings = {
"app.url.default": "${TEST_URL:-http://localhost:8080}",
"app.path.default": "${TEST_PATH:-/path/to/file}",
"app.json.default": '${TEST_JSON:-{"key": "value"}}',
"app.special.chars": "${TEST_SPECIAL:-value-with_special.chars}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["url.default"], "http://localhost:8080")
self.assertEqual(result["path.default"], "/path/to/file")
self.assertEqual(result["json.default"], '{"key": "value"}')
self.assertEqual(result["special.chars"], "value-with_special.chars")
def test_nested_variables_not_expanded(self):
"""Test that nested variable references are not expanded."""
os.environ["TEST_INNER"] = "inner_value"
settings = {
"app.nested.var": "${TEST_OUTER:-$TEST_INNER}",
}
result = get_children_settings(settings, "app")
# Should not expand the inner $TEST_INNER
self.assertEqual(result["nested.var"], "$TEST_INNER")
def test_multiple_variables_in_value(self):
"""Test multiple variable references in a single value."""
os.environ["TEST_HOST"] = "localhost"
os.environ["TEST_PORT"] = "8080"
settings = {
"app.url.composite": "${TEST_PROTO:-http}://${TEST_HOST}:${TEST_PORT}/path",
"app.mixed.defaults": "${TEST_USER:-admin}:${TEST_PASS:-secret}@${TEST_HOST}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["url.composite"], "http://localhost:8080/path")
self.assertEqual(result["mixed.defaults"], "admin:secret@localhost")
def test_non_string_values_unchanged(self):
"""Test that non-string values pass through unchanged."""
settings = {
"app.already.bool": True,
"app.already.int": 42,
"app.already.none": None,
"app.already.list": ["a", "b", "c"],
}
result = get_children_settings(settings, "app")
self.assertEqual(result["already.bool"], True)
self.assertEqual(result["already.int"], 42)
self.assertIsNone(result["already.none"])
self.assertEqual(result["already.list"], ["a", "b", "c"])
def test_parent_key_filtering(self):
"""Test that only settings with parent key are returned."""
settings = {
"app.included.one": "${TEST_VAR:-value1}",
"app.included.two": "${TEST_VAR:-value2}",
"other.excluded.one": "${TEST_VAR:-excluded1}",
"session.excluded.two": "${TEST_VAR:-excluded2}",
}
result = get_children_settings(settings, "app")
self.assertIn("included.one", result)
self.assertIn("included.two", result)
self.assertNotIn("excluded.one", result)
self.assertNotIn("excluded.two", result)
self.assertEqual(len(result), 2)
def test_whitespace_handling(self):
"""Test handling of whitespace in values."""
os.environ["TEST_TRIMMED"] = " trimmed "
settings = {
"app.space.env": "${TEST_TRIMMED}",
"app.space.default": "${TEST_MISSING:- spaced }",
"app.space.empty": "${TEST_MISSING:-}",
}
result = get_children_settings(settings, "app")
# Values should be preserved as-is
self.assertEqual(result["space.env"], " trimmed ")
self.assertEqual(result["space.default"], " spaced ")
self.assertEqual(result["space.empty"], "")
def test_edge_cases(self):
"""Test various edge cases."""
settings = {
"app.edge.dollar_only": "$",
"app.edge.unclosed_brace": "${TEST_VAR",
"app.edge.no_var_name": "${}",
"app.edge.just_default": "${:-default}",
"app.edge.multiple_colons": "${TEST_VAR:-value:with:colons}",
"app.edge.brace_in_default": "${TEST_VAR:-value}with}brace}",
}
result = get_children_settings(settings, "app")
# These should remain unchanged or handle gracefully
self.assertEqual(result["edge.dollar_only"], "$")
self.assertEqual(result["edge.unclosed_brace"], "${TEST_VAR")
self.assertEqual(result["edge.no_var_name"], "${}")
self.assertEqual(result["edge.just_default"], "default")
self.assertEqual(result["edge.multiple_colons"], "value:with:colons")
# The regex stops at the first closing brace, so it parses as ${TEST_VAR:-value} + "with}brace}"
self.assertEqual(result["edge.brace_in_default"], "valuewith}brace}")
class TestPaymentConfigurationExpansion(unittest.TestCase):
"""Test specific payment configuration scenarios."""
def setUp(self):
"""Set up test environment."""
self.original_env = os.environ.copy()
# Clear any test variables
for key in list(os.environ.keys()):
if key.startswith("MPS_"):
del os.environ[key]
def tearDown(self):
"""Restore original environment."""
os.environ.clear()
os.environ.update(self.original_env)
def test_stripe_monero_defaults(self):
"""Test default payment configuration values."""
settings = {
"app.payments.stripe.enabled": "${MPS_PAYMENTS_STRIPE_ENABLED:-true}",
"app.payments.monero.enabled": "${MPS_PAYMENTS_MONERO_ENABLED:-false}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["payments.stripe.enabled"], True)
self.assertEqual(result["payments.monero.enabled"], False)
def test_stripe_monero_overrides(self):
"""Test overriding payment configuration."""
os.environ["MPS_PAYMENTS_STRIPE_ENABLED"] = "false"
os.environ["MPS_PAYMENTS_MONERO_ENABLED"] = "true"
settings = {
"app.payments.stripe.enabled": "${MPS_PAYMENTS_STRIPE_ENABLED:-true}",
"app.payments.monero.enabled": "${MPS_PAYMENTS_MONERO_ENABLED:-false}",
}
result = get_children_settings(settings, "app")
self.assertEqual(result["payments.stripe.enabled"], False)
self.assertEqual(result["payments.monero.enabled"], True)
def test_monero_configuration(self):
"""Test Monero RPC configuration expansion."""
os.environ["MPS_MONERO_RPC_URL"] = "http://192.168.1.100:18082/json_rpc"
os.environ["MPS_MONERO_RPC_USER"] = "monero_user"
os.environ["MPS_MONERO_RPC_PASS"] = "monero_pass"
settings = {
"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:-}",
}
result = get_children_settings(settings, "monero")
self.assertEqual(result["rpc_url"], "http://192.168.1.100:18082/json_rpc")
self.assertEqual(result["rpc_user"], "monero_user")
self.assertEqual(result["rpc_pass"], "monero_pass")

View file

@ -0,0 +1,708 @@
"""Unit and integration tests for crypto payment watcher."""
import json
import time
import unittest
from decimal import Decimal
from unittest.mock import MagicMock, patch, call
from pyramid import testing
from pyramid.paster import get_appsettings
import transaction
from ..models import get_tm_session
from ..models.meta import Base
from ..models.shop import Shop
from ..models.shop_location import ShopLocation
from ..models.user import User, get_or_create_user_by_email
from ..models.product import Product
from ..models.invoice import Invoice
from ..models.crypto_payment import CryptoPayment
from ..models.crypto_processor import CryptoProcessor
from ..models.inventory import Inventory
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.price import Price
from ..lib.crypto_watcher import (
summarize_txs,
process_payment,
finalize_invoice,
auto_sweep_payment,
ATOMIC_UNITS,
MIN_SWEEP_BALANCE,
)
from ..lib.crypto_clients import MockMoneroClient
class CryptoWatcherUnitTests(unittest.TestCase):
"""Unit tests for crypto_watcher functions."""
def test_summarize_txs_empty(self):
"""Test summarizing empty transaction list."""
total, txids, min_conf = summarize_txs([])
self.assertEqual(total, 0)
self.assertEqual(txids, [])
self.assertEqual(min_conf, 0)
def test_summarize_txs_single(self):
"""Test summarizing single transaction."""
transfers = [
{
"amount": 1000000000000, # 1 XMR in atomic units
"txid": "tx123",
"confirmations": 5,
}
]
total, txids, min_conf = summarize_txs(transfers)
self.assertEqual(total, 1000000000000)
self.assertEqual(txids, ["tx123"])
self.assertEqual(min_conf, 5)
def test_summarize_txs_multiple(self):
"""Test summarizing multiple transactions."""
transfers = [
{"amount": 500000000000, "txid": "tx1", "confirmations": 10},
{"amount": 300000000000, "txid": "tx2", "confirmations": 5},
{"amount": 200000000000, "txid": "tx3", "confirmations": 15},
]
total, txids, min_conf = summarize_txs(transfers)
self.assertEqual(total, 1000000000000)
self.assertEqual(set(txids), {"tx1", "tx2", "tx3"})
self.assertEqual(min_conf, 5) # Minimum confirmations
def test_summarize_txs_duplicate_txids(self):
"""Test summarizing with duplicate transaction IDs."""
transfers = [
{"amount": 500000000000, "txid": "tx1", "confirmations": 10},
{"amount": 300000000000, "txid": "tx1", "confirmations": 10}, # Duplicate
]
total, txids, min_conf = summarize_txs(transfers)
self.assertEqual(total, 800000000000)
self.assertEqual(txids, ["tx1"]) # Deduped
self.assertEqual(min_conf, 10)
def test_summarize_txs_missing_fields(self):
"""Test summarizing with missing fields."""
transfers = [
{"amount": None, "txid": "tx1", "confirmations": 5},
{"amount": 100, "confirmations": 3}, # No txid
{"amount": 200, "txid": "tx2"}, # No confirmations
]
total, txids, min_conf = summarize_txs(transfers)
self.assertEqual(total, 300)
self.assertEqual(txids, ["tx1", "tx2"])
self.assertEqual(min_conf, 3)
class CryptoWatcherIntegrationTests(unittest.TestCase):
"""Integration tests for crypto_watcher with database."""
def setUp(self):
from make_post_sell import main
settings = get_appsettings("test.ini")
self.app = main({}, **settings)
self.session_factory = self.app.registry["dbsession_factory"]
self.engine = self.session_factory.kw["bind"]
# Create tables
Base.metadata.create_all(bind=self.engine)
# Create test data
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
# Create test user
self.test_user = get_or_create_user_by_email(
dbsession, "watcher_test@example.com"
)
self.test_user.is_email_verified = True
dbsession.add(self.test_user)
# Create test shop and location
self.test_shop = Shop(
name="WatcherTestShop",
phone_number="555-1234",
billing_address="123 Test St",
description="Test shop for crypto watcher",
)
self.test_shop.stripe_user_id = "test_stripe_user"
self.test_shop.stripe_publishable_api_key = "test_key"
dbsession.add(self.test_shop)
self.test_location = ShopLocation(
shop=self.test_shop,
name="Main Location",
address="789 Main St",
city="Test City",
state="TS",
country="Test Country",
postal_code="12345",
)
dbsession.add(self.test_location)
# Create test product (digital)
self.test_product = Product(
title="Test Digital Product", description="Digital test product"
)
self.test_product.shop_id = self.test_shop.id
self.test_product.price_in_cents = 1500 # $15
self.test_product.is_physical = False
dbsession.add(self.test_product)
# Create price history for digital product
price1 = Price(self.test_product, 1500)
dbsession.add(price1)
# Create physical product
self.physical_product = Product(
title="Test Physical Product", description="Physical test product"
)
self.physical_product.shop_id = self.test_shop.id
self.physical_product.price_in_cents = 2000 # $20
self.physical_product.is_physical = True
dbsession.add(self.physical_product)
# Create price history for physical product
price2 = Price(self.physical_product, 2000)
dbsession.add(price2)
dbsession.flush()
# Create inventory for physical product
self.inventory = Inventory(
product=self.physical_product,
shop_location=self.test_location,
quantity=10,
)
dbsession.add(self.inventory)
# Create test invoice
self.test_invoice = Invoice(self.test_user)
self.test_invoice.shop_id = self.test_shop.id
self.test_invoice.new_line_item(product=self.test_product, quantity=1)
dbsession.add(self.test_invoice)
dbsession.flush()
# Create test crypto payment
self.test_payment = CryptoPayment(
invoice=self.test_invoice,
address="test_xmr_address",
account_index=0,
subaddress_index=1,
coin_type="XMR",
expected_amount=100000000000, # 0.1 XMR
rate_locked_usd_per_coin=150.0,
quote_expires_at_ms=int((time.time() + 900) * 1000), # 15 min from now
confirmations_required=1,
shop_location=self.test_location,
)
dbsession.add(self.test_payment)
dbsession.flush()
self.payment_id = self.test_payment.id
self.invoice_id = self.test_invoice.id
self.product_id = self.test_product.id
self.user_id = self.test_user.id
self.shop_id = self.test_shop.id
self.location_id = self.test_location.id
self.physical_product_id = self.physical_product.id
def tearDown(self):
transaction.abort()
Base.metadata.drop_all(bind=self.engine)
@patch("make_post_sell.lib.crypto_watcher.send_purchase_email")
@patch("make_post_sell.lib.crypto_watcher.send_sale_email")
def test_finalize_invoice(self, mock_sale_email, mock_purchase_email):
"""Test finalizing an invoice unlocks products and sends emails."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
# Create mock request
mock_request = MagicMock()
mock_request.dbsession = dbsession
mock_request.registry.settings = {"app.email.enabled": "true"}
# Finalize the invoice
finalize_invoice(mock_request, payment)
# Check product was unlocked
# Query fresh from database to avoid session issues
from ..models.user_product import UserProduct
userproduct = (
dbsession.query(UserProduct)
.filter_by(product_id=self.product_id, user_id=self.user_id)
.first()
)
self.assertIsNotNone(userproduct)
# Check emails were sent
mock_purchase_email.assert_called_once()
mock_sale_email.assert_called_once()
@patch("make_post_sell.lib.crypto_watcher.send_purchase_email")
@patch("make_post_sell.lib.crypto_watcher.send_sale_email")
def test_finalize_invoice_with_physical_product(
self, mock_sale_email, mock_purchase_email
):
"""Test finalizing invoice with physical product deducts inventory."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
# Get fresh objects from session
user = dbsession.query(User).filter_by(id=self.user_id).first()
physical_product = (
dbsession.query(Product).filter_by(id=self.physical_product_id).first()
)
location = (
dbsession.query(ShopLocation).filter_by(id=self.location_id).first()
)
# Create invoice with physical product
invoice = Invoice(user)
invoice.shop_id = self.shop_id
invoice.new_line_item(product=physical_product, quantity=3)
dbsession.add(invoice)
payment = CryptoPayment(
invoice=invoice,
address="test_address2",
account_index=0,
subaddress_index=2,
coin_type="XMR",
expected_amount=200000000000,
rate_locked_usd_per_coin=150.0,
quote_expires_at_ms=int((time.time() + 900) * 1000),
confirmations_required=1,
shop_location=location,
)
dbsession.add(payment)
dbsession.flush()
# Check initial inventory
inventory = (
dbsession.query(Inventory)
.filter_by(
product_id=self.physical_product_id,
shop_location_id=self.location_id,
)
.first()
)
self.assertEqual(inventory.quantity, 10)
# Create mock request
mock_request = MagicMock()
mock_request.dbsession = dbsession
mock_request.registry.settings = {}
# Finalize
finalize_invoice(mock_request, payment)
# Check inventory was deducted
self.assertEqual(inventory.quantity, 7) # 10 - 3
def test_process_payment_no_transfers(self):
"""Test processing payment with no incoming transfers."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process with no transfers
process_payment(mock_request, payment, [])
# Payment should still be pending
self.assertEqual(payment.status, "pending")
self.assertEqual(payment.received_amount, 0)
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
def test_process_payment_sufficient_funds_and_confirmations(self, mock_finalize):
"""Test processing payment with sufficient funds and confirmations."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process with sufficient transfer
transfers = [
{
"amount": 100000000000, # Exactly what's expected
"txid": "tx123",
"confirmations": 2, # More than required (1)
}
]
process_payment(mock_request, payment, transfers)
# Payment should be confirmed
self.assertEqual(payment.status, "confirmed")
self.assertEqual(payment.received_amount, 100000000000)
self.assertEqual(json.loads(payment.tx_hashes), ["tx123"])
# Invoice should be finalized
mock_finalize.assert_called_once_with(mock_request, payment)
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
def test_process_payment_insufficient_confirmations(self, mock_finalize):
"""Test processing payment with insufficient confirmations."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
payment.confirmations_required = 10 # Require 10 confirmations
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process with insufficient confirmations
transfers = [
{
"amount": 100000000000,
"txid": "tx123",
"confirmations": 5, # Only 5 confirmations
}
]
process_payment(mock_request, payment, transfers)
# Payment should be received (has funds but not enough confirmations)
self.assertEqual(payment.status, "received")
self.assertEqual(payment.received_amount, 100000000000)
# Invoice should NOT be finalized
mock_finalize.assert_not_called()
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
def test_process_payment_overpayment(self, mock_finalize):
"""Test processing overpayment without refund (no refund address)."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process with overpayment
transfers = [
{
"amount": 150000000000, # 1.5x expected
"txid": "tx123",
"confirmations": 5,
}
]
process_payment(mock_request, payment, transfers)
# Payment should be confirmed (not confirmed_overpaid since no refund)
self.assertEqual(payment.status, "confirmed")
self.assertEqual(payment.received_amount, 150000000000)
# Invoice should be finalized
mock_finalize.assert_called_once()
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
@patch("make_post_sell.lib.crypto_watcher.PaymentRescue")
def test_process_payment_overpayment_with_refund(
self, mock_rescue_class, mock_finalize
):
"""Test processing overpayment with refund."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
# Add refund address to simulate user having configured one
payment.refund_address = "refund_address_123"
# Get invoice and user
invoice = payment.invoice
user = invoice.user
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Mock PaymentRescue
mock_rescue = MagicMock()
mock_rescue.handle_overpayment.return_value = {
"payment_id": payment.id,
"refund_address": "refund_address_123",
"amount_xmr": Decimal("0.5"), # Excess amount
}
mock_rescue.execute_refund.return_value = {
"success": True,
"tx_hash": "refund_tx_123",
}
mock_rescue_class.return_value = mock_rescue
# Process with overpayment
transfers = [
{
"amount": 150000000000, # 1.5x expected
"txid": "tx123",
"confirmations": 5,
}
]
# Mock client for PaymentRescue
mock_client = MagicMock()
process_payment(mock_request, payment, transfers, mock_client)
# Payment should be confirmed_overpaid
self.assertEqual(payment.status, "confirmed_overpaid")
self.assertEqual(payment.received_amount, 150000000000)
# Refund should have been executed
mock_rescue.handle_overpayment.assert_called_once()
mock_rescue.execute_refund.assert_called_once()
# Invoice should be finalized
mock_finalize.assert_called_once()
def test_process_payment_expired(self):
"""Test processing expired payment."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
# Set payment as expired
payment.quote_expires_at = int((time.time() - 100) * 1000)
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process expired payment
process_payment(mock_request, payment, [])
# Payment should be marked expired
self.assertEqual(payment.status, "expired")
@patch("make_post_sell.lib.crypto_watcher.PaymentRescue")
def test_process_payment_expired_with_refund(self, mock_rescue_class):
"""Test processing expired payment with funds triggers refund."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
payment.quote_expires_at = int((time.time() - 100) * 1000)
# Get user from DB and add refund address
user = dbsession.query(User).filter_by(id=self.user_id).first()
refund_addr = UserCryptoRefundAddress(
user=user, coin_type="XMR", address="refund_address_123"
)
dbsession.add(refund_addr)
payment.refund_address = "refund_address_123"
# Mock client
mock_client = MagicMock()
# Mock PaymentRescue
mock_rescue = MagicMock()
mock_rescue.handle_expired_payment.return_value = {
"payment_id": payment.id,
"refund_address": "refund_address_123",
"amount_xmr": Decimal("0.1"),
}
mock_rescue.execute_refund.return_value = {
"success": True,
"tx_hash": "refund_tx_123",
}
mock_rescue_class.return_value = mock_rescue
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process expired payment with funds
transfers = [
{"amount": 100000000000, "txid": "late_tx", "confirmations": 10}
]
process_payment(mock_request, payment, transfers, client=mock_client)
# Payment should be expired
self.assertEqual(payment.status, "expired")
# Refund should be attempted
mock_rescue.handle_expired_payment.assert_called_once()
mock_rescue.execute_refund.assert_called_once()
def test_process_payment_idempotent(self):
"""Test processing same payment multiple times is idempotent."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
mock_request = MagicMock()
mock_request.dbsession = dbsession
# First transfer
transfers1 = [{"amount": 50000000000, "txid": "tx1", "confirmations": 0}]
process_payment(mock_request, payment, transfers1)
self.assertEqual(payment.received_amount, 50000000000)
# Process same transfer again - should not double count
process_payment(mock_request, payment, transfers1)
self.assertEqual(payment.received_amount, 50000000000)
# Add new transfer
transfers2 = transfers1 + [
{"amount": 30000000000, "txid": "tx2", "confirmations": 0}
]
process_payment(mock_request, payment, transfers2)
self.assertEqual(payment.received_amount, 80000000000)
@patch("make_post_sell.lib.crypto_watcher.auto_sweep_payment")
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
def test_process_payment_triggers_auto_sweep(self, mock_finalize, mock_sweep):
"""Test that confirming payment triggers auto-sweep."""
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
payment = (
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
)
payment.shop_sweep_to_address = "cold_wallet_address"
mock_client = MagicMock()
# Mock get_balance to return unlocked funds
mock_client._call.return_value = {"unlocked_balance": 100000000000}
mock_request = MagicMock()
mock_request.dbsession = dbsession
# Process with sufficient payment
transfers = [{"amount": 100000000000, "txid": "tx123", "confirmations": 2}]
process_payment(mock_request, payment, transfers, client=mock_client)
# Auto-sweep should be called after balance check
mock_sweep.assert_called_with(mock_client, payment)
self.assertEqual(mock_sweep.call_count, 1)
class AutoSweepTests(unittest.TestCase):
"""Unit tests for auto-sweep functionality."""
def test_auto_sweep_no_address(self):
"""Test auto-sweep with no sweep address configured."""
mock_client = MagicMock()
payment = MagicMock()
payment.id = "test_id"
payment.shop_sweep_to_address = None
payment.is_swept = False
result = auto_sweep_payment(mock_client, payment)
self.assertFalse(result)
mock_client._call.assert_not_called()
def test_auto_sweep_insufficient_balance(self):
"""Test auto-sweep with no balance available."""
mock_client = MagicMock()
mock_client._call.return_value = {"unlocked_balance": 0} # No XMR available
payment = MagicMock()
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet"
payment.account_index = 0
payment.received_amount = 1000000000000 # 1 XMR expected
payment.is_swept = False
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result) # Returns True and marks as swept
# Should only check balance, no sweep needed since balance is 0
mock_client._call.assert_called_once_with("get_balance", {"account_index": 0})
# Should mark payment as swept with pooled_sweep indicator
self.assertEqual(payment.swept_tx_hash, "pooled_sweep")
def test_auto_sweep_success(self):
"""Test successful auto-sweep."""
mock_client = MagicMock()
mock_client._call.side_effect = [
# First call - get_balance
{"unlocked_balance": 1000000000000}, # 1 XMR
# Second call - sweep_all
{"tx_hash": "sweep_tx_123", "amount_list": [1000000000000]},
]
payment = MagicMock()
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet_address"
payment.account_index = 0
payment.received_amount = 500000000000 # 0.5 XMR
payment.is_swept = False
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result)
# Check sweep_all was called with correct parameters
sweep_call = mock_client._call.call_args_list[1]
self.assertEqual(sweep_call[0][0], "sweep_all")
sweep_params = sweep_call[0][1]
self.assertEqual(sweep_params["address"], "cold_wallet_address")
self.assertEqual(sweep_params["account_index"], 0)
# Check sweep tracking fields were set
self.assertEqual(
payment.swept_amount, 1000000000000
) # Uses amount from amount_list
self.assertEqual(payment.swept_tx_hash, "sweep_tx_123")
self.assertIsNotNone(payment.swept_timestamp)
def test_auto_sweep_already_swept(self):
"""Test auto-sweep skips already swept payment."""
mock_client = MagicMock()
payment = MagicMock()
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet_address"
payment.is_swept = True # Already swept
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result) # Returns True but does nothing
# Should not make any RPC calls
mock_client._call.assert_not_called()
def test_auto_sweep_rpc_error(self):
"""Test auto-sweep with RPC error."""
mock_client = MagicMock()
mock_client._call.side_effect = Exception("RPC connection error")
payment = MagicMock()
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet"
payment.account_index = 0
payment.received_amount = 500000000000
payment.is_swept = False
result = auto_sweep_payment(mock_client, payment)
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1250,3 +1250,95 @@ class TestInvoice(unittest.TestCase):
self.assertEqual(line_item.price.price_in_cents, 750)
self.assertEqual(line_item.product, product)
self.assertEqual(line_item.quantity, 2)
class TestUserCryptoRefundAddress(unittest.TestCase):
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
def setUp(self):
self.user = User("crypto@example.com")
def test_create_crypto_refund_address(self):
"""Test creating a crypto refund address."""
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
addr = UserCryptoRefundAddress(
user=self.user,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
label="My Monero Wallet"
)
self.assertEqual(addr.user, self.user)
self.assertEqual(addr.coin_type, "XMR")
self.assertEqual(addr.address, "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A")
self.assertEqual(addr.label, "My Monero Wallet")
self.assertIsNotNone(addr.id)
self.assertGreater(addr.created_timestamp, 0)
self.assertGreater(addr.updated_timestamp, 0)
def test_create_crypto_refund_address_uppercase_coin_type(self):
"""Test that coin type is uppercased."""
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
addr = UserCryptoRefundAddress(
user=self.user,
coin_type="btc",
address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
)
self.assertEqual(addr.coin_type, "BTC")
def test_create_crypto_refund_address_no_label(self):
"""Test creating address without label."""
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
addr = UserCryptoRefundAddress(
user=self.user,
coin_type="DOGE",
address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
)
self.assertEqual(addr.coin_type, "DOGE")
self.assertIsNone(addr.label)
def test_get_user_crypto_refund_address_none(self):
"""Test getting non-existent refund address returns None."""
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
# Mock dbsession
mock_session = mock.Mock()
mock_query = mock.Mock()
mock_filter = mock.Mock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_filter
mock_filter.first.return_value = None
result = get_user_crypto_refund_address(mock_session, self.user, "XMR")
self.assertIsNone(result)
def test_get_user_crypto_refund_address_found(self):
"""Test getting existing refund address."""
from ..models.user_crypto_refund_address import (
UserCryptoRefundAddress,
get_user_crypto_refund_address
)
# Create address
expected_addr = UserCryptoRefundAddress(
user=self.user,
coin_type="XMR",
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
)
# Mock dbsession
mock_session = mock.Mock()
mock_query = mock.Mock()
mock_filter = mock.Mock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_filter
mock_filter.first.return_value = expected_addr
result = get_user_crypto_refund_address(mock_session, self.user, "XMR")
self.assertEqual(result, expected_addr)

View file

@ -42,11 +42,11 @@ def shop_is_ready_required(
flash_msg="Sorry, this shop is not ready to make sales yet. Please try again later.",
flash_level="error",
):
"""This view requires that the request has a shop and that show is_ready."""
"""This view requires that the request has a shop and that shop is_ready_for_payment."""
def wrapped(fn):
def inner(request):
if request.shop and request.shop.is_ready:
if request.shop and request.shop.is_ready_for_payment(request):
return fn(request)
request.session.flash((flash_msg, flash_level))
return HTTPFound(get_referer_or_home(request))

View file

@ -350,7 +350,9 @@ def cart_quantity_product(request):
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="cart_handling_option", request_method="POST")
@view_config(
route_name="cart_handling_option", request_method="POST", require_csrf=True
)
def cart_handling_option(request):
cart = get_cart_from_matchdict(request)
handling_option = request.params.get("handling_option")
@ -450,22 +452,46 @@ def cart_checkout(request):
return HTTPFound("/u/addresses")
# Check for payment information
if cart.requires_payment and stripe_user_shop is None:
# Only force Stripe flow if Stripe is the ONLY enabled payment method
only_stripe_enabled = request.stripe_enabled and not request.monero_enabled
if cart.requires_payment and only_stripe_enabled and stripe_user_shop is None:
msg = ("Please enter your payment information.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
if stripe_user_shop and stripe_user_shop.active_card is None:
if (
only_stripe_enabled
and stripe_user_shop
and stripe_user_shop.active_card is None
):
msg = ("Please make a payment method active.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
# Check if shop has enabled XMR crypto processor
xmr_processor_enabled = False
if request.monero_enabled:
from ..models.crypto_processor import CryptoProcessor
xmr_processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == request.shop.id,
CryptoProcessor.coin_type == "XMR",
CryptoProcessor.enabled == True,
)
.first()
)
xmr_processor_enabled = xmr_processor is not None
msg = ("Please confirm your order.", "info")
request.session.flash(msg)
return {
"cart": cart,
"products": cart.products,
"active_card": stripe_user_shop.active_card if stripe_user_shop else None,
"stripe_enabled": request.stripe_enabled,
"monero_enabled": request.monero_enabled,
"xmr_processor_enabled": xmr_processor_enabled,
}
request.session.flash(msg)
@ -478,6 +504,8 @@ def cart_checkout(request):
@user_required()
@shop_is_ready_required()
def cart_complete_checkout(request):
stripe_enabled = request.stripe_enabled
cart = get_cart_from_matchdict(request)
if cart is None:
@ -495,7 +523,18 @@ def cart_complete_checkout(request):
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
elif cart.requires_payment and request.shop.stripe_customer(request.user) is None:
# If the cart requires payment and Stripe is disabled, this endpoint cannot process payment
if cart.requires_payment and not request.stripe_enabled:
msg = ("Card payments are disabled by configuration.", "error")
request.session.flash(msg)
return HTTPFound("/cart")
# If Stripe is enabled and payment is required, ensure a Stripe customer exists
if (
cart.requires_payment
and request.stripe_enabled
and request.shop.stripe_customer(request.user) is None
):
msg = ("Please enter your payment information.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
@ -529,25 +568,29 @@ def cart_complete_checkout(request):
invoices.append(invoice)
# Attempt payment BEFORE adding invoices to session
for invoice in invoices:
shop = invoice.shop
# Attempt payment BEFORE adding invoices to session when Stripe is enabled
if request.stripe_enabled:
for invoice in invoices:
shop = invoice.shop
if invoice.requires_payment:
stripe_user_shop = shop.stripe_user_shop(request.user)
if stripe_user_shop is None:
msg = ("Payment method required but not found. Please add a payment method.", "error")
request.session.flash(msg)
return HTTPFound("/billing")
shop.stripe.PaymentIntent.create(
amount=invoice.total_in_cents,
currency="usd",
customer=stripe_user_shop.cus_id,
payment_method=stripe_user_shop.active_card_id,
off_session=True,
confirm=True,
)
if invoice.requires_payment:
stripe_user_shop = shop.stripe_user_shop(request.user)
if stripe_user_shop is None:
msg = (
"Payment method required but not found. Please add a payment method.",
"error",
)
request.session.flash(msg)
return HTTPFound("/billing")
payment_intent = shop.stripe.PaymentIntent.create(
amount=invoice.total_in_cents,
currency="usd",
customer=stripe_user_shop.cus_id,
payment_method=stripe_user_shop.active_card_id,
off_session=True,
confirm=True,
)
# Only persist data after successful payment
for invoice in invoices:

View file

@ -0,0 +1,385 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound, HTTPBadRequest
from pyramid.response import Response
from ..models.cart import get_cart_by_id
from ..models.invoice import Invoice
from ..models.crypto_payment import CryptoPayment
from ..lib.crypto_clients import get_client_from_settings
from . import (
user_required,
shop_is_ready_required,
)
import json
import time
import uuid as _uuid
import urllib.request
@view_config(
route_name="crypto_xmr_start",
request_method="POST",
require_csrf=True,
renderer="crypto_checkout.j2",
)
def crypto_xmr_start(request):
# Check Monero enabled toggle first (no DB access)
if not request.monero_enabled:
request.session.flash(
("Monero payments are disabled by configuration.", "error")
)
return HTTPFound("/cart")
cart_id = request.params.get("cart_id")
if not cart_id:
request.session.flash(("Missing cart_id.", "error"))
return HTTPFound("/cart")
# First database access - this establishes the transaction
cart = get_cart_by_id(request.dbsession, cart_id)
if cart is None:
request.session.flash(("Invalid cart.", "error"))
return HTTPFound("/cart")
# Now check user authentication (after DB transaction is established)
if not (request.user and request.user.authenticated):
request.session.flash(
("To use Monero checkout, please verify your email.", "info")
)
return HTTPFound(request.route_url("join-or-log-in"))
# Now check if shop is ready for payment (after DB transaction is established)
if not (request.shop and request.shop.is_ready_for_payment(request)):
request.session.flash(
(
"Sorry, this shop is not ready to make sales yet. Please try again later.",
"error",
)
)
from . import get_referer_or_home
return HTTPFound(get_referer_or_home(request))
if request.user.does_not_own_cart(cart):
request.session.flash(("You do not own this cart.", "error"))
return HTTPFound("/cart")
# single-shop constraint for MVP
if len(cart.shop_product_dict.keys()) != 1:
request.session.flash(
("Monero checkout only supports single-shop carts.", "error")
)
return HTTPFound(f"/cart/{cart.id}")
if not cart.requires_payment:
request.session.flash(("No payment required for this order.", "info"))
return HTTPFound(f"/cart/{cart.id}")
# Ensure config exists; if not, provide a helpful message.
settings = request.registry.settings
if not settings.get("monero.rpc_url"):
request.session.flash(
(
"Monero RPC not configured. Set monero.rpc_url in your ini to enable.",
"error",
)
)
return HTTPFound(f"/cart/{cart.id}")
# Build a pending invoice for this single shop (do not unlock or send emails yet)
try:
# Extract the single shop and items
(shop_id, items) = next(iter(cart.shop_product_dict.items()))
shop = cart.shops[shop_id]
invoice = Invoice(request.user)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.handling_option = cart.handling_option
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
if cart.physical_products and request.user.active_address:
invoice.delivery_address = request.user.active_address.data
for product, quantity in items:
invoice.new_line_item(product=product, quantity=quantity)
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
# Persist invoice now so we can link a CryptoPayment to it
request.dbsession.add(invoice)
request.dbsession.flush()
# Fetch USD/XMR rate (with timeout/retry and sanity checks)
rate_url = settings.get(
"monero.rate_source_url",
"https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd",
)
last_err = None
usd_per_xmr = None
for attempt in range(3):
try:
req = urllib.request.Request(
rate_url, headers={"User-Agent": "make-post-sell/1.0"}
)
with urllib.request.urlopen(req, timeout=5) as rate_resp:
rate_data = json.loads(rate_resp.read())
candidate = float(rate_data.get("monero", {}).get("usd"))
# sanity bounds: reject zero/negative/absurd values
if not (0.01 <= candidate <= 100000.0):
raise RuntimeError("Out-of-bounds USD/XMR rate")
usd_per_xmr = candidate
break
except Exception as e:
last_err = e
time.sleep(0.5)
if usd_per_xmr is None:
raise RuntimeError(f"Failed to fetch USD/XMR rate: {last_err}")
# Compute piconero owed with transaction fee buffer
usd_total = float(invoice.total)
xmr_amount = usd_total / usd_per_xmr
# Add small fee buffer (default 0.0001 XMR) to cover transaction costs
fee_buffer_xmr = float(settings.get("monero.fee_buffer", "0.0001"))
xmr_amount_with_fee = xmr_amount + fee_buffer_xmr
expected_piconero = int(xmr_amount_with_fee * 1_000_000_000_000)
# Quote expiry - use shop-specific setting
shop = invoice.shop
expiry_secs = int(shop.crypto_quote_expiry_seconds)
quote_expires_at_ms = int(time.time() * 1000) + (expiry_secs * 1000)
# Check if invoice contains physical products
has_physical = any(
item.product.is_physical
for item in invoice.line_items
if hasattr(item.product, "is_physical")
)
if has_physical:
# Physical products always require maximum confirmations
confirmations_required = int(settings.get("monero.confirmations.high"))
else:
# Digital products: determine confirmations based on amount
total_cents = invoice.total_in_cents
# Use shop-specific risk thresholds
shop = invoice.shop
threshold_mid_cents = shop.payment_risk_threshold_mid_cents
threshold_high_cents = shop.payment_risk_threshold_high_cents
if total_cents < threshold_mid_cents:
confirmations_required = int(settings.get("monero.confirmations.petty"))
elif total_cents < threshold_high_cents:
confirmations_required = int(settings.get("monero.confirmations.mid"))
else:
confirmations_required = int(settings.get("monero.confirmations.high"))
# Get crypto processor configuration for this shop
from ..models.crypto_processor import CryptoProcessor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == shop.id,
CryptoProcessor.coin_type == "XMR",
CryptoProcessor.enabled == True,
)
.first()
)
if not processor or processor.wallet_label is None:
request.session.flash(("Shop has not configured Monero payments.", "error"))
return HTTPFound(f"/cart/{cart.id}")
# For Monero, wallet_label stores the account index as a string
account_index = int(processor.wallet_label)
client = get_client_from_settings(settings)
label = f"invoice:{invoice.id}"
address, subaddr_index = client.create_subaddress(
account_index=account_index, label=label
)
# Get user's saved refund address for this coin type
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
user_refund_addr_obj = get_user_crypto_refund_address(
request.dbsession, request.user, "XMR"
)
user_refund_address = (
user_refund_addr_obj.address if user_refund_addr_obj else None
)
# Persist CryptoPayment
crypto_payment = CryptoPayment(
invoice=invoice,
address=address,
account_index=account_index,
subaddress_index=subaddr_index,
coin_type="XMR",
expected_amount=expected_piconero,
rate_locked_usd_per_coin=usd_per_xmr,
quote_expires_at_ms=quote_expires_at_ms,
confirmations_required=confirmations_required,
shop_location=request.shop_location,
shop_sweep_to_address=processor.sweep_to_address,
refund_address=user_refund_address,
)
request.dbsession.add(crypto_payment)
request.dbsession.flush()
# Redirect to generic quote page with payment UUID
return HTTPFound(
request.route_url("crypto_quote", payment_id=str(crypto_payment.id))
)
except Exception as e:
request.session.flash((f"Failed to start Monero checkout: {e}", "error"))
return HTTPFound(f"/cart/{cart.id}")
@view_config(
route_name="crypto_quote",
renderer="crypto_checkout.j2",
)
def crypto_quote(request):
"""Generic crypto quote page that works for any coin type"""
payment_id = request.matchdict.get("payment_id")
if not payment_id:
request.session.flash(("Missing payment ID.", "error"))
return HTTPFound("/cart")
try:
pid = _uuid.UUID(payment_id)
except Exception:
request.session.flash(("Invalid payment ID.", "error"))
return HTTPFound("/cart")
crypto_payment = (
request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first()
)
if not crypto_payment:
request.session.flash(("Payment not found.", "error"))
return HTTPFound("/cart")
# Get coin-specific information
coin_type = crypto_payment.coin_type
coin_info = get_coin_info(coin_type)
# Get user's refund address for this coin type
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
user_refund_addr_obj = (
get_user_crypto_refund_address(request.dbsession, request.user, coin_type)
if request.user
else None
)
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)
amount_crypto_with_fee = (
crypto_payment.expected_amount / coin_info["smallest_unit_divisor"]
)
# Estimate base amount and fee (this is approximate since we don't store it separately)
settings = request.registry.settings
fee_buffer = float(
settings.get(f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"])
)
amount_crypto_base = amount_crypto_with_fee - fee_buffer
return {
"cart": None, # Quote page doesn't need cart context
"address": crypto_payment.address,
"coin_name": coin_info["name"],
"coin_symbol": coin_type,
"wallet_uri_scheme": coin_info["wallet_uri_scheme"],
"smallest_unit_name": coin_info["smallest_unit_name"],
"amount_crypto": amount_crypto_with_fee,
"amount_crypto_base": amount_crypto_base,
"fee_buffer_crypto": fee_buffer,
"usd_total": usd_total,
"usd_per_crypto": crypto_payment.rate_locked_usd_per_coin,
"expected_smallest_units": crypto_payment.expected_amount,
"expires_at": crypto_payment.quote_expires_at,
"payment_id": str(crypto_payment.id),
"status": crypto_payment.status,
"has_refund_address": bool(user_refund_address),
"refund_address": user_refund_address,
"now": int(time.time() * 1000),
}
def get_coin_info(coin_type):
"""Get coin-specific information"""
coin_configs = {
"XMR": {
"name": "Monero",
"wallet_uri_scheme": "monero",
"smallest_unit_name": "piconero",
"smallest_unit_divisor": 1_000_000_000_000,
"default_fee_buffer": "0.0001",
},
"BTC": {
"name": "Bitcoin",
"wallet_uri_scheme": "bitcoin",
"smallest_unit_name": "satoshi",
"smallest_unit_divisor": 100_000_000,
"default_fee_buffer": "0.00001",
},
"LTC": {
"name": "Litecoin",
"wallet_uri_scheme": "litecoin",
"smallest_unit_name": "satoshi",
"smallest_unit_divisor": 100_000_000,
"default_fee_buffer": "0.00001",
},
"DOGE": {
"name": "Dogecoin",
"wallet_uri_scheme": "dogecoin",
"smallest_unit_name": "koinu",
"smallest_unit_divisor": 100_000_000,
"default_fee_buffer": "0.01",
},
}
return coin_configs.get(coin_type, coin_configs["XMR"])
@view_config(route_name="crypto_xmr_status")
@user_required()
def crypto_xmr_status(request):
payment_id = request.matchdict.get("payment_id")
if not payment_id:
return HTTPBadRequest("missing payment_id")
try:
pid = _uuid.UUID(payment_id)
except Exception:
return HTTPBadRequest("invalid payment_id")
crypto_payment = (
request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first()
)
if not crypto_payment:
return HTTPBadRequest("payment not found")
payload = {
"payment_id": str(crypto_payment.id),
"status": crypto_payment.status,
"address": crypto_payment.address,
"received_amount": crypto_payment.received_amount,
"expected_amount": crypto_payment.expected_amount,
"confirmations_required": crypto_payment.confirmations_required,
"current_confirmations": crypto_payment.current_confirmations or 0,
"expires_at": crypto_payment.quote_expires_at,
}
return Response(
json.dumps(payload), content_type="application/json", charset="utf-8"
)

View file

@ -0,0 +1,98 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from ..models.crypto_processor import CryptoProcessor
from . import shop_owner_required
@view_config(route_name="crypto_processor_settings", request_method="POST")
@shop_owner_required()
def crypto_processor_settings(request):
"""Handle crypto processor configuration for a specific coin type."""
shop = request.shop
coin_type = request.matchdict.get("coin_type", "").upper()
# Validate coin type
if coin_type not in ["XMR", "DOGE", "LTC", "BTC", "BCH"]:
request.session.flash(("Invalid cryptocurrency type", "error"))
return HTTPFound(f"/s/{shop.id}/settings")
# Get or create processor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == shop.id, CryptoProcessor.coin_type == coin_type
)
.first()
)
# Handle disable action
if request.params.get("disable"):
if processor:
processor.enabled = False
# Keep the sweep address but it will be grayed out in UI
request.dbsession.add(processor)
request.session.flash((f"{coin_type} payments disabled", "success"))
return HTTPFound(f"/s/{shop.id}/settings")
# Get sweep address
sweep_to_address = request.params.get("sweep_to_address", "").strip()
if not sweep_to_address:
request.session.flash(("Sweep address is required", "error"))
return HTTPFound(f"/s/{shop.id}/settings")
# Validate address based on coin type
if coin_type == "XMR":
from ..lib.monero_address import validate_monero_address
is_valid, error_msg = validate_monero_address(sweep_to_address)
if not is_valid:
request.session.flash((f"Invalid Monero address: {error_msg}", "error"))
return HTTPFound(f"/s/{shop.id}/settings")
# TODO: Add validation for other coin types
if processor:
# Update existing
processor.sweep_to_address = sweep_to_address
processor.enabled = True
request.session.flash((f"{coin_type} settings updated", "success"))
else:
# Create new processor
processor = CryptoProcessor(
shop_id=shop.id, coin_type=coin_type, sweep_to_address=sweep_to_address
)
# Assign wallet label based on coin type
if coin_type == "XMR":
# Create new Monero account via RPC
try:
from ..lib.crypto_clients import get_client_from_settings
client = get_client_from_settings(request.registry.settings)
# Create new account with shop name as label
account_label = f"shop_{shop.name[:20]}_{shop.id[:8]}"
result = client._call("create_account", {"label": account_label})
account_index = result.get("account_index")
if account_index is not None:
processor.wallet_label = str(account_index)
else:
raise Exception("Failed to get account_index from create_account")
except Exception as e:
request.session.flash(
(f"Failed to create Monero account: {str(e)}", "error")
)
return HTTPFound(f"/s/{shop.id}/settings")
else:
# For Bitcoin-like coins, use shop UUID as label base
processor.wallet_label = f"shop_{shop.id}_{coin_type.lower()}"
processor.enabled = True
request.dbsession.add(processor)
request.session.flash((f"{coin_type} payments enabled", "success"))
request.dbsession.flush()
return HTTPFound(f"/s/{shop.id}/settings")

View file

@ -38,6 +38,8 @@ from ..lib.mail import send_invite_email
from ..lib.phone_numbers import is_phone_number_valid
from ..lib.currency import dollars_to_cents, cents_to_dollars
from pyramid.httpexceptions import HTTPFound
# feel free to come up with a better plan, GPT-4 made this regex.
@ -398,14 +400,41 @@ def shop_settings(request):
# maint_mode_checkbox = request.params.get("maint-mode-checkbox", bool_to_checkbox(shop.maint_mode))
maint_mode_checkbox = request.params.get("maint-mode-checkbox", "off")
maint_mode = checkbox_to_bool(maint_mode_checkbox)
# Determine which form was submitted
form_section = request.params.get("form_section", "")
# Comment system settings
comments_enabled_checkbox = request.params.get("comments-enabled-checkbox", "off")
comments_enabled = checkbox_to_bool(comments_enabled_checkbox)
comments_require_purchase_checkbox = request.params.get("comments-require-purchase-checkbox", "off")
comments_require_purchase = checkbox_to_bool(comments_require_purchase_checkbox)
comments_require_approval_checkbox = request.params.get("comments-require-approval-checkbox", "off")
comments_require_approval = checkbox_to_bool(comments_require_approval_checkbox)
comments_enabled = shop.comments_enabled # Keep current value by default
comments_require_purchase = shop.comments_require_purchase
comments_require_approval = shop.comments_require_approval
# Only update comment settings if the comment form was submitted
if form_section == "comment-settings":
comments_enabled_checkbox = request.params.get(
"comments-enabled-checkbox", "off"
)
comments_enabled = checkbox_to_bool(comments_enabled_checkbox)
comments_require_purchase_checkbox = request.params.get(
"comments-require-purchase-checkbox", "off"
)
comments_require_purchase = checkbox_to_bool(comments_require_purchase_checkbox)
comments_require_approval_checkbox = request.params.get(
"comments-require-approval-checkbox", "off"
)
comments_require_approval = checkbox_to_bool(comments_require_approval_checkbox)
# Crypto settings
crypto_quote_expiry_seconds = request.params.get(
"crypto_quote_expiry_seconds", shop.crypto_quote_expiry_seconds
)
# Get dollar values from form
payment_risk_threshold_mid_dollars = request.params.get(
"payment_risk_threshold_mid_dollars"
)
payment_risk_threshold_high_dollars = request.params.get(
"payment_risk_threshold_high_dollars"
)
s3_webhook_key = request.params.get("key")
s3_webhook_bucket = request.params.get("bucket")
@ -422,169 +451,258 @@ def shop_settings(request):
)
if request.method == "POST":
if name != shop.name:
if not is_shop_name_valid(name):
msg = (
"Invalid shop name, only use alpha numeric, spaces, dashes, or periods.",
"error",
)
elif not is_shop_name_available(request.dbsession, name):
msg = (
"That shop name is already in use. Please pick another.",
"error",
)
else:
shop.name = name
msg = ("You set the shop's name.", "success")
request.session.flash(msg)
if phone_number != shop.phone_number:
if is_phone_number_valid(phone_number):
shop.phone_number = phone_number
msg = ("You set the shop's phone number.", "success")
else:
msg = ("Invalid phone number. We accept: -.() and numbers.", "error")
request.session.flash(msg)
if billing_address and billing_address != shop.billing_address:
shop.billing_address = billing_address
msg = ("You set the shop's billing address.", "success")
request.session.flash(msg)
if description and description != shop.description:
shop.description = description
msg = ("You set the shop's description.", "success")
request.session.flash(msg)
if domain_name != shop.domain_name:
if not domain_name:
# clear the domain name.
shop.domain_name = domain_name
msg = ("You cleared the shop's domain name.", "success")
elif DOMAIN_NAME_REGEX.match(domain_name):
existing_shop = get_shop_by_domain_name(request.dbsession, domain_name)
if existing_shop:
# Handle shop settings form
if form_section == "shop-settings":
if name != shop.name:
if not is_shop_name_valid(name):
msg = (
"That domain name is already in use. Please pick another.",
"Invalid shop name, only use alpha numeric, spaces, dashes, or periods.",
"error",
)
elif not is_shop_name_available(request.dbsession, name):
msg = (
"That shop name is already in use. Please pick another.",
"error",
)
else:
shop.name = name
msg = ("You set the shop's name.", "success")
request.session.flash(msg)
if phone_number != shop.phone_number:
if is_phone_number_valid(phone_number):
shop.phone_number = phone_number
msg = ("You set the shop's phone number.", "success")
else:
msg = (
"Invalid phone number. We accept: -.() and numbers.",
"error",
)
request.session.flash(msg)
if billing_address and billing_address != shop.billing_address:
shop.billing_address = billing_address
msg = ("You set the shop's billing address.", "success")
request.session.flash(msg)
if description and description != shop.description:
shop.description = description
msg = ("You set the shop's description.", "success")
request.session.flash(msg)
# Handle integration settings form
if form_section == "integration-settings":
if domain_name != shop.domain_name:
if not domain_name:
# clear the domain name.
shop.domain_name = domain_name
msg = ("You set the shop's domain name.", "success")
else:
msg = ("Invalid domain name. Please enter a valid domain.", "error")
request.session.flash(msg)
msg = ("You cleared the shop's domain name.", "success")
elif DOMAIN_NAME_REGEX.match(domain_name):
existing_shop = get_shop_by_domain_name(
request.dbsession, domain_name
)
if existing_shop:
msg = (
"That domain name is already in use. Please pick another.",
"error",
)
else:
shop.domain_name = domain_name
msg = ("You set the shop's domain name.", "success")
else:
msg = ("Invalid domain name. Please enter a valid domain.", "error")
request.session.flash(msg)
if ribbon_text != shop.ribbon_text:
shop.ribbon_text = ribbon_text
msg = ("You set the shop's announcement ribbon text.", "success")
request.session.flash(msg)
if google_analytics_id != (shop.google_analytics_id or ""):
shop.google_analytics_id = google_analytics_id
msg = ("You set the shop's Google Analytics Id.", "success")
request.session.flash(msg)
if ribbon_text_color != shop.ribbon_text_color:
shop.ribbon_text_color = ribbon_text_color
msg = ("You set the shop's announcement ribbon text color.", "success")
request.session.flash(msg)
if plausible_domain_name != (shop.plausible_domain_name or ""):
shop.plausible_domain_name = plausible_domain_name
msg = ("You set the shop's Plausible Analytics Domain Name.", "success")
request.session.flash(msg)
if ribbon_color_1 != shop.ribbon_color_1:
shop.ribbon_color_1 = ribbon_color_1
msg = (
"You set the shop's announcement ribbon background color 1.",
"success",
)
request.session.flash(msg)
# Handle ribbon settings form
if form_section == "ribbon-settings":
if ribbon_text != (shop.ribbon_text or ""):
shop.ribbon_text = ribbon_text
msg = ("You set the shop's announcement ribbon text.", "success")
request.session.flash(msg)
if ribbon_color_2 != shop.ribbon_color_2:
shop.ribbon_color_2 = ribbon_color_2
msg = (
"You set the shop's announcement ribbon background color 2.",
"success",
)
request.session.flash(msg)
if ribbon_text_color != (shop.ribbon_text_color or ""):
shop.ribbon_text_color = ribbon_text_color
msg = ("You set the shop's announcement ribbon text color.", "success")
request.session.flash(msg)
if google_analytics_id != shop.google_analytics_id:
shop.google_analytics_id = google_analytics_id
msg = ("You set the shop's Google Analytics Id.", "success")
request.session.flash(msg)
if ribbon_color_1 != (shop.ribbon_color_1 or ""):
shop.ribbon_color_1 = ribbon_color_1
msg = (
"You set the shop's announcement ribbon background color 1.",
"success",
)
request.session.flash(msg)
if plausible_domain_name != shop.plausible_domain_name:
shop.plausible_domain_name = plausible_domain_name
msg = ("You set the shop's Plausible Analytics Domain Name.", "success")
request.session.flash(msg)
if ribbon_color_2 != (shop.ribbon_color_2 or ""):
shop.ribbon_color_2 = ribbon_color_2
msg = (
"You set the shop's announcement ribbon background color 2.",
"success",
)
request.session.flash(msg)
if stripe_public_api_key != shop.stripe_public_api_key:
if stripe_public_api_key.startswith("pk_"):
if not stripe_test_mode and "_test_" in stripe_public_api_key:
# Handle stripe settings form
if form_section == "stripe-settings":
if stripe_public_api_key != shop.stripe_public_api_key:
if stripe_public_api_key.startswith("pk_"):
if not stripe_test_mode and "_test_" in stripe_public_api_key:
msg = (
"Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.",
"error",
)
else:
shop.stripe_public_api_key = stripe_public_api_key
msg = ("You set the shop's stripe_public_api_key.", "success")
else:
msg = (
"Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.",
"The shop's stripe_public_api_key must start with 'pk_'.",
"error",
)
else:
shop.stripe_public_api_key = stripe_public_api_key
msg = ("You set the shop's stripe_public_api_key.", "success")
else:
msg = (
"The shop's stripe_public_api_key must start with 'pk_'.",
"error",
)
request.session.flash(msg)
request.session.flash(msg)
if stripe_secret_api_key != shop.stripe_secret_api_key:
if stripe_secret_api_key.startswith("sk_"):
if not stripe_test_mode and "_test_" in stripe_secret_api_key:
if stripe_secret_api_key != shop.stripe_secret_api_key:
if stripe_secret_api_key.startswith("sk_"):
if not stripe_test_mode and "_test_" in stripe_secret_api_key:
msg = (
"Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.",
"error",
)
else:
shop.stripe_secret_api_key = stripe_secret_api_key
msg = ("You set the shop's stripe_secret_api_key.", "success")
else:
msg = (
"Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.",
"The shop's stripe_secret_api_key must start with 'sk_'.",
"error",
)
else:
shop.stripe_secret_api_key = stripe_secret_api_key
msg = ("You set the shop's stripe_secret_api_key.", "success")
else:
msg = (
"The shop's stripe_secret_api_key must start with 'sk_'.",
"error",
)
request.session.flash(msg)
request.session.flash(msg)
if shop.maint_mode != maint_mode:
shop.maint_mode = maint_mode
request.session.flash(
(
f"You turned {maint_mode_checkbox} maint_mode",
"success",
# Handle maintenance settings form
if form_section == "maintenance-settings":
if shop.maint_mode != maint_mode:
shop.maint_mode = maint_mode
request.session.flash(
(
f"You turned {'on' if maint_mode else 'off'} maintenance mode",
"success",
)
)
)
# Handle comment system settings
if shop.comments_enabled != comments_enabled:
shop.comments_enabled = comments_enabled
status = "enabled" if comments_enabled else "disabled"
request.session.flash(
(
f"Comments {status} for this shop",
"success",
# Handle comment system settings only if comment form was submitted
if form_section == "comment-settings":
if shop.comments_enabled != comments_enabled:
shop.comments_enabled = comments_enabled
status = "enabled" if comments_enabled else "disabled"
request.session.flash(
(
f"Comments {status} for this shop",
"success",
)
)
)
if shop.comments_require_purchase != comments_require_purchase:
shop.comments_require_purchase = comments_require_purchase
status = "enabled" if comments_require_purchase else "disabled"
request.session.flash(
(
f"Purchase requirement for comments {status}",
"success",
if shop.comments_require_purchase != comments_require_purchase:
shop.comments_require_purchase = comments_require_purchase
status = "enabled" if comments_require_purchase else "disabled"
request.session.flash(
(
f"Purchase requirement for comments {status}",
"success",
)
)
)
if shop.comments_require_approval != comments_require_approval:
shop.comments_require_approval = comments_require_approval
status = "enabled" if comments_require_approval else "disabled"
request.session.flash(
(
f"Comment approval requirement {status}",
"success",
if shop.comments_require_approval != comments_require_approval:
shop.comments_require_approval = comments_require_approval
status = "enabled" if comments_require_approval else "disabled"
request.session.flash(
(
f"Comment approval requirement {status}",
"success",
)
)
)
# Handle crypto settings only if crypto form was submitted
if form_section == "crypto-settings":
try:
seconds = int(crypto_quote_expiry_seconds)
if seconds != shop.crypto_quote_expiry_seconds:
if 300 <= seconds <= 7200:
shop.crypto_quote_expiry_seconds = seconds
request.session.flash(
(
f"Cryptocurrency quote expiry set to {seconds} seconds",
"success",
)
)
else:
request.session.flash(
(
"Quote expiry must be between 300 and 7200 seconds",
"error",
)
)
except (ValueError, TypeError):
request.session.flash(("Invalid quote expiry time", "error"))
# Handle payment risk thresholds
if payment_risk_threshold_mid_dollars is not None:
try:
dollars = float(payment_risk_threshold_mid_dollars)
cents = dollars_to_cents(dollars)
if 100 <= cents <= 100000:
if cents != shop.payment_risk_threshold_mid_cents:
shop.payment_risk_threshold_mid_cents = cents
request.session.flash(
(
f"Medium risk threshold set to ${dollars:.2f}",
"success",
)
)
else:
request.session.flash(
(
"Medium risk threshold must be between $1 and $1000",
"error",
)
)
except (ValueError, TypeError):
request.session.flash(("Invalid medium risk threshold", "error"))
if payment_risk_threshold_high_dollars is not None:
try:
dollars = float(payment_risk_threshold_high_dollars)
cents = dollars_to_cents(dollars)
if 1000 <= cents <= 1000000:
if cents != shop.payment_risk_threshold_high_cents:
shop.payment_risk_threshold_high_cents = cents
request.session.flash(
(
f"High risk threshold set to ${dollars:.2f}",
"success",
)
)
else:
request.session.flash(
(
"High risk threshold must be between $10 and $10000",
"error",
)
)
except (ValueError, TypeError):
request.session.flash(("Invalid high risk threshold", "error"))
# If we processed any form submission, redirect to prevent re-submission
if form_section:
return HTTPFound(f"/s/{shop.id}/settings")
# TODO: Dry out this block, it's a copy pasta from views/product.py
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
@ -691,6 +809,19 @@ def shop_settings(request):
continue
signed_posts[file_key]["fields"].update(condition)
# Get crypto processor for Monero if enabled
xmr_processor = None
if request.monero_enabled:
from ..models.crypto_processor import CryptoProcessor
xmr_processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == shop.id, CryptoProcessor.coin_type == "XMR"
)
.first()
)
return {
"name": shop.name,
"description": shop.description,
@ -705,6 +836,14 @@ def shop_settings(request):
"plausible_domain_name": shop.plausible_domain_name or "",
"stripe_public_api_key": shop.stripe_public_api_key or "",
"stripe_secret_api_key": shop.stripe_secret_api_key or "",
"crypto_quote_expiry_seconds": shop.crypto_quote_expiry_seconds,
"payment_risk_threshold_mid_dollars": cents_to_dollars(
shop.payment_risk_threshold_mid_cents
),
"payment_risk_threshold_high_dollars": cents_to_dollars(
shop.payment_risk_threshold_high_cents
),
"xmr_processor": xmr_processor,
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}

View file

@ -0,0 +1,103 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from ..models.user_crypto_refund_address import (
UserCryptoRefundAddress,
get_user_crypto_refund_address,
)
from . import user_required
def get_enabled_coins(request):
"""Get list of enabled cryptocurrency types from all shops."""
from ..models.crypto_processor import CryptoProcessor
# Get distinct coin types from all enabled crypto processors
enabled_coins = (
request.dbsession.query(CryptoProcessor.coin_type)
.filter(CryptoProcessor.enabled == True)
.distinct()
.all()
)
# Return list of coin type strings
return [coin_type for (coin_type,) in enabled_coins]
@view_config(
route_name="user_crypto_settings",
request_method="GET",
renderer="user_crypto_settings.j2",
)
@user_required()
def user_crypto_settings_view(request):
"""Display crypto payment preferences."""
user = request.user
enabled_coins = get_enabled_coins(request)
# Get all user's crypto addresses
addresses = {}
for coin in enabled_coins:
addr = get_user_crypto_refund_address(request.dbsession, user, coin)
if addr:
addresses[coin] = addr
return {
"enabled_coins": enabled_coins,
"addresses": addresses,
}
@view_config(route_name="user_crypto_settings_update", request_method="POST")
@user_required()
def user_crypto_settings_update(request):
"""Update crypto refund address for a specific coin."""
user = request.user
coin_type = request.matchdict.get("coin_type", "").upper()
# Validate coin type
enabled_coins = get_enabled_coins(request)
if coin_type not in enabled_coins:
request.session.flash(("Invalid or disabled cryptocurrency type", "error"))
return HTTPFound("/u/settings/crypto")
# Get form data
address = request.params.get("address", "").strip()
label = request.params.get("label", "").strip()
if not address:
# If address is empty, treat it as a delete
addr = get_user_crypto_refund_address(request.dbsession, user, coin_type)
if addr:
request.dbsession.delete(addr)
request.session.flash((f"{coin_type} refund address cleared", "success"))
return HTTPFound("/u/settings/crypto")
# TODO: Add address validation for each coin type
# For now, just basic length check
if len(address) < 10 or len(address) > 256:
request.session.flash(("Invalid address format", "error"))
return HTTPFound("/u/settings/crypto")
# Get existing or create new
addr_obj = get_user_crypto_refund_address(request.dbsession, user, coin_type)
if addr_obj:
# Update existing
addr_obj.address = address
addr_obj.label = label if label else None
addr_obj.updated_timestamp = request.now_timestamp()
request.session.flash((f"{coin_type} refund address updated", "success"))
else:
# Create new
addr_obj = UserCryptoRefundAddress(
user=user,
coin_type=coin_type,
address=address,
label=label if label else None,
)
request.dbsession.add(addr_obj)
request.session.flash((f"{coin_type} refund address saved", "success"))
request.dbsession.flush()
return HTTPFound("/u/settings/crypto")

View file

@ -1,3 +1,13 @@
[pytest]
testpaths = make_post_sell
python_files = test*.py
# Uncomment filterwarnings to suppress external library warnings
# filterwarnings =
# # Ignore datetime.utcnow() deprecation from external libraries
# ignore:datetime\.datetime\.utcnow\(\) is deprecated:DeprecationWarning:webob
# ignore:datetime\.datetime\.utcnow\(\) is deprecated:DeprecationWarning:botocore
# # Ignore pkg_resources deprecation warnings
# ignore:pkg_resources is deprecated:DeprecationWarning
# ignore:Deprecated call to.*pkg_resources.*:DeprecationWarning
# # Ignore bleach CSS sanitizer warning
# ignore:'style' attribute specified.*css_sanitizer not set

View file

@ -62,6 +62,8 @@ setup(
"paste.app_factory": ["main = make_post_sell:main"],
"console_scripts": [
"initialize_make_post_sell_db = make_post_sell.scripts.initialize_db:main",
"crypto_watcher = make_post_sell.lib.crypto_watcher:main",
"sweep_to_cold = make_post_sell.lib.sweep_to_cold:main",
],
},
)

View file

@ -30,6 +30,24 @@ app.bucket.secure_uploads.secret_key = ${MPS_APP_SECURE_UPLOADS_SECRET_KEY}
# stripe test mode is enabled for development & disabled by default.
app.stripe.test_mode = True
# Payment method toggles
app.payments.stripe.enabled = True
app.payments.monero.enabled = False
# Monero RPC Configuration for tests
monero.rpc_url = http://127.0.0.1:18083/json_rpc
monero.rpc_user =
monero.rpc_pass =
monero.account_index = 0
monero.quote_expiry_seconds = 900
monero.rate_source_url = https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd
# Monero confirmation requirements by amount tier
# Note: Payment thresholds are now per-shop settings (default $10 and $100)
monero.confirmations.petty = 2
monero.confirmations.mid = 10
monero.confirmations.high = 20
[pshell]
setup = make_post_sell.scripts.pshell.setup