Add Monero (XMR) payment support
This commit is contained in:
parent
0c8e1b4f89
commit
f3cd1e7116
45 changed files with 7200 additions and 759 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -30,3 +30,7 @@ test
|
|||
*.dkim.key
|
||||
|
||||
caddy
|
||||
|
||||
monero-wallet-cli.log
|
||||
monero-wallet-rpc.log
|
||||
|
||||
|
|
|
|||
480
CRYPTO.rst
Normal file
480
CRYPTO.rst
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
Cryptocurrency Payment System Documentation
|
||||
===========================================
|
||||
|
||||
This document provides detailed information about the cryptocurrency payment system in Make Post Sell, including wallet management, security considerations, and operational procedures.
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
The cryptocurrency payment system in Make Post Sell works like a digital cash register that can accept Monero (and eventually other cryptocurrencies). Here's the key concepts:
|
||||
|
||||
**Why You Can't Use External Wallets (like Cake Wallet):**
|
||||
The system needs to generate a unique address for each customer payment (like giving each customer a different invoice number). Only the wallet owner can create these subaddresses, and external wallets can't provide this functionality via API.
|
||||
|
||||
**How Money Flows:**
|
||||
|
||||
1. Customer pays → Your hot wallet receives it
|
||||
2. Payment is detected and order is fulfilled automatically
|
||||
3. You manually move funds to cold storage periodically
|
||||
|
||||
**Security Reality:**
|
||||
|
||||
- Your server holds the wallet keys (like leaving cash register unlocked)
|
||||
- If server is hacked = funds at risk
|
||||
- Best practice: Keep minimal funds, move to cold storage often
|
||||
|
||||
**The Architecture:**
|
||||
|
||||
- **monero-wallet-rpc**: The "cash register" software
|
||||
- **crypto_watcher**: The "cashier" watching for payments
|
||||
- **CryptoPayment**: The "receipt book" tracking who paid what
|
||||
|
||||
Think of it like running a physical store - you need your own cash register (can't use someone else's), you periodically empty it to a safe (cold wallet), and you keep receipts (database records) of all transactions.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
Make Post Sell supports cryptocurrency payments through a modular system designed to handle multiple coins. Currently, Monero (XMR) is implemented, with the architecture ready for Bitcoin, Litecoin, and other cryptocurrencies.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
|
||||
The crypto payment system consists of several components:
|
||||
|
||||
1. **CryptoPayment Model** - Database model tracking payment details
|
||||
2. **Crypto Client Library** - RPC client for communicating with wallet daemons
|
||||
3. **Crypto Views** - Web endpoints for payment flow
|
||||
4. **Crypto Watcher** - Background service monitoring blockchain for payments
|
||||
5. **Auto-Sweep** - Automatically transfers funds to cold storage after each payment
|
||||
|
||||
How Payments Work
|
||||
-----------------
|
||||
|
||||
1. Customer selects "Pay with Crypto" at checkout
|
||||
2. System generates a unique subaddress for the payment
|
||||
3. Current exchange rate is locked for the quote duration
|
||||
4. Customer sends payment to the generated address
|
||||
5. Crypto watcher monitors for incoming transfers
|
||||
6. Order is fulfilled when payment is confirmed
|
||||
7. **Funds are immediately swept to shop's cold wallet** (keeps only 0.001 XMR for fees)
|
||||
|
||||
Per-Shop Wallet Configuration
|
||||
-----------------------------
|
||||
|
||||
Each shop can have its own hot wallet account and cold storage address:
|
||||
|
||||
1. **Hot Wallet Account**: Automatically assigned when shop configures a cold wallet
|
||||
2. **Cold Wallet Address**: Shop owner's secure wallet for receiving swept funds
|
||||
|
||||
To enable Monero payments for a shop:
|
||||
|
||||
1. Go to Shop Settings
|
||||
2. Enter your cold wallet address in the Monero section
|
||||
3. System automatically assigns a unique account index (one-time)
|
||||
4. Shop is now ready to accept Monero payments
|
||||
|
||||
**Changing Cold Wallet Address**:
|
||||
|
||||
- You can update your cold wallet address anytime
|
||||
- Your account index stays the same (no funds lost)
|
||||
- Future sweeps will go to the new address
|
||||
- Existing balance (0.001 XMR reserve) remains available
|
||||
|
||||
**Security Design**:
|
||||
|
||||
- Account indices are hidden from users to prevent confusion
|
||||
- Each shop gets one permanent account index (doesn't change with cold wallet updates)
|
||||
- Each shop's funds are isolated in separate accounts
|
||||
- **Auto-sweep is mandatory** - funds move to cold storage immediately after confirmation
|
||||
- Hot wallet never holds more than 0.001 XMR per account
|
||||
|
||||
Transaction Lifecycle Example
|
||||
-----------------------------
|
||||
|
||||
Let's follow a complete user journey for a $10 digital product purchase:
|
||||
|
||||
**Customer Journey Begins**
|
||||
|
||||
**1. Product Discovery**
|
||||
- Customer visits shop.example.com
|
||||
- Browses products, finds a $10 digital guide
|
||||
- Clicks "Add to Cart"
|
||||
- Cart shows: 1 item, $10.00
|
||||
|
||||
**2. Checkout Process**
|
||||
- Customer clicks "Checkout"
|
||||
- Enters email: customer@email.com
|
||||
- Sees payment options: "Pay with Stripe" or "Pay with Monero"
|
||||
- Chooses "Pay with Monero"
|
||||
|
||||
**3. Payment Quote Generation** (T+0 seconds)
|
||||
- System fetches current XMR/USD rate: 1 XMR = $300 USD
|
||||
- Calculates price: $10 ÷ $300 = 0.03333333 XMR
|
||||
- Displays to customer:
|
||||
```
|
||||
Amount Due: 0.03333333 XMR
|
||||
Exchange Rate: $300.00/XMR
|
||||
Quote Valid For: 15 minutes
|
||||
```
|
||||
|
||||
**4. Payment Address Generation** (T+1 second)
|
||||
- System assigns subaddress from shop's account (e.g., account 5, subaddress 147)
|
||||
- Displays QR code and address: 87BqQYkugEzh6Tg3gmfDPD6u7w6rNwJrTZXSdWgMHMfT...
|
||||
- CryptoPayment record created:
|
||||
- Shop's cold wallet stored for later sweep
|
||||
- Expected amount: 0.03333333 XMR
|
||||
- Quote expiry timestamp set
|
||||
|
||||
**5. Customer Sends Payment** (T+2 minutes)
|
||||
- Customer opens mobile Monero wallet
|
||||
- Scans QR code or copies address
|
||||
- Enters amount: 0.03333333 XMR
|
||||
- Confirms transaction (network fee ~0.00001 XMR)
|
||||
- Transaction broadcasts to Monero network
|
||||
|
||||
**6. Payment Detection** (T+2-3 minutes)
|
||||
- crypto_watcher polls every 20 seconds
|
||||
- Detects incoming transaction in mempool
|
||||
- Status changes: "pending" → "received"
|
||||
- Customer sees: "Payment received! Awaiting 10 confirmations..."
|
||||
- Progress bar shows: 0/10 confirmations
|
||||
|
||||
**7. Confirmation Progress** (T+2 to T+22 minutes)
|
||||
- Monero blocks average 2 minutes each
|
||||
- Customer refreshes page, sees progress:
|
||||
- 2 confirmations (T+6 min): "2/10 confirmations"
|
||||
- 5 confirmations (T+12 min): "5/10 confirmations"
|
||||
- 8 confirmations (T+18 min): "8/10 confirmations"
|
||||
|
||||
**8. Payment Fully Confirmed** (T+22 minutes)
|
||||
- 10th confirmation reached
|
||||
- Status changes: "received" → "confirmed"
|
||||
- Customer sees: "Payment confirmed! ✓"
|
||||
- Invoice finalized:
|
||||
- Digital download link appears
|
||||
- Email sent: "Your purchase from Shop Name"
|
||||
- Shop owner notified: "New sale: $10.00 (0.03333333 XMR)"
|
||||
|
||||
**9. Customer Downloads Product** (T+23 minutes)
|
||||
- Customer clicks download link
|
||||
- Gets their digital guide PDF
|
||||
- Happy customer, transaction complete from their perspective
|
||||
|
||||
**Behind the Scenes - Shop Gets Paid**
|
||||
|
||||
**10. Automatic Sweep Triggers** (T+22 minutes, 5 seconds)
|
||||
- Immediately after confirmation, auto-sweep initiates
|
||||
- Balance check: Account 5 has 0.03333333 XMR
|
||||
- Sweep calculation: 0.03333333 - 0.001 (keep for fees) = 0.03233333 XMR
|
||||
- Transfer initiated to shop's cold wallet: 4A1s7n9...
|
||||
|
||||
**11. Sweep Complete** (T+24 minutes)
|
||||
- Sweep transaction confirmed
|
||||
- Hot wallet account 5 balance: 0.001 XMR (ready for next customer)
|
||||
- Shop's cold wallet receives: 0.03233333 XMR
|
||||
- At $300/XMR, shop owner has $9.70 (after fee reserve)
|
||||
|
||||
**Total Timeline**:
|
||||
- Customer experience: ~23 minutes (browse to download)
|
||||
- Full settlement: ~24 minutes (payment to cold storage)
|
||||
|
||||
**Financial Summary**:
|
||||
- Product price: $10.00
|
||||
- Customer paid: 0.03333333 XMR ($10.00)
|
||||
- Network fee: ~0.00001 XMR ($0.003)
|
||||
- Shop received: 0.03233333 XMR ($9.70)
|
||||
- Fee reserve kept: 0.001 XMR ($0.30)
|
||||
|
||||
**Key Points**:
|
||||
- Customer experience is smooth - they get their product in ~23 minutes
|
||||
- Shop gets paid automatically with no manual intervention
|
||||
- Hot wallet exposure: maximum 22 minutes, only 0.033 XMR
|
||||
- Each shop's funds stay completely isolated
|
||||
- System handles everything: quote, payment detection, fulfillment, sweep
|
||||
|
||||
**What Could Go Wrong**:
|
||||
|
||||
1. **Customer Underpays**: Sends 0.03 instead of 0.03333333 XMR
|
||||
- Payment detected but never confirms
|
||||
- Manual intervention needed or customer adds missing amount
|
||||
|
||||
2. **Quote Expires**: Customer takes 20 minutes to send
|
||||
- Quote expired after 15 minutes
|
||||
- Customer needs new quote at potentially different rate
|
||||
|
||||
3. **Network Congestion**: Monero network is busy
|
||||
- Confirmations take 3-4 minutes per block instead of 2
|
||||
- Customer waits longer but system handles it
|
||||
|
||||
4. **Wrong Copy/Paste**: Customer sends to wrong address
|
||||
- Funds lost (no recovery possible)
|
||||
- Importance of QR codes to avoid this
|
||||
|
||||
5. **Shop Misconfiguration**: Shop entered wrong cold wallet
|
||||
- Sweep succeeds but funds go to wrong wallet
|
||||
- Unrecoverable - emphasizes importance of testing
|
||||
|
||||
Wallet Configuration
|
||||
--------------------
|
||||
|
||||
**Creating a Wallet**
|
||||
|
||||
For production use, create a dedicated wallet::
|
||||
|
||||
# Create new wallet
|
||||
monero-wallet-cli --generate-new-wallet=/path/to/mps-wallet
|
||||
|
||||
# IMPORTANT: Save the 25-word mnemonic seed securely!
|
||||
# This is your only way to recover funds if the wallet file is lost
|
||||
|
||||
**Running the Wallet RPC**
|
||||
|
||||
The wallet must be accessible via RPC. You have two options:
|
||||
|
||||
**Option 1: Production Setup (Run Your Own Node)**
|
||||
|
||||
This is the most secure and reliable setup::
|
||||
|
||||
# Terminal 1: Start your own Monero node (daemon)
|
||||
# This downloads ~150GB blockchain and uses P2P to stay synced
|
||||
monerod --data-dir=/path/to/blockchain \
|
||||
--rpc-bind-ip=127.0.0.1 \
|
||||
--rpc-bind-port=18081 \
|
||||
--confirm-external-bind
|
||||
|
||||
# Terminal 2: Start wallet RPC (after daemon syncs)
|
||||
monero-wallet-rpc \
|
||||
--rpc-bind-ip=127.0.0.1 \
|
||||
--rpc-bind-port=18083 \
|
||||
--disable-rpc-login \
|
||||
--wallet-file=/path/to/mps-wallet \
|
||||
--daemon-address=127.0.0.1:18081 \
|
||||
--trusted-daemon
|
||||
|
||||
**Option 2: Development Setup (Use Remote Node)**
|
||||
|
||||
Quicker to start but less private and relies on external service::
|
||||
|
||||
monero-wallet-rpc \
|
||||
--rpc-bind-ip=127.0.0.1 \
|
||||
--rpc-bind-port=18083 \
|
||||
--disable-rpc-login \
|
||||
--wallet-file=/path/to/mps-wallet \
|
||||
--daemon-address=node.moneroworld.com:18089 \
|
||||
--trusted-daemon
|
||||
|
||||
**Using the Makefile**
|
||||
|
||||
The included Makefile provides convenient targets::
|
||||
|
||||
# First time setup
|
||||
make monero-wallet-create # Create a new wallet
|
||||
|
||||
# Production (your own node)
|
||||
make monero-node # Terminal 1: Start blockchain node
|
||||
make monero-wallet # Terminal 2: Start wallet RPC
|
||||
|
||||
# Development (remote node)
|
||||
make monero-wallet-remote # Start wallet with public node
|
||||
|
||||
# View all options
|
||||
make monero-full-stack # Shows complete setup instructions
|
||||
|
||||
Security notes:
|
||||
|
||||
- ``--disable-rpc-login`` is safe only when binding to localhost
|
||||
- For remote access, use ``--rpc-login username:password``
|
||||
- Running your own node ensures privacy and reliability
|
||||
- The daemon automatically uses P2P to find peers and stay synchronized
|
||||
|
||||
**Account Structure**
|
||||
|
||||
Monero wallets contain multiple accounts, each with many subaddresses:
|
||||
|
||||
- **Account 0** (default): Main receiving account
|
||||
- **Account 1+**: Can be used for organization (optional)
|
||||
- **Subaddresses**: Unique addresses generated per payment
|
||||
|
||||
The ``MPS_MONERO_ACCOUNT_INDEX`` environment variable (default: 0) specifies which account to use.
|
||||
|
||||
Wallet Management
|
||||
-----------------
|
||||
|
||||
**Checking Balance**
|
||||
|
||||
View wallet balance and incoming payments::
|
||||
|
||||
# Via CLI (stop RPC first)
|
||||
monero-wallet-cli --wallet-file=/path/to/mps-wallet
|
||||
[wallet]: balance
|
||||
[wallet]: show_transfers
|
||||
|
||||
# Via RPC
|
||||
curl -X POST http://127.0.0.1:18083/json_rpc -d '
|
||||
{
|
||||
"jsonrpc":"2.0",
|
||||
"id":"0",
|
||||
"method":"get_balance",
|
||||
"params":{"account_index":0}
|
||||
}'
|
||||
|
||||
**Sending Funds (Hot Wallet Management)**
|
||||
|
||||
The hot wallet runs as a service with mandatory auto-sweep:
|
||||
|
||||
1. **Automatic Post-Payment Sweep**:
|
||||
|
||||
Every confirmed payment triggers an immediate sweep to the shop's cold wallet.
|
||||
Only 0.001 XMR remains for future transaction fees.
|
||||
|
||||
2. **Manual Sweep Commands** (for maintenance/recovery):
|
||||
|
||||
Make Post Sell includes a ``sweep_to_cold`` command for automated cold storage transfers:
|
||||
|
||||
**Per-Shop Sweeping**::
|
||||
|
||||
# Sweep a specific shop's wallet (uses shop's configured cold wallet)
|
||||
sweep_to_cold development.ini --shop-id SHOP_UUID --dry-run
|
||||
sweep_to_cold development.ini --shop-id SHOP_UUID
|
||||
|
||||
# Sweep all shops with configured wallets
|
||||
sweep_to_cold development.ini --all-shops --dry-run
|
||||
sweep_to_cold development.ini --all-shops
|
||||
|
||||
# Aggressive sweep all shops
|
||||
sweep_to_cold development.ini --all-shops --sweep-all
|
||||
|
||||
**Command Options**:
|
||||
|
||||
- ``config_uri``: Path to your configuration file (required)
|
||||
- ``--shop-id``: Sweep specific shop's wallet using its configured cold address
|
||||
- ``--all-shops``: Sweep all shops with configured wallets
|
||||
- ``--sweep-all``: Aggressive mode, keep only 0.001 XMR (~7-8 transactions)
|
||||
- ``--account-index``: Override wallet account to sweep from (default: shop's configured account)
|
||||
- ``--priority``: Transaction priority 0-3, affects fee (default: 1)
|
||||
- ``--dry-run``: Check balance without sending funds
|
||||
|
||||
**Security Notes**:
|
||||
|
||||
- The cold wallet address should be from a hardware wallet or air-gapped system
|
||||
- Test with ``--dry-run`` first to verify balance calculations
|
||||
- Monitor the sweep log file for any errors
|
||||
- Consider setting up alerts for failed sweeps
|
||||
|
||||
**Cold Storage Best Practices**
|
||||
|
||||
1. **Minimize Hot Wallet Exposure**:
|
||||
- Keep only enough XMR for daily operations
|
||||
- Sweep excess funds to cold storage regularly
|
||||
|
||||
2. **Hardware Wallet Integration**:
|
||||
- Use a hardware wallet (Ledger/Trezor) for cold storage
|
||||
- Generate cold storage address offline
|
||||
|
||||
3. **Multi-signature Setup** (Advanced):
|
||||
- Use 2-of-3 multisig for large amounts
|
||||
- Requires multiple parties to authorize transfers
|
||||
|
||||
Security Considerations
|
||||
-----------------------
|
||||
|
||||
**Hot Wallet Risks**
|
||||
|
||||
The wallet on the server is a "hot wallet" with inherent risks:
|
||||
|
||||
- Server compromise = potential fund loss
|
||||
- No way to use external wallets (Cake Wallet, etc.) due to subaddress generation requirement
|
||||
- Must trust the server environment
|
||||
|
||||
**Mitigation Strategies**
|
||||
|
||||
1. **Access Controls**:
|
||||
|
||||
- Restrict RPC to localhost only
|
||||
- Use strong authentication if remote access needed
|
||||
- Monitor access logs
|
||||
|
||||
2. **Operational Security**:
|
||||
|
||||
- Regular security updates
|
||||
- Encrypted wallet file storage
|
||||
- Secure backup procedures
|
||||
- Audit trail for all transfers
|
||||
|
||||
3. **Monitoring**:
|
||||
|
||||
- Set up alerts for large incoming payments
|
||||
- Monitor wallet balance changes
|
||||
- Track unusual access patterns
|
||||
|
||||
Backup and Recovery
|
||||
-------------------
|
||||
|
||||
**Backup Requirements**
|
||||
|
||||
1. **Mnemonic Seed** (Critical):
|
||||
- 25-word recovery phrase
|
||||
- Store offline in multiple secure locations
|
||||
- Never store digitally on the server
|
||||
|
||||
2. **Wallet Files**:
|
||||
- Regular backups of wallet file
|
||||
- Include wallet.keys file
|
||||
- Encrypt backups
|
||||
|
||||
**Recovery Procedures**
|
||||
|
||||
From mnemonic seed::
|
||||
|
||||
monero-wallet-cli --restore-deterministic-wallet
|
||||
# Enter 25-word mnemonic
|
||||
# Specify restore height to speed up sync
|
||||
|
||||
From wallet file backup::
|
||||
|
||||
# Copy wallet and wallet.keys files
|
||||
cp backup/mps-wallet* /path/to/
|
||||
# Start normally
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
**Common Issues**
|
||||
|
||||
1. **RPC Connection Failed**:
|
||||
- Check monero-wallet-rpc is running
|
||||
- Verify firewall allows localhost:18083
|
||||
- Check RPC URL in configuration
|
||||
|
||||
2. **Payments Not Detected**:
|
||||
- Ensure crypto_watcher is running
|
||||
- Check wallet sync status
|
||||
- Verify confirmations requirement
|
||||
|
||||
3. **Subaddress Generation Fails**:
|
||||
- Check wallet is not locked
|
||||
- Verify account index exists
|
||||
- Ensure RPC has wallet access
|
||||
|
||||
**Debug Commands**
|
||||
|
||||
Check wallet sync status::
|
||||
|
||||
curl -X POST http://127.0.0.1:18083/json_rpc -d '
|
||||
{"jsonrpc":"2.0","id":"0","method":"get_height"}'
|
||||
|
||||
List recent transfers::
|
||||
|
||||
curl -X POST http://127.0.0.1:18083/json_rpc -d '
|
||||
{
|
||||
"jsonrpc":"2.0","id":"0","method":"get_transfers",
|
||||
"params":{"in":true,"out":true,"pending":true,"failed":true}
|
||||
}'
|
||||
|
||||
|
||||
References
|
||||
----------
|
||||
|
||||
- Monero Documentation: https://www.getmonero.org/resources/
|
||||
- Monero RPC Documentation: https://www.getmonero.org/resources/developer-guides/wallet-rpc.html
|
||||
- Monero Security Best Practices: https://www.getmonero.org/resources/user-guides/securely_purchase.html
|
||||
313
CRYPTO_PROBLEMS.rst
Normal file
313
CRYPTO_PROBLEMS.rst
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
Cryptocurrency Payment Problems and Solutions
|
||||
=============================================
|
||||
|
||||
This document outlines the various failure modes in cryptocurrency payment processing and proposed solutions for each.
|
||||
|
||||
Transaction Failure Scenarios
|
||||
-----------------------------
|
||||
|
||||
1. Underpayment
|
||||
~~~~~~~~~~~~~~~
|
||||
**Problem**: Customer sends less than the required amount (e.g., $9.50 instead of $10)
|
||||
|
||||
**Current Behavior**:
|
||||
- Order not fulfilled (remains 'pending' status)
|
||||
- Automatic refund minus 9% restocking fee (if refund address configured) (✓ RESOLVED)
|
||||
- Remaining funds auto-swept to cold wallet after refund (✓ RESOLVED)
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ PaymentRescue.handle_underpayment() processes partial payments
|
||||
- ✓ Automatic refund minus 9% restocking fee (covers network costs)
|
||||
- ✓ Auto-sweep ensures partial payments don't accumulate in hot wallet
|
||||
- ✓ Minimal reserve (0.001 XMR) maintained for operations
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- **WANT**: Implement configurable underpayment tolerance (e.g., accept if within 1%)
|
||||
- **WANT**: Add manual order completion option in shop admin panel
|
||||
- **WANT**: Track all payment attempts for accounting/recovery
|
||||
- Allow grace period for customer to send remaining amount
|
||||
|
||||
2. Overpayment
|
||||
~~~~~~~~~~~~~~
|
||||
**Problem**: Customer sends more than required amount (e.g., $12 instead of $10)
|
||||
|
||||
**Current Behavior**:
|
||||
- If payment >= expected, order completes
|
||||
- Automatic refund of excess minus 9% restocking fee (if refund address configured) (✓ RESOLVED)
|
||||
- Remaining funds auto-swept to cold wallet after refund (✓ RESOLVED)
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ Optional "refund address" field at checkout (stored in crypto_payment.refund_address)
|
||||
- ✓ Automatic overpayment refunds minus 9% restocking fee (prevents abuse)
|
||||
- ✓ 5% overpayment tolerance before triggering refund
|
||||
- ✓ Auto-sweep prevents excess funds from accumulating in hot wallet
|
||||
- ✓ PaymentRescue class handles all refund logic
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- Credit overpayment to customer's account for future purchases (alternative to refund)
|
||||
- Allow shop to set custom overpayment handling policy
|
||||
- Track overpayments separately for accounting
|
||||
|
||||
3. Payment After Quote Expiry
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Customer sends correct amount after 15-minute pricing window
|
||||
|
||||
**Current Behavior**:
|
||||
- Quote marked as 'expired' after time window
|
||||
- Automatic refund minus 9% restocking fee (if refund address configured) (✓ RESOLVED)
|
||||
- NO grace period - strict expiry enforced as shown in template (✓ RESOLVED)
|
||||
- Remaining funds auto-swept to cold wallet after refund (✓ RESOLVED)
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ PaymentRescue.handle_expired_payment() processes late payments
|
||||
- ✓ Automatic refund minus 9% restocking fee
|
||||
- ✓ 15-minute quote window configurable via monero.quote_expiry_seconds
|
||||
- ✓ Clear warning in checkout UI about strict expiry time
|
||||
- ✓ Auto-sweep ensures expired payment funds don't accumulate
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- **WANT**: Send email notification to customer about expired payment
|
||||
- **WANT**: Allow manual order completion by shop owner (override)
|
||||
- **WANT**: Track expired payments for analytics
|
||||
|
||||
4. Wrong Address
|
||||
~~~~~~~~~~~~~~~~
|
||||
**Problem**: Customer sends to incorrect address (typo or old address)
|
||||
|
||||
**Current Behavior**:
|
||||
- Funds permanently lost (if invalid address)
|
||||
- Funds go to wrong recipient (if valid but wrong address)
|
||||
|
||||
**Solutions**:
|
||||
- Display address as QR code to reduce typing errors
|
||||
- Implement address verification/checksum display
|
||||
- Warn users that addresses are single-use
|
||||
- Add copy-to-clipboard functionality
|
||||
- Use payment URIs with amount included
|
||||
|
||||
5. Double Payment
|
||||
~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Customer accidentally pays twice for same order
|
||||
|
||||
**Current Behavior**:
|
||||
- First payment completes order
|
||||
- Second payment automatically swept to shop's cold wallet (✓ RESOLVED)
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ Refund address collected at checkout for potential refunds
|
||||
- ✓ Auto-sweep prevents duplicate payments from accumulating
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- Detect and flag duplicate payments within time window
|
||||
- Auto-refund second payment if refund address known
|
||||
- **WANT**: Email notification about duplicate payment
|
||||
- Add "payment already received" status check
|
||||
|
||||
6. Network/Mempool Congestion
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Transaction stuck in mempool, confirmations delayed
|
||||
|
||||
**Current Behavior**:
|
||||
- Order remains pending
|
||||
- May arrive after quote expiry
|
||||
|
||||
**Solutions**:
|
||||
- Implement 0-conf acceptance for trusted customers
|
||||
- Dynamic confirmation requirements based on network conditions
|
||||
- Accept transaction once seen in mempool for small amounts
|
||||
- Email updates about confirmation progress
|
||||
- Allow RBF (Replace-By-Fee) for stuck transactions
|
||||
|
||||
7. Insufficient Confirmations
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Payment received but not enough confirmations before timeout
|
||||
|
||||
**Current Behavior**:
|
||||
- System uses risk-based confirmation requirements (✓ RESOLVED)
|
||||
- Order waits for required confirmations based on amount/product type
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ Risk-based confirmation requirements (petty/mid/high tiers)
|
||||
- ✓ Fewer confirmations for digital goods (2/10/20 defaults)
|
||||
- ✓ Physical products always use maximum confirmations
|
||||
- ✓ Per-shop configurable risk thresholds
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- Implement confirmation timeout extension
|
||||
- Progressive order fulfillment (partial access)
|
||||
- Customer notification of confirmation progress
|
||||
|
||||
8. Exchange Rate Fluctuation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Crypto value changes significantly during payment window
|
||||
|
||||
**Current Behavior**:
|
||||
- Customer may pay correct crypto amount but USD value differs
|
||||
- Treated as under/overpayment
|
||||
|
||||
**Solutions**:
|
||||
- Implement rate-lock tolerance (e.g., ±5%)
|
||||
- Use shorter quote windows during volatile periods
|
||||
- Offer "pay extra" option if rate moves against customer
|
||||
- Multiple rate sources for better accuracy
|
||||
- Allow payment in stablecoins
|
||||
|
||||
9. Invalid Cold Wallet Configuration
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Shop owner enters invalid sweep destination address
|
||||
|
||||
**Current Behavior**:
|
||||
- Sweep operation fails but order still completes
|
||||
- Failed sweeps are logged but don't block fulfillment
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ Auto-sweep maintains minimal reserve (0.001 XMR) to prevent total accumulation
|
||||
- ✓ All sweep attempts are logged for debugging
|
||||
- ✓ Failed sweeps don't block order completion
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- **NEED**: Validate cold wallet address format on entry
|
||||
- **NEED**: Alert shop owner of failed sweeps via email/admin panel
|
||||
- **NEED**: Implement sweep retry logic with exponential backoff
|
||||
- **WANT**: Test sweep with minimal amount on configuration
|
||||
|
||||
10. RPC Node Failure
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Cannot communicate with blockchain node
|
||||
|
||||
**Current Behavior**:
|
||||
- Cannot detect incoming payments
|
||||
- Cannot create new addresses
|
||||
- Cannot perform sweeps
|
||||
|
||||
**Remaining Solutions Needed**:
|
||||
- **NEED**: Configure multiple backup nodes
|
||||
- **NEED**: Automatic failover to backup nodes
|
||||
- **WANT**: Node health monitoring dashboard
|
||||
- Cache recent blockchain data
|
||||
- Manual payment verification fallback
|
||||
|
||||
11. Blockchain Reorganization
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
**Problem**: Confirmed transaction gets reversed in chain reorg
|
||||
|
||||
**Current Behavior**:
|
||||
- Product already delivered
|
||||
- Payment no longer valid
|
||||
|
||||
**Implemented Solutions**:
|
||||
- ✓ Higher confirmation requirements for high-value orders (20 confirms)
|
||||
- ✓ Physical products always use maximum confirmations
|
||||
|
||||
**Note**: Further reorg protection marked as TRASH (over-engineering for extremely rare edge case)
|
||||
|
||||
General Solutions Architecture
|
||||
------------------------------
|
||||
|
||||
1. **Payment Tracking System**
|
||||
- Record ALL incoming transactions
|
||||
- Track payment attempts, not just successful payments
|
||||
- Link payments to addresses, quotes, and customers
|
||||
|
||||
2. **Refund Infrastructure**
|
||||
- Optional refund address collection at checkout
|
||||
- Manual refund initiation by shop admin
|
||||
- Automated refund for specific scenarios
|
||||
- Refund policy configuration per shop
|
||||
|
||||
3. **Communication Layer**
|
||||
- Email notifications for payment issues
|
||||
- In-app notifications for customers
|
||||
- Admin alerts for shop owners
|
||||
- Status page for payment processing
|
||||
|
||||
4. **Policy Framework**
|
||||
- Configurable tolerance levels per shop
|
||||
- Clear Terms of Service templates
|
||||
- Automated policy enforcement
|
||||
- Audit trail for all decisions
|
||||
|
||||
5. **Recovery Mechanisms**
|
||||
- Manual payment verification tools
|
||||
- Order completion override for admins
|
||||
- Payment matching algorithms
|
||||
- Orphaned fund recovery process
|
||||
|
||||
6. **Implemented Auto-Sweep Configuration**
|
||||
|
||||
The system now implements automatic sweeping of confirmed payments to minimize hot wallet exposure:
|
||||
|
||||
**Risk-Based Confirmation Requirements**:
|
||||
- Confirmation thresholds are configured in INI files per coin type
|
||||
- Three tiers based on payment amount and product type:
|
||||
|
||||
* **Petty Tier** (default: 2 confirmations for XMR):
|
||||
- Amounts below shop's mid-tier threshold (default $10)
|
||||
- Digital products only
|
||||
- Configurable via: `monero.confirmations.petty`
|
||||
|
||||
* **Mid Tier** (default: 10 confirmations for XMR):
|
||||
- Amounts between mid and high thresholds ($10-$100 default)
|
||||
- Digital products only
|
||||
- Configurable via: `monero.confirmations.mid`
|
||||
|
||||
* **High Tier** (default: 20 confirmations for XMR):
|
||||
- Amounts above high threshold ($100+)
|
||||
- ALL physical products regardless of amount
|
||||
- Configurable via: `monero.confirmations.high`
|
||||
|
||||
**Auto-Sweep Process**:
|
||||
- Executes immediately after required confirmations are reached
|
||||
- Uses "sweep_all" to transfer entire account balance to cold wallet
|
||||
- Each shop has dedicated Monero account (prevents cross-shop fund mixing)
|
||||
- Account isolation: Shop A uses account 0, Shop B uses account 1, etc.
|
||||
- Multiple payments to same shop pool in their account until swept
|
||||
- Not 1:1 payment-to-sweep mapping (more efficient, fewer transactions)
|
||||
- Zero balance handling: If account already swept, payment marked as "pooled_sweep"
|
||||
- Failed sweeps are logged but don't block order fulfillment
|
||||
|
||||
**Per-Shop Risk Thresholds**:
|
||||
- `payment_risk_threshold_mid_cents`: Boundary for petty/mid tiers
|
||||
- `payment_risk_threshold_high_cents`: Boundary for mid/high tiers
|
||||
- Shops can customize based on their risk tolerance
|
||||
- Physical products always use maximum confirmations
|
||||
|
||||
Implementation Status & Priority
|
||||
--------------------------------
|
||||
|
||||
**Completed Features**:
|
||||
- ✓ Auto-sweep with minimal hot wallet exposure
|
||||
- ✓ Risk-based confirmation tiers (petty/mid/high)
|
||||
- ✓ Refund address collection at checkout
|
||||
- ✓ Per-shop configurable risk thresholds
|
||||
- ✓ Sweep failure logging and non-blocking order completion
|
||||
- ✓ Configurable quote expiry windows
|
||||
- ✓ Automated refund system with 9% restocking fee:
|
||||
- ✓ Underpayment refunds (minus fee)
|
||||
- ✓ Overpayment refunds (excess minus fee, 5% tolerance)
|
||||
- ✓ Expired payment refunds (minus fee)
|
||||
- ✓ PaymentRescue class handles all refund scenarios
|
||||
- ✓ Clear payment rules displayed in checkout UI
|
||||
|
||||
**NEED TO HAVE** (Security/Operational Critical):
|
||||
- **Invoice payment status tracking**: ✓ RESOLVED - Added derived properties to Invoice model (payment_status, payment_method, is_paid) that read from related CryptoPayment when present
|
||||
- **Monero account creation**: ✓ RESOLVED - Now creates dedicated Monero accounts via RPC when shops configure crypto processors
|
||||
- Cold wallet address validation on entry (prevent fund loss)
|
||||
- Sweep failure alerts to shop owners (prevent hot wallet accumulation)
|
||||
- Multiple/fallback RPC node support (prevent downtime)
|
||||
- Sweep retry logic with exponential backoff (reliability)
|
||||
|
||||
**WANT TO HAVE** (Good UX/Business Value):
|
||||
- Failed payment email notifications to customers
|
||||
- Manual order completion tools in admin panel
|
||||
- Payment attempt tracking for ALL transactions (analytics)
|
||||
- Underpayment tolerance configuration (e.g., accept if within 1%)
|
||||
- Customer crypto payment history tracking
|
||||
|
||||
**TRASH** (Over-engineering/Low ROI):
|
||||
- 0-conf acceptance for trusted customers (high risk, complex trust system)
|
||||
- Progressive order fulfillment for digital goods (unnecessarily complex)
|
||||
- Blockchain reorg detection and protection (rare edge case)
|
||||
- Payment status webhooks/callbacks (polling works fine)
|
||||
- Credit system for overpayments (refunds work, adds account complexity)
|
||||
- Custom refund policies per shop (9% standard works for all)
|
||||
226
Makefile
226
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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
79
README.rst
79
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/ <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
|
||||
-------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
332
make_post_sell/lib/crypto_clients.py
Normal file
332
make_post_sell/lib/crypto_clients.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import base64
|
||||
|
||||
|
||||
class MoneroClient:
|
||||
"""
|
||||
Minimal JSON-RPC client for monero-wallet-rpc.
|
||||
Uses stdlib only to avoid new dependencies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rpc_url: str,
|
||||
rpc_user: Optional[str] = None,
|
||||
rpc_pass: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
):
|
||||
self.rpc_url = rpc_url.rstrip("/")
|
||||
self.rpc_user = rpc_user
|
||||
self.rpc_pass = rpc_pass
|
||||
self.timeout = timeout
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.rpc_user and self.rpc_pass:
|
||||
auth = f"{self.rpc_user}:{self.rpc_pass}".encode()
|
||||
headers["Authorization"] = "Basic " + base64.b64encode(auth).decode()
|
||||
return headers
|
||||
|
||||
def _call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": int(time.time() * 1000),
|
||||
"method": method,
|
||||
}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(self.rpc_url, data=data, headers=self._headers())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
body = resp.read()
|
||||
obj = json.loads(body)
|
||||
if "error" in obj and obj["error"]:
|
||||
raise RuntimeError(obj["error"]) # bubble up rpc error
|
||||
return obj.get("result")
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Monero RPC connection error: {e}")
|
||||
|
||||
# High-level helpers
|
||||
|
||||
def create_subaddress(
|
||||
self, account_index: int = 0, label: Optional[str] = None
|
||||
) -> Tuple[str, int]:
|
||||
params: Dict[str, Any] = {"account_index": account_index}
|
||||
if label:
|
||||
params["label"] = label
|
||||
res = self._call("create_address", params)
|
||||
return res["address"], res["address_index"]
|
||||
|
||||
def get_transfers_for_subaddr(
|
||||
self, account_index: int, subaddr_indices: List[int]
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
"in": True,
|
||||
"out": False,
|
||||
"pending": True,
|
||||
"failed": False,
|
||||
"pool": True,
|
||||
"filter_by_height": False,
|
||||
"subaddr_indices": subaddr_indices,
|
||||
"account_index": account_index,
|
||||
}
|
||||
return self._call("get_transfers", params) or {}
|
||||
|
||||
def get_height(self) -> int:
|
||||
res = self._call("get_height")
|
||||
return int(res.get("height", 0))
|
||||
|
||||
|
||||
class MockMoneroClient:
|
||||
"""
|
||||
Minimal mock client for development. Reads a JSON file mapping subaddress_index
|
||||
to a list of inbound transfers for testing the watcher without a live RPC.
|
||||
settings:
|
||||
- monero.mock_transfers_file: path to JSON file
|
||||
JSON format example:
|
||||
{
|
||||
"height": 100000,
|
||||
"transfers": {
|
||||
"0": [{"amount": 123000000000, "confirmations": 12, "txid": "tx1"}],
|
||||
"5": [{"amount": 999, "confirmations": 0, "txid": "tx2"}]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self._data = None
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
with open(self.path, "r") as f:
|
||||
self._data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
self._data = {"height": 0, "transfers": {}}
|
||||
|
||||
def create_subaddress(
|
||||
self, account_index: int = 0, label: Optional[str] = None
|
||||
) -> Tuple[str, int]:
|
||||
# Not used by watcher; provided for completeness
|
||||
raise RuntimeError(
|
||||
"MockMoneroClient does not support create_subaddress in this context"
|
||||
)
|
||||
|
||||
def get_transfers_for_subaddr(
|
||||
self, account_index: int, subaddr_indices: List[int]
|
||||
) -> Dict[str, Any]:
|
||||
transfers: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for idx in subaddr_indices:
|
||||
key = str(idx)
|
||||
arr = self._data.get("transfers", {}).get(key, [])
|
||||
if arr:
|
||||
transfers.setdefault("in", []).extend(arr)
|
||||
return transfers
|
||||
|
||||
def get_height(self) -> int:
|
||||
return int(self._data.get("height", 0))
|
||||
|
||||
|
||||
class DogecoinClient:
|
||||
"""
|
||||
Minimal JSON-RPC client for dogecoind.
|
||||
Uses stdlib only to avoid new dependencies.
|
||||
|
||||
Do Only Good Everyday 🐕
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rpc_url: str,
|
||||
rpc_user: Optional[str] = None,
|
||||
rpc_pass: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
):
|
||||
self.rpc_url = rpc_url.rstrip("/")
|
||||
self.rpc_user = rpc_user
|
||||
self.rpc_pass = rpc_pass
|
||||
self.timeout = timeout
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.rpc_user and self.rpc_pass:
|
||||
auth = f"{self.rpc_user}:{self.rpc_pass}".encode()
|
||||
headers["Authorization"] = "Basic " + base64.b64encode(auth).decode()
|
||||
return headers
|
||||
|
||||
def _call(self, method: str, params: Optional[List[Any]] = None) -> Any:
|
||||
"""Note: Dogecoin RPC expects params as array, not dict"""
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": int(time.time() * 1000),
|
||||
"method": method,
|
||||
}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(self.rpc_url, data=data, headers=self._headers())
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
body = resp.read()
|
||||
obj = json.loads(body)
|
||||
if "error" in obj and obj["error"]:
|
||||
raise RuntimeError(f"RPC error: {obj['error']}")
|
||||
return obj.get("result")
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError(f"HTTP error {e.code}: {e.reason}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"RPC call failed: {e}")
|
||||
|
||||
# Wallet Management
|
||||
|
||||
def getnewaddress(self, label: str = "") -> str:
|
||||
"""Generate a new Dogecoin address with optional label."""
|
||||
return self._call("getnewaddress", [label])
|
||||
|
||||
def getaddressesbylabel(self, label: str) -> Dict[str, Any]:
|
||||
"""Get all addresses with a specific label."""
|
||||
return self._call("getaddressesbylabel", [label])
|
||||
|
||||
def validateaddress(self, address: str) -> Dict[str, Any]:
|
||||
"""Validate a Dogecoin address."""
|
||||
return self._call("validateaddress", [address])
|
||||
|
||||
# Balance and Transaction Info
|
||||
|
||||
def getbalance(self) -> float:
|
||||
"""Get total wallet balance."""
|
||||
return self._call("getbalance")
|
||||
|
||||
def getreceivedbyaddress(self, address: str, minconf: int = 1) -> float:
|
||||
"""Get total received by specific address."""
|
||||
return self._call("getreceivedbyaddress", [address, minconf])
|
||||
|
||||
def listtransactions(
|
||||
self, label: str = "*", count: int = 10, skip: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List transactions for a label or all (*)."""
|
||||
return self._call("listtransactions", [label, count, skip])
|
||||
|
||||
def gettransaction(self, txid: str) -> Dict[str, Any]:
|
||||
"""Get detailed information about a specific transaction."""
|
||||
return self._call("gettransaction", [txid])
|
||||
|
||||
# Sending Funds
|
||||
|
||||
def sendtoaddress(self, address: str, amount: float, comment: str = "") -> str:
|
||||
"""Send Dogecoin to an address. Returns transaction ID."""
|
||||
return self._call("sendtoaddress", [address, amount, comment])
|
||||
|
||||
def sendmany(self, from_label: str, addresses_amounts: Dict[str, float]) -> str:
|
||||
"""Send to multiple addresses at once. More efficient for sweeping."""
|
||||
return self._call("sendmany", [from_label, addresses_amounts])
|
||||
|
||||
# Blockchain Info
|
||||
|
||||
def getblockcount(self) -> int:
|
||||
"""Get current block height."""
|
||||
return self._call("getblockcount")
|
||||
|
||||
def getnetworkinfo(self) -> Dict[str, Any]:
|
||||
"""Get network status information."""
|
||||
return self._call("getnetworkinfo")
|
||||
|
||||
|
||||
class MockDogecoinClient:
|
||||
"""Mock client for testing without a real Dogecoin node."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.addresses = {}
|
||||
self.next_address_num = 1
|
||||
self.balance = 100.0 # Start with 100 DOGE for testing
|
||||
|
||||
def getnewaddress(self, label: str = "") -> str:
|
||||
address = f"DTest{self.next_address_num:04d}Address{label[:8]}"
|
||||
self.addresses[address] = {"label": label, "balance": 0.0}
|
||||
self.next_address_num += 1
|
||||
return address
|
||||
|
||||
def getaddressesbylabel(self, label: str) -> Dict[str, Any]:
|
||||
return {
|
||||
addr: {"purpose": "receive"}
|
||||
for addr, info in self.addresses.items()
|
||||
if info["label"] == label
|
||||
}
|
||||
|
||||
def validateaddress(self, address: str) -> Dict[str, Any]:
|
||||
is_valid = address.startswith("D") and len(address) == 34
|
||||
return {
|
||||
"isvalid": is_valid,
|
||||
"address": address if is_valid else "",
|
||||
"ismine": address in self.addresses,
|
||||
}
|
||||
|
||||
def getbalance(self) -> float:
|
||||
return self.balance
|
||||
|
||||
def getreceivedbyaddress(self, address: str, minconf: int = 1) -> float:
|
||||
return self.addresses.get(address, {}).get("balance", 0.0)
|
||||
|
||||
def listtransactions(
|
||||
self, label: str = "*", count: int = 10, skip: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Return mock transactions
|
||||
return (
|
||||
[
|
||||
{
|
||||
"address": next(iter(self.addresses)),
|
||||
"category": "receive",
|
||||
"amount": 10.0,
|
||||
"confirmations": 6,
|
||||
"txid": "mocktxid123",
|
||||
"time": int(time.time()),
|
||||
}
|
||||
]
|
||||
if self.addresses
|
||||
else []
|
||||
)
|
||||
|
||||
def sendtoaddress(self, address: str, amount: float, comment: str = "") -> str:
|
||||
if self.balance >= amount:
|
||||
self.balance -= amount
|
||||
return f"mocktxid{int(time.time())}"
|
||||
raise RuntimeError("Insufficient funds")
|
||||
|
||||
def getblockcount(self) -> int:
|
||||
return 5500000 # Mock block height
|
||||
|
||||
def getnetworkinfo(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"version": 1140200,
|
||||
"subversion": "/Shibetoshi:1.14.2/",
|
||||
"protocolversion": 70015,
|
||||
"connections": 8,
|
||||
}
|
||||
|
||||
|
||||
def get_client_from_settings(settings) -> MoneroClient:
|
||||
"""
|
||||
Helper to construct a client from Pyramid settings.
|
||||
Expects keys:
|
||||
monero.rpc_url, monero.rpc_user, monero.rpc_pass
|
||||
"""
|
||||
if str(settings.get("monero.mock", "false")).lower() in ("1", "true", "yes"):
|
||||
path = settings.get("monero.mock_transfers_file") or "mock_transfers.json"
|
||||
return MockMoneroClient(path)
|
||||
rpc_url = settings.get("monero.rpc_url")
|
||||
if not rpc_url:
|
||||
raise RuntimeError("monero.rpc_url not configured")
|
||||
return MoneroClient(
|
||||
rpc_url=rpc_url,
|
||||
rpc_user=settings.get("monero.rpc_user"),
|
||||
rpc_pass=settings.get("monero.rpc_pass"),
|
||||
)
|
||||
183
make_post_sell/lib/crypto_payment_rescue.py
Normal file
183
make_post_sell/lib/crypto_payment_rescue.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""
|
||||
Payment rescue module for handling crypto payment errors with automatic refunds.
|
||||
|
||||
Handles:
|
||||
- Underpayments: Refund partial payments minus 9% restocking fee
|
||||
- Overpayments: Refund excess amount minus 9% restocking fee
|
||||
- Expired quotes: Refund late payments minus 9% restocking fee
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
|
||||
RESTOCKING_FEE_PERCENT = Decimal("0.09") # 9% restocking fee
|
||||
OVERPAYMENT_THRESHOLD_PERCENT = Decimal("0.05") # 5% overpayment allowed before refund
|
||||
|
||||
|
||||
def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT):
|
||||
"""Calculate refund amount after deducting restocking fee."""
|
||||
fee = amount * fee_percent
|
||||
refund = amount - fee
|
||||
# Ensure refund is not negative
|
||||
return max(refund, Decimal("0"))
|
||||
|
||||
|
||||
class PaymentRescue:
|
||||
"""Handle crypto payment errors and trigger refunds when appropriate."""
|
||||
|
||||
def __init__(self, dbsession, crypto_client):
|
||||
self.dbsession = dbsession
|
||||
self.crypto_client = crypto_client
|
||||
|
||||
def get_user_refund_address(self, user, crypto_type):
|
||||
"""Get user's configured refund address for the crypto type."""
|
||||
if not user:
|
||||
return None
|
||||
|
||||
refund_record = get_user_crypto_refund_address(
|
||||
self.dbsession, user, crypto_type
|
||||
)
|
||||
return refund_record.address if refund_record else None
|
||||
|
||||
def handle_underpayment(self, payment, expected_amount, received_amount, user):
|
||||
"""
|
||||
Handle underpayment scenario.
|
||||
|
||||
Args:
|
||||
payment: CryptoPayment object
|
||||
expected_amount: Expected amount in crypto units
|
||||
received_amount: Actually received amount in crypto units
|
||||
user: User object who made the payment
|
||||
|
||||
Returns:
|
||||
dict with refund details or None if no refund possible
|
||||
"""
|
||||
refund_address = self.get_user_refund_address(user, payment.crypto_type)
|
||||
if not refund_address:
|
||||
return None
|
||||
|
||||
# Calculate refund amount (received minus fee)
|
||||
refund_amount = calculate_refund_amount(received_amount)
|
||||
|
||||
if refund_amount <= 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "underpayment",
|
||||
"payment_id": payment.id,
|
||||
"refund_address": refund_address,
|
||||
"received_amount": received_amount,
|
||||
"expected_amount": expected_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": received_amount - refund_amount,
|
||||
"reason": f"Underpayment: received {received_amount} but expected {expected_amount}",
|
||||
}
|
||||
|
||||
def handle_overpayment(self, payment, expected_amount, received_amount, user):
|
||||
"""
|
||||
Handle overpayment scenario.
|
||||
|
||||
Args:
|
||||
payment: CryptoPayment object
|
||||
expected_amount: Expected amount in crypto units
|
||||
received_amount: Actually received amount in crypto units
|
||||
user: User object who made the payment
|
||||
|
||||
Returns:
|
||||
dict with refund details or None if no refund possible
|
||||
"""
|
||||
# Check if overpayment exceeds threshold
|
||||
overpayment_ratio = (received_amount - expected_amount) / expected_amount
|
||||
if overpayment_ratio <= OVERPAYMENT_THRESHOLD_PERCENT:
|
||||
# Within acceptable threshold, no refund needed
|
||||
return None
|
||||
|
||||
refund_address = self.get_user_refund_address(user, payment.crypto_type)
|
||||
if not refund_address:
|
||||
return None
|
||||
|
||||
# Calculate excess amount
|
||||
excess_amount = received_amount - expected_amount
|
||||
|
||||
# Calculate refund on the excess (minus fee)
|
||||
refund_amount = calculate_refund_amount(excess_amount)
|
||||
|
||||
if refund_amount <= 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "overpayment",
|
||||
"payment_id": payment.id,
|
||||
"refund_address": refund_address,
|
||||
"received_amount": received_amount,
|
||||
"expected_amount": expected_amount,
|
||||
"excess_amount": excess_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": excess_amount - refund_amount,
|
||||
"reason": f"Overpayment exceeds {int(OVERPAYMENT_THRESHOLD_PERCENT * 100)}% threshold: received {received_amount} but expected {expected_amount}",
|
||||
}
|
||||
|
||||
def handle_expired_payment(self, payment, received_amount, user):
|
||||
"""
|
||||
Handle payment received after quote expiration.
|
||||
|
||||
Args:
|
||||
payment: CryptoPayment object
|
||||
received_amount: Actually received amount in crypto units
|
||||
user: User object who made the payment
|
||||
|
||||
Returns:
|
||||
dict with refund details or None if no refund possible
|
||||
"""
|
||||
refund_address = self.get_user_refund_address(user, payment.crypto_type)
|
||||
if not refund_address:
|
||||
return None
|
||||
|
||||
# Calculate refund amount (received minus fee)
|
||||
refund_amount = calculate_refund_amount(received_amount)
|
||||
|
||||
if refund_amount <= 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "expired",
|
||||
"payment_id": payment.id,
|
||||
"refund_address": refund_address,
|
||||
"received_amount": received_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": received_amount - refund_amount,
|
||||
"reason": "Payment received after quote expiration",
|
||||
}
|
||||
|
||||
def execute_refund(self, refund_details):
|
||||
"""
|
||||
Execute the actual refund transaction.
|
||||
|
||||
Args:
|
||||
refund_details: dict with refund information
|
||||
|
||||
Returns:
|
||||
dict with transaction details or raises exception
|
||||
"""
|
||||
try:
|
||||
# Create the refund transaction
|
||||
tx_result = self.crypto_client.transfer(
|
||||
destinations=[
|
||||
{
|
||||
"address": refund_details["refund_address"],
|
||||
"amount": int(
|
||||
refund_details["refund_amount"] * 1e12
|
||||
), # Convert to piconero
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tx_hash": tx_result.get("tx_hash"),
|
||||
"refund_details": refund_details,
|
||||
"fee_charged": refund_details["fee_amount"],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "refund_details": refund_details}
|
||||
456
make_post_sell/lib/crypto_watcher.py
Normal file
456
make_post_sell/lib/crypto_watcher.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import List
|
||||
from decimal import Decimal
|
||||
|
||||
from pyramid.paster import bootstrap, setup_logging
|
||||
|
||||
from .crypto_clients import get_client_from_settings
|
||||
from ..models.crypto_payment import CryptoPayment
|
||||
from ..models.invoice import Invoice
|
||||
from .mail import send_purchase_email, send_sale_email
|
||||
from ..models.inventory import get_inventory_by_product_and_shop_location
|
||||
from .crypto_payment_rescue import PaymentRescue
|
||||
from ..models.meta import now_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
ATOMIC_UNITS = Decimal("1000000000000") # 1 XMR = 10^12 atomic units
|
||||
MIN_SWEEP_BALANCE = Decimal("0.001") # Keep minimal 0.001 XMR for ~7-8 transaction fees
|
||||
|
||||
|
||||
def auto_sweep_payment(client, crypto_payment: CryptoPayment):
|
||||
"""Auto-sweep funds from a confirmed payment to the shop's cold wallet."""
|
||||
logger.info(f"Starting auto-sweep check for payment {crypto_payment.id}")
|
||||
|
||||
if not crypto_payment.shop_sweep_to_address:
|
||||
logger.info(f"Payment {crypto_payment.id} has no sweep address configured")
|
||||
return False
|
||||
|
||||
if crypto_payment.is_swept:
|
||||
logger.info(f"Payment {crypto_payment.id} already swept")
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Payment {crypto_payment.id} needs sweep to {crypto_payment.shop_sweep_to_address}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get balance for the account
|
||||
result = client._call(
|
||||
"get_balance", {"account_index": crypto_payment.account_index}
|
||||
)
|
||||
unlocked_balance = Decimal(result.get("unlocked_balance", 0)) / ATOMIC_UNITS
|
||||
|
||||
logger.info(
|
||||
f"Account {crypto_payment.account_index} unlocked balance: {unlocked_balance} XMR"
|
||||
)
|
||||
|
||||
# Calculate sweep amount for THIS SPECIFIC payment only
|
||||
payment_amount_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
|
||||
|
||||
# If no balance, mark as already swept (another payment swept it)
|
||||
if unlocked_balance == 0:
|
||||
logger.info(
|
||||
f"No balance to sweep for payment {crypto_payment.id} - marking as swept"
|
||||
)
|
||||
crypto_payment.swept_amount = crypto_payment.received_amount
|
||||
crypto_payment.swept_tx_hash = "pooled_sweep"
|
||||
crypto_payment.swept_timestamp = now_timestamp()
|
||||
return True
|
||||
|
||||
# Sweep all available balance minus a small buffer for fees
|
||||
# Use "sweep_all" instead of exact amount to handle fees automatically
|
||||
logger.info(
|
||||
f"Sweeping all funds from account {crypto_payment.account_index} for payment {crypto_payment.id} to {crypto_payment.shop_sweep_to_address}"
|
||||
)
|
||||
|
||||
result = client._call(
|
||||
"sweep_all",
|
||||
{
|
||||
"address": crypto_payment.shop_sweep_to_address,
|
||||
"account_index": crypto_payment.account_index,
|
||||
"priority": 1,
|
||||
"get_tx_hex": False,
|
||||
},
|
||||
)
|
||||
|
||||
tx_hash = result.get("tx_hash")
|
||||
fee = result.get("fee", 0) # Network fee in atomic units
|
||||
|
||||
if tx_hash:
|
||||
# Mark this payment as swept
|
||||
# For sweep_all, the amount_list contains the actual amounts swept
|
||||
amount_list = result.get("amount_list", [])
|
||||
total_swept = (
|
||||
sum(amount_list) if amount_list else unlocked_balance * ATOMIC_UNITS
|
||||
)
|
||||
|
||||
crypto_payment.swept_amount = int(total_swept)
|
||||
crypto_payment.swept_tx_hash = tx_hash
|
||||
crypto_payment.swept_timestamp = now_timestamp()
|
||||
crypto_payment.swept_network_fee = fee
|
||||
logger.info(
|
||||
f"Auto-sweep successful for payment {crypto_payment.id}! TX: {tx_hash}, Swept: {total_swept} atomic units, Fee: {fee} atomic units"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Sweep failed for payment {crypto_payment.id}: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-sweep error for payment {crypto_payment.id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
p = argparse.ArgumentParser(
|
||||
description="Crypto watcher: confirm payments and finalize invoices"
|
||||
)
|
||||
p.add_argument("config_uri", help="Pyramid config file, e.g. development.ini")
|
||||
p.add_argument(
|
||||
"--interval", type=int, default=20, help="Polling interval seconds (default 20)"
|
||||
)
|
||||
p.add_argument("--once", action="store_true", help="Run a single pass then exit")
|
||||
return p.parse_args(argv[1:])
|
||||
|
||||
|
||||
def summarize_txs(transfers: List[dict]):
|
||||
total = 0
|
||||
txids = []
|
||||
confs = []
|
||||
for t in transfers:
|
||||
# monero-wallet-rpc returns atomic units in `amount` and integer `confirmations`
|
||||
amt = int(t.get("amount", 0) or 0)
|
||||
total += amt
|
||||
txid = t.get("txid") or t.get("transaction_id")
|
||||
if txid:
|
||||
txids.append(txid)
|
||||
c = t.get("confirmations")
|
||||
if isinstance(c, int):
|
||||
confs.append(c)
|
||||
min_conf = min(confs) if confs else 0
|
||||
return total, list(dict.fromkeys(txids)), min_conf
|
||||
|
||||
|
||||
# Removed mark_payment_voided - no longer needed without Payment model
|
||||
|
||||
|
||||
def finalize_invoice(env_request, crypto_payment: CryptoPayment):
|
||||
invoice: Invoice = crypto_payment.invoice
|
||||
|
||||
# Unlock products for the purchasing user and notify via email (mirrors Stripe flow)
|
||||
for line_item in invoice.line_items:
|
||||
line_item.product.unlock_for_user(invoice.user)
|
||||
env_request.dbsession.add(line_item.product)
|
||||
|
||||
# Emails (configurable)
|
||||
email_enabled = True
|
||||
try:
|
||||
settings = getattr(getattr(env_request, "registry", None), "settings", {}) or {}
|
||||
val = settings.get("app.email.enabled")
|
||||
if isinstance(val, str):
|
||||
email_enabled = val.strip().lower() in ("1", "true", "yes", "on")
|
||||
elif isinstance(val, bool):
|
||||
email_enabled = val
|
||||
except Exception:
|
||||
# Leave default True if anything goes wrong
|
||||
pass
|
||||
|
||||
if email_enabled:
|
||||
send_purchase_email(
|
||||
env_request,
|
||||
invoice.user.email,
|
||||
[item.product for item in invoice.line_items],
|
||||
invoice.total,
|
||||
)
|
||||
send_sale_email(
|
||||
env_request,
|
||||
invoice.shop,
|
||||
[item.product for item in invoice.line_items],
|
||||
invoice.total,
|
||||
)
|
||||
|
||||
# Deduct inventory for physical products if a shop location is known
|
||||
if crypto_payment.shop_location:
|
||||
for item in invoice.line_items:
|
||||
product = item.product
|
||||
if getattr(product, "is_physical", False):
|
||||
inv = get_inventory_by_product_and_shop_location(
|
||||
env_request.dbsession, product.id, crypto_payment.shop_location.id
|
||||
)
|
||||
if inv:
|
||||
inv.quantity = max(0, int(inv.quantity) - int(item.quantity))
|
||||
env_request.dbsession.add(inv)
|
||||
|
||||
|
||||
def process_payment(
|
||||
env_request,
|
||||
crypto_payment: CryptoPayment,
|
||||
incoming_transfers: List[dict],
|
||||
client=None,
|
||||
):
|
||||
"""Process a single CryptoPayment given already-fetched incoming transfers.
|
||||
|
||||
Mirrors the status and finalization logic for easier unit testing.
|
||||
"""
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
# Initialize payment rescue if client is available
|
||||
payment_rescue = PaymentRescue(env_request.dbsession, client) if client else None
|
||||
|
||||
# Handle expiry
|
||||
if crypto_payment.is_expired:
|
||||
crypto_payment.status = "expired"
|
||||
crypto_payment.updated_timestamp = now_ms
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
|
||||
# Check if we received funds after expiry and can refund
|
||||
if (
|
||||
incoming_transfers
|
||||
and payment_rescue
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
):
|
||||
total_recv, _, _ = summarize_txs(incoming_transfers)
|
||||
if total_recv > 0:
|
||||
received_xmr = Decimal(total_recv) / ATOMIC_UNITS
|
||||
refund_details = payment_rescue.handle_expired_payment(
|
||||
crypto_payment, received_xmr, crypto_payment.invoice.user
|
||||
)
|
||||
if refund_details:
|
||||
logger.info(
|
||||
f"Expired payment {crypto_payment.id} eligible for refund: {refund_details}"
|
||||
)
|
||||
result = payment_rescue.execute_refund(refund_details)
|
||||
if result["success"]:
|
||||
logger.info(
|
||||
f"Refund executed for expired payment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Refund failed for expired payment {crypto_payment.id}: {result['error']}"
|
||||
)
|
||||
return
|
||||
|
||||
# No funds seen yet
|
||||
if not incoming_transfers:
|
||||
crypto_payment.updated_timestamp = now_ms
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
return
|
||||
|
||||
total_recv, txids, min_confs = summarize_txs(incoming_transfers)
|
||||
|
||||
# Existing seen txids
|
||||
try:
|
||||
existing = json.loads(crypto_payment.tx_hashes or "[]")
|
||||
except Exception:
|
||||
existing = []
|
||||
|
||||
# Only count new amounts for txids we haven't seen yet to remain idempotent
|
||||
new_sum = 0
|
||||
seen = set(existing)
|
||||
total_fee = 0
|
||||
for t in incoming_transfers:
|
||||
txid = t.get("txid") or t.get("transaction_id")
|
||||
if txid and txid not in seen:
|
||||
new_sum += int(t.get("amount", 0) or 0)
|
||||
# Capture fee if available (some coins provide this, others don't)
|
||||
fee = t.get("fee", 0)
|
||||
if fee:
|
||||
total_fee += int(fee)
|
||||
|
||||
crypto_payment.received_amount = int(crypto_payment.received_amount or 0) + int(
|
||||
new_sum
|
||||
)
|
||||
|
||||
# Track customer's network fee if available
|
||||
if total_fee > 0:
|
||||
crypto_payment.received_network_fee = total_fee
|
||||
|
||||
# Merge and dedupe txids
|
||||
merged = list(dict.fromkeys(list(existing) + txids))
|
||||
crypto_payment.tx_hashes = json.dumps(merged)
|
||||
|
||||
# Update current confirmation count
|
||||
crypto_payment.current_confirmations = min_confs
|
||||
crypto_payment.updated_timestamp = now_ms
|
||||
|
||||
# Status logic
|
||||
if (
|
||||
crypto_payment.received_amount >= crypto_payment.expected_amount
|
||||
and min_confs >= int(crypto_payment.confirmations_required)
|
||||
):
|
||||
if crypto_payment.status != "confirmed":
|
||||
crypto_payment.status = "confirmed"
|
||||
# Finalize the invoice: unlock products, notify, and update inventory
|
||||
finalize_invoice(env_request, crypto_payment)
|
||||
|
||||
# Check for overpayment refund AFTER confirming the order
|
||||
if (
|
||||
payment_rescue
|
||||
and crypto_payment.received_amount > crypto_payment.expected_amount
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
):
|
||||
received_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
|
||||
expected_xmr = Decimal(crypto_payment.expected_amount) / ATOMIC_UNITS
|
||||
|
||||
refund_details = payment_rescue.handle_overpayment(
|
||||
crypto_payment,
|
||||
expected_xmr,
|
||||
received_xmr,
|
||||
crypto_payment.invoice.user,
|
||||
)
|
||||
|
||||
if refund_details:
|
||||
logger.info(
|
||||
f"Overpayment detected for {crypto_payment.id}: {refund_details}"
|
||||
)
|
||||
result = payment_rescue.execute_refund(refund_details)
|
||||
if result["success"]:
|
||||
logger.info(
|
||||
f"Excess refunded for payment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
)
|
||||
# Mark as confirmed with overpayment refunded
|
||||
crypto_payment.status = "confirmed_overpaid"
|
||||
else:
|
||||
logger.error(
|
||||
f"Excess refund failed for payment {crypto_payment.id}: {result['error']}"
|
||||
)
|
||||
|
||||
# Note: Auto-sweep is handled separately based on unlocked balance, not confirmation status
|
||||
else:
|
||||
if crypto_payment.received_amount > 0 and crypto_payment.status != "received":
|
||||
crypto_payment.status = "received"
|
||||
|
||||
# Check for underpayment rescue when we have enough confirmations
|
||||
if (
|
||||
payment_rescue
|
||||
and min_confs >= int(crypto_payment.confirmations_required)
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
):
|
||||
# Only handle underpayment here (overpayment is handled after confirmation)
|
||||
if crypto_payment.received_amount < crypto_payment.expected_amount:
|
||||
received_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
|
||||
expected_xmr = Decimal(crypto_payment.expected_amount) / ATOMIC_UNITS
|
||||
|
||||
refund_details = payment_rescue.handle_underpayment(
|
||||
crypto_payment,
|
||||
expected_xmr,
|
||||
received_xmr,
|
||||
crypto_payment.invoice.user,
|
||||
)
|
||||
|
||||
if refund_details:
|
||||
logger.info(
|
||||
f"Underpayment {crypto_payment.id} eligible for refund: {refund_details}"
|
||||
)
|
||||
result = payment_rescue.execute_refund(refund_details)
|
||||
if result["success"]:
|
||||
logger.info(
|
||||
f"Refund executed for underpayment {crypto_payment.id}: TX {result['tx_hash']}"
|
||||
)
|
||||
crypto_payment.status = "underpaid_refunded"
|
||||
else:
|
||||
logger.error(
|
||||
f"Refund failed for underpayment {crypto_payment.id}: {result['error']}"
|
||||
)
|
||||
|
||||
# Auto-sweep based on unlocked balance availability, not just confirmation status
|
||||
if (
|
||||
crypto_payment.status in ["confirmed", "confirmed_overpaid"]
|
||||
and client
|
||||
and crypto_payment.shop_sweep_to_address
|
||||
and not crypto_payment.is_swept
|
||||
):
|
||||
logger.info(f"Checking if funds are unlocked for payment {crypto_payment.id}")
|
||||
try:
|
||||
# Check if funds are actually unlocked before attempting sweep
|
||||
result = client._call(
|
||||
"get_balance", {"account_index": crypto_payment.account_index}
|
||||
)
|
||||
unlocked_balance = result.get("unlocked_balance", 0)
|
||||
|
||||
if unlocked_balance > 0:
|
||||
logger.info(
|
||||
f"Funds unlocked ({unlocked_balance} atomic units) - attempting sweep for payment {crypto_payment.id}"
|
||||
)
|
||||
auto_sweep_payment(client, crypto_payment)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Payment {crypto_payment.id} confirmed but funds not yet unlocked"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Auto-sweep check/attempt failed for payment {crypto_payment.id}: {e}"
|
||||
)
|
||||
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
|
||||
|
||||
def run_once(env, interval):
|
||||
request = env["request"]
|
||||
settings = request.registry.settings
|
||||
client = get_client_from_settings(settings)
|
||||
|
||||
logger.info("Crypto watcher starting payment processing cycle")
|
||||
|
||||
with request.tm:
|
||||
db = request.dbsession
|
||||
|
||||
q = db.query(CryptoPayment).filter(
|
||||
CryptoPayment.status.in_(
|
||||
["pending", "received", "confirmed", "confirmed_overpaid"]
|
||||
)
|
||||
)
|
||||
payments = q.all()
|
||||
|
||||
logger.info(
|
||||
f"Found {len(payments)} payments to process: {[p.status for p in payments]}"
|
||||
)
|
||||
|
||||
for crypto_payment in payments:
|
||||
logger.info(
|
||||
f"Processing payment {crypto_payment.id} (status: {crypto_payment.status})"
|
||||
)
|
||||
# Query transfers for subaddress
|
||||
res = (
|
||||
client.get_transfers_for_subaddr(
|
||||
crypto_payment.account_index, [crypto_payment.subaddress_index]
|
||||
)
|
||||
or {}
|
||||
)
|
||||
incoming = res.get("in", []) or []
|
||||
process_payment(request, crypto_payment, incoming, client)
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
args = parse_args(argv)
|
||||
setup_logging(args.config_uri)
|
||||
env = bootstrap(args.config_uri)
|
||||
|
||||
logger.info(
|
||||
f"Crypto watcher started with interval {args.interval}s, once={args.once}"
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
run_once(env, args.interval)
|
||||
if args.once:
|
||||
logger.info("Crypto watcher finished single run")
|
||||
break
|
||||
logger.info(f"Crypto watcher sleeping for {args.interval} seconds")
|
||||
time.sleep(args.interval)
|
||||
finally:
|
||||
logger.info("Crypto watcher shutting down")
|
||||
env["closer"]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
96
make_post_sell/lib/monero_address.py
Normal file
96
make_post_sell/lib/monero_address.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""
|
||||
Monero address validation utilities.
|
||||
|
||||
This module provides functions to validate Monero addresses including
|
||||
checksum validation to catch typos.
|
||||
"""
|
||||
|
||||
import struct
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
# Base58 alphabet used by Monero
|
||||
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
|
||||
def decode_base58(address: str) -> Optional[bytes]:
|
||||
"""Decode a base58 string to bytes."""
|
||||
decoded = 0
|
||||
for char in address:
|
||||
try:
|
||||
decoded = decoded * 58 + BASE58_ALPHABET.index(char)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Convert to bytes
|
||||
hex_str = hex(decoded)[2:]
|
||||
if len(hex_str) % 2:
|
||||
hex_str = "0" + hex_str
|
||||
|
||||
return bytes.fromhex(hex_str)
|
||||
|
||||
|
||||
def keccak_256(data: bytes) -> bytes:
|
||||
"""Compute Keccak-256 hash (not SHA3-256)."""
|
||||
try:
|
||||
from Crypto.Hash import keccak
|
||||
|
||||
k = keccak.new(digest_bits=256)
|
||||
k.update(data)
|
||||
return k.digest()
|
||||
except ImportError:
|
||||
# If pycryptodome not available, we can't validate
|
||||
return b""
|
||||
|
||||
|
||||
def validate_monero_address(address: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate a Monero address.
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
# Basic length check
|
||||
if len(address) not in [95, 106]:
|
||||
return False, "Invalid length - Monero addresses are 95 or 106 characters"
|
||||
|
||||
# Network byte check - Monero mainnet addresses can start with 4, 8, or 5
|
||||
# 4 = standard address, 8 = integrated address, 5 = subaddress
|
||||
if not address[0] in ["4", "8", "5"]:
|
||||
return False, "Invalid network - mainnet addresses start with '4', '8', or '5'"
|
||||
|
||||
# Try to decode base58
|
||||
try:
|
||||
decoded = decode_base58(address)
|
||||
if not decoded:
|
||||
return False, "Invalid base58 encoding"
|
||||
|
||||
# Skip cryptographic validation if we can't import keccak
|
||||
if not keccak_256(b"test"):
|
||||
# Can't do full validation but format is OK
|
||||
return True, ""
|
||||
|
||||
# Validate checksum (last 4 bytes)
|
||||
if len(decoded) < 69: # Minimum size for address
|
||||
return False, "Decoded address too short"
|
||||
|
||||
payload = decoded[:-4]
|
||||
checksum = decoded[-4:]
|
||||
|
||||
# Calculate expected checksum
|
||||
hash_result = keccak_256(payload)
|
||||
expected_checksum = hash_result[:4]
|
||||
|
||||
if checksum != expected_checksum:
|
||||
return False, "Invalid checksum - possible typo in address"
|
||||
|
||||
return True, ""
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Validation error: {str(e)}"
|
||||
|
||||
|
||||
def is_valid_monero_address(address: str) -> bool:
|
||||
"""Simple boolean check for Monero address validity."""
|
||||
valid, _ = validate_monero_address(address)
|
||||
return valid
|
||||
266
make_post_sell/lib/sweep_to_cold.py
Normal file
266
make_post_sell/lib/sweep_to_cold.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
Sweep excess funds from hot wallet to cold storage.
|
||||
|
||||
This script checks the Monero hot wallet balance and automatically
|
||||
transfers any funds above a configured threshold to a cold wallet address.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import requests
|
||||
from pyramid.paster import bootstrap
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
ATOMIC_UNITS = Decimal("1000000000000") # 1 XMR = 10^12 atomic units
|
||||
|
||||
|
||||
class WalletSweeper:
|
||||
"""Handle sweeping excess funds to cold storage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rpc_url: str,
|
||||
rpc_user: Optional[str] = None,
|
||||
rpc_pass: Optional[str] = None,
|
||||
):
|
||||
self.rpc_url = rpc_url
|
||||
self.session = requests.Session()
|
||||
if rpc_user and rpc_pass:
|
||||
self.session.auth = (rpc_user, rpc_pass)
|
||||
|
||||
def _rpc_call(
|
||||
self, method: str, params: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Make RPC call to wallet."""
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "0",
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.post(self.rpc_url, json=payload)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if "error" in result:
|
||||
raise Exception(f"RPC error: {result['error']}")
|
||||
|
||||
return result.get("result", {})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RPC call failed: {e}")
|
||||
raise
|
||||
|
||||
def get_balance(self, account_index: int = 0) -> Dict[str, Decimal]:
|
||||
"""Get wallet balance in XMR."""
|
||||
result = self._rpc_call("get_balance", {"account_index": account_index})
|
||||
|
||||
return {
|
||||
"balance": Decimal(result["balance"]) / ATOMIC_UNITS,
|
||||
"unlocked_balance": Decimal(result["unlocked_balance"]) / ATOMIC_UNITS,
|
||||
}
|
||||
|
||||
def sweep_to_address(
|
||||
self,
|
||||
cold_wallet: str,
|
||||
account_index: int = 0,
|
||||
priority: int = 1,
|
||||
keep_amount: Decimal = Decimal("0.001"),
|
||||
) -> Optional[str]:
|
||||
"""Sweep funds to cold wallet address, keeping minimal reserve."""
|
||||
|
||||
# Get current balance
|
||||
balances = self.get_balance(account_index)
|
||||
unlocked = balances["unlocked_balance"]
|
||||
|
||||
logger.info(f"Current unlocked balance: {unlocked} XMR")
|
||||
|
||||
# Calculate sweep amount (keep minimal for fees)
|
||||
sweep_amount = unlocked - keep_amount
|
||||
|
||||
if sweep_amount <= 0:
|
||||
logger.info(f"Balance too low to sweep (need > {keep_amount} XMR)")
|
||||
return None
|
||||
|
||||
logger.info(f"Sweeping {sweep_amount} XMR to cold storage")
|
||||
|
||||
# Convert to atomic units
|
||||
sweep_atomic = int(sweep_amount * ATOMIC_UNITS)
|
||||
|
||||
# Send transfer
|
||||
try:
|
||||
result = self._rpc_call(
|
||||
"transfer",
|
||||
{
|
||||
"destinations": [{"amount": sweep_atomic, "address": cold_wallet}],
|
||||
"account_index": account_index,
|
||||
"priority": priority,
|
||||
"get_tx_hex": False,
|
||||
},
|
||||
)
|
||||
|
||||
tx_hash = result.get("tx_hash")
|
||||
fee = Decimal(result.get("fee", 0)) / ATOMIC_UNITS
|
||||
|
||||
logger.info(f"Sweep successful! TX: {tx_hash}, Fee: {fee} XMR")
|
||||
return tx_hash
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sweep failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for sweep script."""
|
||||
parser = argparse.ArgumentParser(description="Sweep excess XMR to cold storage")
|
||||
parser.add_argument("config_uri", help="Configuration file (e.g., development.ini)")
|
||||
parser.add_argument(
|
||||
"--account-index",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Account index to sweep from (default: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--priority", type=int, default=1, help="Transaction priority 0-3 (default: 1)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Check balance without sending funds"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sweep-all",
|
||||
action="store_true",
|
||||
help="Aggressive sweep: keep only 0.001 XMR (~7-8 transactions)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shop-id",
|
||||
help="Sweep a specific shop's wallet (uses shop's configured cold wallet)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-shops",
|
||||
action="store_true",
|
||||
help="Sweep all shops with configured wallets",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Bootstrap Pyramid app to get settings
|
||||
env = bootstrap(args.config_uri)
|
||||
settings = env["registry"].settings
|
||||
request = env["request"]
|
||||
|
||||
# Get RPC settings
|
||||
rpc_url = settings.get("monero.rpc_url", "http://127.0.0.1:18083/json_rpc")
|
||||
rpc_user = settings.get("monero.rpc_user")
|
||||
rpc_pass = settings.get("monero.rpc_pass")
|
||||
|
||||
# Create sweeper
|
||||
sweeper = WalletSweeper(rpc_url, rpc_user, rpc_pass)
|
||||
|
||||
# Always sweep to shop-configured addresses
|
||||
from ..models.shop import Shop, get_shop_by_id
|
||||
from ..models.crypto_processor import CryptoProcessor
|
||||
|
||||
if args.shop_id:
|
||||
# Sweep specific shop
|
||||
shop = get_shop_by_id(request.dbsession, args.shop_id)
|
||||
if not shop:
|
||||
logger.error(f"Shop {args.shop_id} not found")
|
||||
sys.exit(1)
|
||||
# Check if shop has XMR processor configured
|
||||
xmr_processor = (
|
||||
request.dbsession.query(CryptoProcessor)
|
||||
.filter_by(shop_id=shop.id, coin_type="XMR", enabled=True)
|
||||
.first()
|
||||
)
|
||||
if not xmr_processor or not xmr_processor.sweep_to_address:
|
||||
logger.error(f"Shop {shop.name} has no Monero processor with cold wallet configured")
|
||||
sys.exit(1)
|
||||
shops_to_sweep = [shop]
|
||||
elif args.all_shops:
|
||||
# Sweep all shops with configured Monero processors
|
||||
shops_with_xmr = (
|
||||
request.dbsession.query(Shop)
|
||||
.join(CryptoProcessor)
|
||||
.filter(
|
||||
CryptoProcessor.coin_type == "XMR",
|
||||
CryptoProcessor.enabled == True,
|
||||
CryptoProcessor.sweep_to_address.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
shops_to_sweep = shops_with_xmr
|
||||
if not shops_to_sweep:
|
||||
logger.info("No shops with configured Monero processors found")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("Must specify --shop-id or --all-shops")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine how much to keep based on sweep mode
|
||||
keep_amount = Decimal("0.001") if args.sweep_all else Decimal("0.005")
|
||||
if args.sweep_all:
|
||||
logger.info("Sweep-all mode: keeping only 0.001 XMR for fees")
|
||||
|
||||
# Process each shop
|
||||
for shop in shops_to_sweep:
|
||||
# Get the XMR processor for this shop
|
||||
xmr_processor = (
|
||||
request.dbsession.query(CryptoProcessor)
|
||||
.filter_by(shop_id=shop.id, coin_type="XMR", enabled=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not xmr_processor:
|
||||
logger.warning(f"Shop {shop.name} has no enabled XMR processor, skipping")
|
||||
continue
|
||||
|
||||
print(f"\n=== Sweeping shop: {shop.name} ===")
|
||||
print(f"Account index: {xmr_processor.wallet_label}")
|
||||
print(f"Cold wallet: {xmr_processor.sweep_to_address}")
|
||||
|
||||
account_index = int(xmr_processor.wallet_label) # wallet_label stores the account index
|
||||
|
||||
try:
|
||||
if args.dry_run:
|
||||
balances = sweeper.get_balance(account_index)
|
||||
print(f"Current balance: {balances['balance']} XMR")
|
||||
print(f"Unlocked balance: {balances['unlocked_balance']} XMR")
|
||||
sweep_amount = balances["unlocked_balance"] - keep_amount
|
||||
if sweep_amount > 0:
|
||||
print(f"Would sweep: {sweep_amount} XMR")
|
||||
else:
|
||||
print("No sweep needed")
|
||||
else:
|
||||
tx_hash = sweeper.sweep_to_address(
|
||||
cold_wallet=xmr_processor.sweep_to_address,
|
||||
account_index=account_index,
|
||||
priority=args.priority,
|
||||
keep_amount=keep_amount,
|
||||
)
|
||||
if tx_hash:
|
||||
print(f"Sweep successful! Transaction: {tx_hash}")
|
||||
else:
|
||||
print("No funds to sweep")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sweep shop {shop.name}: {e}")
|
||||
# Continue with other shops
|
||||
|
||||
env["closer"]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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 *
|
||||
|
||||
|
|
|
|||
139
make_post_sell/models/crypto_payment.py
Normal file
139
make_post_sell/models/crypto_payment.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
BigInteger,
|
||||
String,
|
||||
Numeric,
|
||||
UnicodeText,
|
||||
Unicode,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
|
||||
|
||||
|
||||
class CryptoPayment(RBase, Base):
|
||||
"""
|
||||
Represents a cryptocurrency payment intent tied to a single Invoice.
|
||||
Amounts are stored in the smallest unit (e.g., satoshis for BTC, piconero for XMR).
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
|
||||
# optional: the shop location used for fulfillment (for physical products)
|
||||
shop_location_id = Column(
|
||||
UUIDType, foreign_key("ShopLocation", "id"), nullable=True
|
||||
)
|
||||
|
||||
address = Column(String(128), nullable=False)
|
||||
account_index = Column(Integer, nullable=False, default=0)
|
||||
subaddress_index = Column(Integer, nullable=False)
|
||||
|
||||
# Coin type (e.g., 'XMR', 'BTC', 'LTC', 'DOGE')
|
||||
coin_type = Column(String(10), nullable=False)
|
||||
|
||||
# expected and observed amounts in atomic units (satoshis, piconero, etc.)
|
||||
expected_amount = Column(BigInteger, nullable=False)
|
||||
received_amount = Column(BigInteger, nullable=False, default=0)
|
||||
received_network_fee = Column(
|
||||
BigInteger, nullable=True
|
||||
) # Customer's tx fee (when available from RPC)
|
||||
|
||||
# locked rate at the time of creating the payment (USD per coin)
|
||||
rate_locked_usd_per_coin = Column(Numeric(18, 8), nullable=False)
|
||||
|
||||
quote_expires_at = Column(BigInteger, nullable=False)
|
||||
confirmations_required = Column(Integer, nullable=False, default=10)
|
||||
|
||||
status = Column(String(32), nullable=False, default="pending")
|
||||
# store as JSON string for DB portability
|
||||
tx_hashes = Column(UnicodeText, nullable=False)
|
||||
|
||||
# Shop's wallet configuration at time of payment (for recovery/sweeping)
|
||||
shop_sweep_to_address = Column(
|
||||
Unicode(256), nullable=True
|
||||
) # Where to sweep funds for this payment
|
||||
|
||||
# Customer's refund address (optional, provided at checkout)
|
||||
refund_address = Column(
|
||||
Unicode(256), nullable=True
|
||||
) # Where to send refunds if payment fails/expires
|
||||
|
||||
# Sweep tracking
|
||||
swept_amount = Column(BigInteger, nullable=True) # Amount swept in atomic units
|
||||
swept_tx_hash = Column(Unicode(128), nullable=True) # Transaction hash of the sweep
|
||||
swept_timestamp = Column(BigInteger, nullable=True) # When the sweep occurred
|
||||
swept_network_fee = Column(
|
||||
BigInteger, nullable=True
|
||||
) # Network fee paid (in atomic units, from node)
|
||||
|
||||
# Current confirmation count
|
||||
current_confirmations = Column(
|
||||
Integer, nullable=False, default=0
|
||||
) # Current number of confirmations
|
||||
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
invoice = relationship("Invoice", backref="crypto_payment")
|
||||
shop_location = relationship("ShopLocation", uselist=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
invoice,
|
||||
address,
|
||||
account_index,
|
||||
subaddress_index,
|
||||
coin_type,
|
||||
expected_amount,
|
||||
rate_locked_usd_per_coin: Decimal,
|
||||
quote_expires_at_ms: int,
|
||||
confirmations_required: int = 10,
|
||||
shop_location=None,
|
||||
shop_sweep_to_address=None,
|
||||
refund_address=None,
|
||||
):
|
||||
self.id = uuid.uuid1()
|
||||
self.invoice = invoice
|
||||
self.address = address
|
||||
self.account_index = account_index
|
||||
self.subaddress_index = subaddress_index
|
||||
self.coin_type = coin_type
|
||||
self.expected_amount = int(expected_amount)
|
||||
self.received_amount = 0
|
||||
self.rate_locked_usd_per_coin = rate_locked_usd_per_coin
|
||||
self.quote_expires_at = int(quote_expires_at_ms)
|
||||
self.confirmations_required = confirmations_required
|
||||
self.status = "pending"
|
||||
self.tx_hashes = "[]"
|
||||
self.shop_location = shop_location
|
||||
self.shop_sweep_to_address = shop_sweep_to_address
|
||||
self.refund_address = refund_address
|
||||
now = now_timestamp()
|
||||
self.created_timestamp = now
|
||||
self.updated_timestamp = now
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return now_timestamp() > self.quote_expires_at and self.received_amount == 0
|
||||
|
||||
@property
|
||||
def due_amount(self) -> int:
|
||||
return max(0, self.expected_amount - self.received_amount)
|
||||
|
||||
@property
|
||||
def is_swept(self) -> bool:
|
||||
"""Check if this payment has been swept to cold storage."""
|
||||
return self.swept_tx_hash is not None
|
||||
|
||||
@property
|
||||
def available_to_sweep(self) -> int:
|
||||
"""Calculate amount available to sweep for this specific payment."""
|
||||
if self.is_swept or self.received_amount == 0:
|
||||
return 0
|
||||
# Return the received amount for this specific payment
|
||||
return self.received_amount
|
||||
|
||||
48
make_post_sell/models/crypto_processor.py
Normal file
48
make_post_sell/models/crypto_processor.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import uuid
|
||||
from sqlalchemy import Column, BigInteger, Unicode, Boolean, Index, UniqueConstraint
|
||||
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
|
||||
|
||||
|
||||
class CryptoProcessor(RBase, Base):
|
||||
"""Configuration for cryptocurrency payment processing per shop."""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
|
||||
# Shop this processor belongs to
|
||||
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True)
|
||||
|
||||
# Cryptocurrency type (XMR, DOGE, BTC, LTC, etc)
|
||||
coin_type = Column(Unicode(32), nullable=False, index=True)
|
||||
|
||||
# Is this processor active?
|
||||
enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Remote cold wallet address to sweep funds to
|
||||
sweep_to_address = Column(Unicode(256), nullable=False)
|
||||
|
||||
# Wallet identifier - account index for Monero, label for Bitcoin-like
|
||||
wallet_label = Column(Unicode(128), nullable=False)
|
||||
|
||||
# Timestamps
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
def __init__(self, shop_id, coin_type, sweep_to_address):
|
||||
self.id = uuid.uuid1()
|
||||
self.shop_id = shop_id
|
||||
self.coin_type = coin_type.upper()
|
||||
self.sweep_to_address = sweep_to_address
|
||||
self.created_timestamp = now_timestamp()
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
|
||||
# Create indexes and constraints
|
||||
Index(
|
||||
"ix_crypto_processor_shop_coin", CryptoProcessor.shop_id, CryptoProcessor.coin_type
|
||||
)
|
||||
UniqueConstraint(
|
||||
CryptoProcessor.shop_id,
|
||||
CryptoProcessor.coin_type,
|
||||
name="uq_crypto_processor_shop_coin",
|
||||
)
|
||||
UniqueConstraint(CryptoProcessor.wallet_label, name="uq_crypto_processor_wallet_label")
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
60
make_post_sell/models/user_crypto_refund_address.py
Normal file
60
make_post_sell/models/user_crypto_refund_address.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import uuid
|
||||
from sqlalchemy import Column, BigInteger, Unicode, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
|
||||
|
||||
|
||||
class UserCryptoRefundAddress(RBase, Base):
|
||||
"""
|
||||
Stores cryptocurrency refund addresses for users.
|
||||
One user can have one address per coin type.
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
|
||||
|
||||
# Coin type (e.g., 'XMR', 'BTC', 'LTC', 'DOGE', 'BCH')
|
||||
coin_type = Column(Unicode(32), nullable=False)
|
||||
|
||||
# The refund address for this coin type
|
||||
address = Column(Unicode(256), nullable=False)
|
||||
|
||||
# Optional label/description
|
||||
label = Column(Unicode(128), nullable=True)
|
||||
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="crypto_refund_addresses")
|
||||
|
||||
def __init__(self, user, coin_type, address, label=None):
|
||||
self.id = uuid.uuid1()
|
||||
self.user = user
|
||||
self.coin_type = coin_type.upper()
|
||||
self.address = address
|
||||
self.label = label
|
||||
now = now_timestamp()
|
||||
self.created_timestamp = now
|
||||
self.updated_timestamp = now
|
||||
|
||||
|
||||
# Create unique constraint for user_id + coin_type
|
||||
UniqueConstraint(
|
||||
UserCryptoRefundAddress.user_id,
|
||||
UserCryptoRefundAddress.coin_type,
|
||||
name="uq_user_crypto_refund_address_user_coin",
|
||||
)
|
||||
|
||||
|
||||
def get_user_crypto_refund_address(dbsession, user, coin_type):
|
||||
"""Get the refund address for a user and coin type."""
|
||||
return (
|
||||
dbsession.query(UserCryptoRefundAddress)
|
||||
.filter(
|
||||
UserCryptoRefundAddress.user_id == user.id,
|
||||
UserCryptoRefundAddress.coin_type == coin_type.upper(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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')
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -7,19 +7,25 @@
|
|||
|
||||
<section class="checkout-left well">
|
||||
|
||||
{% if active_card %}
|
||||
<h2><b>Active Card</b></h2>
|
||||
{% if stripe_enabled %}
|
||||
{% if active_card %}
|
||||
<h2><b>Active Card</b></h2>
|
||||
|
||||
{{ stripe.display_card(active_card, actions=False) }}
|
||||
{{ stripe.display_card(active_card, actions=False) }}
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
{% if request.shop and request.shop.is_ready %}
|
||||
<a href="/billing" class="product-edit-button mps-button">Use a different card</a>
|
||||
{% endif %}
|
||||
{% if request.shop and request.shop.is_ready_for_payment(request) %}
|
||||
<a href="/billing" class="product-edit-button mps-button">Use a different card</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h2><b>Payment</b></h2>
|
||||
<p>No active payment method configured. <a href="/billing">Add a payment method</a>.</p>
|
||||
<br>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h2><b>Payment</b></h2>
|
||||
<p>No payment required for this order.</p>
|
||||
<p>Card payments are disabled by configuration.</p>
|
||||
<br>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -36,20 +42,49 @@
|
|||
<h2>Cart Total: ${{ '{:,.2f}'.format(cart.total) }}</h2>
|
||||
|
||||
<br/>
|
||||
Are you sure you want to charge <br/>
|
||||
<b>${{ '{:,.2f}'.format(cart.total) }}</b> to your active credit card?
|
||||
{% if stripe_enabled %}
|
||||
Are you sure you want to charge <br/>
|
||||
<b>${{ '{:,.2f}'.format(cart.total) }}</b> to your active credit card?
|
||||
{% else %}
|
||||
{% if cart.requires_payment %}
|
||||
Choose a payment method below.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<form method="POST" action="{{ request.route_url('user_cart_complete_checkout', cart_id=cart.id) }}">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
<button type="submit" class="cart-checkout-button mps-button">Yes, Complete Checkout</button>
|
||||
</form>
|
||||
{% if stripe_enabled %}
|
||||
<form method="POST" action="{{ request.route_url('user_cart_complete_checkout', cart_id=cart.id) }}">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
<button type="submit" class="cart-checkout-button mps-button">Yes, Complete Checkout</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{# Offer Monero if globally enabled, shop has enabled processor, cart requires payment and is single-shop #}
|
||||
{% if monero_enabled and xmr_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
|
||||
<br/>
|
||||
<form method="POST" action="{{ request.route_url('crypto_xmr_start') }}">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
|
||||
<button type="submit" class="mps-button" style="background:#4c4;">Pay with Monero (XMR)</button>
|
||||
</form>
|
||||
{% if not request.has_xmr_refund_address %}
|
||||
<a href="/u/settings/crypto" target="_blank" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
|
||||
⚠️ Configure an XMR refund address to enable automatic refunds during payment errors.
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not stripe_enabled and not (monero_enabled and xmr_processor_enabled) %}
|
||||
<br/>
|
||||
<p>No payment methods are enabled. Please contact the shop owner.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
258
make_post_sell/templates/crypto_checkout.j2
Normal file
258
make_post_sell/templates/crypto_checkout.j2
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block content -%}
|
||||
<section class="one-column">
|
||||
<h2>{{ coin_name }} ({{ coin_symbol }}) Checkout</h2>
|
||||
|
||||
{% if address and amount_crypto %}
|
||||
{% if coin_symbol == 'XMR' %}
|
||||
{% set amount_fmt = '%.12f' % amount_crypto %}
|
||||
{% else %}
|
||||
{% set amount_fmt = '%.8f' % amount_crypto %}
|
||||
{% endif %}
|
||||
<p>Send exactly <b id="crypto-amount">{{ amount_fmt }}</b> {{ coin_symbol }} to this address:</p>
|
||||
<pre id="crypto-address" style="white-space:pre-wrap;word-wrap:break-word;">{{ address }}</pre>
|
||||
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin: 8px 0;">
|
||||
<button class="mps-button" id="copy-amt" style="background:#4c4;">Copy Amount</button>
|
||||
<button class="mps-button" id="copy-addr" style="background:#4c4;">Copy Address</button>
|
||||
<a class="mps-button" href="{{ wallet_uri_scheme }}:{{ address }}?{% if coin_symbol == 'XMR' %}tx_amount{% else %}amount{% endif %}={{ amount_fmt }}" target="_blank" rel="noopener" style="background:#4c4;">Open in Wallet</a>
|
||||
</div>
|
||||
|
||||
<p>Status: <span id="status">{{ status }}</span></p>
|
||||
<p id="confirmations" style="display:none;">
|
||||
Confirmations: <span id="current-confirmations">0</span> / <span id="required-confirmations">0</span>
|
||||
</p>
|
||||
<p>
|
||||
{% if expires_at %}
|
||||
Expires in: <span id="countdown" data-expires="{{ expires_at }}">--:--</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
<small>Payment ID: <code id="payment-id">{{ payment_id }}</code></small>
|
||||
</p>
|
||||
|
||||
<h3>💰 Quote Details</h3>
|
||||
<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 15px; margin: 10px 0;">
|
||||
<p><strong>Cart Total:</strong> ${{ '%.2f' % usd_total }}</p>
|
||||
<p><strong>Conversion Rate:</strong> ${{ '%.2f' % usd_per_crypto }} USD per {{ coin_symbol }}</p>
|
||||
{% if coin_symbol == 'XMR' %}
|
||||
<p><strong>Base Amount:</strong> {{ '%.12f' % amount_crypto_base }} {{ coin_symbol }}</p>
|
||||
<p><strong>Transaction Fee Buffer:</strong> +{{ '%.12f' % fee_buffer_crypto }} {{ coin_symbol }}</p>
|
||||
<p><strong>Total Amount:</strong> {{ '%.12f' % amount_crypto }} {{ coin_symbol }}</p>
|
||||
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
|
||||
{% else %}
|
||||
<p><strong>Base Amount:</strong> {{ '%.8f' % amount_crypto_base }} {{ coin_symbol }}</p>
|
||||
<p><strong>Transaction Fee Buffer:</strong> +{{ '%.8f' % fee_buffer_crypto }} {{ coin_symbol }}</p>
|
||||
<p><strong>Total Amount:</strong> {{ '%.8f' % amount_crypto }} {{ coin_symbol }}</p>
|
||||
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not has_refund_address %}
|
||||
<small style="color: #666; display: block; margin: 5px 0;">No refund address configured. <a href="/u/settings/crypto">Set one up</a> for automatic refunds in rare error cases.</small>
|
||||
{% endif %}
|
||||
<div id="received" style="display:none;">
|
||||
<small>Received: <span id="recv-piconero"></span> / <span id="exp-piconero"></span> piconero</small>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const paymentId = document.getElementById('payment-id')?.textContent;
|
||||
const statusEl = document.getElementById('status');
|
||||
const recvEl = document.getElementById('recv-piconero');
|
||||
const expEl = document.getElementById('exp-piconero');
|
||||
const recvWrap = document.getElementById('received');
|
||||
const expiresEl = document.getElementById('countdown');
|
||||
const confirmationsEl = document.getElementById('confirmations');
|
||||
const currentConfirmationsEl = document.getElementById('current-confirmations');
|
||||
const requiredConfirmationsEl = document.getElementById('required-confirmations');
|
||||
|
||||
function fmtPico(n){ try { return Number(n).toLocaleString(); } catch(e){ return n; } }
|
||||
|
||||
// Countdown timer
|
||||
let countdownInterval;
|
||||
let expiredWarningShown = false;
|
||||
function disableQuoteButtons() {
|
||||
// Disable payment buttons when quote expires
|
||||
const copyAddrBtn = document.getElementById('copy-addr');
|
||||
const copyAmtBtn = document.getElementById('copy-amt');
|
||||
const walletLink = document.querySelector('a[href^="monero:"], a[href^="bitcoin:"], a[href^="litecoin:"], a[href^="dogecoin:"]');
|
||||
|
||||
[copyAddrBtn, copyAmtBtn].forEach(btn => {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.5';
|
||||
btn.style.cursor = 'not-allowed';
|
||||
btn.style.background = '#ccc';
|
||||
}
|
||||
});
|
||||
|
||||
if (walletLink) {
|
||||
walletLink.style.opacity = '0.5';
|
||||
walletLink.style.pointerEvents = 'none';
|
||||
walletLink.style.background = '#ccc';
|
||||
}
|
||||
}
|
||||
|
||||
// Add warning message for expired payments (only once)
|
||||
function addExpiredWarning() {
|
||||
if (!expiredWarningShown) {
|
||||
const expiredWarning = document.createElement('p');
|
||||
expiredWarning.style.color = '#dc3545';
|
||||
expiredWarning.style.fontWeight = 'bold';
|
||||
expiredWarning.style.marginTop = '10px';
|
||||
expiredWarning.innerHTML = '⚠️ This quote has expired. Please return to cart to generate a new quote.';
|
||||
(statusEl || expiresEl).parentNode.appendChild(expiredWarning);
|
||||
expiredWarningShown = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if payment is already expired even without countdown element
|
||||
if (statusEl && statusEl.textContent.trim() === 'expired') {
|
||||
disableQuoteButtons();
|
||||
addExpiredWarning();
|
||||
// Hide countdown since status already shows expired
|
||||
if (expiresEl) {
|
||||
expiresEl.parentNode.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (expiresEl) {
|
||||
const expiresAt = parseInt(expiresEl.getAttribute('data-expires'), 10);
|
||||
|
||||
// Check if already expired on page load
|
||||
const now = Date.now();
|
||||
if ((now >= expiresAt && statusEl && statusEl.textContent.trim() === 'pending') ||
|
||||
(statusEl && statusEl.textContent.trim() === 'expired')) {
|
||||
disableQuoteButtons();
|
||||
expiresEl.style.color = '#dc3545';
|
||||
expiresEl.textContent = 'EXPIRED';
|
||||
|
||||
addExpiredWarning();
|
||||
|
||||
return; // Don't start countdown if already expired
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
// Stop countdown if payment status is no longer pending
|
||||
if (statusEl && statusEl.textContent.trim() !== 'pending') {
|
||||
expiresEl.textContent = '--:--';
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const ms = Math.max(0, expiresAt - now);
|
||||
|
||||
// Check if expired first
|
||||
if (ms === 0 && statusEl && statusEl.textContent.trim() === 'pending' && !expiredWarningShown) {
|
||||
disableQuoteButtons();
|
||||
expiresEl.style.color = '#dc3545'; // Red color to indicate expiry
|
||||
expiresEl.textContent = 'EXPIRED';
|
||||
|
||||
addExpiredWarning();
|
||||
|
||||
// Stop the countdown timer since quote is expired
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
} else if (!expiredWarningShown) {
|
||||
// Only update countdown if not expired
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
expiresEl.textContent = `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
|
||||
}
|
||||
};
|
||||
tick();
|
||||
countdownInterval = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// Copy buttons
|
||||
const addrBtn = document.getElementById('copy-addr');
|
||||
const amtBtn = document.getElementById('copy-amt');
|
||||
addrBtn?.addEventListener('click', () => {
|
||||
const addr = document.getElementById('crypto-address').textContent;
|
||||
navigator.clipboard.writeText(addr);
|
||||
});
|
||||
amtBtn?.addEventListener('click', () => {
|
||||
const amt = document.getElementById('crypto-amount').textContent;
|
||||
navigator.clipboard.writeText(amt);
|
||||
});
|
||||
|
||||
// Poll status endpoint
|
||||
async function poll(){
|
||||
if (!paymentId) return;
|
||||
try {
|
||||
const res = await fetch(`/crypto/{{ coin_symbol.lower() }}/status/${paymentId}`, { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.status) statusEl.textContent = data.status;
|
||||
|
||||
// Show confirmations if payment has been received
|
||||
if (typeof data.current_confirmations === 'number' && typeof data.confirmations_required === 'number') {
|
||||
currentConfirmationsEl.textContent = data.current_confirmations;
|
||||
requiredConfirmationsEl.textContent = data.confirmations_required;
|
||||
// Show confirmations section if payment is being processed
|
||||
if (data.status !== 'pending' && data.status !== 'expired') {
|
||||
confirmationsEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
const receivedKey = 'received_{{ smallest_unit_name.lower() }}';
|
||||
const expectedKey = 'expected_{{ smallest_unit_name.lower() }}';
|
||||
if (typeof data[receivedKey] === 'number' && typeof data[expectedKey] === 'number'){
|
||||
recvEl.textContent = fmtPico(data[receivedKey]);
|
||||
expEl.textContent = fmtPico(data[expectedKey]);
|
||||
recvWrap.style.display = 'block';
|
||||
}
|
||||
// Optional: redirect when confirmed
|
||||
if (data.status === 'confirmed') {
|
||||
// Redirect to purchases page (matches Stripe flow)
|
||||
window.location.href = '/u/purchases';
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore transient errors
|
||||
}
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p>Monero checkout is initializing. If this message persists, the RPC may not be configured.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h3>⚠️ Payment Information</h3>
|
||||
|
||||
{% if has_refund_address %}
|
||||
<div style="background: #d1fae5; border: 1px solid #10b981; border-radius: 4px; padding: 15px; margin: 20px 0;">
|
||||
<p><strong>✓ Refund address configured</strong></p>
|
||||
<p style="font-family: monospace; background: #f0f0f0; padding: 8px; border-radius: 3px; word-break: break-all;">{{ refund_address }}</p>
|
||||
<p>If you make a payment error, you'll receive an automatic refund minus a 9% restocking fee that covers network costs and prevents abuse.</p>
|
||||
<ul style="margin: 10px 0;">
|
||||
<li><strong>Overpayment:</strong> Fulfilled & excess refunded minus fee</li>
|
||||
<li><strong>Underpayment:</strong> Refunded minus fee</li>
|
||||
<li><strong>Late payment:</strong> Refunded minus fee if sent after the minute expiry</li>
|
||||
<li><strong>Wrong address:</strong> Cannot be recovered</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 4px; padding: 15px; margin: 20px 0;">
|
||||
<p><strong>⚠️ No refund address configured</strong></p>
|
||||
<p>Without a refund address, all payment errors result in lost funds:</p>
|
||||
<ul style="margin: 10px 0;">
|
||||
<li>Underpayments are kept</li>
|
||||
<li>Overpayments are kept</li>
|
||||
<li>Late payments after <strong>{{ ((expires_at - now) // 60000) if expires_at and (expires_at - now) > 0 else 'expiry' }} minutes</strong> are kept</li>
|
||||
<li>Wrong address payments cannot be recovered</li>
|
||||
</ul>
|
||||
<p style="margin-top: 10px;">Consider configuring a refund address for future purchases.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{%- endblock %}
|
||||
|
|
@ -4,6 +4,36 @@
|
|||
<h1>Invoice Identifier<br>{{ invoice.id }}</h1>
|
||||
<p><strong>Customer Name:</strong> {{ invoice.user.name }}</p>
|
||||
<p><strong>Date:</strong> {{ invoice.human_created_timestamp }}</p>
|
||||
<p><strong>Payment Method:</strong> {{ invoice.payment_method.upper() }}</p>
|
||||
<p><strong>Payment Status:</strong> {{ invoice.payment_status }}</p>
|
||||
|
||||
{% if invoice.crypto_payment and invoice.crypto_payment|length > 0 %}
|
||||
{% set crypto_pay = invoice.crypto_payment[0] %}
|
||||
<!-- Crypto Payment Details -->
|
||||
<div style="background-color: #f0f8ff; padding: 15px; border: 1px solid #4169e1; border-radius: 5px; margin: 15px 0;">
|
||||
<h3>Crypto Payment Information</h3>
|
||||
<p><strong>Currency:</strong> {{ crypto_pay.coin_type }}</p>
|
||||
<p><strong>Payment Address:</strong> <code style="font-family: monospace; background-color: #e9e9e9; padding: 2px 5px; border-radius: 3px;">{{ crypto_pay.address }}</code></p>
|
||||
<p><strong>Expected Amount:</strong> {{ '%.12f'|format(crypto_pay.expected_amount / (10**12 if crypto_pay.coin_type == 'XMR' else 10**8)) }} {{ crypto_pay.coin_type }}</p>
|
||||
<p><strong>Received Amount:</strong> {{ '%.12f'|format(crypto_pay.received_amount / (10**12 if crypto_pay.coin_type == 'XMR' else 10**8)) }} {{ crypto_pay.coin_type }}</p>
|
||||
|
||||
{% if crypto_pay.status in ['confirmed', 'confirmed_overpaid'] %}
|
||||
<p style="color: #28a745;"><strong>✓ Payment Confirmed</strong></p>
|
||||
{% elif crypto_pay.status == 'received' %}
|
||||
<p style="color: #ffc107;"><strong>⏳ Awaiting Confirmations ({{ crypto_pay.current_confirmations }}/{{ crypto_pay.confirmations_required }})</strong></p>
|
||||
{% elif crypto_pay.status == 'pending' %}
|
||||
<p style="color: #6c757d;"><strong>⏳ Waiting for Payment</strong></p>
|
||||
{% elif crypto_pay.status == 'expired' %}
|
||||
<p style="color: #dc3545;"><strong>❌ Payment Expired</strong></p>
|
||||
{% elif crypto_pay.status == 'underpaid_refunded' %}
|
||||
<p style="color: #dc3545;"><strong>❌ Underpaid - Refunded</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if crypto_pay.tx_hashes and crypto_pay.tx_hashes != '[]' %}
|
||||
<p><strong>Transaction(s):</strong> <code style="font-size: 0.9em; font-family: monospace;">{{ crypto_pay.tx_hashes }}</code></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pricing Breakdown -->
|
||||
<div style="background-color: #f8f9fa; padding: 15px; border: 1px solid #dee2e6; border-radius: 5px; margin: 15px 0;">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<h3>Shop Settings</h3>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="shop-settings" />
|
||||
|
||||
<label for="name_input">Shop Name</label>
|
||||
<input
|
||||
|
|
@ -77,6 +78,7 @@
|
|||
<h3>Integration Settings</h3>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="integration-settings" />
|
||||
|
||||
<label for="domain_name_input">Custom Shop Domain Name</label>
|
||||
<input
|
||||
|
|
@ -91,23 +93,6 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
<!--
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="google_analytics_id_input">Google Analytics Tracking Id (optional)</label>
|
||||
<input
|
||||
name = "google_analytics_id"
|
||||
type = "text"
|
||||
id = "google_analytics_id"
|
||||
class = "mps-shop-name"
|
||||
value = "{% if google_analytics_id %}{{ google_analytics_id }}{% endif %}"
|
||||
placeholder = "Google Analytics Id"
|
||||
/>
|
||||
|
||||
-->
|
||||
|
||||
<label for="plausible_domain_name_input">Plausible Analytics Domain Name (optional)</label>
|
||||
<input
|
||||
name = "plausible_domain_name"
|
||||
|
|
@ -120,14 +105,35 @@
|
|||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% if request.stripe_enabled %}
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Stripe Settings 💳</h3>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="stripe-settings" />
|
||||
|
||||
<label for="stripe_public_api_key_input">Stripe API Keys</label>
|
||||
|
||||
<br />
|
||||
|
||||
<input type="checkbox" id="toggle" style="">
|
||||
<label for="toggle" style="display: inline;" >show values</label>
|
||||
<input type="checkbox" id="toggle-stripe" style="">
|
||||
<label for="toggle-stripe" style="display: inline;" >show values</label>
|
||||
|
||||
<div class="hidden-control">
|
||||
|
||||
|
|
@ -159,17 +165,163 @@
|
|||
<br />
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Stripe Settings" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
{% endif %}
|
||||
|
||||
{% if request.monero_enabled %}
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Crypto Settings 🪙</h3>
|
||||
|
||||
<h4>Payment Risk Thresholds</h4>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="crypto-settings" />
|
||||
|
||||
<label for="payment_risk_threshold_mid_dollars_input">Medium Risk Threshold ($)</label>
|
||||
<input
|
||||
name = "payment_risk_threshold_mid_dollars"
|
||||
type = "number"
|
||||
id = "payment_risk_threshold_mid_dollars_input"
|
||||
class = "mps-threshold"
|
||||
value = "{{ payment_risk_threshold_mid_dollars }}"
|
||||
placeholder = "10.00"
|
||||
min = "1"
|
||||
max = "1000"
|
||||
step = "0.01"
|
||||
/>
|
||||
<br />
|
||||
<small>Orders below this amount require minimal confirmations. Default: $10.00</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="payment_risk_threshold_high_dollars_input">High Risk Threshold ($)</label>
|
||||
<input
|
||||
name = "payment_risk_threshold_high_dollars"
|
||||
type = "number"
|
||||
id = "payment_risk_threshold_high_dollars_input"
|
||||
class = "mps-threshold"
|
||||
value = "{{ payment_risk_threshold_high_dollars }}"
|
||||
placeholder = "100.00"
|
||||
min = "10"
|
||||
max = "10000"
|
||||
step = "0.01"
|
||||
/>
|
||||
<br />
|
||||
<small>Orders above this amount require maximum confirmations. Default: $100.00</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="crypto_quote_expiry_seconds_input">Cryptocurrency Quote Expiry Time (seconds)</label>
|
||||
<input
|
||||
name = "crypto_quote_expiry_seconds"
|
||||
type = "number"
|
||||
id = "crypto_quote_expiry_seconds_input"
|
||||
class = "mps-crypto-quote-expiry"
|
||||
value = "{{ crypto_quote_expiry_seconds }}"
|
||||
placeholder = "3600"
|
||||
min = "300"
|
||||
max = "7200"
|
||||
step = "60"
|
||||
/>
|
||||
<br />
|
||||
<small>How long cryptocurrency payment quotes are valid (300-7200 seconds, default 3600 = 60 minutes)</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Crypto Settings" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
|
||||
<h4>Monero (XMR) Configuration</h4>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/crypto-processor/XMR" onsubmit="submit.disabled = true; return true;">
|
||||
|
||||
<label for="xmr_sweep_to_address_input">Cold Wallet Address (Required to accept Monero)</label>
|
||||
<input
|
||||
name = "sweep_to_address"
|
||||
type = "text"
|
||||
id = "xmr_sweep_to_address_input"
|
||||
class = "mps-sweep-address"
|
||||
value = "{% if xmr_processor %}{{ xmr_processor.sweep_to_address }}{% endif %}"
|
||||
placeholder = "Monero address to sweep funds to"
|
||||
style = "width: 100%;{% if xmr_processor and not xmr_processor.enabled %} opacity: 0.5; background-color: #f5f5f5;{% endif %}"
|
||||
{% if xmr_processor and not xmr_processor.enabled %}readonly{% endif %}
|
||||
/>
|
||||
<br />
|
||||
<small>Configure your cold wallet address where this shop's Monero funds will be swept. This is required to accept Monero payments.</small>
|
||||
|
||||
{% if xmr_processor %}
|
||||
{% if xmr_processor.enabled %}
|
||||
<br />
|
||||
<br />
|
||||
<small style="color: green;">✓ Monero wallet configured and ready to accept payments</small>
|
||||
<br />
|
||||
<small style="color: #666;">
|
||||
Note: We cannot verify you control this address. Please double-check it's correct!
|
||||
Consider sending a small test amount from another wallet to verify.
|
||||
</small>
|
||||
{% else %}
|
||||
<br />
|
||||
<br />
|
||||
<small style="color: #d44;">✗ Monero payments are currently disabled</small>
|
||||
<br />
|
||||
<small style="color: #666;">
|
||||
Your cold wallet address is preserved but customers cannot select Monero as a payment method.
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% if xmr_processor %}
|
||||
{% if xmr_processor.enabled %}
|
||||
<input type="submit" name="submit" class="mps-submit" value="Update Monero Settings" />
|
||||
<input type="submit" name="disable" class="mps-submit" value="Disable Monero" style="background: #d44; color: white;" />
|
||||
{% else %}
|
||||
<input type="submit" name="submit" class="mps-submit" value="Re-enable Monero" style="background: #4a4;" />
|
||||
<small style="color: #666;">Enter your cold wallet address to re-enable Monero payments</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<input type="submit" name="submit" class="mps-submit" value="Enable Monero Payments" />
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
|
|
@ -214,6 +366,7 @@
|
|||
<section class="shop-settings well">
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="ribbon-settings" />
|
||||
|
||||
<h3>Announcement Ribbon Settings 🎀</h3>
|
||||
|
||||
|
|
@ -280,10 +433,13 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings">
|
||||
<input type="hidden" name="form_section" value="maintenance-settings" />
|
||||
|
||||
<h3>Maintenance Mode 🏗️</h3>
|
||||
|
||||
|
|
@ -318,6 +474,7 @@ Existing sales honored for download buy purchasers.
|
|||
<section class="shop-settings well">
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings">
|
||||
<input type="hidden" name="form_section" value="comment-settings" />
|
||||
|
||||
<h3>Comment System Settings 💬</h3>
|
||||
|
||||
|
|
|
|||
71
make_post_sell/templates/user_crypto_settings.j2
Normal file
71
make_post_sell/templates/user_crypto_settings.j2
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
<section class="one-column">
|
||||
<section class="well">
|
||||
|
||||
<h3>Crypto Payment Preferences</h3>
|
||||
|
||||
<p>Configure your cryptocurrency refund addresses. These addresses will be used automatically by our system in rare cases where refunds are necessary (e.g., system errors). <strong>Note: This is handled entirely by our automated system with no human support.</strong></p>
|
||||
|
||||
<br/>
|
||||
|
||||
{% if enabled_coins %}
|
||||
{% for coin in enabled_coins %}
|
||||
<form method="post" action="/u/settings/crypto/{{ coin }}" onsubmit="submit.disabled = true; return true;">
|
||||
{% include "snippets/csrf.j2" %}
|
||||
|
||||
<h4>{{ coin }} Refund Address</h4>
|
||||
|
||||
<label for="{{ coin.lower() }}_address_input">{{ coin }} Address</label>
|
||||
<input
|
||||
name = "address"
|
||||
type = "text"
|
||||
id = "{{ coin.lower() }}_address_input"
|
||||
class = "mps-crypto-address"
|
||||
value = "{% if addresses.get(coin) %}{{ addresses[coin].address }}{% endif %}"
|
||||
placeholder = "Your {{ coin }} address for automatic refunds"
|
||||
style = "width: 100%;"
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<label for="{{ coin.lower() }}_label_input">Label (Optional)</label>
|
||||
<input
|
||||
name = "label"
|
||||
type = "text"
|
||||
id = "{{ coin.lower() }}_label_input"
|
||||
class = "mps-crypto-label"
|
||||
value = "{% if addresses.get(coin) %}{{ addresses[coin].label }}{% endif %}"
|
||||
placeholder = "e.g., My Hardware Wallet"
|
||||
style = "width: 100%;"
|
||||
/>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% if addresses.get(coin) %}
|
||||
<input type="submit" name="submit" class="mps-submit" value="Update {{ coin }} Address" />
|
||||
{% else %}
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save {{ coin }} Address" />
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
<hr />
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No cryptocurrencies are currently enabled on this platform.</p>
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
|
||||
<a href="/u/settings" class="mps-button">Back to Settings</a>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
@ -66,10 +66,12 @@
|
|||
<a href="/u/shops" class="product-edit-button mps-button">My Shops</a>
|
||||
{% endif %}
|
||||
|
||||
{% if request.shop and request.shop.is_ready %}
|
||||
{% if request.shop and request.shop.is_ready_for_payment(request) %}
|
||||
<a href="/u/purchases" class="product-download-button mps-button">My Purchases</a>
|
||||
<br/>
|
||||
<a href="/billing" class="product-edit-button mps-button">Payment Preferences</a>
|
||||
<a href="/billing" class="product-edit-button mps-button">Credit Card Payment Preferences</a>
|
||||
<br/>
|
||||
<a href="/u/settings/crypto" class="product-edit-button mps-button">Crypto Payment Preferences</a>
|
||||
<br/>
|
||||
<a href="/u/addresses" class="product-edit-button mps-button">Shipping Addresses</a>
|
||||
{% endif %}
|
||||
|
|
|
|||
315
make_post_sell/tests/test_config_expansion.py
Normal file
315
make_post_sell/tests/test_config_expansion.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""Tests for environment variable expansion in configuration."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import TestCase
|
||||
|
||||
from make_post_sell import get_children_settings
|
||||
|
||||
|
||||
class TestEnvironmentVariableExpansion(unittest.TestCase):
|
||||
"""Test environment variable expansion with ${VAR:-default} syntax."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
# Store original environment
|
||||
self.original_env = os.environ.copy()
|
||||
# Clear any test variables
|
||||
for key in list(os.environ.keys()):
|
||||
if key.startswith("TEST_"):
|
||||
del os.environ[key]
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore original environment."""
|
||||
os.environ.clear()
|
||||
os.environ.update(self.original_env)
|
||||
|
||||
def test_simple_expansion_with_value(self):
|
||||
"""Test ${VAR} expansion when variable is set."""
|
||||
os.environ["TEST_VAR"] = "test_value"
|
||||
settings = {"app.test.setting": "${TEST_VAR}", "app.other": "static"}
|
||||
result = get_children_settings(settings, "app")
|
||||
self.assertEqual(result["test.setting"], "test_value")
|
||||
self.assertEqual(result["other"], "static")
|
||||
|
||||
def test_expansion_with_default_used(self):
|
||||
"""Test ${VAR:-default} uses default when var is not set."""
|
||||
# Ensure TEST_MISSING is not in environment
|
||||
if "TEST_MISSING" in os.environ:
|
||||
del os.environ["TEST_MISSING"]
|
||||
|
||||
settings = {
|
||||
"app.payment.enabled": "${TEST_MISSING:-true}",
|
||||
"app.payment.provider": "${TEST_MISSING:-stripe}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
self.assertEqual(
|
||||
result["payment.enabled"], True
|
||||
) # get_int_or_bool_or_none_or_str converts
|
||||
self.assertEqual(result["payment.provider"], "stripe")
|
||||
|
||||
def test_expansion_with_default_not_used(self):
|
||||
"""Test ${VAR:-default} uses env value when var is set."""
|
||||
os.environ["TEST_PAYMENT_ENABLED"] = "false"
|
||||
os.environ["TEST_PROVIDER"] = "monero"
|
||||
|
||||
settings = {
|
||||
"app.payment.enabled": "${TEST_PAYMENT_ENABLED:-true}",
|
||||
"app.payment.provider": "${TEST_PROVIDER:-stripe}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
self.assertEqual(
|
||||
result["payment.enabled"], False
|
||||
) # get_int_or_bool_or_none_or_str converts
|
||||
self.assertEqual(result["payment.provider"], "monero")
|
||||
|
||||
def test_empty_default_value(self):
|
||||
"""Test ${VAR:-} with empty default."""
|
||||
settings = {
|
||||
"app.optional.setting": "${TEST_OPTIONAL:-}",
|
||||
"app.optional.another": "${TEST_ANOTHER:-}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
self.assertEqual(result["optional.setting"], "")
|
||||
self.assertEqual(result["optional.another"], "")
|
||||
|
||||
def test_boolean_conversion(self):
|
||||
"""Test boolean string conversion."""
|
||||
settings = {
|
||||
"app.bool.true1": "${TEST_BOOL:-true}",
|
||||
"app.bool.true2": "${TEST_BOOL:-True}",
|
||||
"app.bool.true3": "${TEST_BOOL:-1}",
|
||||
"app.bool.true4": "${TEST_BOOL:-yes}",
|
||||
"app.bool.false1": "${TEST_BOOL:-false}",
|
||||
"app.bool.false2": "${TEST_BOOL:-False}",
|
||||
"app.bool.false3": "${TEST_BOOL:-0}",
|
||||
"app.bool.false4": "${TEST_BOOL:-no}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
# All true values
|
||||
self.assertEqual(result["bool.true1"], True)
|
||||
self.assertEqual(result["bool.true2"], True)
|
||||
self.assertEqual(result["bool.true3"], True)
|
||||
self.assertEqual(result["bool.true4"], True)
|
||||
|
||||
# All false values
|
||||
self.assertEqual(result["bool.false1"], False)
|
||||
self.assertEqual(result["bool.false2"], False)
|
||||
self.assertEqual(result["bool.false3"], False)
|
||||
self.assertEqual(result["bool.false4"], False)
|
||||
|
||||
def test_integer_conversion(self):
|
||||
"""Test integer string conversion."""
|
||||
settings = {
|
||||
"app.number.int1": "${TEST_NUM:-123}",
|
||||
"app.number.int2": "${TEST_NUM:-0}",
|
||||
"app.number.negative": "${TEST_NUM:--456}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["number.int1"], 123)
|
||||
self.assertEqual(result["number.int2"], 0)
|
||||
self.assertEqual(result["number.negative"], -456)
|
||||
|
||||
def test_none_conversion(self):
|
||||
"""Test None string conversion."""
|
||||
settings = {
|
||||
"app.nullable.none1": "${TEST_NULL:-None}",
|
||||
"app.nullable.none2": "${TEST_NULL:-none}",
|
||||
"app.nullable.null": "${TEST_NULL:-null}",
|
||||
"app.nullable.null2": "${TEST_NULL:-NULL}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertIsNone(result["nullable.none1"])
|
||||
self.assertIsNone(result["nullable.none2"])
|
||||
self.assertIsNone(result["nullable.null"])
|
||||
self.assertIsNone(result["nullable.null2"])
|
||||
|
||||
def test_mixed_syntax(self):
|
||||
"""Test mixing of different variable syntaxes."""
|
||||
os.environ["TEST_SIMPLE"] = "simple_value"
|
||||
os.environ["TEST_BRACES"] = "braces_value"
|
||||
os.environ["TEST_DEFAULT_OVERRIDE"] = "override_value"
|
||||
|
||||
settings = {
|
||||
"app.var.simple": "$TEST_SIMPLE",
|
||||
"app.var.braces": "${TEST_BRACES}",
|
||||
"app.var.default_used": "${TEST_MISSING:-default_value}",
|
||||
"app.var.default_override": "${TEST_DEFAULT_OVERRIDE:-default_value}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["var.simple"], "simple_value")
|
||||
self.assertEqual(result["var.braces"], "braces_value")
|
||||
self.assertEqual(result["var.default_used"], "default_value")
|
||||
self.assertEqual(result["var.default_override"], "override_value")
|
||||
|
||||
def test_complex_default_values(self):
|
||||
"""Test default values with special characters."""
|
||||
settings = {
|
||||
"app.url.default": "${TEST_URL:-http://localhost:8080}",
|
||||
"app.path.default": "${TEST_PATH:-/path/to/file}",
|
||||
"app.json.default": '${TEST_JSON:-{"key": "value"}}',
|
||||
"app.special.chars": "${TEST_SPECIAL:-value-with_special.chars}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["url.default"], "http://localhost:8080")
|
||||
self.assertEqual(result["path.default"], "/path/to/file")
|
||||
self.assertEqual(result["json.default"], '{"key": "value"}')
|
||||
self.assertEqual(result["special.chars"], "value-with_special.chars")
|
||||
|
||||
def test_nested_variables_not_expanded(self):
|
||||
"""Test that nested variable references are not expanded."""
|
||||
os.environ["TEST_INNER"] = "inner_value"
|
||||
|
||||
settings = {
|
||||
"app.nested.var": "${TEST_OUTER:-$TEST_INNER}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
# Should not expand the inner $TEST_INNER
|
||||
self.assertEqual(result["nested.var"], "$TEST_INNER")
|
||||
|
||||
def test_multiple_variables_in_value(self):
|
||||
"""Test multiple variable references in a single value."""
|
||||
os.environ["TEST_HOST"] = "localhost"
|
||||
os.environ["TEST_PORT"] = "8080"
|
||||
|
||||
settings = {
|
||||
"app.url.composite": "${TEST_PROTO:-http}://${TEST_HOST}:${TEST_PORT}/path",
|
||||
"app.mixed.defaults": "${TEST_USER:-admin}:${TEST_PASS:-secret}@${TEST_HOST}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["url.composite"], "http://localhost:8080/path")
|
||||
self.assertEqual(result["mixed.defaults"], "admin:secret@localhost")
|
||||
|
||||
def test_non_string_values_unchanged(self):
|
||||
"""Test that non-string values pass through unchanged."""
|
||||
settings = {
|
||||
"app.already.bool": True,
|
||||
"app.already.int": 42,
|
||||
"app.already.none": None,
|
||||
"app.already.list": ["a", "b", "c"],
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["already.bool"], True)
|
||||
self.assertEqual(result["already.int"], 42)
|
||||
self.assertIsNone(result["already.none"])
|
||||
self.assertEqual(result["already.list"], ["a", "b", "c"])
|
||||
|
||||
def test_parent_key_filtering(self):
|
||||
"""Test that only settings with parent key are returned."""
|
||||
settings = {
|
||||
"app.included.one": "${TEST_VAR:-value1}",
|
||||
"app.included.two": "${TEST_VAR:-value2}",
|
||||
"other.excluded.one": "${TEST_VAR:-excluded1}",
|
||||
"session.excluded.two": "${TEST_VAR:-excluded2}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertIn("included.one", result)
|
||||
self.assertIn("included.two", result)
|
||||
self.assertNotIn("excluded.one", result)
|
||||
self.assertNotIn("excluded.two", result)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Test handling of whitespace in values."""
|
||||
os.environ["TEST_TRIMMED"] = " trimmed "
|
||||
|
||||
settings = {
|
||||
"app.space.env": "${TEST_TRIMMED}",
|
||||
"app.space.default": "${TEST_MISSING:- spaced }",
|
||||
"app.space.empty": "${TEST_MISSING:-}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
# Values should be preserved as-is
|
||||
self.assertEqual(result["space.env"], " trimmed ")
|
||||
self.assertEqual(result["space.default"], " spaced ")
|
||||
self.assertEqual(result["space.empty"], "")
|
||||
|
||||
def test_edge_cases(self):
|
||||
"""Test various edge cases."""
|
||||
settings = {
|
||||
"app.edge.dollar_only": "$",
|
||||
"app.edge.unclosed_brace": "${TEST_VAR",
|
||||
"app.edge.no_var_name": "${}",
|
||||
"app.edge.just_default": "${:-default}",
|
||||
"app.edge.multiple_colons": "${TEST_VAR:-value:with:colons}",
|
||||
"app.edge.brace_in_default": "${TEST_VAR:-value}with}brace}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
# These should remain unchanged or handle gracefully
|
||||
self.assertEqual(result["edge.dollar_only"], "$")
|
||||
self.assertEqual(result["edge.unclosed_brace"], "${TEST_VAR")
|
||||
self.assertEqual(result["edge.no_var_name"], "${}")
|
||||
self.assertEqual(result["edge.just_default"], "default")
|
||||
self.assertEqual(result["edge.multiple_colons"], "value:with:colons")
|
||||
# The regex stops at the first closing brace, so it parses as ${TEST_VAR:-value} + "with}brace}"
|
||||
self.assertEqual(result["edge.brace_in_default"], "valuewith}brace}")
|
||||
|
||||
|
||||
class TestPaymentConfigurationExpansion(unittest.TestCase):
|
||||
"""Test specific payment configuration scenarios."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
self.original_env = os.environ.copy()
|
||||
# Clear any test variables
|
||||
for key in list(os.environ.keys()):
|
||||
if key.startswith("MPS_"):
|
||||
del os.environ[key]
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore original environment."""
|
||||
os.environ.clear()
|
||||
os.environ.update(self.original_env)
|
||||
|
||||
def test_stripe_monero_defaults(self):
|
||||
"""Test default payment configuration values."""
|
||||
settings = {
|
||||
"app.payments.stripe.enabled": "${MPS_PAYMENTS_STRIPE_ENABLED:-true}",
|
||||
"app.payments.monero.enabled": "${MPS_PAYMENTS_MONERO_ENABLED:-false}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["payments.stripe.enabled"], True)
|
||||
self.assertEqual(result["payments.monero.enabled"], False)
|
||||
|
||||
def test_stripe_monero_overrides(self):
|
||||
"""Test overriding payment configuration."""
|
||||
os.environ["MPS_PAYMENTS_STRIPE_ENABLED"] = "false"
|
||||
os.environ["MPS_PAYMENTS_MONERO_ENABLED"] = "true"
|
||||
|
||||
settings = {
|
||||
"app.payments.stripe.enabled": "${MPS_PAYMENTS_STRIPE_ENABLED:-true}",
|
||||
"app.payments.monero.enabled": "${MPS_PAYMENTS_MONERO_ENABLED:-false}",
|
||||
}
|
||||
result = get_children_settings(settings, "app")
|
||||
|
||||
self.assertEqual(result["payments.stripe.enabled"], False)
|
||||
self.assertEqual(result["payments.monero.enabled"], True)
|
||||
|
||||
def test_monero_configuration(self):
|
||||
"""Test Monero RPC configuration expansion."""
|
||||
os.environ["MPS_MONERO_RPC_URL"] = "http://192.168.1.100:18082/json_rpc"
|
||||
os.environ["MPS_MONERO_RPC_USER"] = "monero_user"
|
||||
os.environ["MPS_MONERO_RPC_PASS"] = "monero_pass"
|
||||
|
||||
settings = {
|
||||
"monero.rpc_url": "${MPS_MONERO_RPC_URL:-http://127.0.0.1:18083/json_rpc}",
|
||||
"monero.rpc_user": "${MPS_MONERO_RPC_USER:-}",
|
||||
"monero.rpc_pass": "${MPS_MONERO_RPC_PASS:-}",
|
||||
}
|
||||
result = get_children_settings(settings, "monero")
|
||||
|
||||
self.assertEqual(result["rpc_url"], "http://192.168.1.100:18082/json_rpc")
|
||||
self.assertEqual(result["rpc_user"], "monero_user")
|
||||
self.assertEqual(result["rpc_pass"], "monero_pass")
|
||||
708
make_post_sell/tests/test_crypto_watcher.py
Normal file
708
make_post_sell/tests/test_crypto_watcher.py
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
"""Unit and integration tests for crypto payment watcher."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
from pyramid import testing
|
||||
from pyramid.paster import get_appsettings
|
||||
import transaction
|
||||
|
||||
from ..models import get_tm_session
|
||||
from ..models.meta import Base
|
||||
from ..models.shop import Shop
|
||||
from ..models.shop_location import ShopLocation
|
||||
from ..models.user import User, get_or_create_user_by_email
|
||||
from ..models.product import Product
|
||||
from ..models.invoice import Invoice
|
||||
from ..models.crypto_payment import CryptoPayment
|
||||
from ..models.crypto_processor import CryptoProcessor
|
||||
from ..models.inventory import Inventory
|
||||
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
||||
from ..models.price import Price
|
||||
|
||||
from ..lib.crypto_watcher import (
|
||||
summarize_txs,
|
||||
process_payment,
|
||||
finalize_invoice,
|
||||
auto_sweep_payment,
|
||||
ATOMIC_UNITS,
|
||||
MIN_SWEEP_BALANCE,
|
||||
)
|
||||
from ..lib.crypto_clients import MockMoneroClient
|
||||
|
||||
|
||||
class CryptoWatcherUnitTests(unittest.TestCase):
|
||||
"""Unit tests for crypto_watcher functions."""
|
||||
|
||||
def test_summarize_txs_empty(self):
|
||||
"""Test summarizing empty transaction list."""
|
||||
total, txids, min_conf = summarize_txs([])
|
||||
self.assertEqual(total, 0)
|
||||
self.assertEqual(txids, [])
|
||||
self.assertEqual(min_conf, 0)
|
||||
|
||||
def test_summarize_txs_single(self):
|
||||
"""Test summarizing single transaction."""
|
||||
transfers = [
|
||||
{
|
||||
"amount": 1000000000000, # 1 XMR in atomic units
|
||||
"txid": "tx123",
|
||||
"confirmations": 5,
|
||||
}
|
||||
]
|
||||
total, txids, min_conf = summarize_txs(transfers)
|
||||
self.assertEqual(total, 1000000000000)
|
||||
self.assertEqual(txids, ["tx123"])
|
||||
self.assertEqual(min_conf, 5)
|
||||
|
||||
def test_summarize_txs_multiple(self):
|
||||
"""Test summarizing multiple transactions."""
|
||||
transfers = [
|
||||
{"amount": 500000000000, "txid": "tx1", "confirmations": 10},
|
||||
{"amount": 300000000000, "txid": "tx2", "confirmations": 5},
|
||||
{"amount": 200000000000, "txid": "tx3", "confirmations": 15},
|
||||
]
|
||||
total, txids, min_conf = summarize_txs(transfers)
|
||||
self.assertEqual(total, 1000000000000)
|
||||
self.assertEqual(set(txids), {"tx1", "tx2", "tx3"})
|
||||
self.assertEqual(min_conf, 5) # Minimum confirmations
|
||||
|
||||
def test_summarize_txs_duplicate_txids(self):
|
||||
"""Test summarizing with duplicate transaction IDs."""
|
||||
transfers = [
|
||||
{"amount": 500000000000, "txid": "tx1", "confirmations": 10},
|
||||
{"amount": 300000000000, "txid": "tx1", "confirmations": 10}, # Duplicate
|
||||
]
|
||||
total, txids, min_conf = summarize_txs(transfers)
|
||||
self.assertEqual(total, 800000000000)
|
||||
self.assertEqual(txids, ["tx1"]) # Deduped
|
||||
self.assertEqual(min_conf, 10)
|
||||
|
||||
def test_summarize_txs_missing_fields(self):
|
||||
"""Test summarizing with missing fields."""
|
||||
transfers = [
|
||||
{"amount": None, "txid": "tx1", "confirmations": 5},
|
||||
{"amount": 100, "confirmations": 3}, # No txid
|
||||
{"amount": 200, "txid": "tx2"}, # No confirmations
|
||||
]
|
||||
total, txids, min_conf = summarize_txs(transfers)
|
||||
self.assertEqual(total, 300)
|
||||
self.assertEqual(txids, ["tx1", "tx2"])
|
||||
self.assertEqual(min_conf, 3)
|
||||
|
||||
|
||||
class CryptoWatcherIntegrationTests(unittest.TestCase):
|
||||
"""Integration tests for crypto_watcher with database."""
|
||||
|
||||
def setUp(self):
|
||||
from make_post_sell import main
|
||||
|
||||
settings = get_appsettings("test.ini")
|
||||
self.app = main({}, **settings)
|
||||
self.session_factory = self.app.registry["dbsession_factory"]
|
||||
self.engine = self.session_factory.kw["bind"]
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
|
||||
# Create test data
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
|
||||
# Create test user
|
||||
self.test_user = get_or_create_user_by_email(
|
||||
dbsession, "watcher_test@example.com"
|
||||
)
|
||||
self.test_user.is_email_verified = True
|
||||
dbsession.add(self.test_user)
|
||||
|
||||
# Create test shop and location
|
||||
self.test_shop = Shop(
|
||||
name="WatcherTestShop",
|
||||
phone_number="555-1234",
|
||||
billing_address="123 Test St",
|
||||
description="Test shop for crypto watcher",
|
||||
)
|
||||
self.test_shop.stripe_user_id = "test_stripe_user"
|
||||
self.test_shop.stripe_publishable_api_key = "test_key"
|
||||
dbsession.add(self.test_shop)
|
||||
|
||||
self.test_location = ShopLocation(
|
||||
shop=self.test_shop,
|
||||
name="Main Location",
|
||||
address="789 Main St",
|
||||
city="Test City",
|
||||
state="TS",
|
||||
country="Test Country",
|
||||
postal_code="12345",
|
||||
)
|
||||
dbsession.add(self.test_location)
|
||||
|
||||
# Create test product (digital)
|
||||
self.test_product = Product(
|
||||
title="Test Digital Product", description="Digital test product"
|
||||
)
|
||||
self.test_product.shop_id = self.test_shop.id
|
||||
self.test_product.price_in_cents = 1500 # $15
|
||||
self.test_product.is_physical = False
|
||||
dbsession.add(self.test_product)
|
||||
|
||||
# Create price history for digital product
|
||||
price1 = Price(self.test_product, 1500)
|
||||
dbsession.add(price1)
|
||||
|
||||
# Create physical product
|
||||
self.physical_product = Product(
|
||||
title="Test Physical Product", description="Physical test product"
|
||||
)
|
||||
self.physical_product.shop_id = self.test_shop.id
|
||||
self.physical_product.price_in_cents = 2000 # $20
|
||||
self.physical_product.is_physical = True
|
||||
dbsession.add(self.physical_product)
|
||||
|
||||
# Create price history for physical product
|
||||
price2 = Price(self.physical_product, 2000)
|
||||
dbsession.add(price2)
|
||||
|
||||
dbsession.flush()
|
||||
|
||||
# Create inventory for physical product
|
||||
self.inventory = Inventory(
|
||||
product=self.physical_product,
|
||||
shop_location=self.test_location,
|
||||
quantity=10,
|
||||
)
|
||||
dbsession.add(self.inventory)
|
||||
|
||||
# Create test invoice
|
||||
self.test_invoice = Invoice(self.test_user)
|
||||
self.test_invoice.shop_id = self.test_shop.id
|
||||
self.test_invoice.new_line_item(product=self.test_product, quantity=1)
|
||||
dbsession.add(self.test_invoice)
|
||||
|
||||
dbsession.flush()
|
||||
|
||||
# Create test crypto payment
|
||||
self.test_payment = CryptoPayment(
|
||||
invoice=self.test_invoice,
|
||||
address="test_xmr_address",
|
||||
account_index=0,
|
||||
subaddress_index=1,
|
||||
coin_type="XMR",
|
||||
expected_amount=100000000000, # 0.1 XMR
|
||||
rate_locked_usd_per_coin=150.0,
|
||||
quote_expires_at_ms=int((time.time() + 900) * 1000), # 15 min from now
|
||||
confirmations_required=1,
|
||||
shop_location=self.test_location,
|
||||
)
|
||||
dbsession.add(self.test_payment)
|
||||
|
||||
dbsession.flush()
|
||||
self.payment_id = self.test_payment.id
|
||||
self.invoice_id = self.test_invoice.id
|
||||
self.product_id = self.test_product.id
|
||||
self.user_id = self.test_user.id
|
||||
self.shop_id = self.test_shop.id
|
||||
self.location_id = self.test_location.id
|
||||
self.physical_product_id = self.physical_product.id
|
||||
|
||||
def tearDown(self):
|
||||
transaction.abort()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.send_purchase_email")
|
||||
@patch("make_post_sell.lib.crypto_watcher.send_sale_email")
|
||||
def test_finalize_invoice(self, mock_sale_email, mock_purchase_email):
|
||||
"""Test finalizing an invoice unlocks products and sends emails."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
|
||||
# Create mock request
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
mock_request.registry.settings = {"app.email.enabled": "true"}
|
||||
|
||||
# Finalize the invoice
|
||||
finalize_invoice(mock_request, payment)
|
||||
|
||||
# Check product was unlocked
|
||||
# Query fresh from database to avoid session issues
|
||||
from ..models.user_product import UserProduct
|
||||
|
||||
userproduct = (
|
||||
dbsession.query(UserProduct)
|
||||
.filter_by(product_id=self.product_id, user_id=self.user_id)
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(userproduct)
|
||||
|
||||
# Check emails were sent
|
||||
mock_purchase_email.assert_called_once()
|
||||
mock_sale_email.assert_called_once()
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.send_purchase_email")
|
||||
@patch("make_post_sell.lib.crypto_watcher.send_sale_email")
|
||||
def test_finalize_invoice_with_physical_product(
|
||||
self, mock_sale_email, mock_purchase_email
|
||||
):
|
||||
"""Test finalizing invoice with physical product deducts inventory."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
|
||||
# Get fresh objects from session
|
||||
user = dbsession.query(User).filter_by(id=self.user_id).first()
|
||||
physical_product = (
|
||||
dbsession.query(Product).filter_by(id=self.physical_product_id).first()
|
||||
)
|
||||
location = (
|
||||
dbsession.query(ShopLocation).filter_by(id=self.location_id).first()
|
||||
)
|
||||
|
||||
# Create invoice with physical product
|
||||
invoice = Invoice(user)
|
||||
invoice.shop_id = self.shop_id
|
||||
invoice.new_line_item(product=physical_product, quantity=3)
|
||||
dbsession.add(invoice)
|
||||
|
||||
payment = CryptoPayment(
|
||||
invoice=invoice,
|
||||
address="test_address2",
|
||||
account_index=0,
|
||||
subaddress_index=2,
|
||||
coin_type="XMR",
|
||||
expected_amount=200000000000,
|
||||
rate_locked_usd_per_coin=150.0,
|
||||
quote_expires_at_ms=int((time.time() + 900) * 1000),
|
||||
confirmations_required=1,
|
||||
shop_location=location,
|
||||
)
|
||||
dbsession.add(payment)
|
||||
dbsession.flush()
|
||||
|
||||
# Check initial inventory
|
||||
inventory = (
|
||||
dbsession.query(Inventory)
|
||||
.filter_by(
|
||||
product_id=self.physical_product_id,
|
||||
shop_location_id=self.location_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
self.assertEqual(inventory.quantity, 10)
|
||||
|
||||
# Create mock request
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
mock_request.registry.settings = {}
|
||||
|
||||
# Finalize
|
||||
finalize_invoice(mock_request, payment)
|
||||
|
||||
# Check inventory was deducted
|
||||
self.assertEqual(inventory.quantity, 7) # 10 - 3
|
||||
|
||||
def test_process_payment_no_transfers(self):
|
||||
"""Test processing payment with no incoming transfers."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process with no transfers
|
||||
process_payment(mock_request, payment, [])
|
||||
|
||||
# Payment should still be pending
|
||||
self.assertEqual(payment.status, "pending")
|
||||
self.assertEqual(payment.received_amount, 0)
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
|
||||
def test_process_payment_sufficient_funds_and_confirmations(self, mock_finalize):
|
||||
"""Test processing payment with sufficient funds and confirmations."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process with sufficient transfer
|
||||
transfers = [
|
||||
{
|
||||
"amount": 100000000000, # Exactly what's expected
|
||||
"txid": "tx123",
|
||||
"confirmations": 2, # More than required (1)
|
||||
}
|
||||
]
|
||||
|
||||
process_payment(mock_request, payment, transfers)
|
||||
|
||||
# Payment should be confirmed
|
||||
self.assertEqual(payment.status, "confirmed")
|
||||
self.assertEqual(payment.received_amount, 100000000000)
|
||||
self.assertEqual(json.loads(payment.tx_hashes), ["tx123"])
|
||||
|
||||
# Invoice should be finalized
|
||||
mock_finalize.assert_called_once_with(mock_request, payment)
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
|
||||
def test_process_payment_insufficient_confirmations(self, mock_finalize):
|
||||
"""Test processing payment with insufficient confirmations."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
payment.confirmations_required = 10 # Require 10 confirmations
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process with insufficient confirmations
|
||||
transfers = [
|
||||
{
|
||||
"amount": 100000000000,
|
||||
"txid": "tx123",
|
||||
"confirmations": 5, # Only 5 confirmations
|
||||
}
|
||||
]
|
||||
|
||||
process_payment(mock_request, payment, transfers)
|
||||
|
||||
# Payment should be received (has funds but not enough confirmations)
|
||||
self.assertEqual(payment.status, "received")
|
||||
self.assertEqual(payment.received_amount, 100000000000)
|
||||
|
||||
# Invoice should NOT be finalized
|
||||
mock_finalize.assert_not_called()
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
|
||||
def test_process_payment_overpayment(self, mock_finalize):
|
||||
"""Test processing overpayment without refund (no refund address)."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process with overpayment
|
||||
transfers = [
|
||||
{
|
||||
"amount": 150000000000, # 1.5x expected
|
||||
"txid": "tx123",
|
||||
"confirmations": 5,
|
||||
}
|
||||
]
|
||||
|
||||
process_payment(mock_request, payment, transfers)
|
||||
|
||||
# Payment should be confirmed (not confirmed_overpaid since no refund)
|
||||
self.assertEqual(payment.status, "confirmed")
|
||||
self.assertEqual(payment.received_amount, 150000000000)
|
||||
|
||||
# Invoice should be finalized
|
||||
mock_finalize.assert_called_once()
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
|
||||
@patch("make_post_sell.lib.crypto_watcher.PaymentRescue")
|
||||
def test_process_payment_overpayment_with_refund(
|
||||
self, mock_rescue_class, mock_finalize
|
||||
):
|
||||
"""Test processing overpayment with refund."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
|
||||
# Add refund address to simulate user having configured one
|
||||
payment.refund_address = "refund_address_123"
|
||||
|
||||
# Get invoice and user
|
||||
invoice = payment.invoice
|
||||
user = invoice.user
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Mock PaymentRescue
|
||||
mock_rescue = MagicMock()
|
||||
mock_rescue.handle_overpayment.return_value = {
|
||||
"payment_id": payment.id,
|
||||
"refund_address": "refund_address_123",
|
||||
"amount_xmr": Decimal("0.5"), # Excess amount
|
||||
}
|
||||
mock_rescue.execute_refund.return_value = {
|
||||
"success": True,
|
||||
"tx_hash": "refund_tx_123",
|
||||
}
|
||||
mock_rescue_class.return_value = mock_rescue
|
||||
|
||||
# Process with overpayment
|
||||
transfers = [
|
||||
{
|
||||
"amount": 150000000000, # 1.5x expected
|
||||
"txid": "tx123",
|
||||
"confirmations": 5,
|
||||
}
|
||||
]
|
||||
|
||||
# Mock client for PaymentRescue
|
||||
mock_client = MagicMock()
|
||||
process_payment(mock_request, payment, transfers, mock_client)
|
||||
|
||||
# Payment should be confirmed_overpaid
|
||||
self.assertEqual(payment.status, "confirmed_overpaid")
|
||||
self.assertEqual(payment.received_amount, 150000000000)
|
||||
|
||||
# Refund should have been executed
|
||||
mock_rescue.handle_overpayment.assert_called_once()
|
||||
mock_rescue.execute_refund.assert_called_once()
|
||||
|
||||
# Invoice should be finalized
|
||||
mock_finalize.assert_called_once()
|
||||
|
||||
def test_process_payment_expired(self):
|
||||
"""Test processing expired payment."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
# Set payment as expired
|
||||
payment.quote_expires_at = int((time.time() - 100) * 1000)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process expired payment
|
||||
process_payment(mock_request, payment, [])
|
||||
|
||||
# Payment should be marked expired
|
||||
self.assertEqual(payment.status, "expired")
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.PaymentRescue")
|
||||
def test_process_payment_expired_with_refund(self, mock_rescue_class):
|
||||
"""Test processing expired payment with funds triggers refund."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
payment.quote_expires_at = int((time.time() - 100) * 1000)
|
||||
|
||||
# Get user from DB and add refund address
|
||||
user = dbsession.query(User).filter_by(id=self.user_id).first()
|
||||
refund_addr = UserCryptoRefundAddress(
|
||||
user=user, coin_type="XMR", address="refund_address_123"
|
||||
)
|
||||
dbsession.add(refund_addr)
|
||||
payment.refund_address = "refund_address_123"
|
||||
|
||||
# Mock client
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Mock PaymentRescue
|
||||
mock_rescue = MagicMock()
|
||||
mock_rescue.handle_expired_payment.return_value = {
|
||||
"payment_id": payment.id,
|
||||
"refund_address": "refund_address_123",
|
||||
"amount_xmr": Decimal("0.1"),
|
||||
}
|
||||
mock_rescue.execute_refund.return_value = {
|
||||
"success": True,
|
||||
"tx_hash": "refund_tx_123",
|
||||
}
|
||||
mock_rescue_class.return_value = mock_rescue
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process expired payment with funds
|
||||
transfers = [
|
||||
{"amount": 100000000000, "txid": "late_tx", "confirmations": 10}
|
||||
]
|
||||
|
||||
process_payment(mock_request, payment, transfers, client=mock_client)
|
||||
|
||||
# Payment should be expired
|
||||
self.assertEqual(payment.status, "expired")
|
||||
|
||||
# Refund should be attempted
|
||||
mock_rescue.handle_expired_payment.assert_called_once()
|
||||
mock_rescue.execute_refund.assert_called_once()
|
||||
|
||||
def test_process_payment_idempotent(self):
|
||||
"""Test processing same payment multiple times is idempotent."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# First transfer
|
||||
transfers1 = [{"amount": 50000000000, "txid": "tx1", "confirmations": 0}]
|
||||
|
||||
process_payment(mock_request, payment, transfers1)
|
||||
self.assertEqual(payment.received_amount, 50000000000)
|
||||
|
||||
# Process same transfer again - should not double count
|
||||
process_payment(mock_request, payment, transfers1)
|
||||
self.assertEqual(payment.received_amount, 50000000000)
|
||||
|
||||
# Add new transfer
|
||||
transfers2 = transfers1 + [
|
||||
{"amount": 30000000000, "txid": "tx2", "confirmations": 0}
|
||||
]
|
||||
|
||||
process_payment(mock_request, payment, transfers2)
|
||||
self.assertEqual(payment.received_amount, 80000000000)
|
||||
|
||||
@patch("make_post_sell.lib.crypto_watcher.auto_sweep_payment")
|
||||
@patch("make_post_sell.lib.crypto_watcher.finalize_invoice")
|
||||
def test_process_payment_triggers_auto_sweep(self, mock_finalize, mock_sweep):
|
||||
"""Test that confirming payment triggers auto-sweep."""
|
||||
with transaction.manager:
|
||||
dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
payment = (
|
||||
dbsession.query(CryptoPayment).filter_by(id=self.payment_id).first()
|
||||
)
|
||||
payment.shop_sweep_to_address = "cold_wallet_address"
|
||||
|
||||
mock_client = MagicMock()
|
||||
# Mock get_balance to return unlocked funds
|
||||
mock_client._call.return_value = {"unlocked_balance": 100000000000}
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.dbsession = dbsession
|
||||
|
||||
# Process with sufficient payment
|
||||
transfers = [{"amount": 100000000000, "txid": "tx123", "confirmations": 2}]
|
||||
|
||||
process_payment(mock_request, payment, transfers, client=mock_client)
|
||||
|
||||
# Auto-sweep should be called after balance check
|
||||
mock_sweep.assert_called_with(mock_client, payment)
|
||||
self.assertEqual(mock_sweep.call_count, 1)
|
||||
|
||||
|
||||
class AutoSweepTests(unittest.TestCase):
|
||||
"""Unit tests for auto-sweep functionality."""
|
||||
|
||||
def test_auto_sweep_no_address(self):
|
||||
"""Test auto-sweep with no sweep address configured."""
|
||||
mock_client = MagicMock()
|
||||
payment = MagicMock()
|
||||
payment.id = "test_id"
|
||||
payment.shop_sweep_to_address = None
|
||||
payment.is_swept = False
|
||||
|
||||
result = auto_sweep_payment(mock_client, payment)
|
||||
self.assertFalse(result)
|
||||
mock_client._call.assert_not_called()
|
||||
|
||||
def test_auto_sweep_insufficient_balance(self):
|
||||
"""Test auto-sweep with no balance available."""
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.return_value = {"unlocked_balance": 0} # No XMR available
|
||||
|
||||
payment = MagicMock()
|
||||
payment.id = "test_payment_id"
|
||||
payment.shop_sweep_to_address = "cold_wallet"
|
||||
payment.account_index = 0
|
||||
payment.received_amount = 1000000000000 # 1 XMR expected
|
||||
payment.is_swept = False
|
||||
|
||||
result = auto_sweep_payment(mock_client, payment)
|
||||
self.assertTrue(result) # Returns True and marks as swept
|
||||
|
||||
# Should only check balance, no sweep needed since balance is 0
|
||||
mock_client._call.assert_called_once_with("get_balance", {"account_index": 0})
|
||||
|
||||
# Should mark payment as swept with pooled_sweep indicator
|
||||
self.assertEqual(payment.swept_tx_hash, "pooled_sweep")
|
||||
|
||||
def test_auto_sweep_success(self):
|
||||
"""Test successful auto-sweep."""
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.side_effect = [
|
||||
# First call - get_balance
|
||||
{"unlocked_balance": 1000000000000}, # 1 XMR
|
||||
# Second call - sweep_all
|
||||
{"tx_hash": "sweep_tx_123", "amount_list": [1000000000000]},
|
||||
]
|
||||
|
||||
payment = MagicMock()
|
||||
payment.id = "test_payment_id"
|
||||
payment.shop_sweep_to_address = "cold_wallet_address"
|
||||
payment.account_index = 0
|
||||
payment.received_amount = 500000000000 # 0.5 XMR
|
||||
payment.is_swept = False
|
||||
|
||||
result = auto_sweep_payment(mock_client, payment)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Check sweep_all was called with correct parameters
|
||||
sweep_call = mock_client._call.call_args_list[1]
|
||||
self.assertEqual(sweep_call[0][0], "sweep_all")
|
||||
sweep_params = sweep_call[0][1]
|
||||
self.assertEqual(sweep_params["address"], "cold_wallet_address")
|
||||
self.assertEqual(sweep_params["account_index"], 0)
|
||||
|
||||
# Check sweep tracking fields were set
|
||||
self.assertEqual(
|
||||
payment.swept_amount, 1000000000000
|
||||
) # Uses amount from amount_list
|
||||
self.assertEqual(payment.swept_tx_hash, "sweep_tx_123")
|
||||
self.assertIsNotNone(payment.swept_timestamp)
|
||||
|
||||
def test_auto_sweep_already_swept(self):
|
||||
"""Test auto-sweep skips already swept payment."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
payment = MagicMock()
|
||||
payment.id = "test_payment_id"
|
||||
payment.shop_sweep_to_address = "cold_wallet_address"
|
||||
payment.is_swept = True # Already swept
|
||||
|
||||
result = auto_sweep_payment(mock_client, payment)
|
||||
self.assertTrue(result) # Returns True but does nothing
|
||||
|
||||
# Should not make any RPC calls
|
||||
mock_client._call.assert_not_called()
|
||||
|
||||
def test_auto_sweep_rpc_error(self):
|
||||
"""Test auto-sweep with RPC error."""
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.side_effect = Exception("RPC connection error")
|
||||
|
||||
payment = MagicMock()
|
||||
payment.id = "test_payment_id"
|
||||
payment.shop_sweep_to_address = "cold_wallet"
|
||||
payment.account_index = 0
|
||||
payment.received_amount = 500000000000
|
||||
payment.is_swept = False
|
||||
|
||||
result = auto_sweep_payment(mock_client, payment)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
385
make_post_sell/views/crypto.py
Normal file
385
make_post_sell/views/crypto.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
from pyramid.view import view_config
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPBadRequest
|
||||
from pyramid.response import Response
|
||||
|
||||
from ..models.cart import get_cart_by_id
|
||||
from ..models.invoice import Invoice
|
||||
from ..models.crypto_payment import CryptoPayment
|
||||
|
||||
from ..lib.crypto_clients import get_client_from_settings
|
||||
|
||||
from . import (
|
||||
user_required,
|
||||
shop_is_ready_required,
|
||||
)
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid as _uuid
|
||||
import urllib.request
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="crypto_xmr_start",
|
||||
request_method="POST",
|
||||
require_csrf=True,
|
||||
renderer="crypto_checkout.j2",
|
||||
)
|
||||
def crypto_xmr_start(request):
|
||||
# Check Monero enabled toggle first (no DB access)
|
||||
if not request.monero_enabled:
|
||||
request.session.flash(
|
||||
("Monero payments are disabled by configuration.", "error")
|
||||
)
|
||||
return HTTPFound("/cart")
|
||||
|
||||
cart_id = request.params.get("cart_id")
|
||||
if not cart_id:
|
||||
request.session.flash(("Missing cart_id.", "error"))
|
||||
return HTTPFound("/cart")
|
||||
|
||||
# First database access - this establishes the transaction
|
||||
cart = get_cart_by_id(request.dbsession, cart_id)
|
||||
if cart is None:
|
||||
request.session.flash(("Invalid cart.", "error"))
|
||||
return HTTPFound("/cart")
|
||||
|
||||
# Now check user authentication (after DB transaction is established)
|
||||
if not (request.user and request.user.authenticated):
|
||||
request.session.flash(
|
||||
("To use Monero checkout, please verify your email.", "info")
|
||||
)
|
||||
return HTTPFound(request.route_url("join-or-log-in"))
|
||||
|
||||
# Now check if shop is ready for payment (after DB transaction is established)
|
||||
if not (request.shop and request.shop.is_ready_for_payment(request)):
|
||||
request.session.flash(
|
||||
(
|
||||
"Sorry, this shop is not ready to make sales yet. Please try again later.",
|
||||
"error",
|
||||
)
|
||||
)
|
||||
from . import get_referer_or_home
|
||||
|
||||
return HTTPFound(get_referer_or_home(request))
|
||||
|
||||
if request.user.does_not_own_cart(cart):
|
||||
request.session.flash(("You do not own this cart.", "error"))
|
||||
return HTTPFound("/cart")
|
||||
|
||||
# single-shop constraint for MVP
|
||||
if len(cart.shop_product_dict.keys()) != 1:
|
||||
request.session.flash(
|
||||
("Monero checkout only supports single-shop carts.", "error")
|
||||
)
|
||||
return HTTPFound(f"/cart/{cart.id}")
|
||||
|
||||
if not cart.requires_payment:
|
||||
request.session.flash(("No payment required for this order.", "info"))
|
||||
return HTTPFound(f"/cart/{cart.id}")
|
||||
|
||||
# Ensure config exists; if not, provide a helpful message.
|
||||
settings = request.registry.settings
|
||||
if not settings.get("monero.rpc_url"):
|
||||
request.session.flash(
|
||||
(
|
||||
"Monero RPC not configured. Set monero.rpc_url in your ini to enable.",
|
||||
"error",
|
||||
)
|
||||
)
|
||||
return HTTPFound(f"/cart/{cart.id}")
|
||||
|
||||
# Build a pending invoice for this single shop (do not unlock or send emails yet)
|
||||
try:
|
||||
# Extract the single shop and items
|
||||
(shop_id, items) = next(iter(cart.shop_product_dict.items()))
|
||||
shop = cart.shops[shop_id]
|
||||
|
||||
invoice = Invoice(request.user)
|
||||
invoice.shop = shop
|
||||
invoice.shop_id = shop.id
|
||||
invoice.handling_option = cart.handling_option
|
||||
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
|
||||
|
||||
if cart.physical_products and request.user.active_address:
|
||||
invoice.delivery_address = request.user.active_address.data
|
||||
|
||||
for product, quantity in items:
|
||||
invoice.new_line_item(product=product, quantity=quantity)
|
||||
|
||||
for coupon in cart.coupons:
|
||||
invoice.new_coupon_redemption(coupon)
|
||||
|
||||
# Persist invoice now so we can link a CryptoPayment to it
|
||||
request.dbsession.add(invoice)
|
||||
request.dbsession.flush()
|
||||
|
||||
# Fetch USD/XMR rate (with timeout/retry and sanity checks)
|
||||
rate_url = settings.get(
|
||||
"monero.rate_source_url",
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd",
|
||||
)
|
||||
last_err = None
|
||||
usd_per_xmr = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
rate_url, headers={"User-Agent": "make-post-sell/1.0"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as rate_resp:
|
||||
rate_data = json.loads(rate_resp.read())
|
||||
candidate = float(rate_data.get("monero", {}).get("usd"))
|
||||
# sanity bounds: reject zero/negative/absurd values
|
||||
if not (0.01 <= candidate <= 100000.0):
|
||||
raise RuntimeError("Out-of-bounds USD/XMR rate")
|
||||
usd_per_xmr = candidate
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.5)
|
||||
if usd_per_xmr is None:
|
||||
raise RuntimeError(f"Failed to fetch USD/XMR rate: {last_err}")
|
||||
|
||||
# Compute piconero owed with transaction fee buffer
|
||||
usd_total = float(invoice.total)
|
||||
xmr_amount = usd_total / usd_per_xmr
|
||||
|
||||
# Add small fee buffer (default 0.0001 XMR) to cover transaction costs
|
||||
fee_buffer_xmr = float(settings.get("monero.fee_buffer", "0.0001"))
|
||||
xmr_amount_with_fee = xmr_amount + fee_buffer_xmr
|
||||
expected_piconero = int(xmr_amount_with_fee * 1_000_000_000_000)
|
||||
|
||||
# Quote expiry - use shop-specific setting
|
||||
shop = invoice.shop
|
||||
expiry_secs = int(shop.crypto_quote_expiry_seconds)
|
||||
quote_expires_at_ms = int(time.time() * 1000) + (expiry_secs * 1000)
|
||||
|
||||
# Check if invoice contains physical products
|
||||
has_physical = any(
|
||||
item.product.is_physical
|
||||
for item in invoice.line_items
|
||||
if hasattr(item.product, "is_physical")
|
||||
)
|
||||
|
||||
if has_physical:
|
||||
# Physical products always require maximum confirmations
|
||||
confirmations_required = int(settings.get("monero.confirmations.high"))
|
||||
else:
|
||||
# Digital products: determine confirmations based on amount
|
||||
total_cents = invoice.total_in_cents
|
||||
|
||||
# Use shop-specific risk thresholds
|
||||
shop = invoice.shop
|
||||
threshold_mid_cents = shop.payment_risk_threshold_mid_cents
|
||||
threshold_high_cents = shop.payment_risk_threshold_high_cents
|
||||
|
||||
if total_cents < threshold_mid_cents:
|
||||
confirmations_required = int(settings.get("monero.confirmations.petty"))
|
||||
elif total_cents < threshold_high_cents:
|
||||
confirmations_required = int(settings.get("monero.confirmations.mid"))
|
||||
else:
|
||||
confirmations_required = int(settings.get("monero.confirmations.high"))
|
||||
|
||||
# Get crypto processor configuration for this shop
|
||||
from ..models.crypto_processor import CryptoProcessor
|
||||
|
||||
processor = (
|
||||
request.dbsession.query(CryptoProcessor)
|
||||
.filter(
|
||||
CryptoProcessor.shop_id == shop.id,
|
||||
CryptoProcessor.coin_type == "XMR",
|
||||
CryptoProcessor.enabled == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not processor or processor.wallet_label is None:
|
||||
request.session.flash(("Shop has not configured Monero payments.", "error"))
|
||||
return HTTPFound(f"/cart/{cart.id}")
|
||||
|
||||
# For Monero, wallet_label stores the account index as a string
|
||||
account_index = int(processor.wallet_label)
|
||||
|
||||
client = get_client_from_settings(settings)
|
||||
label = f"invoice:{invoice.id}"
|
||||
address, subaddr_index = client.create_subaddress(
|
||||
account_index=account_index, label=label
|
||||
)
|
||||
|
||||
# Get user's saved refund address for this coin type
|
||||
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
|
||||
user_refund_addr_obj = get_user_crypto_refund_address(
|
||||
request.dbsession, request.user, "XMR"
|
||||
)
|
||||
user_refund_address = (
|
||||
user_refund_addr_obj.address if user_refund_addr_obj else None
|
||||
)
|
||||
|
||||
# Persist CryptoPayment
|
||||
crypto_payment = CryptoPayment(
|
||||
invoice=invoice,
|
||||
address=address,
|
||||
account_index=account_index,
|
||||
subaddress_index=subaddr_index,
|
||||
coin_type="XMR",
|
||||
expected_amount=expected_piconero,
|
||||
rate_locked_usd_per_coin=usd_per_xmr,
|
||||
quote_expires_at_ms=quote_expires_at_ms,
|
||||
confirmations_required=confirmations_required,
|
||||
shop_location=request.shop_location,
|
||||
shop_sweep_to_address=processor.sweep_to_address,
|
||||
refund_address=user_refund_address,
|
||||
)
|
||||
request.dbsession.add(crypto_payment)
|
||||
request.dbsession.flush()
|
||||
|
||||
# Redirect to generic quote page with payment UUID
|
||||
return HTTPFound(
|
||||
request.route_url("crypto_quote", payment_id=str(crypto_payment.id))
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
request.session.flash((f"Failed to start Monero checkout: {e}", "error"))
|
||||
return HTTPFound(f"/cart/{cart.id}")
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="crypto_quote",
|
||||
renderer="crypto_checkout.j2",
|
||||
)
|
||||
def crypto_quote(request):
|
||||
"""Generic crypto quote page that works for any coin type"""
|
||||
payment_id = request.matchdict.get("payment_id")
|
||||
if not payment_id:
|
||||
request.session.flash(("Missing payment ID.", "error"))
|
||||
return HTTPFound("/cart")
|
||||
|
||||
try:
|
||||
pid = _uuid.UUID(payment_id)
|
||||
except Exception:
|
||||
request.session.flash(("Invalid payment ID.", "error"))
|
||||
return HTTPFound("/cart")
|
||||
|
||||
crypto_payment = (
|
||||
request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first()
|
||||
)
|
||||
if not crypto_payment:
|
||||
request.session.flash(("Payment not found.", "error"))
|
||||
return HTTPFound("/cart")
|
||||
|
||||
# Get coin-specific information
|
||||
coin_type = crypto_payment.coin_type
|
||||
coin_info = get_coin_info(coin_type)
|
||||
|
||||
# Get user's refund address for this coin type
|
||||
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
|
||||
user_refund_addr_obj = (
|
||||
get_user_crypto_refund_address(request.dbsession, request.user, coin_type)
|
||||
if request.user
|
||||
else None
|
||||
)
|
||||
user_refund_address = user_refund_addr_obj.address if user_refund_addr_obj else None
|
||||
|
||||
# Calculate amounts for display
|
||||
usd_total = float(crypto_payment.invoice.total)
|
||||
amount_crypto_with_fee = (
|
||||
crypto_payment.expected_amount / coin_info["smallest_unit_divisor"]
|
||||
)
|
||||
|
||||
# Estimate base amount and fee (this is approximate since we don't store it separately)
|
||||
settings = request.registry.settings
|
||||
fee_buffer = float(
|
||||
settings.get(f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"])
|
||||
)
|
||||
amount_crypto_base = amount_crypto_with_fee - fee_buffer
|
||||
|
||||
return {
|
||||
"cart": None, # Quote page doesn't need cart context
|
||||
"address": crypto_payment.address,
|
||||
"coin_name": coin_info["name"],
|
||||
"coin_symbol": coin_type,
|
||||
"wallet_uri_scheme": coin_info["wallet_uri_scheme"],
|
||||
"smallest_unit_name": coin_info["smallest_unit_name"],
|
||||
"amount_crypto": amount_crypto_with_fee,
|
||||
"amount_crypto_base": amount_crypto_base,
|
||||
"fee_buffer_crypto": fee_buffer,
|
||||
"usd_total": usd_total,
|
||||
"usd_per_crypto": crypto_payment.rate_locked_usd_per_coin,
|
||||
"expected_smallest_units": crypto_payment.expected_amount,
|
||||
"expires_at": crypto_payment.quote_expires_at,
|
||||
"payment_id": str(crypto_payment.id),
|
||||
"status": crypto_payment.status,
|
||||
"has_refund_address": bool(user_refund_address),
|
||||
"refund_address": user_refund_address,
|
||||
"now": int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
def get_coin_info(coin_type):
|
||||
"""Get coin-specific information"""
|
||||
coin_configs = {
|
||||
"XMR": {
|
||||
"name": "Monero",
|
||||
"wallet_uri_scheme": "monero",
|
||||
"smallest_unit_name": "piconero",
|
||||
"smallest_unit_divisor": 1_000_000_000_000,
|
||||
"default_fee_buffer": "0.0001",
|
||||
},
|
||||
"BTC": {
|
||||
"name": "Bitcoin",
|
||||
"wallet_uri_scheme": "bitcoin",
|
||||
"smallest_unit_name": "satoshi",
|
||||
"smallest_unit_divisor": 100_000_000,
|
||||
"default_fee_buffer": "0.00001",
|
||||
},
|
||||
"LTC": {
|
||||
"name": "Litecoin",
|
||||
"wallet_uri_scheme": "litecoin",
|
||||
"smallest_unit_name": "satoshi",
|
||||
"smallest_unit_divisor": 100_000_000,
|
||||
"default_fee_buffer": "0.00001",
|
||||
},
|
||||
"DOGE": {
|
||||
"name": "Dogecoin",
|
||||
"wallet_uri_scheme": "dogecoin",
|
||||
"smallest_unit_name": "koinu",
|
||||
"smallest_unit_divisor": 100_000_000,
|
||||
"default_fee_buffer": "0.01",
|
||||
},
|
||||
}
|
||||
return coin_configs.get(coin_type, coin_configs["XMR"])
|
||||
|
||||
|
||||
@view_config(route_name="crypto_xmr_status")
|
||||
@user_required()
|
||||
def crypto_xmr_status(request):
|
||||
payment_id = request.matchdict.get("payment_id")
|
||||
if not payment_id:
|
||||
return HTTPBadRequest("missing payment_id")
|
||||
|
||||
try:
|
||||
pid = _uuid.UUID(payment_id)
|
||||
except Exception:
|
||||
return HTTPBadRequest("invalid payment_id")
|
||||
|
||||
crypto_payment = (
|
||||
request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first()
|
||||
)
|
||||
if not crypto_payment:
|
||||
return HTTPBadRequest("payment not found")
|
||||
|
||||
payload = {
|
||||
"payment_id": str(crypto_payment.id),
|
||||
"status": crypto_payment.status,
|
||||
"address": crypto_payment.address,
|
||||
"received_amount": crypto_payment.received_amount,
|
||||
"expected_amount": crypto_payment.expected_amount,
|
||||
"confirmations_required": crypto_payment.confirmations_required,
|
||||
"current_confirmations": crypto_payment.current_confirmations or 0,
|
||||
"expires_at": crypto_payment.quote_expires_at,
|
||||
}
|
||||
return Response(
|
||||
json.dumps(payload), content_type="application/json", charset="utf-8"
|
||||
)
|
||||
98
make_post_sell/views/crypto_processor.py
Normal file
98
make_post_sell/views/crypto_processor.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from pyramid.view import view_config
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from ..models.crypto_processor import CryptoProcessor
|
||||
from . import shop_owner_required
|
||||
|
||||
|
||||
@view_config(route_name="crypto_processor_settings", request_method="POST")
|
||||
@shop_owner_required()
|
||||
def crypto_processor_settings(request):
|
||||
"""Handle crypto processor configuration for a specific coin type."""
|
||||
shop = request.shop
|
||||
coin_type = request.matchdict.get("coin_type", "").upper()
|
||||
|
||||
# Validate coin type
|
||||
if coin_type not in ["XMR", "DOGE", "LTC", "BTC", "BCH"]:
|
||||
request.session.flash(("Invalid cryptocurrency type", "error"))
|
||||
return HTTPFound(f"/s/{shop.id}/settings")
|
||||
|
||||
# Get or create processor
|
||||
processor = (
|
||||
request.dbsession.query(CryptoProcessor)
|
||||
.filter(
|
||||
CryptoProcessor.shop_id == shop.id, CryptoProcessor.coin_type == coin_type
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Handle disable action
|
||||
if request.params.get("disable"):
|
||||
if processor:
|
||||
processor.enabled = False
|
||||
# Keep the sweep address but it will be grayed out in UI
|
||||
request.dbsession.add(processor)
|
||||
request.session.flash((f"{coin_type} payments disabled", "success"))
|
||||
return HTTPFound(f"/s/{shop.id}/settings")
|
||||
|
||||
# Get sweep address
|
||||
sweep_to_address = request.params.get("sweep_to_address", "").strip()
|
||||
|
||||
if not sweep_to_address:
|
||||
request.session.flash(("Sweep address is required", "error"))
|
||||
return HTTPFound(f"/s/{shop.id}/settings")
|
||||
|
||||
# Validate address based on coin type
|
||||
if coin_type == "XMR":
|
||||
from ..lib.monero_address import validate_monero_address
|
||||
|
||||
is_valid, error_msg = validate_monero_address(sweep_to_address)
|
||||
if not is_valid:
|
||||
request.session.flash((f"Invalid Monero address: {error_msg}", "error"))
|
||||
return HTTPFound(f"/s/{shop.id}/settings")
|
||||
# TODO: Add validation for other coin types
|
||||
|
||||
if processor:
|
||||
# Update existing
|
||||
processor.sweep_to_address = sweep_to_address
|
||||
processor.enabled = True
|
||||
request.session.flash((f"{coin_type} settings updated", "success"))
|
||||
else:
|
||||
# Create new processor
|
||||
processor = CryptoProcessor(
|
||||
shop_id=shop.id, coin_type=coin_type, sweep_to_address=sweep_to_address
|
||||
)
|
||||
|
||||
# Assign wallet label based on coin type
|
||||
if coin_type == "XMR":
|
||||
# Create new Monero account via RPC
|
||||
try:
|
||||
from ..lib.crypto_clients import get_client_from_settings
|
||||
|
||||
client = get_client_from_settings(request.registry.settings)
|
||||
|
||||
# Create new account with shop name as label
|
||||
account_label = f"shop_{shop.name[:20]}_{shop.id[:8]}"
|
||||
result = client._call("create_account", {"label": account_label})
|
||||
account_index = result.get("account_index")
|
||||
|
||||
if account_index is not None:
|
||||
processor.wallet_label = str(account_index)
|
||||
else:
|
||||
raise Exception("Failed to get account_index from create_account")
|
||||
|
||||
except Exception as e:
|
||||
request.session.flash(
|
||||
(f"Failed to create Monero account: {str(e)}", "error")
|
||||
)
|
||||
return HTTPFound(f"/s/{shop.id}/settings")
|
||||
else:
|
||||
# For Bitcoin-like coins, use shop UUID as label base
|
||||
processor.wallet_label = f"shop_{shop.id}_{coin_type.lower()}"
|
||||
|
||||
processor.enabled = True
|
||||
request.dbsession.add(processor)
|
||||
request.session.flash((f"{coin_type} payments enabled", "success"))
|
||||
|
||||
request.dbsession.flush()
|
||||
return HTTPFound(f"/s/{shop.id}/settings")
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
103
make_post_sell/views/user_crypto_settings.py
Normal file
103
make_post_sell/views/user_crypto_settings.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from pyramid.view import view_config
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from ..models.user_crypto_refund_address import (
|
||||
UserCryptoRefundAddress,
|
||||
get_user_crypto_refund_address,
|
||||
)
|
||||
from . import user_required
|
||||
|
||||
|
||||
def get_enabled_coins(request):
|
||||
"""Get list of enabled cryptocurrency types from all shops."""
|
||||
from ..models.crypto_processor import CryptoProcessor
|
||||
|
||||
# Get distinct coin types from all enabled crypto processors
|
||||
enabled_coins = (
|
||||
request.dbsession.query(CryptoProcessor.coin_type)
|
||||
.filter(CryptoProcessor.enabled == True)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Return list of coin type strings
|
||||
return [coin_type for (coin_type,) in enabled_coins]
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="user_crypto_settings",
|
||||
request_method="GET",
|
||||
renderer="user_crypto_settings.j2",
|
||||
)
|
||||
@user_required()
|
||||
def user_crypto_settings_view(request):
|
||||
"""Display crypto payment preferences."""
|
||||
user = request.user
|
||||
enabled_coins = get_enabled_coins(request)
|
||||
|
||||
# Get all user's crypto addresses
|
||||
addresses = {}
|
||||
for coin in enabled_coins:
|
||||
addr = get_user_crypto_refund_address(request.dbsession, user, coin)
|
||||
if addr:
|
||||
addresses[coin] = addr
|
||||
|
||||
return {
|
||||
"enabled_coins": enabled_coins,
|
||||
"addresses": addresses,
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="user_crypto_settings_update", request_method="POST")
|
||||
@user_required()
|
||||
def user_crypto_settings_update(request):
|
||||
"""Update crypto refund address for a specific coin."""
|
||||
user = request.user
|
||||
coin_type = request.matchdict.get("coin_type", "").upper()
|
||||
|
||||
# Validate coin type
|
||||
enabled_coins = get_enabled_coins(request)
|
||||
if coin_type not in enabled_coins:
|
||||
request.session.flash(("Invalid or disabled cryptocurrency type", "error"))
|
||||
return HTTPFound("/u/settings/crypto")
|
||||
|
||||
# Get form data
|
||||
address = request.params.get("address", "").strip()
|
||||
label = request.params.get("label", "").strip()
|
||||
|
||||
if not address:
|
||||
# If address is empty, treat it as a delete
|
||||
addr = get_user_crypto_refund_address(request.dbsession, user, coin_type)
|
||||
if addr:
|
||||
request.dbsession.delete(addr)
|
||||
request.session.flash((f"{coin_type} refund address cleared", "success"))
|
||||
return HTTPFound("/u/settings/crypto")
|
||||
|
||||
# TODO: Add address validation for each coin type
|
||||
# For now, just basic length check
|
||||
if len(address) < 10 or len(address) > 256:
|
||||
request.session.flash(("Invalid address format", "error"))
|
||||
return HTTPFound("/u/settings/crypto")
|
||||
|
||||
# Get existing or create new
|
||||
addr_obj = get_user_crypto_refund_address(request.dbsession, user, coin_type)
|
||||
|
||||
if addr_obj:
|
||||
# Update existing
|
||||
addr_obj.address = address
|
||||
addr_obj.label = label if label else None
|
||||
addr_obj.updated_timestamp = request.now_timestamp()
|
||||
request.session.flash((f"{coin_type} refund address updated", "success"))
|
||||
else:
|
||||
# Create new
|
||||
addr_obj = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
coin_type=coin_type,
|
||||
address=address,
|
||||
label=label if label else None,
|
||||
)
|
||||
request.dbsession.add(addr_obj)
|
||||
request.session.flash((f"{coin_type} refund address saved", "success"))
|
||||
|
||||
request.dbsession.flush()
|
||||
return HTTPFound("/u/settings/crypto")
|
||||
10
pytest.ini
10
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
|
||||
|
|
|
|||
2
setup.py
2
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",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
|
|||
18
test.ini
18
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue