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 %}



-
- {% include "snippets/csrf.j2" %} - -
+ {% if stripe_enabled %} +
+ {% include "snippets/csrf.j2" %} + +
+ {% 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 %} +
+
+ {% include "snippets/csrf.j2" %} + + +
+ {% 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 }}
+ + +
+ + + Open in Wallet +
+ +

Status: {{ status }}

+ +

+ {% 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 %} + + + {% 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

+ Integration Settings +
- -
+ + +
+
+ +
+ + + + +
+
+ +{% if request.stripe_enabled %} +
+
+ +

Stripe Settings 💳

+ +
+
- - + +
@@ -159,17 +165,163 @@

+ +
- +

- +
+
+
+{% endif %} + +{% if request.monero_enabled %} +
+
+ +

Crypto Settings 🪙

+ +

Payment Risk Thresholds

+ +
+ + + + +
+ Orders below this amount require minimal confirmations. Default: $10.00 + +
+
+ + + +
+ Orders above this amount require maximum confirmations. Default: $100.00 + +
+
+ + + +
+ How long cryptocurrency payment quotes are valid (300-7200 seconds, default 3600 = 60 minutes) + +
+
+ + + +
+
+ +
+ +
+ +

Monero (XMR) Configuration

+ +
+ + + +
+ Configure your cold wallet address where this shop's Monero funds will be swept. This is required to accept Monero payments. + + {% if xmr_processor %} + {% if xmr_processor.enabled %} +
+
+ ✓ Monero wallet configured and ready to accept payments +
+ + Note: We cannot verify you control this address. Please double-check it's correct! + Consider sending a small test amount from another wallet to verify. + + {% else %} +
+
+ ✗ Monero payments are currently disabled +
+ + Your cold wallet address is preserved but customers cannot select Monero as a payment method. + + {% endif %} + {% endif %} + +
+
+ + {% if xmr_processor %} + {% if xmr_processor.enabled %} + + + {% else %} + + Enter your cold wallet address to re-enable Monero payments + {% endif %} + {% else %} + + {% endif %} + +
+
+ +
+ +
+
+ +
+
+{% endif %} +

@@ -214,6 +366,7 @@
+

Announcement Ribbon Settings 🎀

@@ -280,10 +433,13 @@

+ +
+

Maintenance Mode 🏗️

@@ -318,6 +474,7 @@ Existing sales honored for download buy purchasers.
+

Comment System Settings 💬

diff --git a/make_post_sell/templates/user_crypto_settings.j2 b/make_post_sell/templates/user_crypto_settings.j2 new file mode 100644 index 0000000..0ebc5a4 --- /dev/null +++ b/make_post_sell/templates/user_crypto_settings.j2 @@ -0,0 +1,71 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
+
+ +

Crypto Payment Preferences

+ +

Configure your cryptocurrency refund addresses. These addresses will be used automatically by our system in rare cases where refunds are necessary (e.g., system errors). Note: This is handled entirely by our automated system with no human support.

+ +
+ + {% if enabled_coins %} + {% for coin in enabled_coins %} + + {% include "snippets/csrf.j2" %} + +

{{ coin }} Refund Address

+ + + + +
+ + + + +
+
+ + {% if addresses.get(coin) %} + + {% else %} + + {% endif %} + +
+
+ + +
+ {% endfor %} + {% else %} +

No cryptocurrencies are currently enabled on this platform.

+ {% endif %} + +
+ + Back to Settings + +
+
+ +{%- endblock -%} \ No newline at end of file diff --git a/make_post_sell/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2 index e75fe73..9554f60 100644 --- a/make_post_sell/templates/user_settings.j2 +++ b/make_post_sell/templates/user_settings.j2 @@ -66,10 +66,12 @@ My Shops {% endif %} - {% if request.shop and request.shop.is_ready %} + {% if request.shop and request.shop.is_ready_for_payment(request) %} My Purchases
- Payment Preferences + Credit Card Payment Preferences +
+ Crypto Payment Preferences
Shipping Addresses {% endif %} diff --git a/make_post_sell/tests/test_config_expansion.py b/make_post_sell/tests/test_config_expansion.py new file mode 100644 index 0000000..05fa1cc --- /dev/null +++ b/make_post_sell/tests/test_config_expansion.py @@ -0,0 +1,315 @@ +"""Tests for environment variable expansion in configuration.""" + +import os +import unittest +from unittest import TestCase + +from make_post_sell import get_children_settings + + +class TestEnvironmentVariableExpansion(unittest.TestCase): + """Test environment variable expansion with ${VAR:-default} syntax.""" + + def setUp(self): + """Set up test environment.""" + # Store original environment + self.original_env = os.environ.copy() + # Clear any test variables + for key in list(os.environ.keys()): + if key.startswith("TEST_"): + del os.environ[key] + + def tearDown(self): + """Restore original environment.""" + os.environ.clear() + os.environ.update(self.original_env) + + def test_simple_expansion_with_value(self): + """Test ${VAR} expansion when variable is set.""" + os.environ["TEST_VAR"] = "test_value" + settings = {"app.test.setting": "${TEST_VAR}", "app.other": "static"} + result = get_children_settings(settings, "app") + self.assertEqual(result["test.setting"], "test_value") + self.assertEqual(result["other"], "static") + + def test_expansion_with_default_used(self): + """Test ${VAR:-default} uses default when var is not set.""" + # Ensure TEST_MISSING is not in environment + if "TEST_MISSING" in os.environ: + del os.environ["TEST_MISSING"] + + settings = { + "app.payment.enabled": "${TEST_MISSING:-true}", + "app.payment.provider": "${TEST_MISSING:-stripe}", + } + result = get_children_settings(settings, "app") + self.assertEqual( + result["payment.enabled"], True + ) # get_int_or_bool_or_none_or_str converts + self.assertEqual(result["payment.provider"], "stripe") + + def test_expansion_with_default_not_used(self): + """Test ${VAR:-default} uses env value when var is set.""" + os.environ["TEST_PAYMENT_ENABLED"] = "false" + os.environ["TEST_PROVIDER"] = "monero" + + settings = { + "app.payment.enabled": "${TEST_PAYMENT_ENABLED:-true}", + "app.payment.provider": "${TEST_PROVIDER:-stripe}", + } + result = get_children_settings(settings, "app") + self.assertEqual( + result["payment.enabled"], False + ) # get_int_or_bool_or_none_or_str converts + self.assertEqual(result["payment.provider"], "monero") + + def test_empty_default_value(self): + """Test ${VAR:-} with empty default.""" + settings = { + "app.optional.setting": "${TEST_OPTIONAL:-}", + "app.optional.another": "${TEST_ANOTHER:-}", + } + result = get_children_settings(settings, "app") + self.assertEqual(result["optional.setting"], "") + self.assertEqual(result["optional.another"], "") + + def test_boolean_conversion(self): + """Test boolean string conversion.""" + settings = { + "app.bool.true1": "${TEST_BOOL:-true}", + "app.bool.true2": "${TEST_BOOL:-True}", + "app.bool.true3": "${TEST_BOOL:-1}", + "app.bool.true4": "${TEST_BOOL:-yes}", + "app.bool.false1": "${TEST_BOOL:-false}", + "app.bool.false2": "${TEST_BOOL:-False}", + "app.bool.false3": "${TEST_BOOL:-0}", + "app.bool.false4": "${TEST_BOOL:-no}", + } + result = get_children_settings(settings, "app") + + # All true values + self.assertEqual(result["bool.true1"], True) + self.assertEqual(result["bool.true2"], True) + self.assertEqual(result["bool.true3"], True) + self.assertEqual(result["bool.true4"], True) + + # All false values + self.assertEqual(result["bool.false1"], False) + self.assertEqual(result["bool.false2"], False) + self.assertEqual(result["bool.false3"], False) + self.assertEqual(result["bool.false4"], False) + + def test_integer_conversion(self): + """Test integer string conversion.""" + settings = { + "app.number.int1": "${TEST_NUM:-123}", + "app.number.int2": "${TEST_NUM:-0}", + "app.number.negative": "${TEST_NUM:--456}", + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["number.int1"], 123) + self.assertEqual(result["number.int2"], 0) + self.assertEqual(result["number.negative"], -456) + + def test_none_conversion(self): + """Test None string conversion.""" + settings = { + "app.nullable.none1": "${TEST_NULL:-None}", + "app.nullable.none2": "${TEST_NULL:-none}", + "app.nullable.null": "${TEST_NULL:-null}", + "app.nullable.null2": "${TEST_NULL:-NULL}", + } + result = get_children_settings(settings, "app") + + self.assertIsNone(result["nullable.none1"]) + self.assertIsNone(result["nullable.none2"]) + self.assertIsNone(result["nullable.null"]) + self.assertIsNone(result["nullable.null2"]) + + def test_mixed_syntax(self): + """Test mixing of different variable syntaxes.""" + os.environ["TEST_SIMPLE"] = "simple_value" + os.environ["TEST_BRACES"] = "braces_value" + os.environ["TEST_DEFAULT_OVERRIDE"] = "override_value" + + settings = { + "app.var.simple": "$TEST_SIMPLE", + "app.var.braces": "${TEST_BRACES}", + "app.var.default_used": "${TEST_MISSING:-default_value}", + "app.var.default_override": "${TEST_DEFAULT_OVERRIDE:-default_value}", + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["var.simple"], "simple_value") + self.assertEqual(result["var.braces"], "braces_value") + self.assertEqual(result["var.default_used"], "default_value") + self.assertEqual(result["var.default_override"], "override_value") + + def test_complex_default_values(self): + """Test default values with special characters.""" + settings = { + "app.url.default": "${TEST_URL:-http://localhost:8080}", + "app.path.default": "${TEST_PATH:-/path/to/file}", + "app.json.default": '${TEST_JSON:-{"key": "value"}}', + "app.special.chars": "${TEST_SPECIAL:-value-with_special.chars}", + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["url.default"], "http://localhost:8080") + self.assertEqual(result["path.default"], "/path/to/file") + self.assertEqual(result["json.default"], '{"key": "value"}') + self.assertEqual(result["special.chars"], "value-with_special.chars") + + def test_nested_variables_not_expanded(self): + """Test that nested variable references are not expanded.""" + os.environ["TEST_INNER"] = "inner_value" + + settings = { + "app.nested.var": "${TEST_OUTER:-$TEST_INNER}", + } + result = get_children_settings(settings, "app") + + # Should not expand the inner $TEST_INNER + self.assertEqual(result["nested.var"], "$TEST_INNER") + + def test_multiple_variables_in_value(self): + """Test multiple variable references in a single value.""" + os.environ["TEST_HOST"] = "localhost" + os.environ["TEST_PORT"] = "8080" + + settings = { + "app.url.composite": "${TEST_PROTO:-http}://${TEST_HOST}:${TEST_PORT}/path", + "app.mixed.defaults": "${TEST_USER:-admin}:${TEST_PASS:-secret}@${TEST_HOST}", + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["url.composite"], "http://localhost:8080/path") + self.assertEqual(result["mixed.defaults"], "admin:secret@localhost") + + def test_non_string_values_unchanged(self): + """Test that non-string values pass through unchanged.""" + settings = { + "app.already.bool": True, + "app.already.int": 42, + "app.already.none": None, + "app.already.list": ["a", "b", "c"], + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["already.bool"], True) + self.assertEqual(result["already.int"], 42) + self.assertIsNone(result["already.none"]) + self.assertEqual(result["already.list"], ["a", "b", "c"]) + + def test_parent_key_filtering(self): + """Test that only settings with parent key are returned.""" + settings = { + "app.included.one": "${TEST_VAR:-value1}", + "app.included.two": "${TEST_VAR:-value2}", + "other.excluded.one": "${TEST_VAR:-excluded1}", + "session.excluded.two": "${TEST_VAR:-excluded2}", + } + result = get_children_settings(settings, "app") + + self.assertIn("included.one", result) + self.assertIn("included.two", result) + self.assertNotIn("excluded.one", result) + self.assertNotIn("excluded.two", result) + self.assertEqual(len(result), 2) + + def test_whitespace_handling(self): + """Test handling of whitespace in values.""" + os.environ["TEST_TRIMMED"] = " trimmed " + + settings = { + "app.space.env": "${TEST_TRIMMED}", + "app.space.default": "${TEST_MISSING:- spaced }", + "app.space.empty": "${TEST_MISSING:-}", + } + result = get_children_settings(settings, "app") + + # Values should be preserved as-is + self.assertEqual(result["space.env"], " trimmed ") + self.assertEqual(result["space.default"], " spaced ") + self.assertEqual(result["space.empty"], "") + + def test_edge_cases(self): + """Test various edge cases.""" + settings = { + "app.edge.dollar_only": "$", + "app.edge.unclosed_brace": "${TEST_VAR", + "app.edge.no_var_name": "${}", + "app.edge.just_default": "${:-default}", + "app.edge.multiple_colons": "${TEST_VAR:-value:with:colons}", + "app.edge.brace_in_default": "${TEST_VAR:-value}with}brace}", + } + result = get_children_settings(settings, "app") + + # These should remain unchanged or handle gracefully + self.assertEqual(result["edge.dollar_only"], "$") + self.assertEqual(result["edge.unclosed_brace"], "${TEST_VAR") + self.assertEqual(result["edge.no_var_name"], "${}") + self.assertEqual(result["edge.just_default"], "default") + self.assertEqual(result["edge.multiple_colons"], "value:with:colons") + # The regex stops at the first closing brace, so it parses as ${TEST_VAR:-value} + "with}brace}" + self.assertEqual(result["edge.brace_in_default"], "valuewith}brace}") + + +class TestPaymentConfigurationExpansion(unittest.TestCase): + """Test specific payment configuration scenarios.""" + + def setUp(self): + """Set up test environment.""" + self.original_env = os.environ.copy() + # Clear any test variables + for key in list(os.environ.keys()): + if key.startswith("MPS_"): + del os.environ[key] + + def tearDown(self): + """Restore original environment.""" + os.environ.clear() + os.environ.update(self.original_env) + + def test_stripe_monero_defaults(self): + """Test default payment configuration values.""" + settings = { + "app.payments.stripe.enabled": "${MPS_PAYMENTS_STRIPE_ENABLED:-true}", + "app.payments.monero.enabled": "${MPS_PAYMENTS_MONERO_ENABLED:-false}", + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["payments.stripe.enabled"], True) + self.assertEqual(result["payments.monero.enabled"], False) + + def test_stripe_monero_overrides(self): + """Test overriding payment configuration.""" + os.environ["MPS_PAYMENTS_STRIPE_ENABLED"] = "false" + os.environ["MPS_PAYMENTS_MONERO_ENABLED"] = "true" + + settings = { + "app.payments.stripe.enabled": "${MPS_PAYMENTS_STRIPE_ENABLED:-true}", + "app.payments.monero.enabled": "${MPS_PAYMENTS_MONERO_ENABLED:-false}", + } + result = get_children_settings(settings, "app") + + self.assertEqual(result["payments.stripe.enabled"], False) + self.assertEqual(result["payments.monero.enabled"], True) + + def test_monero_configuration(self): + """Test Monero RPC configuration expansion.""" + os.environ["MPS_MONERO_RPC_URL"] = "http://192.168.1.100:18082/json_rpc" + os.environ["MPS_MONERO_RPC_USER"] = "monero_user" + os.environ["MPS_MONERO_RPC_PASS"] = "monero_pass" + + settings = { + "monero.rpc_url": "${MPS_MONERO_RPC_URL:-http://127.0.0.1:18083/json_rpc}", + "monero.rpc_user": "${MPS_MONERO_RPC_USER:-}", + "monero.rpc_pass": "${MPS_MONERO_RPC_PASS:-}", + } + result = get_children_settings(settings, "monero") + + self.assertEqual(result["rpc_url"], "http://192.168.1.100:18082/json_rpc") + self.assertEqual(result["rpc_user"], "monero_user") + self.assertEqual(result["rpc_pass"], "monero_pass") diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py new file mode 100644 index 0000000..dea6054 --- /dev/null +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -0,0 +1,708 @@ +"""Unit and integration tests for crypto payment watcher.""" + +import json +import time +import unittest +from decimal import Decimal +from unittest.mock import MagicMock, patch, call + +from pyramid import testing +from pyramid.paster import get_appsettings +import transaction + +from ..models import get_tm_session +from ..models.meta import Base +from ..models.shop import Shop +from ..models.shop_location import ShopLocation +from ..models.user import User, get_or_create_user_by_email +from ..models.product import Product +from ..models.invoice import Invoice +from ..models.crypto_payment import CryptoPayment +from ..models.crypto_processor import CryptoProcessor +from ..models.inventory import Inventory +from ..models.user_crypto_refund_address import UserCryptoRefundAddress +from ..models.price import Price + +from ..lib.crypto_watcher import ( + summarize_txs, + process_payment, + finalize_invoice, + auto_sweep_payment, + ATOMIC_UNITS, + MIN_SWEEP_BALANCE, +) +from ..lib.crypto_clients import MockMoneroClient + + +class CryptoWatcherUnitTests(unittest.TestCase): + """Unit tests for crypto_watcher functions.""" + + def test_summarize_txs_empty(self): + """Test summarizing empty transaction list.""" + total, txids, min_conf = summarize_txs([]) + self.assertEqual(total, 0) + self.assertEqual(txids, []) + self.assertEqual(min_conf, 0) + + def test_summarize_txs_single(self): + """Test summarizing single transaction.""" + transfers = [ + { + "amount": 1000000000000, # 1 XMR in atomic units + "txid": "tx123", + "confirmations": 5, + } + ] + total, txids, min_conf = summarize_txs(transfers) + self.assertEqual(total, 1000000000000) + self.assertEqual(txids, ["tx123"]) + self.assertEqual(min_conf, 5) + + def test_summarize_txs_multiple(self): + """Test summarizing multiple transactions.""" + transfers = [ + {"amount": 500000000000, "txid": "tx1", "confirmations": 10}, + {"amount": 300000000000, "txid": "tx2", "confirmations": 5}, + {"amount": 200000000000, "txid": "tx3", "confirmations": 15}, + ] + total, txids, min_conf = summarize_txs(transfers) + self.assertEqual(total, 1000000000000) + self.assertEqual(set(txids), {"tx1", "tx2", "tx3"}) + self.assertEqual(min_conf, 5) # Minimum confirmations + + def test_summarize_txs_duplicate_txids(self): + """Test summarizing with duplicate transaction IDs.""" + transfers = [ + {"amount": 500000000000, "txid": "tx1", "confirmations": 10}, + {"amount": 300000000000, "txid": "tx1", "confirmations": 10}, # Duplicate + ] + total, txids, min_conf = summarize_txs(transfers) + self.assertEqual(total, 800000000000) + self.assertEqual(txids, ["tx1"]) # Deduped + self.assertEqual(min_conf, 10) + + def test_summarize_txs_missing_fields(self): + """Test summarizing with missing fields.""" + transfers = [ + {"amount": None, "txid": "tx1", "confirmations": 5}, + {"amount": 100, "confirmations": 3}, # No txid + {"amount": 200, "txid": "tx2"}, # No confirmations + ] + total, txids, min_conf = summarize_txs(transfers) + self.assertEqual(total, 300) + self.assertEqual(txids, ["tx1", "tx2"]) + self.assertEqual(min_conf, 3) + + +class CryptoWatcherIntegrationTests(unittest.TestCase): + """Integration tests for crypto_watcher with database.""" + + def setUp(self): + from make_post_sell import main + + settings = get_appsettings("test.ini") + self.app = main({}, **settings) + self.session_factory = self.app.registry["dbsession_factory"] + self.engine = self.session_factory.kw["bind"] + + # Create tables + Base.metadata.create_all(bind=self.engine) + + # Create test data + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + + # Create test user + self.test_user = get_or_create_user_by_email( + dbsession, "watcher_test@example.com" + ) + self.test_user.is_email_verified = True + dbsession.add(self.test_user) + + # Create test shop and location + self.test_shop = Shop( + name="WatcherTestShop", + phone_number="555-1234", + billing_address="123 Test St", + description="Test shop for crypto watcher", + ) + self.test_shop.stripe_user_id = "test_stripe_user" + self.test_shop.stripe_publishable_api_key = "test_key" + dbsession.add(self.test_shop) + + self.test_location = ShopLocation( + shop=self.test_shop, + name="Main Location", + address="789 Main St", + city="Test City", + state="TS", + country="Test Country", + postal_code="12345", + ) + dbsession.add(self.test_location) + + # Create test product (digital) + self.test_product = Product( + title="Test Digital Product", description="Digital test product" + ) + self.test_product.shop_id = self.test_shop.id + self.test_product.price_in_cents = 1500 # $15 + self.test_product.is_physical = False + dbsession.add(self.test_product) + + # Create price history for digital product + price1 = Price(self.test_product, 1500) + dbsession.add(price1) + + # Create physical product + self.physical_product = Product( + title="Test Physical Product", description="Physical test product" + ) + self.physical_product.shop_id = self.test_shop.id + self.physical_product.price_in_cents = 2000 # $20 + self.physical_product.is_physical = True + dbsession.add(self.physical_product) + + # Create price history for physical product + price2 = Price(self.physical_product, 2000) + dbsession.add(price2) + + dbsession.flush() + + # Create inventory for physical product + self.inventory = Inventory( + product=self.physical_product, + shop_location=self.test_location, + quantity=10, + ) + dbsession.add(self.inventory) + + # Create test invoice + self.test_invoice = Invoice(self.test_user) + self.test_invoice.shop_id = self.test_shop.id + self.test_invoice.new_line_item(product=self.test_product, quantity=1) + dbsession.add(self.test_invoice) + + dbsession.flush() + + # Create test crypto payment + self.test_payment = CryptoPayment( + invoice=self.test_invoice, + address="test_xmr_address", + account_index=0, + subaddress_index=1, + coin_type="XMR", + expected_amount=100000000000, # 0.1 XMR + rate_locked_usd_per_coin=150.0, + quote_expires_at_ms=int((time.time() + 900) * 1000), # 15 min from now + confirmations_required=1, + shop_location=self.test_location, + ) + dbsession.add(self.test_payment) + + dbsession.flush() + self.payment_id = self.test_payment.id + self.invoice_id = self.test_invoice.id + self.product_id = self.test_product.id + self.user_id = self.test_user.id + self.shop_id = self.test_shop.id + self.location_id = self.test_location.id + self.physical_product_id = self.physical_product.id + + def tearDown(self): + transaction.abort() + Base.metadata.drop_all(bind=self.engine) + + @patch("make_post_sell.lib.crypto_watcher.send_purchase_email") + @patch("make_post_sell.lib.crypto_watcher.send_sale_email") + def test_finalize_invoice(self, mock_sale_email, mock_purchase_email): + """Test finalizing an invoice unlocks products and sends emails.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + + # Create mock request + mock_request = MagicMock() + mock_request.dbsession = dbsession + mock_request.registry.settings = {"app.email.enabled": "true"} + + # Finalize the invoice + finalize_invoice(mock_request, payment) + + # Check product was unlocked + # Query fresh from database to avoid session issues + from ..models.user_product import UserProduct + + userproduct = ( + dbsession.query(UserProduct) + .filter_by(product_id=self.product_id, user_id=self.user_id) + .first() + ) + self.assertIsNotNone(userproduct) + + # Check emails were sent + mock_purchase_email.assert_called_once() + mock_sale_email.assert_called_once() + + @patch("make_post_sell.lib.crypto_watcher.send_purchase_email") + @patch("make_post_sell.lib.crypto_watcher.send_sale_email") + def test_finalize_invoice_with_physical_product( + self, mock_sale_email, mock_purchase_email + ): + """Test finalizing invoice with physical product deducts inventory.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + + # Get fresh objects from session + user = dbsession.query(User).filter_by(id=self.user_id).first() + physical_product = ( + dbsession.query(Product).filter_by(id=self.physical_product_id).first() + ) + location = ( + dbsession.query(ShopLocation).filter_by(id=self.location_id).first() + ) + + # Create invoice with physical product + invoice = Invoice(user) + invoice.shop_id = self.shop_id + invoice.new_line_item(product=physical_product, quantity=3) + dbsession.add(invoice) + + payment = CryptoPayment( + invoice=invoice, + address="test_address2", + account_index=0, + subaddress_index=2, + coin_type="XMR", + expected_amount=200000000000, + rate_locked_usd_per_coin=150.0, + quote_expires_at_ms=int((time.time() + 900) * 1000), + confirmations_required=1, + shop_location=location, + ) + dbsession.add(payment) + dbsession.flush() + + # Check initial inventory + inventory = ( + dbsession.query(Inventory) + .filter_by( + product_id=self.physical_product_id, + shop_location_id=self.location_id, + ) + .first() + ) + self.assertEqual(inventory.quantity, 10) + + # Create mock request + mock_request = MagicMock() + mock_request.dbsession = dbsession + mock_request.registry.settings = {} + + # Finalize + finalize_invoice(mock_request, payment) + + # Check inventory was deducted + self.assertEqual(inventory.quantity, 7) # 10 - 3 + + def test_process_payment_no_transfers(self): + """Test processing payment with no incoming transfers.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process with no transfers + process_payment(mock_request, payment, []) + + # Payment should still be pending + self.assertEqual(payment.status, "pending") + self.assertEqual(payment.received_amount, 0) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + def test_process_payment_sufficient_funds_and_confirmations(self, mock_finalize): + """Test processing payment with sufficient funds and confirmations.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process with sufficient transfer + transfers = [ + { + "amount": 100000000000, # Exactly what's expected + "txid": "tx123", + "confirmations": 2, # More than required (1) + } + ] + + process_payment(mock_request, payment, transfers) + + # Payment should be confirmed + self.assertEqual(payment.status, "confirmed") + self.assertEqual(payment.received_amount, 100000000000) + self.assertEqual(json.loads(payment.tx_hashes), ["tx123"]) + + # Invoice should be finalized + mock_finalize.assert_called_once_with(mock_request, payment) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + def test_process_payment_insufficient_confirmations(self, mock_finalize): + """Test processing payment with insufficient confirmations.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + payment.confirmations_required = 10 # Require 10 confirmations + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process with insufficient confirmations + transfers = [ + { + "amount": 100000000000, + "txid": "tx123", + "confirmations": 5, # Only 5 confirmations + } + ] + + process_payment(mock_request, payment, transfers) + + # Payment should be received (has funds but not enough confirmations) + self.assertEqual(payment.status, "received") + self.assertEqual(payment.received_amount, 100000000000) + + # Invoice should NOT be finalized + mock_finalize.assert_not_called() + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + def test_process_payment_overpayment(self, mock_finalize): + """Test processing overpayment without refund (no refund address).""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process with overpayment + transfers = [ + { + "amount": 150000000000, # 1.5x expected + "txid": "tx123", + "confirmations": 5, + } + ] + + process_payment(mock_request, payment, transfers) + + # Payment should be confirmed (not confirmed_overpaid since no refund) + self.assertEqual(payment.status, "confirmed") + self.assertEqual(payment.received_amount, 150000000000) + + # Invoice should be finalized + mock_finalize.assert_called_once() + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.PaymentRescue") + def test_process_payment_overpayment_with_refund( + self, mock_rescue_class, mock_finalize + ): + """Test processing overpayment with refund.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + + # Add refund address to simulate user having configured one + payment.refund_address = "refund_address_123" + + # Get invoice and user + invoice = payment.invoice + user = invoice.user + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Mock PaymentRescue + mock_rescue = MagicMock() + mock_rescue.handle_overpayment.return_value = { + "payment_id": payment.id, + "refund_address": "refund_address_123", + "amount_xmr": Decimal("0.5"), # Excess amount + } + mock_rescue.execute_refund.return_value = { + "success": True, + "tx_hash": "refund_tx_123", + } + mock_rescue_class.return_value = mock_rescue + + # Process with overpayment + transfers = [ + { + "amount": 150000000000, # 1.5x expected + "txid": "tx123", + "confirmations": 5, + } + ] + + # Mock client for PaymentRescue + mock_client = MagicMock() + process_payment(mock_request, payment, transfers, mock_client) + + # Payment should be confirmed_overpaid + self.assertEqual(payment.status, "confirmed_overpaid") + self.assertEqual(payment.received_amount, 150000000000) + + # Refund should have been executed + mock_rescue.handle_overpayment.assert_called_once() + mock_rescue.execute_refund.assert_called_once() + + # Invoice should be finalized + mock_finalize.assert_called_once() + + def test_process_payment_expired(self): + """Test processing expired payment.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + # Set payment as expired + payment.quote_expires_at = int((time.time() - 100) * 1000) + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process expired payment + process_payment(mock_request, payment, []) + + # Payment should be marked expired + self.assertEqual(payment.status, "expired") + + @patch("make_post_sell.lib.crypto_watcher.PaymentRescue") + def test_process_payment_expired_with_refund(self, mock_rescue_class): + """Test processing expired payment with funds triggers refund.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + payment.quote_expires_at = int((time.time() - 100) * 1000) + + # Get user from DB and add refund address + user = dbsession.query(User).filter_by(id=self.user_id).first() + refund_addr = UserCryptoRefundAddress( + user=user, coin_type="XMR", address="refund_address_123" + ) + dbsession.add(refund_addr) + payment.refund_address = "refund_address_123" + + # Mock client + mock_client = MagicMock() + + # Mock PaymentRescue + mock_rescue = MagicMock() + mock_rescue.handle_expired_payment.return_value = { + "payment_id": payment.id, + "refund_address": "refund_address_123", + "amount_xmr": Decimal("0.1"), + } + mock_rescue.execute_refund.return_value = { + "success": True, + "tx_hash": "refund_tx_123", + } + mock_rescue_class.return_value = mock_rescue + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process expired payment with funds + transfers = [ + {"amount": 100000000000, "txid": "late_tx", "confirmations": 10} + ] + + process_payment(mock_request, payment, transfers, client=mock_client) + + # Payment should be expired + self.assertEqual(payment.status, "expired") + + # Refund should be attempted + mock_rescue.handle_expired_payment.assert_called_once() + mock_rescue.execute_refund.assert_called_once() + + def test_process_payment_idempotent(self): + """Test processing same payment multiple times is idempotent.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # First transfer + transfers1 = [{"amount": 50000000000, "txid": "tx1", "confirmations": 0}] + + process_payment(mock_request, payment, transfers1) + self.assertEqual(payment.received_amount, 50000000000) + + # Process same transfer again - should not double count + process_payment(mock_request, payment, transfers1) + self.assertEqual(payment.received_amount, 50000000000) + + # Add new transfer + transfers2 = transfers1 + [ + {"amount": 30000000000, "txid": "tx2", "confirmations": 0} + ] + + process_payment(mock_request, payment, transfers2) + self.assertEqual(payment.received_amount, 80000000000) + + @patch("make_post_sell.lib.crypto_watcher.auto_sweep_payment") + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + def test_process_payment_triggers_auto_sweep(self, mock_finalize, mock_sweep): + """Test that confirming payment triggers auto-sweep.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + payment = ( + dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first() + ) + payment.shop_sweep_to_address = "cold_wallet_address" + + mock_client = MagicMock() + # Mock get_balance to return unlocked funds + mock_client._call.return_value = {"unlocked_balance": 100000000000} + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process with sufficient payment + transfers = [{"amount": 100000000000, "txid": "tx123", "confirmations": 2}] + + process_payment(mock_request, payment, transfers, client=mock_client) + + # Auto-sweep should be called after balance check + mock_sweep.assert_called_with(mock_client, payment) + self.assertEqual(mock_sweep.call_count, 1) + + +class AutoSweepTests(unittest.TestCase): + """Unit tests for auto-sweep functionality.""" + + def test_auto_sweep_no_address(self): + """Test auto-sweep with no sweep address configured.""" + mock_client = MagicMock() + payment = MagicMock() + payment.id = "test_id" + payment.shop_sweep_to_address = None + payment.is_swept = False + + result = auto_sweep_payment(mock_client, payment) + self.assertFalse(result) + mock_client._call.assert_not_called() + + def test_auto_sweep_insufficient_balance(self): + """Test auto-sweep with no balance available.""" + mock_client = MagicMock() + mock_client._call.return_value = {"unlocked_balance": 0} # No XMR available + + payment = MagicMock() + payment.id = "test_payment_id" + payment.shop_sweep_to_address = "cold_wallet" + payment.account_index = 0 + payment.received_amount = 1000000000000 # 1 XMR expected + payment.is_swept = False + + result = auto_sweep_payment(mock_client, payment) + self.assertTrue(result) # Returns True and marks as swept + + # Should only check balance, no sweep needed since balance is 0 + mock_client._call.assert_called_once_with("get_balance", {"account_index": 0}) + + # Should mark payment as swept with pooled_sweep indicator + self.assertEqual(payment.swept_tx_hash, "pooled_sweep") + + def test_auto_sweep_success(self): + """Test successful auto-sweep.""" + mock_client = MagicMock() + mock_client._call.side_effect = [ + # First call - get_balance + {"unlocked_balance": 1000000000000}, # 1 XMR + # Second call - sweep_all + {"tx_hash": "sweep_tx_123", "amount_list": [1000000000000]}, + ] + + payment = MagicMock() + payment.id = "test_payment_id" + payment.shop_sweep_to_address = "cold_wallet_address" + payment.account_index = 0 + payment.received_amount = 500000000000 # 0.5 XMR + payment.is_swept = False + + result = auto_sweep_payment(mock_client, payment) + self.assertTrue(result) + + # Check sweep_all was called with correct parameters + sweep_call = mock_client._call.call_args_list[1] + self.assertEqual(sweep_call[0][0], "sweep_all") + sweep_params = sweep_call[0][1] + self.assertEqual(sweep_params["address"], "cold_wallet_address") + self.assertEqual(sweep_params["account_index"], 0) + + # Check sweep tracking fields were set + self.assertEqual( + payment.swept_amount, 1000000000000 + ) # Uses amount from amount_list + self.assertEqual(payment.swept_tx_hash, "sweep_tx_123") + self.assertIsNotNone(payment.swept_timestamp) + + def test_auto_sweep_already_swept(self): + """Test auto-sweep skips already swept payment.""" + mock_client = MagicMock() + + payment = MagicMock() + payment.id = "test_payment_id" + payment.shop_sweep_to_address = "cold_wallet_address" + payment.is_swept = True # Already swept + + result = auto_sweep_payment(mock_client, payment) + self.assertTrue(result) # Returns True but does nothing + + # Should not make any RPC calls + mock_client._call.assert_not_called() + + def test_auto_sweep_rpc_error(self): + """Test auto-sweep with RPC error.""" + mock_client = MagicMock() + mock_client._call.side_effect = Exception("RPC connection error") + + payment = MagicMock() + payment.id = "test_payment_id" + payment.shop_sweep_to_address = "cold_wallet" + payment.account_index = 0 + payment.received_amount = 500000000000 + payment.is_swept = False + + result = auto_sweep_payment(mock_client, payment) + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index b29a9a8..b5e9ea0 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -61,6 +61,13 @@ class FunctionalTests(unittest.TestCase): transaction.abort() Base.metadata.drop_all(bind=self.engine) + def _get_flash_messages(self, res): + """Extract flash messages from the response.""" + alerts = res.html.find("div", id="alerts") + if alerts: + return alerts.text + return "" + def get_csrf_token(self, shop_id, keywords="xyznotfound"): """ GET the search page scoped to the given shop_id (and a dummy keywords param @@ -68,13 +75,13 @@ class FunctionalTests(unittest.TestCase): """ url = f"/search?shop_id={shop_id}&keywords={keywords}" res = self.testapp.get(url, status=[200, 302]) - + # If search redirects (no results go to home, single result goes to product) if res.status_int == 302: res = res.follow() - + html = res.body.decode("utf-8") - + m = re.search( r'name=["\']csrf_token["\'].*?value=["\']([^"\']+)["\']', html, @@ -173,7 +180,7 @@ class AuthenticatedFunctionalTests(FunctionalTests): "action_type": "dollar-off", "action_value": "6.00", # $6.00 off (more than $3.50 product = free cart) "max_redemptions": "1", - "cart_qualifier": "1", # $1 minimum (way below $3.50) + "cart_qualifier": "1", # $1 minimum (way below $3.50) "max_redemptions_per_user": "1", "expiration_date": "2030-12-31", # Within allowed range } @@ -296,9 +303,10 @@ class AuthenticatedFunctionalTests(FunctionalTests): res = redirect_res.follow() self.assertIn(b"You must have a shop editor role to access that.", res.body) - def test_create_new_shop( + def _create_shop_helper( self, user_creds=None, shop_params=None, log_out_user=False ): + """Helper method to create a shop and return it.""" if user_creds is None: user_creds = self.user1_creds @@ -317,8 +325,10 @@ class AuthenticatedFunctionalTests(FunctionalTests): res = redirect_res res_body = res.body.decode() if "Great work, you created a shop!" not in res_body: - self.fail(f"Shop creation failed. Status: {res.status_int}, Body: {res_body[:500]}") - + self.fail( + f"Shop creation failed. Status: {res.status_int}, Body: {res_body[:500]}" + ) + self.assertIn( "Great work, you created a shop! You may continue to setup your shop or start posting products!", res.body.decode(), @@ -327,12 +337,22 @@ class AuthenticatedFunctionalTests(FunctionalTests): # query the new shop from database. shop = get_shop_by_name(self.dbsession, shop_params["name"]) - # prove that shop is not ready. - self.assertFalse(shop.is_ready) - self.assertTrue(shop.is_not_ready) + # prove that shop is not ready for Stripe. + self.assertFalse(shop.is_stripe_ready) + self.assertTrue(shop.is_stripe_not_ready) # add stripe keys to the newly created shop to make it ready. - res = self.testapp.post(f"/s/{shop.id}/settings", shop_params) + # Add form_section to shop_params for the stripe settings + stripe_params = { + "form_section": "stripe-settings", + "stripe_public_api_key": shop_params["stripe_public_api_key"], + "stripe_secret_api_key": shop_params["stripe_secret_api_key"], + "submit": "Save Settings", + } + res = self.testapp.post(f"/s/{shop.id}/settings", stripe_params) + # Follow redirect if present + if res.status_int == 302: + res = res.follow() res_body = res.body.decode() self.assertIn( "You set the shop's stripe_public_api_key.", @@ -346,15 +366,19 @@ class AuthenticatedFunctionalTests(FunctionalTests): # refresh shop attributes from database. self.dbsession.refresh(shop) - # prove that shop is ready. - self.assertTrue(shop.is_ready) - self.assertFalse(shop.is_not_ready) + # prove that shop is ready for Stripe. + self.assertTrue(shop.is_stripe_ready) + self.assertFalse(shop.is_stripe_not_ready) if log_out_user: self.testapp.get("/log-out") return shop + def test_create_new_shop(self): + """Test creating a new shop.""" + self._create_shop_helper() + def test_new_shop_invalid_shop_name(self): self.log_in_user(self.user1_creds) params = self.shop1_params @@ -383,7 +407,7 @@ class AuthenticatedFunctionalTests(FunctionalTests): def test_new_product_missing_required_fields(self): # log in and create a new shop. - shop = self.test_create_new_shop( + shop = self._create_shop_helper( user_creds=self.user1_creds, shop_params=self.shop1_params, log_out_user=False, @@ -409,7 +433,7 @@ class AuthenticatedFunctionalTests(FunctionalTests): product_params = self.product1_params # log in and create a new shop. - shop = self.test_create_new_shop( + shop = self._create_shop_helper( user_creds=user_creds, shop_params=shop_params, ) @@ -543,66 +567,81 @@ class AuthenticatedFunctionalTests(FunctionalTests): def test_cart_checkout_logic_with_none_stripe_user_shop(self): """Test the specific logic that was causing AttributeError in cart checkout. - + This tests the exact conditions that led to the production bug: stripe_user_shop.active_card when stripe_user_shop is None. """ # Test the exact logic from cart.py lines 458 and 468 stripe_user_shop = None # This is what causes the crash - + # OLD BUGGY CODE (would crash): with self.assertRaises(AttributeError): # This is the old buggy line that would crash: if stripe_user_shop.active_card is None: # AttributeError! pass - + with self.assertRaises(AttributeError): # This is the old buggy template context that would crash: active_card = stripe_user_shop.active_card # AttributeError! - + # NEW FIXED CODE (should work): # Test line 458: if stripe_user_shop and stripe_user_shop.active_card is None: try: result1 = stripe_user_shop and stripe_user_shop.active_card is None self.assertFalse(result1) # Should be False, not crash except AttributeError: - self.fail("Line 458 fix not working - still crashes on None stripe_user_shop") - + self.fail( + "Line 458 fix not working - still crashes on None stripe_user_shop" + ) + # Test line 468: "active_card": stripe_user_shop.active_card if stripe_user_shop else None, try: result2 = stripe_user_shop.active_card if stripe_user_shop else None self.assertIsNone(result2) # Should be None, not crash except AttributeError: - self.fail("Line 468 fix not working - still crashes on None stripe_user_shop") - + self.fail( + "Line 468 fix not working - still crashes on None stripe_user_shop" + ) + # Test with a mock stripe_user_shop object to ensure normal flow still works class MockStripeUserShop: def __init__(self, active_card_value): self.active_card = active_card_value - + stripe_user_shop_with_card = MockStripeUserShop("card_123") stripe_user_shop_no_card = MockStripeUserShop(None) - + # Test normal cases still work - result3 = stripe_user_shop_with_card and stripe_user_shop_with_card.active_card is None + result3 = ( + stripe_user_shop_with_card + and stripe_user_shop_with_card.active_card is None + ) self.assertFalse(result3) # Has card, so not None - - result4 = stripe_user_shop_no_card and stripe_user_shop_no_card.active_card is None + + result4 = ( + stripe_user_shop_no_card and stripe_user_shop_no_card.active_card is None + ) self.assertTrue(result4) # No card, so is None - - result5 = stripe_user_shop_with_card.active_card if stripe_user_shop_with_card else None + + result5 = ( + stripe_user_shop_with_card.active_card + if stripe_user_shop_with_card + else None + ) self.assertEqual(result5, "card_123") - - result6 = stripe_user_shop_no_card.active_card if stripe_user_shop_no_card else None + + result6 = ( + stripe_user_shop_no_card.active_card if stripe_user_shop_no_card else None + ) self.assertIsNone(result6) @patch("smtplib.SMTP") @patch("make_post_sell.models.Product.is_ready", mock_always_true) def test_cart_checkout_free_coupon_full_flow_regression(self, mock_smtp): """Full functional test for free cart checkout with coupon (regression test). - + This tests the complete flow that was causing the AttributeError in production: - 1. Create shop and cheap product + 1. Create shop and cheap product 2. Create coupon that makes cart free 3. Add product to cart 4. Apply coupon (cart becomes free, requires_payment = False) @@ -614,73 +653,100 @@ class AuthenticatedFunctionalTests(FunctionalTests): if coupon_res.status_int == 302: coupon_res.follow() # Follow the redirect after successful creation else: - self.fail(f"Coupon creation failed. Status: {coupon_res.status_int}, Body: {coupon_res.body.decode()[:500]}") - + self.fail( + f"Coupon creation failed. Status: {coupon_res.status_int}, Body: {coupon_res.body.decode()[:500]}" + ) + # Get the Product and Shop objects from the database. all_products = get_all_products(self.dbsession) product = all_products.one() shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) - + # Configure Stripe keys on the shop to make it ready for sales # Use the proper settings form as the working test does (with CSRF token) stripe_settings_data = dict(self.shop1_params) stripe_settings_data["csrf_token"] = self.get_csrf_token(shop.uuid_str) - stripe_settings_res = self.testapp.post(f"/s/{shop.id}/settings", stripe_settings_data) + stripe_settings_res = self.testapp.post( + f"/s/{shop.id}/settings", stripe_settings_data + ) stripe_body = stripe_settings_res.body.decode() - + # Check for success flash messages in the returned HTML # The flash messages appear in the HTML after successful form submission - success_msg_found = ("You set the shop's stripe_public_api_key." in stripe_body and - "You set the shop's stripe_secret_api_key." in stripe_body) - + success_msg_found = ( + "You set the shop's stripe_public_api_key." in stripe_body + and "You set the shop's stripe_secret_api_key." in stripe_body + ) + if not success_msg_found: # Debug: check for specific error messages about test keys if "Test Stripe keys are not allowed" in stripe_body: - print("✓ Test keys rejected in production mode - this is expected behavior") + print( + "✓ Test keys rejected in production mode - this is expected behavior" + ) # Manually set the keys in the database for testing since production rejects test keys shop.stripe_public_api_key = self.shop1_params["stripe_public_api_key"] shop.stripe_secret_api_key = self.shop1_params["stripe_secret_api_key"] self.dbsession.add(shop) self.dbsession.flush() - print("✓ Stripe keys set directly in database (test keys rejected by production validation)") + print( + "✓ Stripe keys set directly in database (test keys rejected by production validation)" + ) else: # Debug: print validation info about the keys pub_key = self.shop1_params["stripe_public_api_key"] sec_key = self.shop1_params["stripe_secret_api_key"] - print(f"Debug: Public key: {pub_key[:20]}... (starts with pk_: {pub_key.startswith('pk_')}, has _test_: {'_test_' in pub_key})") - print(f"Debug: Secret key: {sec_key[:20]}... (starts with sk_: {sec_key.startswith('sk_')}, has _test_: {'_test_' in sec_key})") - + print( + f"Debug: Public key: {pub_key[:20]}... (starts with pk_: {pub_key.startswith('pk_')}, has _test_: {'_test_' in pub_key})" + ) + print( + f"Debug: Secret key: {sec_key[:20]}... (starts with sk_: {sec_key.startswith('sk_')}, has _test_: {'_test_' in sec_key})" + ) + # Check if the database values actually changed despite no flash messages self.dbsession.refresh(shop) - keys_actually_set = (shop.stripe_public_api_key == self.shop1_params["stripe_public_api_key"] and - shop.stripe_secret_api_key == self.shop1_params["stripe_secret_api_key"]) - + keys_actually_set = ( + shop.stripe_public_api_key + == self.shop1_params["stripe_public_api_key"] + and shop.stripe_secret_api_key + == self.shop1_params["stripe_secret_api_key"] + ) + if keys_actually_set: - print("✓ Stripe keys were actually set in database despite no flash messages") + print( + "✓ Stripe keys were actually set in database despite no flash messages" + ) else: print(f"Debug: DB public key: {shop.stripe_public_api_key}") print(f"Debug: DB secret key: {shop.stripe_secret_api_key}") - self.fail("Stripe settings form did not process - keys not set in database") - + self.fail( + "Stripe settings form did not process - keys not set in database" + ) + print("✓ Stripe keys configured successfully via settings form") - + # Refresh shop and verify it's now ready self.dbsession.refresh(shop) - self.assertTrue(shop.is_ready, "Shop should be ready for sales with Stripe keys configured") - + self.assertTrue( + shop.is_stripe_ready, + "Shop should be ready for Stripe with API keys configured", + ) + # Re-query users to ensure they're attached to current session self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) - + # Log out user1 (shop owner) self.testapp.get("/log-out") - + # 3. Customer adds product to cart self.log_in_user(self.user2_creds) - + # Access the product page first to establish session context (like working test) res_csrf_redirect2 = self.testapp.get(f"/p/{product.uuid_str}") - res_csrf2 = res_csrf_redirect2.follow() # Follow the single redirect to the slug version. - + res_csrf2 = ( + res_csrf_redirect2.follow() + ) # Follow the single redirect to the slug version. + # Add single product to cart add_to_cart_res = self.testapp.post( "/cart/add", @@ -691,19 +757,23 @@ class AuthenticatedFunctionalTests(FunctionalTests): }, ) add_to_cart_res.follow().follow() # Follow redirects - + # Verify product in cart user_cart = shop.get_active_cart_for_user(self.user2) self.assertEqual(1, user_cart.count) # 1 product self.assertEqual(user_cart.total_price_in_cents, 350) # $3.50 = 350 cents self.assertTrue(user_cart.requires_payment) # $3.50 is above 64 cent threshold - + # 4. Apply the free coupon # Get the coupon from database (use coupon2_params code) coupons = get_coupons_by_code(self.dbsession, "FREECART") - self.assertEqual(len(coupons), 1, f"Expected 1 coupon with code FREECART, found {len(coupons)}") + self.assertEqual( + len(coupons), + 1, + f"Expected 1 coupon with code FREECART, found {len(coupons)}", + ) coupon = coupons[0] - + coupon_apply_res = self.testapp.post( f"/coupon/apply?shop_id={shop.uuid_str}", { @@ -712,31 +782,31 @@ class AuthenticatedFunctionalTests(FunctionalTests): "csrf_token": self.get_csrf_token(shop.uuid_str), }, ) - + # Apply coupon and refresh cart state self.dbsession.refresh(user_cart) # Clear any memoized attributes after session refresh user_cart._bust_memoized_attributes() - + # Verify coupon was applied self.assertEqual(len(user_cart.coupons), 1) self.assertEqual(user_cart.coupons[0].code, "FREECART") - + # Verify coupon applied correctly: $3.50 - $6.00 = FREE! coupon = user_cart.coupons[0] - self.assertEqual(coupon.action_value, 600) # $6.00 off + self.assertEqual(coupon.action_value, 600) # $6.00 off self.assertEqual(coupon.cart_qualifier, 100) # $1.00 minimum self.assertEqual(user_cart.total_price_in_cents, 350) # $3.50 original - + # Verify coupon applied correctly: $3.50 - $6.00 = FREE! self.assertTrue(user_cart.is_discounted) # Should now be discounted self.assertEqual(user_cart.total_discounted_price_in_cents, 0) # FREE! self.assertFalse(user_cart.requires_payment) # No payment needed - + # 5. Attempt checkout (this is where the bug would occur) # Since cart is free, no stripe_user_shop is created, so stripe_user_shop = None # The bug was: stripe_user_shop.active_card would crash with AttributeError - + # This should NOT crash with AttributeError: 'NoneType' object has no attribute 'active_card' checkout_res = self.testapp.post( f"/u/cart/{user_cart.uuid_str}/checkout", @@ -745,245 +815,302 @@ class AuthenticatedFunctionalTests(FunctionalTests): "csrf_token": self.get_csrf_token(shop.uuid_str), }, ) - + # 6. Verify successful checkout flow (no crash) # For free cart, should render checkout confirmation (200) not redirect to billing (302) self.assertEqual(checkout_res.status_int, 200) checkout_body = checkout_res.body.decode() - + # Should NOT contain payment-related messaging since cart is free self.assertNotIn("Please enter your payment information", checkout_body) self.assertNotIn("Please make a payment method active", checkout_body) - + # Should contain order confirmation elements self.assertIn("Please confirm your order", checkout_body) - + # 6. SUCCESS! We reached the checkout page without AttributeError crash # This validates that the original bug (stripe_user_shop.active_card AttributeError) is fixed - - print("✓ REGRESSION TEST PASSED: Free coupon checkout reaches confirmation without AttributeError") - print("✓ Core bug fix validated: stripe_user_shop=None properly handled in cart.py:712") - + + print( + "✓ REGRESSION TEST PASSED: Free coupon checkout reaches confirmation without AttributeError" + ) + print( + "✓ Core bug fix validated: stripe_user_shop=None properly handled in cart.py:712" + ) + # 7. Complete the actual checkout to create a real invoice # Now that transaction management is fixed, we can safely complete checkout - + # Debug: Check shop readiness immediately before checkout self.dbsession.refresh(shop) - print(f"DEBUG: Shop readiness before checkout: {shop.is_ready}") + print(f"DEBUG: Shop Stripe readiness before checkout: {shop.is_stripe_ready}") print(f"DEBUG: Shop public key: {shop.stripe_public_api_key}") print(f"DEBUG: Shop secret key: {shop.stripe_secret_api_key}") - + # Commit any pending transactions to ensure Stripe keys are persisted transaction.manager.commit() - + # Re-establish session after transaction commit - get fresh session self.dbsession = get_tm_session(self.session_factory, transaction.manager) - - # Re-query all objects to ensure we have the latest data after transaction commit + + # Re-query all objects to ensure we have the latest data after transaction commit self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) user_cart = shop.get_active_cart_for_user(self.user1) - print(f"DEBUG: Shop readiness after transaction commit: {shop.is_ready}") + print( + f"DEBUG: Shop Stripe readiness after transaction commit: {shop.is_stripe_ready}" + ) print(f"DEBUG: Cart ID after transaction commit: {user_cart.id}") - + # Ensure user1's active shop is set to the shop with Stripe keys if self.user1.active_shop_id != shop.id: - print(f"DEBUG: Setting user active shop from {self.user1.active_shop_id} to {shop.id}") + print( + f"DEBUG: Setting user active shop from {self.user1.active_shop_id} to {shop.id}" + ) self.user1.active_shop_id = shop.id self.dbsession.add(self.user1) self.dbsession.flush() else: - print(f"DEBUG: User active shop already set correctly: {self.user1.active_shop_id}") - - # Debug cart ownership - print(f"DEBUG: Cart user_id: {user_cart.user_id}, Current user ID: {self.user1.id}") + print( + f"DEBUG: User active shop already set correctly: {self.user1.active_shop_id}" + ) + + # Debug cart ownership + print( + f"DEBUG: Cart user_id: {user_cart.user_id}, Current user ID: {self.user1.id}" + ) print(f"DEBUG: Cart public: {user_cart.public}, cart.user: {user_cart.user}") - print(f"DEBUG: User owns cart check: {not self.user1.does_not_own_cart(user_cart)}") - + print( + f"DEBUG: User owns cart check: {not self.user1.does_not_own_cart(user_cart)}" + ) + # Also check the cart's properties used in the ownership check print(f"DEBUG: cart.is_not_public: {user_cart.is_not_public}") - print(f"DEBUG: user.does_not_own_cart(cart): {self.user1.does_not_own_cart(user_cart)}") - + print( + f"DEBUG: user.does_not_own_cart(cart): {self.user1.does_not_own_cart(user_cart)}" + ) + # The core regression test (AttributeError fix) has already passed. # Now try to complete the checkout to test the invoice display. # If checkout fails due to session issues, the regression test still passed. - + complete_checkout_res = self.testapp.post( f"/u/cart/{user_cart.id}/complete/checkout?shop_id={shop.id}", { "csrf_token": self.get_csrf_token(shop.uuid_str), }, ) - + # Should redirect after successful completion self.assertEqual(complete_checkout_res.status_int, 302) print(f"✓ CHECKOUT COMPLETED: Redirected to {complete_checkout_res.location}") - + # Follow the redirect to see if it's to purchases page or an error page redirect_res = complete_checkout_res.follow() redirect_body = redirect_res.body.decode() print(f"Redirect page content preview: {redirect_body[:200]}") - + # Check for any error messages that might explain why checkout didn't work if "Payment failed" in redirect_body or "error" in redirect_body.lower(): - print(f"⚠ Checkout failed with session/ownership error: {redirect_body[:500]}") + print( + f"⚠ Checkout failed with session/ownership error: {redirect_body[:500]}" + ) print("✓ CORE REGRESSION TEST STILL PASSED: AttributeError fix validated") - print(" The primary goal (fixing the stripe_user_shop=None AttributeError) is working correctly") - print(" Checkout session management after transaction commits needs additional work") + print( + " The primary goal (fixing the stripe_user_shop=None AttributeError) is working correctly" + ) + print( + " Checkout session management after transaction commits needs additional work" + ) # Don't fail the test - the regression fix is validated return - + # Transaction should have committed successfully - + # 8. Find and verify the invoice shows coupon information # Get the created invoice from the database from ..models.invoice import Invoice + invoice = self.dbsession.query(Invoice).first() - self.assertIsNotNone(invoice, "Invoice should be created after checkout completion") - + self.assertIsNotNone( + invoice, "Invoice should be created after checkout completion" + ) + # Verify invoice has the coupon redemption coupon_redemptions = list(invoice.coupon_redemptions) - self.assertEqual(len(coupon_redemptions), 1, "Invoice should have one coupon redemption") + self.assertEqual( + len(coupon_redemptions), 1, "Invoice should have one coupon redemption" + ) self.assertEqual(coupon_redemptions[0].coupon.code, self.coupon2_params["code"]) - + # 9. Test the invoice page displays coupon information invoice_res = self.testapp.get(f"/i/{invoice.id}") self.assertEqual(invoice_res.status_int, 200) invoice_body = invoice_res.body.decode() - + # Verify coupon information appears on invoice self.assertIn("Discounts Applied", invoice_body) self.assertIn(self.coupon2_params["code"], invoice_body) # Coupon code - self.assertIn(self.coupon2_params["description"], invoice_body) # Coupon description + self.assertIn( + self.coupon2_params["description"], invoice_body + ) # Coupon description self.assertIn("Total Discounts:", invoice_body) # Discount section self.assertIn("Subtotal:", invoice_body) # Subtotal before discount - + # Verify the discount amount calculation - self.assertGreater(invoice.discount_amount_in_cents, 0, "Should have discount applied") - self.assertEqual(invoice.total_in_cents, 0, "Total should be $0 after $6 discount on $6 product") - - print("✓ INVOICE VERIFICATION: Coupon information correctly displayed on invoice page") - print(f"✓ Coupon applied: {self.coupon2_params['code']} - {self.coupon2_params['description']}") + self.assertGreater( + invoice.discount_amount_in_cents, 0, "Should have discount applied" + ) + self.assertEqual( + invoice.total_in_cents, + 0, + "Total should be $0 after $6 discount on $6 product", + ) + + print( + "✓ INVOICE VERIFICATION: Coupon information correctly displayed on invoice page" + ) + print( + f"✓ Coupon applied: {self.coupon2_params['code']} - {self.coupon2_params['description']}" + ) print(f"✓ Discount amount: ${invoice.discount_amount_in_cents / 100:.2f}") - + # Create the user-product relationship for download testing from ..models.user_product import UserProduct + # Check if relationship already exists from checkout completion if not self.user2.can_download_product(product): user_product = UserProduct(user=self.user2, product=product) self.dbsession.add(user_product) self.dbsession.flush() - + # Verify user can now download the product self.assertTrue(self.user2.can_download_product(product)) - + # Commit the purchase to database transaction.manager.commit() transaction.manager.begin() - + # Re-query to get fresh objects product = get_all_products(self.dbsession).one() self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) - + # Log back in as purchasing user and access product page for download self.testapp.get("/log-out") self.log_in_user(self.user2_creds) - + # Access product page and verify download button is in HTML product_res = self.testapp.get(f"/p/{product.uuid_str}") - + # Check if we can access the product or if there are shop ownership issues if product_res.status_int == 302: product_follow = product_res.follow() # Follow redirect to slug version product_body = product_follow.body.decode() - + # Check for shop ownership errors - if "Refusing to display" in product_body or "You don't have any shops" in product_body: - print("⚠️ Shop ownership lost after transaction - testing permissions directly") + if ( + "Refusing to display" in product_body + or "You don't have any shops" in product_body + ): + print( + "⚠️ Shop ownership lost after transaction - testing permissions directly" + ) # Test permissions directly since web interface has session issues self.assertTrue(self.user2.can_download_product(product)) - print("✓ DOWNLOAD PERMISSIONS VERIFIED: User can download after purchase") + print( + "✓ DOWNLOAD PERMISSIONS VERIFIED: User can download after purchase" + ) else: # Verify download button appears in HTML self.assertIn("product-download-button", product_body) self.assertIn("Download", product_body) self.assertIn("⭳", product_body) # Download symbol - print("✓ DOWNLOAD BUTTON VERIFIED: Download button appears in HTML after purchase") + print( + "✓ DOWNLOAD BUTTON VERIFIED: Download button appears in HTML after purchase" + ) else: # Direct response without redirect - check for errors product_body = product_res.body.decode() - if "Refusing to display" in product_body or "You don't have any shops" in product_body: - print("⚠️ Shop ownership lost after transaction - testing permissions directly") + if ( + "Refusing to display" in product_body + or "You don't have any shops" in product_body + ): + print( + "⚠️ Shop ownership lost after transaction - testing permissions directly" + ) # Test permissions directly since web interface has session issues self.assertTrue(self.user2.can_download_product(product)) - print("✓ DOWNLOAD PERMISSIONS VERIFIED: User can download after purchase") + print( + "✓ DOWNLOAD PERMISSIONS VERIFIED: User can download after purchase" + ) else: self.fail(f"Unexpected product page response: {product_body[:200]}") - + # Always verify permissions work at the model level self.assertTrue(self.user2.can_download_product(product)) - + print("✓ FULL GROUPR REGRESSION: Free checkout → download access working") @patch("smtplib.SMTP") def test_product_download_permissions_basic(self, mock_smtp): """Simple test to verify download permissions work correctly.""" - + # Create shop and product self.test_new_product( user_creds=self.user1_creds, shop_params=self.shop1_params, product_params=self.product1_params, ) - + all_products = get_all_products(self.dbsession) product = all_products.one() - + # Before purchase: user2 should NOT have download access self.assertFalse(self.user2.can_download_product(product)) - + # Shop owner should have download access self.assertTrue(self.user1.can_download_product(product)) - + # Create purchase relationship from ..models.user_product import UserProduct + user_product = UserProduct(user=self.user2, product=product) self.dbsession.add(user_product) self.dbsession.flush() - + # After purchase: user2 should have download access self.assertTrue(self.user2.can_download_product(product)) - + print("✓ DOWNLOAD PERMISSIONS TEST PASSED") - + # Verify scenario #3: Download button doesn't appear when no file # Since we don't mock is_ready here, product has no file and should not be ready self.assertFalse(product.has_product_file) self.assertFalse(product.is_ready) - + # Fix database transaction state before web testing transaction.manager.commit() transaction.manager.begin() - + # Re-query objects to avoid detached instance errors product = get_all_products(self.dbsession).one() self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) - + # Log in as purchasing user and check product page self.testapp.get("/log-out") self.log_in_user(self.user2_creds) - + product_res = self.testapp.get(f"/p/{product.uuid_str}") if product_res.status_int == 302: product_follow = product_res.follow() product_body = product_follow.body.decode() else: product_body = product_res.body.decode() - + # Should NOT contain download button (no file exists) self.assertNotIn("product-download-button", product_body) self.assertNotIn("⭳", product_body) # Download symbol - + print("✓ NO DOWNLOAD BUTTON VERIFIED: Button absent when product has no file") @patch("make_post_sell.models.Product.is_ready", mock_always_true) @@ -1030,3 +1157,466 @@ class AuthenticatedFunctionalTests(FunctionalTests): del coupon_params["code"] res = self.make_new_coupon_for_shop(coupon_params) self.assertIn("Please submit all required fields.", res.body.decode()) + + def test_shop_settings_form_isolation_crypto_doesnt_affect_comments(self): + """Test that saving crypto settings doesn't change comment settings.""" + # Create a shop using the helper which handles transactions properly + shop = self._create_shop_helper( + user_creds=self.user1_creds, shop_params=self.shop1_params + ) + + # Store initial comment settings state + self.dbsession.expire(shop) + initial_comments_enabled = shop.comments_enabled + initial_comments_require_purchase = shop.comments_require_purchase + initial_comments_require_approval = shop.comments_require_approval + + # Submit crypto settings form to change values + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "crypto-settings", + "payment_risk_threshold_mid_dollars": "20.00", # Change from default + "payment_risk_threshold_high_dollars": "200.00", # Change from default + "crypto_quote_expiry_seconds": "1800", + "submit": "Save Crypto Settings", + }, + status=302, + ) + + # Refresh shop from database + self.dbsession.expire(shop) + + # Check that crypto settings changed + self.assertEqual(shop.payment_risk_threshold_mid_cents, 2000) # $20 + self.assertEqual(shop.payment_risk_threshold_high_cents, 20000) # $200 + self.assertEqual(shop.crypto_quote_expiry_seconds, 1800) + + # Check that comment settings didn't change + self.assertEqual(shop.comments_enabled, initial_comments_enabled) + self.assertEqual( + shop.comments_require_purchase, initial_comments_require_purchase + ) + self.assertEqual( + shop.comments_require_approval, initial_comments_require_approval + ) + + def test_shop_settings_form_isolation_comments_dont_affect_crypto(self): + """Test that saving comment settings doesn't change crypto settings.""" + # Create a shop using the helper which handles transactions properly + shop = self._create_shop_helper( + user_creds=self.user1_creds, shop_params=self.shop1_params + ) + + # First set crypto settings to known values via form + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "crypto-settings", + "payment_risk_threshold_mid_dollars": "15.00", + "payment_risk_threshold_high_dollars": "150.00", + "crypto_quote_expiry_seconds": "1200", + "submit": "Save Crypto Settings", + }, + status=302, + ) + + # Verify the values were set and store them + self.dbsession.expire(shop) + initial_crypto_mid = shop.payment_risk_threshold_mid_cents + initial_crypto_high = shop.payment_risk_threshold_high_cents + initial_crypto_expiry = shop.crypto_quote_expiry_seconds + + # Submit comment settings form to change them + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "comment-settings", + "comments-enabled-checkbox": "on", + "comments-require-purchase-checkbox": "on", + "comments-require-approval-checkbox": "on", + "submit": "Save Settings", + }, + status=302, + ) + + # Refresh shop from database + self.dbsession.expire(shop) + + # Check that comment settings changed + self.assertEqual(shop.comments_enabled, True) + self.assertEqual(shop.comments_require_purchase, True) + self.assertEqual(shop.comments_require_approval, True) + + # Check that crypto settings didn't change + self.assertEqual(shop.payment_risk_threshold_mid_cents, initial_crypto_mid) + self.assertEqual(shop.payment_risk_threshold_high_cents, initial_crypto_high) + self.assertEqual(shop.crypto_quote_expiry_seconds, initial_crypto_expiry) + + def test_shop_settings_form_isolation_shop_settings_dont_affect_others(self): + """Test that updating shop name/description doesn't affect other settings.""" + # Create a shop using the helper which handles transactions properly + shop = self._create_shop_helper( + user_creds=self.user1_creds, shop_params=self.shop1_params + ) + + # Set initial values via forms + # Set comments + self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "comment-settings", + "comments-enabled-checkbox": "on", + "submit": "Save Settings", + }, + status=302, + ) + + # Set crypto + self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "crypto-settings", + "payment_risk_threshold_mid_dollars": "25.00", + "payment_risk_threshold_high_dollars": "250.00", + "crypto_quote_expiry_seconds": "600", + "submit": "Save Crypto Settings", + }, + status=302, + ) + + # Store initial state + self.dbsession.expire(shop) + initial_comments_enabled = shop.comments_enabled + initial_crypto_mid = shop.payment_risk_threshold_mid_cents + + # Update shop settings + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "shop-settings", + "name": shop.name, # Keep same to avoid validation errors + "description": "Updated description!", + "phone_number": shop.phone_number, + "billing_address": shop.billing_address, + "submit": "Save Settings", + }, + status=302, + ) + + # Verify shop settings changed + self.dbsession.expire(shop) + self.assertEqual(shop.description, "Updated description!") + + # Verify other settings didn't change + self.assertEqual(shop.comments_enabled, initial_comments_enabled) + self.assertEqual(shop.payment_risk_threshold_mid_cents, initial_crypto_mid) + + def test_crypto_settings_only_flash_when_changed(self): + """Test that crypto settings only show flash messages when values actually change.""" + # Create a shop + shop = self._create_shop_helper( + user_creds=self.user1_creds, shop_params=self.shop1_params + ) + + # First, set all three crypto values to non-default values + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "crypto-settings", + "payment_risk_threshold_mid_dollars": "15.00", # Different from default $10 + "payment_risk_threshold_high_dollars": "150.00", # Different from default $100 + "crypto_quote_expiry_seconds": "600", + "submit": "Save Crypto Settings", + }, + status=302, + ) + + # Follow redirect to see flash messages + res = res.follow() + self.assertIn("Medium risk threshold set to $15.00", res.text) + self.assertIn("High risk threshold set to $150.00", res.text) + self.assertIn("Cryptocurrency quote expiry set to 600 seconds", res.text) + + # Now submit the same form with only the medium threshold changed + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "crypto-settings", + "payment_risk_threshold_mid_dollars": "20.00", # Changed + "payment_risk_threshold_high_dollars": "150.00", # Same as before + "crypto_quote_expiry_seconds": "600", # Same as before + "submit": "Save Crypto Settings", + }, + status=302, + ) + + # Follow redirect to see flash messages + res = res.follow() + + # Should only see message for the changed value + self.assertIn("Medium risk threshold set to $20.00", res.text) + + # Should NOT see messages for unchanged values + flash_messages = self._get_flash_messages(res) + self.assertNotIn("High risk threshold set to $150.00", flash_messages) + self.assertNotIn( + "Cryptocurrency quote expiry set to 600 seconds", flash_messages + ) + + def test_all_settings_forms_sequential_submission(self): + """Test submitting all settings forms in sequence, changing one field each.""" + # Create a shop + shop = self._create_shop_helper( + user_creds=self.user1_creds, shop_params=self.shop1_params + ) + + # Store initial values to verify they don't change unexpectedly + initial_values = { + "name": shop.name, + "phone_number": shop.phone_number, + "billing_address": shop.billing_address, + "description": shop.description, + "domain_name": shop.domain_name, + "google_analytics_id": shop.google_analytics_id, + "plausible_domain_name": shop.plausible_domain_name, + "ribbon_text": shop.ribbon_text, + "ribbon_text_color": shop.ribbon_text_color, + "ribbon_color_1": shop.ribbon_color_1, + "ribbon_color_2": shop.ribbon_color_2, + "stripe_public_api_key": shop.stripe_public_api_key, + "stripe_secret_api_key": shop.stripe_secret_api_key, + "payment_risk_threshold_mid_cents": shop.payment_risk_threshold_mid_cents, + "payment_risk_threshold_high_cents": shop.payment_risk_threshold_high_cents, + "crypto_quote_expiry_seconds": shop.crypto_quote_expiry_seconds, + "maint_mode": shop.maint_mode, + "comments_enabled": shop.comments_enabled, + "comments_require_purchase": shop.comments_require_purchase, + "comments_require_approval": shop.comments_require_approval, + } + + # 1. Submit shop-settings form (change description only) + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "shop-settings", + "name": initial_values["name"], + "phone_number": initial_values["phone_number"], + "billing_address": initial_values["billing_address"], + "description": "New description from test", # CHANGED + "submit": "Save Settings", + }, + status=302, + ) + res = res.follow() + # Should only see message for description + self.assertIn("You set the shop's description.", res.text) + # Should NOT see messages for unchanged fields + flash_messages = self._get_flash_messages(res) + self.assertNotIn("You set the shop's name.", flash_messages) + self.assertNotIn("You set the shop's phone number.", flash_messages) + self.assertNotIn("You set the shop's billing address.", flash_messages) + + # Verify only description changed + self.dbsession.expire(shop) + self.assertEqual(shop.description, "New description from test") + self.assertEqual(shop.name, initial_values["name"]) + self.assertEqual(shop.phone_number, initial_values["phone_number"]) + self.assertEqual(shop.comments_enabled, initial_values["comments_enabled"]) + self.assertEqual( + shop.payment_risk_threshold_mid_cents, + initial_values["payment_risk_threshold_mid_cents"], + ) + + # 2. Submit integration-settings form (change domain_name only) + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "integration-settings", + "domain_name": "testshop.example.com", # CHANGED + "google_analytics_id": initial_values["google_analytics_id"] or "", + "plausible_domain_name": initial_values["plausible_domain_name"] or "", + "submit": "Save Settings", + }, + status=302, + ) + res = res.follow() + # Should only see message for domain name + self.assertIn("You set the shop's domain name.", res.text) + # Should NOT see messages for unchanged fields - check specifically in flash messages + flash_messages = self._get_flash_messages(res) + self.assertNotIn("Google Analytics", flash_messages) + self.assertNotIn("Plausible Analytics Domain Name", flash_messages) + + # Verify only domain_name changed + self.dbsession.expire(shop) + self.assertEqual(shop.domain_name, "testshop.example.com") + self.assertEqual( + shop.description, "New description from test" + ) # Still changed from step 1 + self.assertEqual( + shop.google_analytics_id, initial_values["google_analytics_id"] + ) + self.assertEqual(shop.comments_enabled, initial_values["comments_enabled"]) + + # 3. Submit ribbon-settings form (change ribbon_text only) + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "ribbon-settings", + "ribbon_text": "Special announcement!", # CHANGED + "ribbon_text_color": initial_values["ribbon_text_color"] or "", + "ribbon_color_1": initial_values["ribbon_color_1"] or "", + "ribbon_color_2": initial_values["ribbon_color_2"] or "", + "submit": "Save Settings", + }, + status=302, + ) + res = res.follow() + # Should only see message for ribbon text + self.assertIn("You set the shop's announcement ribbon text.", res.text) + # Should NOT see messages for unchanged fields + flash_messages = self._get_flash_messages(res) + self.assertNotIn("ribbon text color", flash_messages) + self.assertNotIn("ribbon background color", flash_messages) + + # Verify only ribbon_text changed + self.dbsession.expire(shop) + self.assertEqual(shop.ribbon_text, "Special announcement!") + self.assertEqual(shop.ribbon_text_color, initial_values["ribbon_text_color"]) + self.assertEqual( + shop.domain_name, "testshop.example.com" + ) # Still changed from step 2 + self.assertEqual( + shop.stripe_public_api_key, initial_values["stripe_public_api_key"] + ) + + # 4. Submit crypto-settings form (change mid threshold only) + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "crypto-settings", + "payment_risk_threshold_mid_dollars": "25.00", # CHANGED from default $10 + "payment_risk_threshold_high_dollars": "100.00", # Keep default + "crypto_quote_expiry_seconds": str( + initial_values["crypto_quote_expiry_seconds"] + ), + "submit": "Save Crypto Settings", + }, + status=302, + ) + res = res.follow() + self.assertIn("Medium risk threshold set to $25.00", res.text) + # Should NOT see messages for unchanged values + flash_messages = self._get_flash_messages(res) + self.assertNotIn("High risk threshold", flash_messages) + self.assertNotIn("Cryptocurrency quote expiry", flash_messages) + + # Verify only mid threshold changed + self.dbsession.expire(shop) + self.assertEqual(shop.payment_risk_threshold_mid_cents, 2500) + self.assertEqual( + shop.payment_risk_threshold_high_cents, + initial_values["payment_risk_threshold_high_cents"], + ) + self.assertEqual( + shop.crypto_quote_expiry_seconds, + initial_values["crypto_quote_expiry_seconds"], + ) + self.assertEqual( + shop.ribbon_text, "Special announcement!" + ) # Still changed from step 3 + + # 5. Submit maintenance-settings form (turn on maintenance mode) + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "maintenance-settings", + "maint-mode-checkbox": "on", # CHANGED + "submit": "Save Settings", + }, + status=302, + ) + res = res.follow() + self.assertIn("You turned on maintenance mode", res.text) + + # Verify only maint_mode changed + self.dbsession.expire(shop) + self.assertEqual(shop.maint_mode, True) + self.assertEqual( + shop.payment_risk_threshold_mid_cents, 2500 + ) # Still changed from step 4 + self.assertEqual(shop.comments_enabled, initial_values["comments_enabled"]) + + # 6. Submit comment-settings form (enable comments require purchase) + res = self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "comment-settings", + # Keep comments_enabled the same + "comments-enabled-checkbox": "on", + "comments-require-purchase-checkbox": "on", # CHANGED + # Keep comments_require_approval the same (off) + "submit": "Save Settings", + }, + status=302, + ) + res = res.follow() + # Should only see message for the changed field + self.assertIn("Purchase requirement for comments enabled", res.text) + # Should NOT see messages for unchanged fields + flash_messages = self._get_flash_messages(res) + self.assertNotIn("Comments enabled", flash_messages) + self.assertNotIn("Comments disabled", flash_messages) + self.assertNotIn("Comment approval", flash_messages) + + # Verify only comments_require_purchase changed + self.dbsession.expire(shop) + self.assertEqual(shop.comments_require_purchase, True) + self.assertEqual(shop.comments_enabled, initial_values["comments_enabled"]) + self.assertEqual( + shop.comments_require_approval, initial_values["comments_require_approval"] + ) + self.assertEqual(shop.maint_mode, True) # Still changed from step 5 + + # Final verification: All intended changes persisted, nothing else changed + self.dbsession.expire(shop) + # Changed values + self.assertEqual(shop.description, "New description from test") + self.assertEqual(shop.domain_name, "testshop.example.com") + self.assertEqual(shop.ribbon_text, "Special announcement!") + self.assertEqual(shop.payment_risk_threshold_mid_cents, 2500) + self.assertEqual(shop.maint_mode, True) + self.assertEqual(shop.comments_require_purchase, True) + + # Unchanged values + self.assertEqual(shop.name, initial_values["name"]) + self.assertEqual(shop.phone_number, initial_values["phone_number"]) + self.assertEqual(shop.billing_address, initial_values["billing_address"]) + self.assertEqual( + shop.google_analytics_id, initial_values["google_analytics_id"] + ) + self.assertEqual( + shop.plausible_domain_name, initial_values["plausible_domain_name"] + ) + self.assertEqual(shop.ribbon_text_color, initial_values["ribbon_text_color"]) + self.assertEqual(shop.ribbon_color_1, initial_values["ribbon_color_1"]) + self.assertEqual(shop.ribbon_color_2, initial_values["ribbon_color_2"]) + self.assertEqual( + shop.stripe_public_api_key, initial_values["stripe_public_api_key"] + ) + self.assertEqual( + shop.stripe_secret_api_key, initial_values["stripe_secret_api_key"] + ) + self.assertEqual( + shop.payment_risk_threshold_high_cents, + initial_values["payment_risk_threshold_high_cents"], + ) + self.assertEqual( + shop.crypto_quote_expiry_seconds, + initial_values["crypto_quote_expiry_seconds"], + ) + self.assertEqual(shop.comments_enabled, initial_values["comments_enabled"]) + self.assertEqual( + shop.comments_require_approval, initial_values["comments_require_approval"] + ) diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index c16f0c3..4e641bd 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -26,19 +26,19 @@ from ..models.price import Price class DatabaseIntegrationTests(unittest.TestCase): """Base class for integration tests that need real database.""" - + def setUp(self): from make_post_sell import main - + self.settings = get_appsettings("test.ini") self.app = main({}, **self.settings) - + self.session_factory = self.app.registry["dbsession_factory"] self.engine = self.session_factory.kw["bind"] Base.metadata.create_all(bind=self.engine) - + self.dbsession = get_tm_session(self.session_factory, transaction.manager) - + def tearDown(self): transaction.abort() Base.metadata.drop_all(bind=self.engine) @@ -46,69 +46,65 @@ class DatabaseIntegrationTests(unittest.TestCase): class TestCartOrmIntegration(DatabaseIntegrationTests): """Integration tests for Cart model with real ORM objects.""" - + def test_cart_with_real_products_and_shops(self): """Test cart operations with real Product and Shop ORM objects.""" # Create real user user = get_or_create_user_by_email(self.dbsession, "test@example.com") self.dbsession.add(user) - + # Create real shop shop = Shop( name="Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(shop) - + # Create real products - product1 = Product( - title="Digital Product 1", - description="Test product" - ) + product1 = Product(title="Digital Product 1", description="Test product") product1.shop_id = shop.id product1.price_in_cents = 1000 # $10.00 product1.is_physical = False - + product2 = Product( - title="Digital Product 2", - description="Another test product" + title="Digital Product 2", description="Another test product" ) product2.shop_id = shop.id - product2.price_in_cents = 500 # $5.00 + product2.price_in_cents = 500 # $5.00 product2.is_physical = False self.dbsession.add(product1) self.dbsession.add(product2) self.dbsession.flush() # Get IDs - + # Create real cart cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) - + # Test adding products to cart cart.add_product(product1) cart.add_product(product1) # Add twice cart.add_product(product2) - + # Test cart calculations with real ORM relationships self.assertEqual(cart.count, 3) # 2 + 1 self.assertEqual(cart.get_product_quantity(product1), 2) self.assertEqual(cart.get_product_quantity(product2), 1) - + # Test total calculation expected_total = (1000 * 2) + (500 * 1) # $25.00 self.assertEqual(cart.total_price_in_cents, expected_total) - + # Test requires_payment logic self.assertTrue(cart.requires_payment) # Above 64 cent threshold - + transaction.commit() - + def test_cart_with_real_coupon_integration(self): """Test cart with real coupon objects and validation.""" # Create real user and shop @@ -117,25 +113,22 @@ class TestCartOrmIntegration(DatabaseIntegrationTests): name="Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create real product - product = Product( - title="Test Product", - description="Test product" - ) + product = Product(title="Test Product", description="Test product") product.shop_id = shop.id product.price_in_cents = 1000 # $10.00 product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create real coupon coupon = Coupon( shop=shop, @@ -145,37 +138,37 @@ class TestCartOrmIntegration(DatabaseIntegrationTests): action_value=5, # $5.00 off max_redemptions=10, max_redemptions_per_user=1, - cart_qualifier=5 # Minimum $5.00 cart + cart_qualifier=5, # Minimum $5.00 cart ) self.dbsession.add(coupon) self.dbsession.flush() - + # Create real cart cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) - + # Add product to cart cart.add_product(product) - + # Attach coupon to cart cart_coupon = CartCoupon(cart=cart, coupon=coupon) self.dbsession.add(cart_coupon) self.dbsession.flush() - + # Test coupon validation with real ORM relationships errors = cart.validate_attached_coupons() self.assertEqual(errors, []) # Should be valid - + # Test discounted total self.assertTrue(cart.is_discounted) self.assertEqual(cart.total_discounted_price_in_cents, 500) # $10 - $5 = $5 - + # Test that discounted cart still requires payment (above 64 cent threshold) self.assertTrue(cart.requires_payment) - + transaction.commit() - + def test_cart_free_with_coupon_integration(self): """Test cart that becomes free with coupon - the original bug scenario.""" # Create real user and shop @@ -184,25 +177,22 @@ class TestCartOrmIntegration(DatabaseIntegrationTests): name="Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create real cheap product - product = Product( - title="Cheap Product", - description="Test product" - ) + product = Product(title="Cheap Product", description="Test product") product.shop_id = shop.id product.price_in_cents = 50 # $0.50 product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create coupon that makes cart free coupon = Coupon( shop=shop, @@ -212,48 +202,50 @@ class TestCartOrmIntegration(DatabaseIntegrationTests): action_value=1, # $1.00 off (more than product cost) max_redemptions=100, max_redemptions_per_user=1, - cart_qualifier=0 # No minimum + cart_qualifier=0, # No minimum ) self.dbsession.add(coupon) self.dbsession.flush() - + # Create real cart cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) - + # Add product to cart cart.add_product(product) - + # Attach coupon to cart cart_coupon = CartCoupon(cart=cart, coupon=coupon) self.dbsession.add(cart_coupon) self.dbsession.flush() - + # Test the original bug scenario self.assertTrue(cart.is_discounted) self.assertEqual(cart.total_discounted_price_in_cents, 0) # Free! - + # This is the key test - free cart should not require payment self.assertFalse(cart.requires_payment) # Below 64 cent threshold - + # Test the checkout logic that was crashing stripe_user_shop = None # No payment method needed for free cart - + # These are the exact conditions from cart.py that we fixed requires_billing_redirect = cart.requires_payment and stripe_user_shop is None self.assertFalse(requires_billing_redirect) # Should NOT redirect - + # This was the line that crashed - now fixed - requires_active_card_redirect = stripe_user_shop and stripe_user_shop.active_card is None + requires_active_card_redirect = ( + stripe_user_shop and stripe_user_shop.active_card is None + ) self.assertFalse(requires_active_card_redirect) # Should NOT crash - + # This was the return value that crashed - now fixed active_card = stripe_user_shop.active_card if stripe_user_shop else None self.assertIsNone(active_card) # Should be None, not crash - + transaction.commit() - + def test_cart_physical_products_integration(self): """Test cart with physical products requiring shipping.""" # Create real user and shop @@ -262,58 +254,57 @@ class TestCartOrmIntegration(DatabaseIntegrationTests): name="Physical Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A shop with physical items" + description="A shop with physical items", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create physical product physical_product = Product( - title="Physical Item", - description="A physical product" + title="Physical Item", description="A physical product" ) physical_product.shop_id = shop.id physical_product.price_in_cents = 2000 # $20.00 physical_product.is_physical = True self.dbsession.add(physical_product) self.dbsession.flush() - + # Create real cart cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) - + # Add physical product to cart cart.add_product(physical_product) - + # Test physical product detection self.assertTrue(len(cart.physical_products) > 0) self.assertIn(str(physical_product.id), cart.physical_products) - + # Test handling options cart.handling_option = "shipping" cart.handling_cost_in_cents = 500 # $5.00 shipping - + # Test total with handling expected_total = 2000 + 500 # Product + shipping self.assertEqual(cart.total_in_cents, expected_total) - + # Test remove handling when no physical products cart.remove_product(physical_product) cart.remove_handling_if_no_physical_products() - + self.assertIsNone(cart.handling_option) self.assertEqual(cart.handling_cost_in_cents, 0) - + transaction.commit() class TestStripeUserShopIntegration(DatabaseIntegrationTests): """Integration tests for StripeUserShop boundaries.""" - + def test_stripe_user_shop_creation_integration(self): """Test creating StripeUserShop with real User and Shop objects.""" # Create real user and shop @@ -322,7 +313,7 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Stripe Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A shop with Stripe" + description="A shop with Stripe", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" @@ -330,24 +321,24 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): self.dbsession.add(user) self.dbsession.add(shop) self.dbsession.flush() - + # Create StripeUserShop stripe_user_shop = StripeUserShop(user=user, shop=shop) stripe_user_shop.cus_id = "cus_test123" stripe_user_shop.active_card_id = "card_test123" self.dbsession.add(stripe_user_shop) - + # Test ORM relationships self.assertEqual(stripe_user_shop.user, user) self.assertEqual(stripe_user_shop.shop, shop) self.assertEqual(stripe_user_shop.cus_id, "cus_test123") self.assertEqual(stripe_user_shop.active_card_id, "card_test123") - + # Test the scenario where active_card exists self.assertIsNotNone(stripe_user_shop.active_card_id) - + transaction.commit() - + def test_cart_checkout_with_stripe_user_shop_integration(self): """Test the full checkout flow with real StripeUserShop object.""" # Create real user and shop @@ -356,61 +347,64 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Payment Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A shop requiring payment" + description="A shop requiring payment", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create product requiring payment product = Product( - title="Paid Product", - description="A product requiring payment" + title="Paid Product", description="A product requiring payment" ) product.shop_id = shop.id product.price_in_cents = 1500 # $15.00 product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create cart cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) cart.add_product(product) - + # Test scenario 1: No StripeUserShop (should require billing) stripe_user_shop = None requires_billing = cart.requires_payment and stripe_user_shop is None self.assertTrue(requires_billing) - + # Test scenario 2: StripeUserShop exists but no active card stripe_user_shop = StripeUserShop(user=user, shop=shop) stripe_user_shop.cus_id = "cus_test123" stripe_user_shop.active_card_id = None # No active card self.dbsession.add(stripe_user_shop) - + # Test the logic directly without accessing the property that calls Stripe - requires_active_card = stripe_user_shop and stripe_user_shop.active_card_id is None + requires_active_card = ( + stripe_user_shop and stripe_user_shop.active_card_id is None + ) self.assertTrue(requires_active_card) - + # Test scenario 3: StripeUserShop with active card (should allow checkout) stripe_user_shop.active_card_id = "card_test123" - + requires_billing = cart.requires_payment and stripe_user_shop is None - requires_active_card = stripe_user_shop and stripe_user_shop.active_card_id is None - + requires_active_card = ( + stripe_user_shop and stripe_user_shop.active_card_id is None + ) + self.assertFalse(requires_billing) self.assertFalse(requires_active_card) - + # Test the return value for template (using active_card_id since active_card would call Stripe) active_card_id = stripe_user_shop.active_card_id if stripe_user_shop else None self.assertEqual(active_card_id, "card_test123") - + transaction.commit() - + def test_cart_detailed_properties_integration(self): """Test cart properties that require real database access for 100% coverage.""" # Create real user and shop @@ -419,65 +413,65 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Detail Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create multiple products for different shops product1 = Product(title="Product 1", description="Test product 1") product1.shop_id = shop.id product1.price_in_cents = 1000 # $10.00 product1.is_physical = False - + product2 = Product(title="Product 2", description="Test product 2") product2.shop_id = shop.id product2.price_in_cents = 1500 # $15.00 product2.is_physical = False - + self.dbsession.add(product1) self.dbsession.add(product2) self.dbsession.flush() - + # Create real cart cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) self.dbsession.flush() - + # Add products to cart cart.add_product(product1) cart.add_product(product1) # Add twice for quantity 2 cart.add_product(product2) - + # Test detailed properties that require database access # These should exercise the missing lines in the coverage report - + # Test shops property (aggregates unique shops from products) shops = cart.shops self.assertIn(str(shop.id), shops) self.assertEqual(shops[str(shop.id)], shop) - + # Test shop_product_dict property shop_product_dict = cart.shop_product_dict self.assertIn(str(shop.id), shop_product_dict) shop_products = shop_product_dict[str(shop.id)] self.assertEqual(len(shop_products), 2) # Two different products - + # Test shop_totals_in_cents property shop_totals_cents = cart.shop_totals_in_cents self.assertIn(str(shop.id), shop_totals_cents) # Just verify the calculation is working, don't hardcode expected values self.assertGreater(shop_totals_cents[str(shop.id)], 0) - + # Test shop_totals property (dollars) shop_totals = cart.shop_totals # Verify conversion from cents to dollars is working self.assertGreater(shop_totals[str(shop.id)], 0) - + # Test line_totals property line_totals = cart.line_totals # Verify line totals exist for both products @@ -485,15 +479,15 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): self.assertIn(str(product2.id), line_totals) self.assertGreater(line_totals[str(product1.id)], 0) self.assertGreater(line_totals[str(product2.id)], 0) - + # Test human timestamp properties created_timestamp = cart.human_created_timestamp updated_timestamp = cart.human_updated_timestamp self.assertIsInstance(created_timestamp, str) self.assertIsInstance(updated_timestamp, str) - + transaction.commit() - + def test_cart_coupon_validation_complete_integration(self): """Test all coupon validation scenarios with real objects for 100% coverage.""" # Create real user and shop @@ -502,12 +496,12 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Coupon Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) self.dbsession.add(user) self.dbsession.add(shop) self.dbsession.flush() - + # Create a product product = Product(title="Test Product", description="Test product") product.shop_id = shop.id @@ -515,73 +509,78 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Test 1: Multiple coupons (coupon stacking) cart1 = Cart(user=user) cart1.shop = shop self.dbsession.add(cart1) cart1.add_product(product) - + # Create two coupons coupon1 = Coupon( shop=shop, code="COUPON1", description="First coupon", action_type="dollar-off", - action_value=5 # $5.00 off + action_value=5, # $5.00 off ) coupon2 = Coupon( shop=shop, - code="COUPON2", + code="COUPON2", description="Second coupon", action_type="dollar-off", - action_value=3 # $3.00 off + action_value=3, # $3.00 off ) self.dbsession.add(coupon1) self.dbsession.add(coupon2) self.dbsession.flush() - + # Add both coupons to cart to trigger stacking error from make_post_sell.models.cart_coupon import CartCoupon + cart_coupon1 = CartCoupon(cart=cart1, coupon=coupon1) cart_coupon2 = CartCoupon(cart=cart1, coupon=coupon2) self.dbsession.add(cart_coupon1) self.dbsession.add(cart_coupon2) self.dbsession.flush() - + # Test coupon stacking validation errors = cart1.validate_attached_coupons() - self.assertIn("We don't support coupon stacking. Please choose one coupon.", errors) - + self.assertIn( + "We don't support coupon stacking. Please choose one coupon.", errors + ) + # Test 2: Invalid coupon (expired/disabled) cart2 = Cart(user=user) cart2.shop = shop self.dbsession.add(cart2) cart2.add_product(product) - + expired_coupon = Coupon( shop=shop, code="EXPIRED", description="Expired coupon", action_type="dollar-off", action_value=5, - expiration_date="2020-01-01" # Past date + expiration_date="2020-01-01", # Past date ) self.dbsession.add(expired_coupon) self.dbsession.flush() - + cart_coupon3 = CartCoupon(cart=cart2, coupon=expired_coupon) self.dbsession.add(cart_coupon3) self.dbsession.flush() - + errors = cart2.validate_attached_coupons() - self.assertIn("Sorry, the coupon 'EXPIRED' is not valid (expired or disabled).", errors) - + self.assertIn( + "Sorry, the coupon 'EXPIRED' is not valid (expired or disabled).", errors + ) + # Test 3: Cart total doesn't meet qualifier cart3 = Cart(user=user) cart3.shop = shop self.dbsession.add(cart3) - + # Create cheap product cheap_product = Product(title="Cheap Product", description="Cheap") cheap_product.shop_id = shop.id @@ -589,9 +588,9 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): cheap_product.is_physical = False self.dbsession.add(cheap_product) self.dbsession.flush() - + cart3.add_product(cheap_product) - + # Coupon requires minimum $10 but cart only has $3 min_coupon = Coupon( shop=shop, @@ -599,20 +598,22 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): description="Minimum $10 coupon", action_type="dollar-off", action_value=2, - cart_qualifier=10 # Requires $10.00 minimum + cart_qualifier=10, # Requires $10.00 minimum ) self.dbsession.add(min_coupon) self.dbsession.flush() - + cart_coupon4 = CartCoupon(cart=cart3, coupon=min_coupon) self.dbsession.add(cart_coupon4) self.dbsession.flush() - + errors = cart3.validate_attached_coupons() - self.assertIn("Please review the terms for coupon 'MIN10': shop total not met.", errors) - + self.assertIn( + "Please review the terms for coupon 'MIN10': shop total not met.", errors + ) + transaction.commit() - + def test_cart_merge_with_coupon_integration(self): """Test cart merging with coupons to cover coupon append logic.""" # Create real user and shop @@ -621,27 +622,27 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Merge Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) self.dbsession.add(user) self.dbsession.add(shop) self.dbsession.flush() - + # Create products product1 = Product(title="Product 1", description="Test product 1") product1.shop_id = shop.id product1.price_in_cents = 1000 product1.is_physical = False - - product2 = Product(title="Product 2", description="Test product 2") + + product2 = Product(title="Product 2", description="Test product 2") product2.shop_id = shop.id product2.price_in_cents = 1500 product2.is_physical = False - + self.dbsession.add(product1) self.dbsession.add(product2) self.dbsession.flush() - + # Create carts cart1 = Cart(user=user) cart1.shop = shop @@ -650,79 +651,79 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): self.dbsession.add(cart1) self.dbsession.add(cart2) self.dbsession.flush() - + # Add products to carts cart1.add_product(product1) cart2.add_product(product2) - + # Create coupons coupon1 = Coupon( shop=shop, code="CART1COUPON", description="Cart 1 coupon", action_type="dollar-off", - action_value=2 + action_value=2, ) coupon2 = Coupon( shop=shop, code="CART2COUPON", - description="Cart 2 coupon", + description="Cart 2 coupon", action_type="dollar-off", - action_value=3 + action_value=3, ) coupon_shared = Coupon( shop=shop, code="SHARED", description="Shared coupon", action_type="dollar-off", - action_value=1 + action_value=1, ) self.dbsession.add(coupon1) self.dbsession.add(coupon2) self.dbsession.add(coupon_shared) self.dbsession.flush() - + # Add coupons to carts from make_post_sell.models.cart_coupon import CartCoupon - + # Cart1 has coupon1 and shared coupon cart_coupon1 = CartCoupon(cart=cart1, coupon=coupon1) cart_coupon_shared1 = CartCoupon(cart=cart1, coupon=coupon_shared) self.dbsession.add(cart_coupon1) self.dbsession.add(cart_coupon_shared1) - + # Cart2 has coupon2 and the same shared coupon cart_coupon2 = CartCoupon(cart=cart2, coupon=coupon2) cart_coupon_shared2 = CartCoupon(cart=cart2, coupon=coupon_shared) self.dbsession.add(cart_coupon2) self.dbsession.add(cart_coupon_shared2) self.dbsession.flush() - + # Count original coupons original_cart1_coupons = len(cart1.coupons) original_cart2_coupons = len(cart2.coupons) - + # Merge cart2 into cart1 - this should trigger the coupon merge logic cart1.merge_in_cart(cart2) - + # Verify products merged cart_data = cart1.get_cart() self.assertIn(str(product1.id), cart_data) self.assertIn(str(product2.id), cart_data) - + # Verify coupons merged (should not duplicate shared coupon) # This exercises lines 152-153 in merge_in_cart merged_coupon_codes = [c.code for c in cart1.coupons] self.assertIn("CART1COUPON", merged_coupon_codes) self.assertIn("CART2COUPON", merged_coupon_codes) self.assertIn("SHARED", merged_coupon_codes) - + # Should not have duplicated the shared coupon shared_count = merged_coupon_codes.count("SHARED") self.assertEqual(shared_count, 1) - + transaction.commit() - + def test_cart_final_coverage_lines_integration(self): """Test remaining lines to achieve 100% coverage.""" # Create real user and shop @@ -731,13 +732,14 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Inventory Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) self.dbsession.add(user) self.dbsession.add(shop) - + # Create shop location for inventory management from make_post_sell.models.shop_location import ShopLocation + shop_location = ShopLocation( shop=shop, name="Main Location", @@ -745,95 +747,98 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): city="Test City", state="Test State", country="USA", - postal_code="12345" + postal_code="12345", ) self.dbsession.add(shop_location) self.dbsession.flush() - + # Create physical products for inventory testing - physical_product = Product(title="Physical Product", description="Physical item") + physical_product = Product( + title="Physical Product", description="Physical item" + ) physical_product.shop_id = shop.id physical_product.price_in_cents = 1000 # $10.00 physical_product.is_physical = True - + self.dbsession.add(physical_product) self.dbsession.flush() - + # Create inventory for the physical product from make_post_sell.models.inventory import Inventory + inventory = Inventory( product=physical_product, shop_location=shop_location, - quantity=5 # 5 items in stock + quantity=5, # 5 items in stock ) self.dbsession.add(inventory) self.dbsession.flush() - + # Create cart with physical products cart = Cart(user=user) cart.shop = shop self.dbsession.add(cart) self.dbsession.flush() - + # Add 3 physical products to cart cart.add_product(physical_product) cart.add_product(physical_product) cart.add_product(physical_product) - + # Test inventory check - should pass (3 needed, 5 available) errors = cart.check_inventory(shop_location) self.assertEqual(len(errors), 0) # No errors - + # Add more products to exceed inventory cart.add_product(physical_product) # 4 total cart.add_product(physical_product) # 5 total cart.add_product(physical_product) # 6 total - exceeds inventory - + # Test inventory check - should fail (6 needed, 5 available) errors = cart.check_inventory(shop_location) self.assertEqual(len(errors), 1) self.assertIn("Not enough stock for Physical Product", errors[0]) self.assertIn("Needed: 6, Available: 5", errors[0]) - + # Test inventory update (reduce quantity back to 3 for successful update) cart.set_product_quantity(physical_product, 3) - + # Update inventory - should reduce quantity from 5 to 2 cart.update_inventory(shop_location) - + # Verify inventory was reduced self.dbsession.refresh(inventory) self.assertEqual(inventory.quantity, 2) # 5 - 3 = 2 - + # Test physical product identification physical_products = cart.physical_products self.assertIn(str(physical_product.id), physical_products) self.assertEqual(physical_products[str(physical_product.id)], physical_product) - + # Test handling cost updates for all methods cart.handling_option = "local_pickup" cart.update_handling_cost(shop_location) self.assertEqual(cart.handling_cost_in_cents, 0) # Pickup is free - + # Set rates on shop location for testing shop_location.local_delivery_rate_in_cents = 500 shop_location.local_shipping_rate_in_cents = 750 shop_location.international_shipping_rate_in_cents = 1500 self.dbsession.add(shop_location) self.dbsession.flush() - + cart.handling_option = "local_delivery" cart.update_handling_cost(shop_location) self.assertEqual(cart.handling_cost_in_cents, 500) - + cart.handling_option = "local_shipping" cart.update_handling_cost(shop_location) self.assertEqual(cart.handling_cost_in_cents, 750) - + cart.handling_option = "international_shipping" cart.update_handling_cost(shop_location) self.assertEqual(cart.handling_cost_in_cents, 1500) - + # Test remove handling if no physical products # First add a digital product and remove the physical one digital_product = Product(title="Digital Product", description="Digital item") @@ -842,47 +847,50 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): digital_product.is_physical = False self.dbsession.add(digital_product) self.dbsession.flush() - + cart.add_product(digital_product) cart.remove_product(physical_product) # Remove physical product - + # Now cart has only digital products cart.remove_handling_if_no_physical_products() self.assertIsNone(cart.handling_option) self.assertEqual(cart.handling_cost_in_cents, 0) - + # Test line 345-347: Cart.total property when discounted coupon = Coupon( shop=shop, code="DISCOUNT", description="Test discount", action_type="dollar-off", - action_value=5 + action_value=5, ) self.dbsession.add(coupon) self.dbsession.flush() - + cart_with_discount = Cart(user=user) cart_with_discount.shop = shop self.dbsession.add(cart_with_discount) cart_with_discount.add_product(physical_product) - + from make_post_sell.models.cart_coupon import CartCoupon + cart_coupon = CartCoupon(cart=cart_with_discount, coupon=coupon) self.dbsession.add(cart_coupon) self.dbsession.flush() - + # This should exercise line 345-347: if self.is_discounted return discounted_price discounted_total = cart_with_discount.total self.assertTrue(cart_with_discount.is_discounted) self.assertEqual(discounted_total, cart_with_discount.total_discounted_price) - + # Test line 374: Cart.is_empty property empty_cart = Cart(user=user) empty_cart.shop = shop self.dbsession.add(empty_cart) - self.assertTrue(empty_cart.is_empty) # This tests line 374: return self.count <= 0 - + self.assertTrue( + empty_cart.is_empty + ) # This tests line 374: return self.count <= 0 + # Test lines 408 and 416: Coupon validation max redemptions # Create coupon with max redemptions max_coupon = Coupon( @@ -892,65 +900,69 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): action_type="dollar-off", action_value=1, max_redemptions=1, - max_redemptions_per_user=1 + max_redemptions_per_user=1, ) self.dbsession.add(max_coupon) self.dbsession.flush() - + # Create a redemption to test max_redemptions logic from make_post_sell.models.coupon_redemption import CouponRedemption from make_post_sell.models.invoice import Invoice - + # Create invoice for redemption invoice = Invoice(user) invoice.shop = shop self.dbsession.add(invoice) self.dbsession.flush() - - redemption = CouponRedemption(coupon=max_coupon, invoice=invoice, shop=shop, user=user) + + redemption = CouponRedemption( + coupon=max_coupon, invoice=invoice, shop=shop, user=user + ) self.dbsession.add(redemption) self.dbsession.flush() - + cart_max_test = Cart(user=user) cart_max_test.shop = shop self.dbsession.add(cart_max_test) cart_max_test.add_product(physical_product) - + cart_coupon_max = CartCoupon(cart=cart_max_test, coupon=max_coupon) self.dbsession.add(cart_coupon_max) self.dbsession.flush() - + # This should exercise lines 408 and 416 errors = cart_max_test.validate_attached_coupons() self.assertTrue(len(errors) >= 1) # Should have errors for max redemptions - + # Test lines 426-436: Inventory check with no inventory record # Create a product without inventory - product_no_inventory = Product(title="No Inventory Product", description="No inventory") + product_no_inventory = Product( + title="No Inventory Product", description="No inventory" + ) product_no_inventory.shop_id = shop.id product_no_inventory.price_in_cents = 500 product_no_inventory.is_physical = True self.dbsession.add(product_no_inventory) self.dbsession.flush() - + cart_no_inventory = Cart(user=user) cart_no_inventory.shop = shop self.dbsession.add(cart_no_inventory) cart_no_inventory.add_product(product_no_inventory) - + # This should exercise lines 432-435: if inventory is None or insufficient inventory_errors = cart_no_inventory.check_inventory(shop_location) self.assertEqual(len(inventory_errors), 1) self.assertIn("Not enough stock", inventory_errors[0]) self.assertIn("Available: 0", inventory_errors[0]) - + # Test lines 466-478: update_inventory with no inventory records # This should exercise the case where inventory is None (lines 472-475 won't run) cart_no_inventory.update_inventory(shop_location) # No inventory record means no changes made - + transaction.commit() - + def test_cart_functions_and_remaining_lines(self): """Test the module-level functions to complete 100% coverage.""" # Create test data @@ -959,11 +971,11 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): name="Functions Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop" + description="A test shop", ) self.dbsession.add(user) self.dbsession.add(shop) - + cart1 = Cart(user=user) cart1.shop = shop cart2 = Cart(user=user) @@ -971,27 +983,29 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests): self.dbsession.add(cart1) self.dbsession.add(cart2) self.dbsession.flush() - + # Test line 486: get_all_carts function from make_post_sell.models.cart import get_all_carts + all_carts = get_all_carts(self.dbsession) cart_ids = [str(c.id) for c in all_carts] self.assertIn(str(cart1.id), cart_ids) self.assertIn(str(cart2.id), cart_ids) - + # Test line 491: get_cart_by_id function from make_post_sell.models.cart import get_cart_by_id + retrieved_cart = get_cart_by_id(self.dbsession, cart1.id) self.assertEqual(retrieved_cart.id, cart1.id) - + # Function tests completed - + transaction.commit() class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): """Integration tests for Invoice discount calculations with real coupons.""" - + def test_invoice_discount_calculation_with_coupon_integration(self): """Test invoice discount calculations with real coupon redemptions.""" # Create real user and shop @@ -1000,40 +1014,38 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): name="Invoice Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="A test shop for invoice calculations" + description="A test shop for invoice calculations", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create real products product1 = Product( - title="Invoice Product 1", - description="Test product for invoice" + title="Invoice Product 1", description="Test product for invoice" ) product1.shop = shop product1.is_physical = False - + product2 = Product( - title="Invoice Product 2", - description="Another test product for invoice" + title="Invoice Product 2", description="Another test product for invoice" ) product2.shop = shop product2.is_physical = False - + self.dbsession.add(product1) self.dbsession.add(product2) self.dbsession.flush() - + # Create prices for products price1 = Price(product1, 500) # $5.00 price2 = Price(product2, 300) # $3.00 self.dbsession.add(price1) self.dbsession.add(price2) self.dbsession.flush() - + # Create real invoice invoice = Invoice(user) invoice.shop = shop @@ -1041,32 +1053,28 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): invoice.handling_cost_in_cents = 300 # $3.00 handling self.dbsession.add(invoice) self.dbsession.flush() - + # Add line items to invoice line_item1 = InvoiceLineItem( - invoice=invoice, - product=product1, - quantity=2 # 2x $20 = $40 + invoice=invoice, product=product1, quantity=2 # 2x $20 = $40 ) line_item2 = InvoiceLineItem( - invoice=invoice, - product=product2, - quantity=1 # 1x $15 = $15 + invoice=invoice, product=product2, quantity=1 # 1x $15 = $15 ) self.dbsession.add(line_item1) self.dbsession.add(line_item2) - + # Test subtotal calculation without discounts expected_subtotal = (500 * 2) + (300 * 1) # $10 + $3 = $13 self.assertEqual(invoice.subtotal_in_cents, expected_subtotal) - + # Test discount calculation without coupons self.assertEqual(invoice.discount_amount_in_cents, 0) - + # Test total calculation without discounts expected_total = expected_subtotal + 300 # $13 + $3 handling = $16 self.assertEqual(invoice.total_in_cents, expected_total) - + # Create real coupon for discount testing coupon = Coupon( shop=shop, @@ -1076,33 +1084,30 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): action_value=5, # $5.00 off max_redemptions=100, max_redemptions_per_user=1, - cart_qualifier=10 # Minimum $10.00 (our invoice is $13) + cart_qualifier=10, # Minimum $10.00 (our invoice is $13) ) self.dbsession.add(coupon) self.dbsession.flush() - + # Add coupon redemption to invoice coupon_redemption = CouponRedemption( - coupon=coupon, - invoice=invoice, - shop=shop, - user=user + coupon=coupon, invoice=invoice, shop=shop, user=user ) self.dbsession.add(coupon_redemption) self.dbsession.flush() - + # Test discount calculation with coupon self.assertEqual(invoice.discount_amount_in_cents, 500) # $5.00 discount - + # Test total calculation with discount expected_discounted_total = expected_subtotal - 500 + 300 # $13 - $5 + $3 = $11 self.assertEqual(invoice.total_in_cents, expected_discounted_total) - + # Test requires_payment logic with discounted amount self.assertTrue(invoice.requires_payment) # $11 > $0.64 threshold - + transaction.commit() - + def test_invoice_free_with_coupon_no_payment_required(self): """Test invoice that becomes free with coupon - no payment required scenario.""" # Create real user and shop @@ -1111,29 +1116,26 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): name="Free Invoice Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="Shop for free invoice testing" + description="Shop for free invoice testing", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create a small product - product = Product( - title="Small Product", - description="A small, cheap product" - ) + product = Product(title="Small Product", description="A small, cheap product") product.shop = shop product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create price for product price = Price(product, 350) # $3.50 self.dbsession.add(price) self.dbsession.flush() - + # Create invoice invoice = Invoice(user) invoice.shop = shop @@ -1141,15 +1143,11 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): invoice.handling_cost_in_cents = 0 # No handling self.dbsession.add(invoice) self.dbsession.flush() - + # Add line item - line_item = InvoiceLineItem( - invoice=invoice, - product=product, - quantity=1 - ) + line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1) self.dbsession.add(line_item) - + # Create coupon that makes invoice free free_coupon = Coupon( shop=shop, @@ -1159,31 +1157,30 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): action_value=4, # $4.00 off (more than $3.50 product) max_redemptions=100, max_redemptions_per_user=1, - cart_qualifier=3 # Minimum $3.00 + cart_qualifier=3, # Minimum $3.00 ) self.dbsession.add(free_coupon) self.dbsession.flush() - + # Add coupon redemption coupon_redemption = CouponRedemption( - coupon=free_coupon, - invoice=invoice, - shop=shop, - user=user + coupon=free_coupon, invoice=invoice, shop=shop, user=user ) self.dbsession.add(coupon_redemption) self.dbsession.flush() - + # Test calculations self.assertEqual(invoice.subtotal_in_cents, 350) # $3.50 - self.assertEqual(invoice.discount_amount_in_cents, 350) # $3.50 discount (limited by subtotal) + self.assertEqual( + invoice.discount_amount_in_cents, 350 + ) # $3.50 discount (limited by subtotal) self.assertEqual(invoice.total_in_cents, 0) # Free! max(0, 350 - 350 + 0) - + # Test no payment required for free invoice self.assertFalse(invoice.requires_payment) # $0 <= $0.64 threshold - + transaction.commit() - + def test_invoice_multiple_coupons_stacking_integration(self): """Test invoice with multiple coupon redemptions (if allowed).""" # Create real user and shop @@ -1192,29 +1189,29 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): name="Multi Coupon Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="Shop for multiple coupon testing" + description="Shop for multiple coupon testing", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create product product = Product( title="Multi Coupon Product", - description="Product for testing multiple coupons" + description="Product for testing multiple coupons", ) product.shop = shop product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create price for product price = Price(product, 1500) # $15.00 self.dbsession.add(price) self.dbsession.flush() - + # Create invoice invoice = Invoice(user) invoice.shop = shop @@ -1222,15 +1219,11 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): invoice.handling_cost_in_cents = 500 # $5.00 handling self.dbsession.add(invoice) self.dbsession.flush() - + # Add line item - line_item = InvoiceLineItem( - invoice=invoice, - product=product, - quantity=1 - ) + line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1) self.dbsession.add(line_item) - + # Create multiple coupons coupon1 = Coupon( shop=shop, @@ -1240,7 +1233,7 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): action_value=5, # $5.00 off max_redemptions=100, max_redemptions_per_user=1, - cart_qualifier=0 + cart_qualifier=0, ) coupon2 = Coupon( shop=shop, @@ -1250,41 +1243,37 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): action_value=3, # $3.00 off max_redemptions=100, max_redemptions_per_user=1, - cart_qualifier=0 + cart_qualifier=0, ) self.dbsession.add(coupon1) self.dbsession.add(coupon2) self.dbsession.flush() - + # Add both coupon redemptions redemption1 = CouponRedemption( - coupon=coupon1, - invoice=invoice, - shop=shop, - user=user + coupon=coupon1, invoice=invoice, shop=shop, user=user ) redemption2 = CouponRedemption( - coupon=coupon2, - invoice=invoice, - shop=shop, - user=user + coupon=coupon2, invoice=invoice, shop=shop, user=user ) self.dbsession.add(redemption1) self.dbsession.add(redemption2) self.dbsession.flush() - + # Test stacked discount calculation # First coupon: $15 - $5 = $10 # Second coupon: $10 - $3 = $7 self.assertEqual(invoice.subtotal_in_cents, 1500) # $15.00 - self.assertEqual(invoice.discount_amount_in_cents, 800) # $5 + $3 = $8.00 total discount - + self.assertEqual( + invoice.discount_amount_in_cents, 800 + ) # $5 + $3 = $8.00 total discount + # Test final total: $15 - $8 + $5 handling = $12 expected_total = 1500 - 800 + 500 self.assertEqual(invoice.total_in_cents, expected_total) - + transaction.commit() - + def test_invoice_invalid_coupon_no_discount_integration(self): """Test invoice with invalid coupon redemption - should not apply discount.""" # Create real user and shop @@ -1293,44 +1282,40 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): name="Invalid Coupon Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="Shop for invalid coupon testing" + description="Shop for invalid coupon testing", ) shop.stripe_public_api_key = "pk_test_123" shop.stripe_secret_api_key = "sk_test_123" shop.domain_name = "test.com" self.dbsession.add(user) self.dbsession.add(shop) - + # Create product product = Product( title="Invalid Coupon Product", - description="Product for testing invalid coupons" + description="Product for testing invalid coupons", ) product.shop = shop product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create price for product price = Price(product, 800) # $8.00 self.dbsession.add(price) self.dbsession.flush() - + # Create invoice invoice = Invoice(user) invoice.shop = shop invoice.shop_id = shop.id self.dbsession.add(invoice) self.dbsession.flush() - + # Add line item - line_item = InvoiceLineItem( - invoice=invoice, - product=product, - quantity=1 - ) + line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1) self.dbsession.add(line_item) - + # Create expired coupon expired_coupon = Coupon( shop=shop, @@ -1341,28 +1326,27 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): max_redemptions=100, max_redemptions_per_user=1, cart_qualifier=0, - expiration_date="2020-01-01" # Expired + expiration_date="2020-01-01", # Expired ) self.dbsession.add(expired_coupon) self.dbsession.flush() - + # Add coupon redemption for expired coupon redemption = CouponRedemption( - coupon=expired_coupon, - invoice=invoice, - shop=shop, - user=user + coupon=expired_coupon, invoice=invoice, shop=shop, user=user ) self.dbsession.add(redemption) self.dbsession.flush() - + # Test that invalid coupon doesn't apply discount self.assertFalse(expired_coupon.is_valid) # Coupon should be invalid - self.assertEqual(invoice.discount_amount_in_cents, 0) # No discount from invalid coupon + self.assertEqual( + invoice.discount_amount_in_cents, 0 + ) # No discount from invalid coupon self.assertEqual(invoice.total_in_cents, 800) # Full price, no discount - + transaction.commit() - + def test_invoice_handling_cost_edge_cases_integration(self): """Test invoice total calculation with various handling cost scenarios.""" # Create real user and shop @@ -1371,77 +1355,64 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): name="Handling Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="Shop for handling cost testing" + description="Shop for handling cost testing", ) self.dbsession.add(user) self.dbsession.add(shop) - + # Create product product = Product( - title="Handling Product", - description="Product for handling testing" + title="Handling Product", description="Product for handling testing" ) product.shop = shop product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create price for product price = Price(product, 500) # $5.00 self.dbsession.add(price) self.dbsession.flush() - + # Test Case 1: No handling cost (None) invoice1 = Invoice(user) invoice1.shop = shop invoice1.handling_cost_in_cents = None self.dbsession.add(invoice1) self.dbsession.flush() - - line_item1 = InvoiceLineItem( - invoice=invoice1, - product=product, - quantity=1 - ) + + line_item1 = InvoiceLineItem(invoice=invoice1, product=product, quantity=1) self.dbsession.add(line_item1) - + # Should treat None handling as 0 self.assertEqual(invoice1.total_in_cents, 500) # $5 + $0 handling - + # Test Case 2: Zero handling cost invoice2 = Invoice(user) invoice2.shop = shop invoice2.handling_cost_in_cents = 0 self.dbsession.add(invoice2) self.dbsession.flush() - - line_item2 = InvoiceLineItem( - invoice=invoice2, - product=product, - quantity=1 - ) + + line_item2 = InvoiceLineItem(invoice=invoice2, product=product, quantity=1) self.dbsession.add(line_item2) - + self.assertEqual(invoice2.total_in_cents, 500) # $5 + $0 handling - + # Test Case 3: High handling cost invoice3 = Invoice(user) invoice3.shop = shop invoice3.handling_cost_in_cents = 800 # $8.00 handling self.dbsession.add(invoice3) self.dbsession.flush() - - line_item3 = InvoiceLineItem( - invoice=invoice3, - product=product, - quantity=1 - ) + + line_item3 = InvoiceLineItem(invoice=invoice3, product=product, quantity=1) self.dbsession.add(line_item3) - + self.assertEqual(invoice3.total_in_cents, 1300) # $5 + $8 handling - + transaction.commit() - + def test_invoice_negative_total_protection_integration(self): """Test that invoice total never goes negative with large discounts.""" # Create real user and shop @@ -1450,41 +1421,34 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): name="Negative Test Shop", phone_number="555-555-5555", billing_address="123 Test St", - description="Shop for negative total testing" + description="Shop for negative total testing", ) self.dbsession.add(user) self.dbsession.add(shop) - + # Create small product - product = Product( - title="Small Product", - description="Very small product" - ) + product = Product(title="Small Product", description="Very small product") product.shop = shop product.is_physical = False self.dbsession.add(product) self.dbsession.flush() - + # Create price for product price = Price(product, 300) # $3.00 self.dbsession.add(price) self.dbsession.flush() - + # Create invoice invoice = Invoice(user) invoice.shop = shop invoice.handling_cost_in_cents = 100 # $1.00 handling self.dbsession.add(invoice) self.dbsession.flush() - + # Add line item - line_item = InvoiceLineItem( - invoice=invoice, - product=product, - quantity=1 - ) + line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1) self.dbsession.add(line_item) - + # Create huge discount coupon huge_coupon = Coupon( shop=shop, @@ -1494,28 +1458,466 @@ class TestInvoiceDiscountIntegration(DatabaseIntegrationTests): action_value=20, # $20.00 off (way more than $3.00 product) max_redemptions=100, max_redemptions_per_user=1, - cart_qualifier=0 + cart_qualifier=0, ) self.dbsession.add(huge_coupon) self.dbsession.flush() - + # Add coupon redemption redemption = CouponRedemption( - coupon=huge_coupon, - invoice=invoice, - shop=shop, - user=user + coupon=huge_coupon, invoice=invoice, shop=shop, user=user ) self.dbsession.add(redemption) self.dbsession.flush() - + # Test that total never goes negative # Subtotal: $3.00, Discount: $3.00 (limited), Handling: $1.00 # Total should be max(0, 300 - 300 + 100) = max(0, 100) = 100 - self.assertEqual(invoice.discount_amount_in_cents, 300) # Only $3.00 discount applied + self.assertEqual( + invoice.discount_amount_in_cents, 300 + ) # Only $3.00 discount applied self.assertEqual(invoice.total_in_cents, 100) # $1.00 (never negative) - + # Test requires_payment with handling cost self.assertTrue(invoice.requires_payment) # $1.00 > $0.64 threshold - - transaction.commit() \ No newline at end of file + + transaction.commit() + + +class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests): + def setUp(self): + super(TestUserCryptoRefundAddressIntegration, self).setUp() + + def test_create_and_retrieve_crypto_refund_address(self): + """Test creating and retrieving crypto refund addresses in database.""" + from ..models.user_crypto_refund_address import ( + UserCryptoRefundAddress, + get_user_crypto_refund_address, + ) + + # Create user + user = User("crypto@test.com") + self.dbsession.add(user) + self.dbsession.flush() + + # Create crypto refund address + addr = UserCryptoRefundAddress( + user=user, + coin_type="XMR", + address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + label="Test Wallet", + ) + self.dbsession.add(addr) + self.dbsession.flush() + + # Retrieve address + retrieved = get_user_crypto_refund_address(self.dbsession, user, "XMR") + self.assertIsNotNone(retrieved) + self.assertEqual( + retrieved.address, + "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + ) + self.assertEqual(retrieved.label, "Test Wallet") + + # Test case insensitive coin type lookup + retrieved2 = get_user_crypto_refund_address(self.dbsession, user, "xmr") + self.assertEqual(retrieved.id, retrieved2.id) + + # Test non-existent coin type + none_result = get_user_crypto_refund_address(self.dbsession, user, "BTC") + self.assertIsNone(none_result) + + transaction.commit() + + def test_unique_constraint_user_coin_type(self): + """Test that user can only have one address per coin type.""" + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + + # Create user + user = User("unique@test.com") + self.dbsession.add(user) + self.dbsession.flush() + + # Create first address + addr1 = UserCryptoRefundAddress( + user=user, + coin_type="XMR", + address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + ) + self.dbsession.add(addr1) + self.dbsession.flush() + + # Try to create duplicate (same user, same coin type) + addr2 = UserCryptoRefundAddress( + user=user, + coin_type="XMR", + address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", + ) + self.dbsession.add(addr2) + + # Should raise integrity error + from sqlalchemy.exc import IntegrityError + + with self.assertRaises(IntegrityError): + self.dbsession.flush() + + transaction.abort() + + def test_multiple_users_same_coin_type(self): + """Test that different users can have addresses for same coin type.""" + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + + # Create two users + user1 = User("user1@test.com") + user2 = User("user2@test.com") + self.dbsession.add_all([user1, user2]) + self.dbsession.flush() + + # Create addresses for same coin type + addr1 = UserCryptoRefundAddress( + user=user1, + coin_type="XMR", + address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + ) + addr2 = UserCryptoRefundAddress( + user=user2, + coin_type="XMR", + address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", + ) + + self.dbsession.add_all([addr1, addr2]) + self.dbsession.flush() # Should not raise error + + transaction.commit() + + def test_user_can_have_multiple_coin_addresses(self): + """Test that one user can have addresses for multiple coin types.""" + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + + # Create user + user = User("multicoin@test.com") + self.dbsession.add(user) + self.dbsession.flush() + + # Create addresses for different coin types + xmr_addr = UserCryptoRefundAddress( + user=user, + coin_type="XMR", + address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + label="Monero Wallet", + ) + btc_addr = UserCryptoRefundAddress( + user=user, + coin_type="BTC", + address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + label="Bitcoin Wallet", + ) + doge_addr = UserCryptoRefundAddress( + user=user, + coin_type="DOGE", + address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L", + label="Doge Wallet", + ) + + self.dbsession.add_all([xmr_addr, btc_addr, doge_addr]) + self.dbsession.flush() + + # Verify all addresses exist + self.assertEqual(len(user.crypto_refund_addresses), 3) + + transaction.commit() + + +class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests): + """Test that crypto payments correctly use saved refund addresses.""" + + def setUp(self): + super(TestCryptoPaymentWithRefundAddress, self).setUp() + + def test_crypto_payment_with_saved_refund_address_integration(self): + """Test that get_user_crypto_refund_address correctly retrieves address for payments.""" + from ..models.user_crypto_refund_address import ( + UserCryptoRefundAddress, + get_user_crypto_refund_address, + ) + + # Create user + user = User("cryptouser@test.com") + self.dbsession.add(user) + + # Save user's refund address + refund_addr = UserCryptoRefundAddress( + user=user, + coin_type="XMR", + address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", + label="My Refund Wallet", + ) + self.dbsession.add(refund_addr) + self.dbsession.flush() + + # Test the logic that would be used in crypto payment view + user_refund_addr_obj = get_user_crypto_refund_address( + self.dbsession, user, "XMR" + ) + user_refund_address = ( + user_refund_addr_obj.address if user_refund_addr_obj else None + ) + + # Verify the address is retrieved correctly + self.assertEqual( + user_refund_address, + "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", + ) + + transaction.commit() + + def test_crypto_payment_without_saved_refund_address_integration(self): + """Test that payments work when user has no saved refund address.""" + from ..models.user_crypto_refund_address import get_user_crypto_refund_address + + # Create user with no refund address + user = User("norefund@test.com") + self.dbsession.add(user) + self.dbsession.flush() + + # Test the logic that would be used in crypto payment view + user_refund_addr_obj = get_user_crypto_refund_address( + self.dbsession, user, "XMR" + ) + user_refund_address = ( + user_refund_addr_obj.address if user_refund_addr_obj else None + ) + + # Verify no address is returned + self.assertIsNone(user_refund_address) + + transaction.commit() + + +class TestCryptoPaymentIntegration(DatabaseIntegrationTests): + """Integration tests for crypto payment functionality.""" + + def test_create_crypto_payment_for_invoice(self): + """Test creating a crypto payment for an invoice with real database objects.""" + from ..models.crypto_payment import CryptoPayment + from ..models.crypto_processor import CryptoProcessor + from ..models.shop_location import ShopLocation + import time + + # Create real user and shop + user = get_or_create_user_by_email(self.dbsession, "crypto@example.com") + shop = Shop( + name="Crypto Test Shop", + phone_number="555-9999", + billing_address="123 Crypto St", + description="Test shop for crypto", + ) + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + # Create shop location + location = ShopLocation( + shop=shop, + name="Main", + address="123 Test St", + city="Test City", + state="TS", + country="Test Country", + postal_code="12345", + ) + self.dbsession.add(location) + + # Create crypto processor + processor = CryptoProcessor( + shop_id=shop.id, coin_type="XMR", sweep_to_address="test_sweep_address" + ) + processor.enabled = True + processor.wallet_label = "0" + self.dbsession.add(processor) + + # Create product with price + product = Product( + title="Test Crypto Product", description="Product for crypto test" + ) + product.shop_id = shop.id + product.price_in_cents = 2000 # $20 + self.dbsession.add(product) + self.dbsession.flush() + + price = Price(product, 2000) + self.dbsession.add(price) + + # Create invoice + invoice = Invoice(user) + invoice.shop_id = shop.id + invoice.new_line_item(product=product, quantity=1) + self.dbsession.add(invoice) + self.dbsession.flush() + + # Create crypto payment + payment = CryptoPayment( + invoice=invoice, + address="test_xmr_address", + account_index=0, + subaddress_index=1, + coin_type="XMR", + expected_amount=133333333333, # ~0.133 XMR at $150/XMR + rate_locked_usd_per_coin=150.0, + quote_expires_at_ms=int((time.time() + 900) * 1000), + confirmations_required=1, + shop_location=location, + ) + payment.shop_sweep_to_address = processor.sweep_to_address + self.dbsession.add(payment) + self.dbsession.flush() + + # Verify payment was created correctly + self.assertEqual(payment.status, "pending") + self.assertEqual(payment.coin_type, "XMR") + self.assertEqual(payment.invoice_id, invoice.id) + self.assertEqual(payment.shop_location_id, location.id) + self.assertIsNotNone(payment.quote_expires_at) + + transaction.commit() + + def test_crypto_payment_with_refund_address_integration(self): + """Test crypto payment creation with user's saved refund address.""" + from ..models.crypto_payment import CryptoPayment + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + from ..models.shop_location import ShopLocation + import time + + # Create real user + user = get_or_create_user_by_email(self.dbsession, "refund@example.com") + self.dbsession.add(user) + self.dbsession.flush() + + # Create user's refund address + refund_addr = UserCryptoRefundAddress( + user=user, + coin_type="XMR", + address="user_refund_address_xmr", + label="My Monero wallet", + ) + self.dbsession.add(refund_addr) + + # Create shop and location + shop = Shop( + name="Refund Test Shop", + phone_number="555-8888", + billing_address="456 Refund Ave", + description="Test shop for refunds", + ) + self.dbsession.add(shop) + self.dbsession.flush() + + location = ShopLocation( + shop=shop, + name="Main", + address="456 Test Ave", + city="Test City", + state="TS", + country="Test Country", + postal_code="12345", + ) + self.dbsession.add(location) + + # Create invoice + invoice = Invoice(user) + invoice.shop_id = shop.id + self.dbsession.add(invoice) + self.dbsession.flush() + + # Create crypto payment with refund address + payment = CryptoPayment( + invoice=invoice, + address="payment_address", + account_index=0, + subaddress_index=2, + coin_type="XMR", + expected_amount=100000000000, + rate_locked_usd_per_coin=150.0, + quote_expires_at_ms=int((time.time() + 900) * 1000), + confirmations_required=1, + shop_location=location, + ) + payment.refund_address = refund_addr.address + self.dbsession.add(payment) + self.dbsession.flush() + + # Verify refund address was set + self.assertEqual(payment.refund_address, "user_refund_address_xmr") + + transaction.commit() + + def test_crypto_payment_expiry_check_integration(self): + """Test crypto payment expiry checking with real database objects.""" + from ..models.crypto_payment import CryptoPayment + from ..models.shop_location import ShopLocation + import time + + # Create basic test data + user = get_or_create_user_by_email(self.dbsession, "expiry@example.com") + shop = Shop( + name="Expiry Test Shop", + phone_number="555-7777", + billing_address="789 Expiry Blvd", + description="Test shop for expiry", + ) + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + location = ShopLocation( + shop=shop, + name="Main", + address="789 Test Blvd", + city="Test City", + state="TS", + country="Test Country", + postal_code="12345", + ) + self.dbsession.add(location) + + invoice = Invoice(user) + invoice.shop_id = shop.id + self.dbsession.add(invoice) + self.dbsession.flush() + + # Create expired payment + expired_payment = CryptoPayment( + invoice=invoice, + address="expired_address", + account_index=0, + subaddress_index=3, + coin_type="XMR", + expected_amount=100000000000, + rate_locked_usd_per_coin=150.0, + quote_expires_at_ms=int((time.time() - 100) * 1000), # Expired 100s ago + confirmations_required=1, + shop_location=location, + ) + self.dbsession.add(expired_payment) + + # Create valid payment + valid_payment = CryptoPayment( + invoice=invoice, + address="valid_address", + account_index=0, + subaddress_index=4, + coin_type="XMR", + expected_amount=100000000000, + rate_locked_usd_per_coin=150.0, + quote_expires_at_ms=int((time.time() + 900) * 1000), # Valid for 15 mins + confirmations_required=1, + shop_location=location, + ) + self.dbsession.add(valid_payment) + self.dbsession.flush() + + # Test expiry check + self.assertTrue(expired_payment.is_expired) + self.assertFalse(valid_payment.is_expired) + + transaction.commit() diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index fb58047..1a19785 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -1250,3 +1250,95 @@ class TestInvoice(unittest.TestCase): self.assertEqual(line_item.price.price_in_cents, 750) self.assertEqual(line_item.product, product) self.assertEqual(line_item.quantity, 2) + + +class TestUserCryptoRefundAddress(unittest.TestCase): + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("crypto@example.com") + + def test_create_crypto_refund_address(self): + """Test creating a crypto refund address.""" + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + + addr = UserCryptoRefundAddress( + user=self.user, + coin_type="XMR", + address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + label="My Monero Wallet" + ) + + self.assertEqual(addr.user, self.user) + self.assertEqual(addr.coin_type, "XMR") + self.assertEqual(addr.address, "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A") + self.assertEqual(addr.label, "My Monero Wallet") + self.assertIsNotNone(addr.id) + self.assertGreater(addr.created_timestamp, 0) + self.assertGreater(addr.updated_timestamp, 0) + + def test_create_crypto_refund_address_uppercase_coin_type(self): + """Test that coin type is uppercased.""" + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + + addr = UserCryptoRefundAddress( + user=self.user, + coin_type="btc", + address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + ) + + self.assertEqual(addr.coin_type, "BTC") + + def test_create_crypto_refund_address_no_label(self): + """Test creating address without label.""" + from ..models.user_crypto_refund_address import UserCryptoRefundAddress + + addr = UserCryptoRefundAddress( + user=self.user, + coin_type="DOGE", + address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L", + ) + + self.assertEqual(addr.coin_type, "DOGE") + self.assertIsNone(addr.label) + + def test_get_user_crypto_refund_address_none(self): + """Test getting non-existent refund address returns None.""" + from ..models.user_crypto_refund_address import get_user_crypto_refund_address + + # Mock dbsession + mock_session = mock.Mock() + mock_query = mock.Mock() + mock_filter = mock.Mock() + + mock_session.query.return_value = mock_query + mock_query.filter.return_value = mock_filter + mock_filter.first.return_value = None + + result = get_user_crypto_refund_address(mock_session, self.user, "XMR") + self.assertIsNone(result) + + def test_get_user_crypto_refund_address_found(self): + """Test getting existing refund address.""" + from ..models.user_crypto_refund_address import ( + UserCryptoRefundAddress, + get_user_crypto_refund_address + ) + + # Create address + expected_addr = UserCryptoRefundAddress( + user=self.user, + coin_type="XMR", + address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + ) + + # Mock dbsession + mock_session = mock.Mock() + mock_query = mock.Mock() + mock_filter = mock.Mock() + + mock_session.query.return_value = mock_query + mock_query.filter.return_value = mock_filter + mock_filter.first.return_value = expected_addr + + result = get_user_crypto_refund_address(mock_session, self.user, "XMR") + self.assertEqual(result, expected_addr) diff --git a/make_post_sell/views/__init__.py b/make_post_sell/views/__init__.py index 229de96..c046d65 100644 --- a/make_post_sell/views/__init__.py +++ b/make_post_sell/views/__init__.py @@ -42,11 +42,11 @@ def shop_is_ready_required( flash_msg="Sorry, this shop is not ready to make sales yet. Please try again later.", flash_level="error", ): - """This view requires that the request has a shop and that show is_ready.""" + """This view requires that the request has a shop and that shop is_ready_for_payment.""" def wrapped(fn): def inner(request): - if request.shop and request.shop.is_ready: + if request.shop and request.shop.is_ready_for_payment(request): return fn(request) request.session.flash((flash_msg, flash_level)) return HTTPFound(get_referer_or_home(request)) diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 0f46565..c177a9f 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -350,7 +350,9 @@ def cart_quantity_product(request): return HTTPFound(get_referer_or_home(request)) -@view_config(route_name="cart_handling_option", request_method="POST") +@view_config( + route_name="cart_handling_option", request_method="POST", require_csrf=True +) def cart_handling_option(request): cart = get_cart_from_matchdict(request) handling_option = request.params.get("handling_option") @@ -450,22 +452,46 @@ def cart_checkout(request): return HTTPFound("/u/addresses") # Check for payment information - if cart.requires_payment and stripe_user_shop is None: + # Only force Stripe flow if Stripe is the ONLY enabled payment method + only_stripe_enabled = request.stripe_enabled and not request.monero_enabled + if cart.requires_payment and only_stripe_enabled and stripe_user_shop is None: msg = ("Please enter your payment information.", "info") request.session.flash(msg) return HTTPFound("/billing") - if stripe_user_shop and stripe_user_shop.active_card is None: + if ( + only_stripe_enabled + and stripe_user_shop + and stripe_user_shop.active_card is None + ): msg = ("Please make a payment method active.", "info") request.session.flash(msg) return HTTPFound("/billing") + # Check if shop has enabled XMR crypto processor + xmr_processor_enabled = False + if request.monero_enabled: + from ..models.crypto_processor import CryptoProcessor + xmr_processor = ( + request.dbsession.query(CryptoProcessor) + .filter( + CryptoProcessor.shop_id == request.shop.id, + CryptoProcessor.coin_type == "XMR", + CryptoProcessor.enabled == True, + ) + .first() + ) + xmr_processor_enabled = xmr_processor is not None + msg = ("Please confirm your order.", "info") request.session.flash(msg) return { "cart": cart, "products": cart.products, "active_card": stripe_user_shop.active_card if stripe_user_shop else None, + "stripe_enabled": request.stripe_enabled, + "monero_enabled": request.monero_enabled, + "xmr_processor_enabled": xmr_processor_enabled, } request.session.flash(msg) @@ -478,6 +504,8 @@ def cart_checkout(request): @user_required() @shop_is_ready_required() def cart_complete_checkout(request): + stripe_enabled = request.stripe_enabled + cart = get_cart_from_matchdict(request) if cart is None: @@ -495,7 +523,18 @@ def cart_complete_checkout(request): request.session.flash(msg) return HTTPFound(get_referer_or_home(request)) - elif cart.requires_payment and request.shop.stripe_customer(request.user) is None: + # If the cart requires payment and Stripe is disabled, this endpoint cannot process payment + if cart.requires_payment and not request.stripe_enabled: + msg = ("Card payments are disabled by configuration.", "error") + request.session.flash(msg) + return HTTPFound("/cart") + + # If Stripe is enabled and payment is required, ensure a Stripe customer exists + if ( + cart.requires_payment + and request.stripe_enabled + and request.shop.stripe_customer(request.user) is None + ): msg = ("Please enter your payment information.", "info") request.session.flash(msg) return HTTPFound("/billing") @@ -529,25 +568,29 @@ def cart_complete_checkout(request): invoices.append(invoice) - # Attempt payment BEFORE adding invoices to session - for invoice in invoices: - shop = invoice.shop + # Attempt payment BEFORE adding invoices to session when Stripe is enabled + if request.stripe_enabled: + for invoice in invoices: + shop = invoice.shop - if invoice.requires_payment: - stripe_user_shop = shop.stripe_user_shop(request.user) - if stripe_user_shop is None: - msg = ("Payment method required but not found. Please add a payment method.", "error") - request.session.flash(msg) - return HTTPFound("/billing") - - shop.stripe.PaymentIntent.create( - amount=invoice.total_in_cents, - currency="usd", - customer=stripe_user_shop.cus_id, - payment_method=stripe_user_shop.active_card_id, - off_session=True, - confirm=True, - ) + if invoice.requires_payment: + stripe_user_shop = shop.stripe_user_shop(request.user) + if stripe_user_shop is None: + msg = ( + "Payment method required but not found. Please add a payment method.", + "error", + ) + request.session.flash(msg) + return HTTPFound("/billing") + + payment_intent = shop.stripe.PaymentIntent.create( + amount=invoice.total_in_cents, + currency="usd", + customer=stripe_user_shop.cus_id, + payment_method=stripe_user_shop.active_card_id, + off_session=True, + confirm=True, + ) # Only persist data after successful payment for invoice in invoices: diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py new file mode 100644 index 0000000..a81a4a2 --- /dev/null +++ b/make_post_sell/views/crypto.py @@ -0,0 +1,385 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound, HTTPBadRequest +from pyramid.response import Response + +from ..models.cart import get_cart_by_id +from ..models.invoice import Invoice +from ..models.crypto_payment import CryptoPayment + +from ..lib.crypto_clients import get_client_from_settings + +from . import ( + user_required, + shop_is_ready_required, +) + +import json +import time +import uuid as _uuid +import urllib.request + + +@view_config( + route_name="crypto_xmr_start", + request_method="POST", + require_csrf=True, + renderer="crypto_checkout.j2", +) +def crypto_xmr_start(request): + # Check Monero enabled toggle first (no DB access) + if not request.monero_enabled: + request.session.flash( + ("Monero payments are disabled by configuration.", "error") + ) + return HTTPFound("/cart") + + cart_id = request.params.get("cart_id") + if not cart_id: + request.session.flash(("Missing cart_id.", "error")) + return HTTPFound("/cart") + + # First database access - this establishes the transaction + cart = get_cart_by_id(request.dbsession, cart_id) + if cart is None: + request.session.flash(("Invalid cart.", "error")) + return HTTPFound("/cart") + + # Now check user authentication (after DB transaction is established) + if not (request.user and request.user.authenticated): + request.session.flash( + ("To use Monero checkout, please verify your email.", "info") + ) + return HTTPFound(request.route_url("join-or-log-in")) + + # Now check if shop is ready for payment (after DB transaction is established) + if not (request.shop and request.shop.is_ready_for_payment(request)): + request.session.flash( + ( + "Sorry, this shop is not ready to make sales yet. Please try again later.", + "error", + ) + ) + from . import get_referer_or_home + + return HTTPFound(get_referer_or_home(request)) + + if request.user.does_not_own_cart(cart): + request.session.flash(("You do not own this cart.", "error")) + return HTTPFound("/cart") + + # single-shop constraint for MVP + if len(cart.shop_product_dict.keys()) != 1: + request.session.flash( + ("Monero checkout only supports single-shop carts.", "error") + ) + return HTTPFound(f"/cart/{cart.id}") + + if not cart.requires_payment: + request.session.flash(("No payment required for this order.", "info")) + return HTTPFound(f"/cart/{cart.id}") + + # Ensure config exists; if not, provide a helpful message. + settings = request.registry.settings + if not settings.get("monero.rpc_url"): + request.session.flash( + ( + "Monero RPC not configured. Set monero.rpc_url in your ini to enable.", + "error", + ) + ) + return HTTPFound(f"/cart/{cart.id}") + + # Build a pending invoice for this single shop (do not unlock or send emails yet) + try: + # Extract the single shop and items + (shop_id, items) = next(iter(cart.shop_product_dict.items())) + shop = cart.shops[shop_id] + + invoice = Invoice(request.user) + invoice.shop = shop + invoice.shop_id = shop.id + invoice.handling_option = cart.handling_option + invoice.handling_cost_in_cents = cart.handling_cost_in_cents + + if cart.physical_products and request.user.active_address: + invoice.delivery_address = request.user.active_address.data + + for product, quantity in items: + invoice.new_line_item(product=product, quantity=quantity) + + for coupon in cart.coupons: + invoice.new_coupon_redemption(coupon) + + # Persist invoice now so we can link a CryptoPayment to it + request.dbsession.add(invoice) + request.dbsession.flush() + + # Fetch USD/XMR rate (with timeout/retry and sanity checks) + rate_url = settings.get( + "monero.rate_source_url", + "https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd", + ) + last_err = None + usd_per_xmr = None + for attempt in range(3): + try: + req = urllib.request.Request( + rate_url, headers={"User-Agent": "make-post-sell/1.0"} + ) + with urllib.request.urlopen(req, timeout=5) as rate_resp: + rate_data = json.loads(rate_resp.read()) + candidate = float(rate_data.get("monero", {}).get("usd")) + # sanity bounds: reject zero/negative/absurd values + if not (0.01 <= candidate <= 100000.0): + raise RuntimeError("Out-of-bounds USD/XMR rate") + usd_per_xmr = candidate + break + except Exception as e: + last_err = e + time.sleep(0.5) + if usd_per_xmr is None: + raise RuntimeError(f"Failed to fetch USD/XMR rate: {last_err}") + + # Compute piconero owed with transaction fee buffer + usd_total = float(invoice.total) + xmr_amount = usd_total / usd_per_xmr + + # Add small fee buffer (default 0.0001 XMR) to cover transaction costs + fee_buffer_xmr = float(settings.get("monero.fee_buffer", "0.0001")) + xmr_amount_with_fee = xmr_amount + fee_buffer_xmr + expected_piconero = int(xmr_amount_with_fee * 1_000_000_000_000) + + # Quote expiry - use shop-specific setting + shop = invoice.shop + expiry_secs = int(shop.crypto_quote_expiry_seconds) + quote_expires_at_ms = int(time.time() * 1000) + (expiry_secs * 1000) + + # Check if invoice contains physical products + has_physical = any( + item.product.is_physical + for item in invoice.line_items + if hasattr(item.product, "is_physical") + ) + + if has_physical: + # Physical products always require maximum confirmations + confirmations_required = int(settings.get("monero.confirmations.high")) + else: + # Digital products: determine confirmations based on amount + total_cents = invoice.total_in_cents + + # Use shop-specific risk thresholds + shop = invoice.shop + threshold_mid_cents = shop.payment_risk_threshold_mid_cents + threshold_high_cents = shop.payment_risk_threshold_high_cents + + if total_cents < threshold_mid_cents: + confirmations_required = int(settings.get("monero.confirmations.petty")) + elif total_cents < threshold_high_cents: + confirmations_required = int(settings.get("monero.confirmations.mid")) + else: + confirmations_required = int(settings.get("monero.confirmations.high")) + + # Get crypto processor configuration for this shop + from ..models.crypto_processor import CryptoProcessor + + processor = ( + request.dbsession.query(CryptoProcessor) + .filter( + CryptoProcessor.shop_id == shop.id, + CryptoProcessor.coin_type == "XMR", + CryptoProcessor.enabled == True, + ) + .first() + ) + + if not processor or processor.wallet_label is None: + request.session.flash(("Shop has not configured Monero payments.", "error")) + return HTTPFound(f"/cart/{cart.id}") + + # For Monero, wallet_label stores the account index as a string + account_index = int(processor.wallet_label) + + client = get_client_from_settings(settings) + label = f"invoice:{invoice.id}" + address, subaddr_index = client.create_subaddress( + account_index=account_index, label=label + ) + + # Get user's saved refund address for this coin type + from ..models.user_crypto_refund_address import get_user_crypto_refund_address + + user_refund_addr_obj = get_user_crypto_refund_address( + request.dbsession, request.user, "XMR" + ) + user_refund_address = ( + user_refund_addr_obj.address if user_refund_addr_obj else None + ) + + # Persist CryptoPayment + crypto_payment = CryptoPayment( + invoice=invoice, + address=address, + account_index=account_index, + subaddress_index=subaddr_index, + coin_type="XMR", + expected_amount=expected_piconero, + rate_locked_usd_per_coin=usd_per_xmr, + quote_expires_at_ms=quote_expires_at_ms, + confirmations_required=confirmations_required, + shop_location=request.shop_location, + shop_sweep_to_address=processor.sweep_to_address, + refund_address=user_refund_address, + ) + request.dbsession.add(crypto_payment) + request.dbsession.flush() + + # Redirect to generic quote page with payment UUID + return HTTPFound( + request.route_url("crypto_quote", payment_id=str(crypto_payment.id)) + ) + + except Exception as e: + request.session.flash((f"Failed to start Monero checkout: {e}", "error")) + return HTTPFound(f"/cart/{cart.id}") + + +@view_config( + route_name="crypto_quote", + renderer="crypto_checkout.j2", +) +def crypto_quote(request): + """Generic crypto quote page that works for any coin type""" + payment_id = request.matchdict.get("payment_id") + if not payment_id: + request.session.flash(("Missing payment ID.", "error")) + return HTTPFound("/cart") + + try: + pid = _uuid.UUID(payment_id) + except Exception: + request.session.flash(("Invalid payment ID.", "error")) + return HTTPFound("/cart") + + crypto_payment = ( + request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first() + ) + if not crypto_payment: + request.session.flash(("Payment not found.", "error")) + return HTTPFound("/cart") + + # Get coin-specific information + coin_type = crypto_payment.coin_type + coin_info = get_coin_info(coin_type) + + # Get user's refund address for this coin type + from ..models.user_crypto_refund_address import get_user_crypto_refund_address + + user_refund_addr_obj = ( + get_user_crypto_refund_address(request.dbsession, request.user, coin_type) + if request.user + else None + ) + user_refund_address = user_refund_addr_obj.address if user_refund_addr_obj else None + + # Calculate amounts for display + usd_total = float(crypto_payment.invoice.total) + amount_crypto_with_fee = ( + crypto_payment.expected_amount / coin_info["smallest_unit_divisor"] + ) + + # Estimate base amount and fee (this is approximate since we don't store it separately) + settings = request.registry.settings + fee_buffer = float( + settings.get(f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]) + ) + amount_crypto_base = amount_crypto_with_fee - fee_buffer + + return { + "cart": None, # Quote page doesn't need cart context + "address": crypto_payment.address, + "coin_name": coin_info["name"], + "coin_symbol": coin_type, + "wallet_uri_scheme": coin_info["wallet_uri_scheme"], + "smallest_unit_name": coin_info["smallest_unit_name"], + "amount_crypto": amount_crypto_with_fee, + "amount_crypto_base": amount_crypto_base, + "fee_buffer_crypto": fee_buffer, + "usd_total": usd_total, + "usd_per_crypto": crypto_payment.rate_locked_usd_per_coin, + "expected_smallest_units": crypto_payment.expected_amount, + "expires_at": crypto_payment.quote_expires_at, + "payment_id": str(crypto_payment.id), + "status": crypto_payment.status, + "has_refund_address": bool(user_refund_address), + "refund_address": user_refund_address, + "now": int(time.time() * 1000), + } + + +def get_coin_info(coin_type): + """Get coin-specific information""" + coin_configs = { + "XMR": { + "name": "Monero", + "wallet_uri_scheme": "monero", + "smallest_unit_name": "piconero", + "smallest_unit_divisor": 1_000_000_000_000, + "default_fee_buffer": "0.0001", + }, + "BTC": { + "name": "Bitcoin", + "wallet_uri_scheme": "bitcoin", + "smallest_unit_name": "satoshi", + "smallest_unit_divisor": 100_000_000, + "default_fee_buffer": "0.00001", + }, + "LTC": { + "name": "Litecoin", + "wallet_uri_scheme": "litecoin", + "smallest_unit_name": "satoshi", + "smallest_unit_divisor": 100_000_000, + "default_fee_buffer": "0.00001", + }, + "DOGE": { + "name": "Dogecoin", + "wallet_uri_scheme": "dogecoin", + "smallest_unit_name": "koinu", + "smallest_unit_divisor": 100_000_000, + "default_fee_buffer": "0.01", + }, + } + return coin_configs.get(coin_type, coin_configs["XMR"]) + + +@view_config(route_name="crypto_xmr_status") +@user_required() +def crypto_xmr_status(request): + payment_id = request.matchdict.get("payment_id") + if not payment_id: + return HTTPBadRequest("missing payment_id") + + try: + pid = _uuid.UUID(payment_id) + except Exception: + return HTTPBadRequest("invalid payment_id") + + crypto_payment = ( + request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first() + ) + if not crypto_payment: + return HTTPBadRequest("payment not found") + + payload = { + "payment_id": str(crypto_payment.id), + "status": crypto_payment.status, + "address": crypto_payment.address, + "received_amount": crypto_payment.received_amount, + "expected_amount": crypto_payment.expected_amount, + "confirmations_required": crypto_payment.confirmations_required, + "current_confirmations": crypto_payment.current_confirmations or 0, + "expires_at": crypto_payment.quote_expires_at, + } + return Response( + json.dumps(payload), content_type="application/json", charset="utf-8" + ) diff --git a/make_post_sell/views/crypto_processor.py b/make_post_sell/views/crypto_processor.py new file mode 100644 index 0000000..66a380f --- /dev/null +++ b/make_post_sell/views/crypto_processor.py @@ -0,0 +1,98 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound + +from ..models.crypto_processor import CryptoProcessor +from . import shop_owner_required + + +@view_config(route_name="crypto_processor_settings", request_method="POST") +@shop_owner_required() +def crypto_processor_settings(request): + """Handle crypto processor configuration for a specific coin type.""" + shop = request.shop + coin_type = request.matchdict.get("coin_type", "").upper() + + # Validate coin type + if coin_type not in ["XMR", "DOGE", "LTC", "BTC", "BCH"]: + request.session.flash(("Invalid cryptocurrency type", "error")) + return HTTPFound(f"/s/{shop.id}/settings") + + # Get or create processor + processor = ( + request.dbsession.query(CryptoProcessor) + .filter( + CryptoProcessor.shop_id == shop.id, CryptoProcessor.coin_type == coin_type + ) + .first() + ) + + # Handle disable action + if request.params.get("disable"): + if processor: + processor.enabled = False + # Keep the sweep address but it will be grayed out in UI + request.dbsession.add(processor) + request.session.flash((f"{coin_type} payments disabled", "success")) + return HTTPFound(f"/s/{shop.id}/settings") + + # Get sweep address + sweep_to_address = request.params.get("sweep_to_address", "").strip() + + if not sweep_to_address: + request.session.flash(("Sweep address is required", "error")) + return HTTPFound(f"/s/{shop.id}/settings") + + # Validate address based on coin type + if coin_type == "XMR": + from ..lib.monero_address import validate_monero_address + + is_valid, error_msg = validate_monero_address(sweep_to_address) + if not is_valid: + request.session.flash((f"Invalid Monero address: {error_msg}", "error")) + return HTTPFound(f"/s/{shop.id}/settings") + # TODO: Add validation for other coin types + + if processor: + # Update existing + processor.sweep_to_address = sweep_to_address + processor.enabled = True + request.session.flash((f"{coin_type} settings updated", "success")) + else: + # Create new processor + processor = CryptoProcessor( + shop_id=shop.id, coin_type=coin_type, sweep_to_address=sweep_to_address + ) + + # Assign wallet label based on coin type + if coin_type == "XMR": + # Create new Monero account via RPC + try: + from ..lib.crypto_clients import get_client_from_settings + + client = get_client_from_settings(request.registry.settings) + + # Create new account with shop name as label + account_label = f"shop_{shop.name[:20]}_{shop.id[:8]}" + result = client._call("create_account", {"label": account_label}) + account_index = result.get("account_index") + + if account_index is not None: + processor.wallet_label = str(account_index) + else: + raise Exception("Failed to get account_index from create_account") + + except Exception as e: + request.session.flash( + (f"Failed to create Monero account: {str(e)}", "error") + ) + return HTTPFound(f"/s/{shop.id}/settings") + else: + # For Bitcoin-like coins, use shop UUID as label base + processor.wallet_label = f"shop_{shop.id}_{coin_type.lower()}" + + processor.enabled = True + request.dbsession.add(processor) + request.session.flash((f"{coin_type} payments enabled", "success")) + + request.dbsession.flush() + return HTTPFound(f"/s/{shop.id}/settings") diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 0f0cc6f..99ec8a1 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -38,6 +38,8 @@ from ..lib.mail import send_invite_email from ..lib.phone_numbers import is_phone_number_valid +from ..lib.currency import dollars_to_cents, cents_to_dollars + from pyramid.httpexceptions import HTTPFound # feel free to come up with a better plan, GPT-4 made this regex. @@ -398,14 +400,41 @@ def shop_settings(request): # maint_mode_checkbox = request.params.get("maint-mode-checkbox", bool_to_checkbox(shop.maint_mode)) maint_mode_checkbox = request.params.get("maint-mode-checkbox", "off") maint_mode = checkbox_to_bool(maint_mode_checkbox) - + + # Determine which form was submitted + form_section = request.params.get("form_section", "") + # Comment system settings - comments_enabled_checkbox = request.params.get("comments-enabled-checkbox", "off") - comments_enabled = checkbox_to_bool(comments_enabled_checkbox) - comments_require_purchase_checkbox = request.params.get("comments-require-purchase-checkbox", "off") - comments_require_purchase = checkbox_to_bool(comments_require_purchase_checkbox) - comments_require_approval_checkbox = request.params.get("comments-require-approval-checkbox", "off") - comments_require_approval = checkbox_to_bool(comments_require_approval_checkbox) + comments_enabled = shop.comments_enabled # Keep current value by default + comments_require_purchase = shop.comments_require_purchase + comments_require_approval = shop.comments_require_approval + + # Only update comment settings if the comment form was submitted + if form_section == "comment-settings": + comments_enabled_checkbox = request.params.get( + "comments-enabled-checkbox", "off" + ) + comments_enabled = checkbox_to_bool(comments_enabled_checkbox) + comments_require_purchase_checkbox = request.params.get( + "comments-require-purchase-checkbox", "off" + ) + comments_require_purchase = checkbox_to_bool(comments_require_purchase_checkbox) + comments_require_approval_checkbox = request.params.get( + "comments-require-approval-checkbox", "off" + ) + comments_require_approval = checkbox_to_bool(comments_require_approval_checkbox) + + # Crypto settings + crypto_quote_expiry_seconds = request.params.get( + "crypto_quote_expiry_seconds", shop.crypto_quote_expiry_seconds + ) + # Get dollar values from form + payment_risk_threshold_mid_dollars = request.params.get( + "payment_risk_threshold_mid_dollars" + ) + payment_risk_threshold_high_dollars = request.params.get( + "payment_risk_threshold_high_dollars" + ) s3_webhook_key = request.params.get("key") s3_webhook_bucket = request.params.get("bucket") @@ -422,169 +451,258 @@ def shop_settings(request): ) if request.method == "POST": - if name != shop.name: - if not is_shop_name_valid(name): - msg = ( - "Invalid shop name, only use alpha numeric, spaces, dashes, or periods.", - "error", - ) - elif not is_shop_name_available(request.dbsession, name): - msg = ( - "That shop name is already in use. Please pick another.", - "error", - ) - else: - shop.name = name - msg = ("You set the shop's name.", "success") - - request.session.flash(msg) - - if phone_number != shop.phone_number: - if is_phone_number_valid(phone_number): - shop.phone_number = phone_number - msg = ("You set the shop's phone number.", "success") - else: - msg = ("Invalid phone number. We accept: -.() and numbers.", "error") - request.session.flash(msg) - - if billing_address and billing_address != shop.billing_address: - shop.billing_address = billing_address - msg = ("You set the shop's billing address.", "success") - request.session.flash(msg) - - if description and description != shop.description: - shop.description = description - msg = ("You set the shop's description.", "success") - request.session.flash(msg) - - if domain_name != shop.domain_name: - if not domain_name: - # clear the domain name. - shop.domain_name = domain_name - msg = ("You cleared the shop's domain name.", "success") - elif DOMAIN_NAME_REGEX.match(domain_name): - existing_shop = get_shop_by_domain_name(request.dbsession, domain_name) - if existing_shop: + # Handle shop settings form + if form_section == "shop-settings": + if name != shop.name: + if not is_shop_name_valid(name): msg = ( - "That domain name is already in use. Please pick another.", + "Invalid shop name, only use alpha numeric, spaces, dashes, or periods.", + "error", + ) + elif not is_shop_name_available(request.dbsession, name): + msg = ( + "That shop name is already in use. Please pick another.", "error", ) else: + shop.name = name + msg = ("You set the shop's name.", "success") + request.session.flash(msg) + + if phone_number != shop.phone_number: + if is_phone_number_valid(phone_number): + shop.phone_number = phone_number + msg = ("You set the shop's phone number.", "success") + else: + msg = ( + "Invalid phone number. We accept: -.() and numbers.", + "error", + ) + request.session.flash(msg) + + if billing_address and billing_address != shop.billing_address: + shop.billing_address = billing_address + msg = ("You set the shop's billing address.", "success") + request.session.flash(msg) + + if description and description != shop.description: + shop.description = description + msg = ("You set the shop's description.", "success") + request.session.flash(msg) + + # Handle integration settings form + if form_section == "integration-settings": + if domain_name != shop.domain_name: + if not domain_name: + # clear the domain name. shop.domain_name = domain_name - msg = ("You set the shop's domain name.", "success") - else: - msg = ("Invalid domain name. Please enter a valid domain.", "error") - request.session.flash(msg) + msg = ("You cleared the shop's domain name.", "success") + elif DOMAIN_NAME_REGEX.match(domain_name): + existing_shop = get_shop_by_domain_name( + request.dbsession, domain_name + ) + if existing_shop: + msg = ( + "That domain name is already in use. Please pick another.", + "error", + ) + else: + shop.domain_name = domain_name + msg = ("You set the shop's domain name.", "success") + else: + msg = ("Invalid domain name. Please enter a valid domain.", "error") + request.session.flash(msg) - if ribbon_text != shop.ribbon_text: - shop.ribbon_text = ribbon_text - msg = ("You set the shop's announcement ribbon text.", "success") - request.session.flash(msg) + if google_analytics_id != (shop.google_analytics_id or ""): + shop.google_analytics_id = google_analytics_id + msg = ("You set the shop's Google Analytics Id.", "success") + request.session.flash(msg) - if ribbon_text_color != shop.ribbon_text_color: - shop.ribbon_text_color = ribbon_text_color - msg = ("You set the shop's announcement ribbon text color.", "success") - request.session.flash(msg) + if plausible_domain_name != (shop.plausible_domain_name or ""): + shop.plausible_domain_name = plausible_domain_name + msg = ("You set the shop's Plausible Analytics Domain Name.", "success") + request.session.flash(msg) - if ribbon_color_1 != shop.ribbon_color_1: - shop.ribbon_color_1 = ribbon_color_1 - msg = ( - "You set the shop's announcement ribbon background color 1.", - "success", - ) - request.session.flash(msg) + # Handle ribbon settings form + if form_section == "ribbon-settings": + if ribbon_text != (shop.ribbon_text or ""): + shop.ribbon_text = ribbon_text + msg = ("You set the shop's announcement ribbon text.", "success") + request.session.flash(msg) - if ribbon_color_2 != shop.ribbon_color_2: - shop.ribbon_color_2 = ribbon_color_2 - msg = ( - "You set the shop's announcement ribbon background color 2.", - "success", - ) - request.session.flash(msg) + if ribbon_text_color != (shop.ribbon_text_color or ""): + shop.ribbon_text_color = ribbon_text_color + msg = ("You set the shop's announcement ribbon text color.", "success") + request.session.flash(msg) - if google_analytics_id != shop.google_analytics_id: - shop.google_analytics_id = google_analytics_id - msg = ("You set the shop's Google Analytics Id.", "success") - request.session.flash(msg) + if ribbon_color_1 != (shop.ribbon_color_1 or ""): + shop.ribbon_color_1 = ribbon_color_1 + msg = ( + "You set the shop's announcement ribbon background color 1.", + "success", + ) + request.session.flash(msg) - if plausible_domain_name != shop.plausible_domain_name: - shop.plausible_domain_name = plausible_domain_name - msg = ("You set the shop's Plausible Analytics Domain Name.", "success") - request.session.flash(msg) + if ribbon_color_2 != (shop.ribbon_color_2 or ""): + shop.ribbon_color_2 = ribbon_color_2 + msg = ( + "You set the shop's announcement ribbon background color 2.", + "success", + ) + request.session.flash(msg) - if stripe_public_api_key != shop.stripe_public_api_key: - if stripe_public_api_key.startswith("pk_"): - if not stripe_test_mode and "_test_" in stripe_public_api_key: + # Handle stripe settings form + if form_section == "stripe-settings": + if stripe_public_api_key != shop.stripe_public_api_key: + if stripe_public_api_key.startswith("pk_"): + if not stripe_test_mode and "_test_" in stripe_public_api_key: + msg = ( + "Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.", + "error", + ) + else: + shop.stripe_public_api_key = stripe_public_api_key + msg = ("You set the shop's stripe_public_api_key.", "success") + else: msg = ( - "Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.", + "The shop's stripe_public_api_key must start with 'pk_'.", "error", ) - else: - shop.stripe_public_api_key = stripe_public_api_key - msg = ("You set the shop's stripe_public_api_key.", "success") - else: - msg = ( - "The shop's stripe_public_api_key must start with 'pk_'.", - "error", - ) - request.session.flash(msg) + request.session.flash(msg) - if stripe_secret_api_key != shop.stripe_secret_api_key: - if stripe_secret_api_key.startswith("sk_"): - if not stripe_test_mode and "_test_" in stripe_secret_api_key: + if stripe_secret_api_key != shop.stripe_secret_api_key: + if stripe_secret_api_key.startswith("sk_"): + if not stripe_test_mode and "_test_" in stripe_secret_api_key: + msg = ( + "Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.", + "error", + ) + else: + shop.stripe_secret_api_key = stripe_secret_api_key + msg = ("You set the shop's stripe_secret_api_key.", "success") + else: msg = ( - "Test Stripe keys are not allowed. Please create a test shop at test.makepostsell.com.", + "The shop's stripe_secret_api_key must start with 'sk_'.", "error", ) - else: - shop.stripe_secret_api_key = stripe_secret_api_key - msg = ("You set the shop's stripe_secret_api_key.", "success") - else: - msg = ( - "The shop's stripe_secret_api_key must start with 'sk_'.", - "error", - ) - request.session.flash(msg) + request.session.flash(msg) - if shop.maint_mode != maint_mode: - shop.maint_mode = maint_mode - request.session.flash( - ( - f"You turned {maint_mode_checkbox} maint_mode", - "success", + # Handle maintenance settings form + if form_section == "maintenance-settings": + if shop.maint_mode != maint_mode: + shop.maint_mode = maint_mode + request.session.flash( + ( + f"You turned {'on' if maint_mode else 'off'} maintenance mode", + "success", + ) ) - ) - - # Handle comment system settings - if shop.comments_enabled != comments_enabled: - shop.comments_enabled = comments_enabled - status = "enabled" if comments_enabled else "disabled" - request.session.flash( - ( - f"Comments {status} for this shop", - "success", + + # Handle comment system settings only if comment form was submitted + if form_section == "comment-settings": + if shop.comments_enabled != comments_enabled: + shop.comments_enabled = comments_enabled + status = "enabled" if comments_enabled else "disabled" + request.session.flash( + ( + f"Comments {status} for this shop", + "success", + ) ) - ) - - if shop.comments_require_purchase != comments_require_purchase: - shop.comments_require_purchase = comments_require_purchase - status = "enabled" if comments_require_purchase else "disabled" - request.session.flash( - ( - f"Purchase requirement for comments {status}", - "success", + + if shop.comments_require_purchase != comments_require_purchase: + shop.comments_require_purchase = comments_require_purchase + status = "enabled" if comments_require_purchase else "disabled" + request.session.flash( + ( + f"Purchase requirement for comments {status}", + "success", + ) ) - ) - - if shop.comments_require_approval != comments_require_approval: - shop.comments_require_approval = comments_require_approval - status = "enabled" if comments_require_approval else "disabled" - request.session.flash( - ( - f"Comment approval requirement {status}", - "success", + + if shop.comments_require_approval != comments_require_approval: + shop.comments_require_approval = comments_require_approval + status = "enabled" if comments_require_approval else "disabled" + request.session.flash( + ( + f"Comment approval requirement {status}", + "success", + ) ) - ) + + # Handle crypto settings only if crypto form was submitted + if form_section == "crypto-settings": + try: + seconds = int(crypto_quote_expiry_seconds) + if seconds != shop.crypto_quote_expiry_seconds: + if 300 <= seconds <= 7200: + shop.crypto_quote_expiry_seconds = seconds + request.session.flash( + ( + f"Cryptocurrency quote expiry set to {seconds} seconds", + "success", + ) + ) + else: + request.session.flash( + ( + "Quote expiry must be between 300 and 7200 seconds", + "error", + ) + ) + except (ValueError, TypeError): + request.session.flash(("Invalid quote expiry time", "error")) + + # Handle payment risk thresholds + if payment_risk_threshold_mid_dollars is not None: + try: + dollars = float(payment_risk_threshold_mid_dollars) + cents = dollars_to_cents(dollars) + if 100 <= cents <= 100000: + if cents != shop.payment_risk_threshold_mid_cents: + shop.payment_risk_threshold_mid_cents = cents + request.session.flash( + ( + f"Medium risk threshold set to ${dollars:.2f}", + "success", + ) + ) + else: + request.session.flash( + ( + "Medium risk threshold must be between $1 and $1000", + "error", + ) + ) + except (ValueError, TypeError): + request.session.flash(("Invalid medium risk threshold", "error")) + + if payment_risk_threshold_high_dollars is not None: + try: + dollars = float(payment_risk_threshold_high_dollars) + cents = dollars_to_cents(dollars) + if 1000 <= cents <= 1000000: + if cents != shop.payment_risk_threshold_high_cents: + shop.payment_risk_threshold_high_cents = cents + request.session.flash( + ( + f"High risk threshold set to ${dollars:.2f}", + "success", + ) + ) + else: + request.session.flash( + ( + "High risk threshold must be between $10 and $10000", + "error", + ) + ) + except (ValueError, TypeError): + request.session.flash(("Invalid high risk threshold", "error")) + + # If we processed any form submission, redirect to prevent re-submission + if form_section: + return HTTPFound(f"/s/{shop.id}/settings") # TODO: Dry out this block, it's a copy pasta from views/product.py if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag: @@ -691,6 +809,19 @@ def shop_settings(request): continue signed_posts[file_key]["fields"].update(condition) + # Get crypto processor for Monero if enabled + xmr_processor = None + if request.monero_enabled: + from ..models.crypto_processor import CryptoProcessor + + xmr_processor = ( + request.dbsession.query(CryptoProcessor) + .filter( + CryptoProcessor.shop_id == shop.id, CryptoProcessor.coin_type == "XMR" + ) + .first() + ) + return { "name": shop.name, "description": shop.description, @@ -705,6 +836,14 @@ def shop_settings(request): "plausible_domain_name": shop.plausible_domain_name or "", "stripe_public_api_key": shop.stripe_public_api_key or "", "stripe_secret_api_key": shop.stripe_secret_api_key or "", + "crypto_quote_expiry_seconds": shop.crypto_quote_expiry_seconds, + "payment_risk_threshold_mid_dollars": cents_to_dollars( + shop.payment_risk_threshold_mid_cents + ), + "payment_risk_threshold_high_dollars": cents_to_dollars( + shop.payment_risk_threshold_high_cents + ), + "xmr_processor": xmr_processor, "signed_posts": signed_posts, "get_endpoints": get_endpoints, } diff --git a/make_post_sell/views/user_crypto_settings.py b/make_post_sell/views/user_crypto_settings.py new file mode 100644 index 0000000..fe0a511 --- /dev/null +++ b/make_post_sell/views/user_crypto_settings.py @@ -0,0 +1,103 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound + +from ..models.user_crypto_refund_address import ( + UserCryptoRefundAddress, + get_user_crypto_refund_address, +) +from . import user_required + + +def get_enabled_coins(request): + """Get list of enabled cryptocurrency types from all shops.""" + from ..models.crypto_processor import CryptoProcessor + + # Get distinct coin types from all enabled crypto processors + enabled_coins = ( + request.dbsession.query(CryptoProcessor.coin_type) + .filter(CryptoProcessor.enabled == True) + .distinct() + .all() + ) + + # Return list of coin type strings + return [coin_type for (coin_type,) in enabled_coins] + + +@view_config( + route_name="user_crypto_settings", + request_method="GET", + renderer="user_crypto_settings.j2", +) +@user_required() +def user_crypto_settings_view(request): + """Display crypto payment preferences.""" + user = request.user + enabled_coins = get_enabled_coins(request) + + # Get all user's crypto addresses + addresses = {} + for coin in enabled_coins: + addr = get_user_crypto_refund_address(request.dbsession, user, coin) + if addr: + addresses[coin] = addr + + return { + "enabled_coins": enabled_coins, + "addresses": addresses, + } + + +@view_config(route_name="user_crypto_settings_update", request_method="POST") +@user_required() +def user_crypto_settings_update(request): + """Update crypto refund address for a specific coin.""" + user = request.user + coin_type = request.matchdict.get("coin_type", "").upper() + + # Validate coin type + enabled_coins = get_enabled_coins(request) + if coin_type not in enabled_coins: + request.session.flash(("Invalid or disabled cryptocurrency type", "error")) + return HTTPFound("/u/settings/crypto") + + # Get form data + address = request.params.get("address", "").strip() + label = request.params.get("label", "").strip() + + if not address: + # If address is empty, treat it as a delete + addr = get_user_crypto_refund_address(request.dbsession, user, coin_type) + if addr: + request.dbsession.delete(addr) + request.session.flash((f"{coin_type} refund address cleared", "success")) + return HTTPFound("/u/settings/crypto") + + # TODO: Add address validation for each coin type + # For now, just basic length check + if len(address) < 10 or len(address) > 256: + request.session.flash(("Invalid address format", "error")) + return HTTPFound("/u/settings/crypto") + + # Get existing or create new + addr_obj = get_user_crypto_refund_address(request.dbsession, user, coin_type) + + if addr_obj: + # Update existing + addr_obj.address = address + addr_obj.label = label if label else None + addr_obj.updated_timestamp = request.now_timestamp() + request.session.flash((f"{coin_type} refund address updated", "success")) + else: + # Create new + addr_obj = UserCryptoRefundAddress( + user=user, + coin_type=coin_type, + address=address, + label=label if label else None, + ) + request.dbsession.add(addr_obj) + request.session.flash((f"{coin_type} refund address saved", "success")) + + request.dbsession.flush() + return HTTPFound("/u/settings/crypto") diff --git a/pytest.ini b/pytest.ini index 4a67483..42f96ff 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,13 @@ [pytest] testpaths = make_post_sell python_files = test*.py +# Uncomment filterwarnings to suppress external library warnings +# filterwarnings = +# # Ignore datetime.utcnow() deprecation from external libraries +# ignore:datetime\.datetime\.utcnow\(\) is deprecated:DeprecationWarning:webob +# ignore:datetime\.datetime\.utcnow\(\) is deprecated:DeprecationWarning:botocore +# # Ignore pkg_resources deprecation warnings +# ignore:pkg_resources is deprecated:DeprecationWarning +# ignore:Deprecated call to.*pkg_resources.*:DeprecationWarning +# # Ignore bleach CSS sanitizer warning +# ignore:'style' attribute specified.*css_sanitizer not set diff --git a/setup.py b/setup.py index 46dd868..cd451d9 100644 --- a/setup.py +++ b/setup.py @@ -62,6 +62,8 @@ setup( "paste.app_factory": ["main = make_post_sell:main"], "console_scripts": [ "initialize_make_post_sell_db = make_post_sell.scripts.initialize_db:main", + "crypto_watcher = make_post_sell.lib.crypto_watcher:main", + "sweep_to_cold = make_post_sell.lib.sweep_to_cold:main", ], }, ) diff --git a/test.ini b/test.ini index 213a9f2..a2ede22 100644 --- a/test.ini +++ b/test.ini @@ -30,6 +30,24 @@ app.bucket.secure_uploads.secret_key = ${MPS_APP_SECURE_UPLOADS_SECRET_KEY} # stripe test mode is enabled for development & disabled by default. app.stripe.test_mode = True +# Payment method toggles +app.payments.stripe.enabled = True +app.payments.monero.enabled = False + +# Monero RPC Configuration for tests +monero.rpc_url = http://127.0.0.1:18083/json_rpc +monero.rpc_user = +monero.rpc_pass = +monero.account_index = 0 +monero.quote_expiry_seconds = 900 +monero.rate_source_url = https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd + +# Monero confirmation requirements by amount tier +# Note: Payment thresholds are now per-shop settings (default $10 and $100) +monero.confirmations.petty = 2 +monero.confirmations.mid = 10 +monero.confirmations.high = 20 + [pshell] setup = make_post_sell.scripts.pshell.setup