diff --git a/.gitignore b/.gitignore
index 6dca61d..8d55972 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,7 @@ test
*.dkim.key
caddy
+
+monero-wallet-cli.log
+monero-wallet-rpc.log
+
diff --git a/CRYPTO.rst b/CRYPTO.rst
new file mode 100644
index 0000000..ca0acf3
--- /dev/null
+++ b/CRYPTO.rst
@@ -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
diff --git a/CRYPTO_PROBLEMS.rst b/CRYPTO_PROBLEMS.rst
new file mode 100644
index 0000000..c8defbf
--- /dev/null
+++ b/CRYPTO_PROBLEMS.rst
@@ -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)
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 83ad927..3b0d844 100644
--- a/Makefile
+++ b/Makefile
@@ -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
# -----------------------------------------------------------------------------
diff --git a/README.rst b/README.rst
index e3a373b..e43445b 100644
--- a/README.rst
+++ b/README.rst
@@ -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/ `_ 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
-------------
diff --git a/development.ini b/development.ini
index eb9015e..7c58e3a 100644
--- a/development.ini
+++ b/development.ini
@@ -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/
diff --git a/make_post_sell/__init__.py b/make_post_sell/__init__.py
index 5f5fb69..e2929f5 100644
--- a/make_post_sell/__init__.py
+++ b/make_post_sell/__init__.py
@@ -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")
diff --git a/make_post_sell/lib/crypto_clients.py b/make_post_sell/lib/crypto_clients.py
new file mode 100644
index 0000000..2a733a3
--- /dev/null
+++ b/make_post_sell/lib/crypto_clients.py
@@ -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"),
+ )
diff --git a/make_post_sell/lib/crypto_payment_rescue.py b/make_post_sell/lib/crypto_payment_rescue.py
new file mode 100644
index 0000000..edf95da
--- /dev/null
+++ b/make_post_sell/lib/crypto_payment_rescue.py
@@ -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}
diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py
new file mode 100644
index 0000000..d986cb3
--- /dev/null
+++ b/make_post_sell/lib/crypto_watcher.py
@@ -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()
diff --git a/make_post_sell/lib/monero_address.py b/make_post_sell/lib/monero_address.py
new file mode 100644
index 0000000..4159558
--- /dev/null
+++ b/make_post_sell/lib/monero_address.py
@@ -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
diff --git a/make_post_sell/lib/sweep_to_cold.py b/make_post_sell/lib/sweep_to_cold.py
new file mode 100644
index 0000000..ea33382
--- /dev/null
+++ b/make_post_sell/lib/sweep_to_cold.py
@@ -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()
diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py
index 9ce17e1..f7b746d 100644
--- a/make_post_sell/models/__init__.py
+++ b/make_post_sell/models/__init__.py
@@ -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 *
diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py
new file mode 100644
index 0000000..5294046
--- /dev/null
+++ b/make_post_sell/models/crypto_payment.py
@@ -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
+
diff --git a/make_post_sell/models/crypto_processor.py b/make_post_sell/models/crypto_processor.py
new file mode 100644
index 0000000..b8d7e1c
--- /dev/null
+++ b/make_post_sell/models/crypto_processor.py
@@ -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")
diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py
index e7b816a..2532a8c 100644
--- a/make_post_sell/models/invoice.py
+++ b/make_post_sell/models/invoice.py
@@ -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."""
diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py
index eb63963..35616f6 100644
--- a/make_post_sell/models/meta.py
+++ b/make_post_sell/models/meta.py
@@ -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",
}
diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index 3b3c98a..2959342 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -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):
diff --git a/make_post_sell/models/stripe_user_shop.py b/make_post_sell/models/stripe_user_shop.py
index 094f208..b6cbf89 100644
--- a/make_post_sell/models/stripe_user_shop.py
+++ b/make_post_sell/models/stripe_user_shop.py
@@ -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):
diff --git a/make_post_sell/models/user_crypto_refund_address.py b/make_post_sell/models/user_crypto_refund_address.py
new file mode 100644
index 0000000..3909576
--- /dev/null
+++ b/make_post_sell/models/user_crypto_refund_address.py
@@ -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()
+ )
diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py
index c4c65c5..85a56e1 100644
--- a/make_post_sell/request_methods.py
+++ b/make_post_sell/request_methods.py
@@ -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
+ )
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index 0ebe1be..11fc9ee 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -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}")
diff --git a/make_post_sell/scripts/alembic/versions/0f59018f6537_add_crypto_quote_expiry_seconds_to_shop.py b/make_post_sell/scripts/alembic/versions/0f59018f6537_add_crypto_quote_expiry_seconds_to_shop.py
new file mode 100644
index 0000000..7423b80
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/0f59018f6537_add_crypto_quote_expiry_seconds_to_shop.py
@@ -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")
diff --git a/make_post_sell/scripts/alembic/versions/193438acaa95_add_crypto_quote_expiry_seconds_to_shop.py b/make_post_sell/scripts/alembic/versions/193438acaa95_add_crypto_quote_expiry_seconds_to_shop.py
new file mode 100644
index 0000000..553466e
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/193438acaa95_add_crypto_quote_expiry_seconds_to_shop.py
@@ -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')
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index 300cc80..55ef6ad 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -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 */
diff --git a/make_post_sell/templates/cart_checkout.j2 b/make_post_sell/templates/cart_checkout.j2
index 4c39caa..78c3b35 100644
--- a/make_post_sell/templates/cart_checkout.j2
+++ b/make_post_sell/templates/cart_checkout.j2
@@ -7,19 +7,25 @@
- {% if active_card %}
- Active Card
+ {% if stripe_enabled %}
+ {% if active_card %}
+ Active Card
- {{ stripe.display_card(active_card, actions=False) }}
+ {{ stripe.display_card(active_card, actions=False) }}
-
+
- {% if request.shop and request.shop.is_ready %}
- Use a different card
- {% endif %}
+ {% if request.shop and request.shop.is_ready_for_payment(request) %}
+ Use a different card
+ {% endif %}
+ {% else %}
+ Payment
+ No active payment method configured. Add a payment method.
+
+ {% endif %}
{% else %}
Payment
- No payment required for this order.
+ Card payments are disabled by configuration.
{% endif %}
@@ -36,20 +42,49 @@
Cart Total: ${{ '{:,.2f}'.format(cart.total) }}
- Are you sure you want to charge
- ${{ '{:,.2f}'.format(cart.total) }} to your active credit card?
+ {% if stripe_enabled %}
+ Are you sure you want to charge
+ ${{ '{:,.2f}'.format(cart.total) }} to your active credit card?
+ {% else %}
+ {% if cart.requires_payment %}
+ Choose a payment method below.
+ {% endif %}
+ {% endif %}
-
+ {% if stripe_enabled %}
+
+ {% 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 %}
+
+
+ {% if not request.has_xmr_refund_address %}
+
+ ⚠️ Configure an XMR refund address to enable automatic refunds during payment errors.
+
+ {% endif %}
+ {% endif %}
+
+ {% if not stripe_enabled and not (monero_enabled and xmr_processor_enabled) %}
+
+ No payment methods are enabled. Please contact the shop owner.
+ {% endif %}
+
+
diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2
new file mode 100644
index 0000000..cf0899d
--- /dev/null
+++ b/make_post_sell/templates/crypto_checkout.j2
@@ -0,0 +1,258 @@
+{% extends "base.j2" -%}
+
+{% block content -%}
+
+ {{ coin_name }} ({{ coin_symbol }}) Checkout
+
+ {% if address and amount_crypto %}
+ {% if coin_symbol == 'XMR' %}
+ {% set amount_fmt = '%.12f' % amount_crypto %}
+ {% else %}
+ {% set amount_fmt = '%.8f' % amount_crypto %}
+ {% endif %}
+ Send exactly {{ amount_fmt }} {{ coin_symbol }} to this address:
+ {{ address }}
+
+
+
+
+ Status: {{ status }}
+
+ Confirmations: 0 / 0
+
+
+ {% if expires_at %}
+ Expires in: --:--
+ {% endif %}
+
+
+ Payment ID: {{ payment_id }}
+
+
+ 💰 Quote Details
+
+
Cart Total: ${{ '%.2f' % usd_total }}
+
Conversion Rate: ${{ '%.2f' % usd_per_crypto }} USD per {{ coin_symbol }}
+ {% if coin_symbol == 'XMR' %}
+
Base Amount: {{ '%.12f' % amount_crypto_base }} {{ coin_symbol }}
+
Transaction Fee Buffer: +{{ '%.12f' % fee_buffer_crypto }} {{ coin_symbol }}
+
Total Amount: {{ '%.12f' % amount_crypto }} {{ coin_symbol }}
+
Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}
+ {% else %}
+
Base Amount: {{ '%.8f' % amount_crypto_base }} {{ coin_symbol }}
+
Transaction Fee Buffer: +{{ '%.8f' % fee_buffer_crypto }} {{ coin_symbol }}
+
Total Amount: {{ '%.8f' % amount_crypto }} {{ coin_symbol }}
+
Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}
+ {% endif %}
+
+ {% if not has_refund_address %}
+ No refund address configured. Set one up for automatic refunds in rare error cases.
+ {% endif %}
+
+ Received: / piconero
+
+
+ {% else %}
+ Monero checkout is initializing. If this message persists, the RPC may not be configured.
+ {% endif %}
+
+
+ ⚠️ Payment Information
+
+ {% if has_refund_address %}
+
+
✓ Refund address configured
+
{{ refund_address }}
+
If you make a payment error, you'll receive an automatic refund minus a 9% restocking fee that covers network costs and prevents abuse.
+
+ - Overpayment: Fulfilled & excess refunded minus fee
+ - Underpayment: Refunded minus fee
+ - Late payment: Refunded minus fee if sent after the minute expiry
+ - Wrong address: Cannot be recovered
+
+
+ {% else %}
+
+
⚠️ No refund address configured
+
Without a refund address, all payment errors result in lost funds:
+
+ - Underpayments are kept
+ - Overpayments are kept
+ - Late payments after {{ ((expires_at - now) // 60000) if expires_at and (expires_at - now) > 0 else 'expiry' }} minutes are kept
+ - Wrong address payments cannot be recovered
+
+
Consider configuring a refund address for future purchases.
+
+ {% endif %}
+
+{%- endblock %}
diff --git a/make_post_sell/templates/invoice.j2 b/make_post_sell/templates/invoice.j2
index 1193def..6a999cb 100644
--- a/make_post_sell/templates/invoice.j2
+++ b/make_post_sell/templates/invoice.j2
@@ -4,6 +4,36 @@
Invoice Identifier
{{ invoice.id }}
Customer Name: {{ invoice.user.name }}
Date: {{ invoice.human_created_timestamp }}
+ Payment Method: {{ invoice.payment_method.upper() }}
+ Payment Status: {{ invoice.payment_status }}
+
+ {% if invoice.crypto_payment and invoice.crypto_payment|length > 0 %}
+ {% set crypto_pay = invoice.crypto_payment[0] %}
+
+
+
Crypto Payment Information
+
Currency: {{ crypto_pay.coin_type }}
+
Payment Address: {{ crypto_pay.address }}
+
Expected Amount: {{ '%.12f'|format(crypto_pay.expected_amount / (10**12 if crypto_pay.coin_type == 'XMR' else 10**8)) }} {{ crypto_pay.coin_type }}
+
Received Amount: {{ '%.12f'|format(crypto_pay.received_amount / (10**12 if crypto_pay.coin_type == 'XMR' else 10**8)) }} {{ crypto_pay.coin_type }}
+
+ {% if crypto_pay.status in ['confirmed', 'confirmed_overpaid'] %}
+
✓ Payment Confirmed
+ {% elif crypto_pay.status == 'received' %}
+
⏳ Awaiting Confirmations ({{ crypto_pay.current_confirmations }}/{{ crypto_pay.confirmations_required }})
+ {% elif crypto_pay.status == 'pending' %}
+
⏳ Waiting for Payment
+ {% elif crypto_pay.status == 'expired' %}
+
❌ Payment Expired
+ {% elif crypto_pay.status == 'underpaid_refunded' %}
+
❌ Underpaid - Refunded
+ {% endif %}
+
+ {% if crypto_pay.tx_hashes and crypto_pay.tx_hashes != '[]' %}
+
Transaction(s): {{ crypto_pay.tx_hashes }}
+ {% endif %}
+
+ {% endif %}
diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2
index 4e7bd9d..067a9fd 100644
--- a/make_post_sell/templates/shop_settings.j2
+++ b/make_post_sell/templates/shop_settings.j2
@@ -8,6 +8,7 @@
Shop Settings
+
+
+
+
+
+
+
+{% if request.stripe_enabled %}
+
+
+
+ Stripe Settings 💳
+
+
-
+
+
+
+{% endif %}
+
+{% if request.monero_enabled %}
+
+
+
+ Crypto Settings 🪙
+
+ Payment Risk Thresholds
+
+
+
+
+
+ Monero (XMR) Configuration
+
+
+
+
+
+
+
+
+{% endif %}
+
@@ -214,6 +366,7 @@