summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater')
-rw-r--r--Biz/PodcastItLater/Billing.py100
-rw-r--r--Biz/PodcastItLater/STRIPE_TESTING.md461
2 files changed, 176 insertions, 385 deletions
diff --git a/Biz/PodcastItLater/Billing.py b/Biz/PodcastItLater/Billing.py
index 9c0e1db..3716660 100644
--- a/Biz/PodcastItLater/Billing.py
+++ b/Biz/PodcastItLater/Billing.py
@@ -9,9 +9,12 @@ Stripe subscription management and usage enforcement.
# : dep pytest
# : dep pytest-mock
import Biz.PodcastItLater.Core as Core
+import Omni.App as App
import Omni.Log as Log
+import Omni.Test as Test
import os
import stripe
+import sys
import typing
from datetime import datetime
from datetime import timezone
@@ -422,3 +425,100 @@ def get_tier_info(tier: str) -> dict[str, typing.Any]:
},
}
return tier_info.get(tier, tier_info["free"]) # type: ignore[return-value]
+
+
+# Tests
+# ruff: noqa: PLR6301, PLW0603, S101
+
+
+class TestWebhookHandling(Test.TestCase):
+ """Test Stripe webhook handling."""
+
+ def setUp(self) -> None:
+ """Set up test database."""
+ Core.Database.init_db()
+
+ def tearDown(self) -> None:
+ """Clean up test database."""
+ Core.Database.teardown()
+
+ def test_subscription_created(self) -> None:
+ """Test handling subscription.created webhook with mock data."""
+ # Create test user
+ user_id, _token = Core.Database.create_user("test@example.com")
+ Core.Database.set_user_stripe_customer(user_id, "cus_test123")
+
+ # Mock subscription event
+ subscription = {
+ "id": "sub_test123",
+ "customer": "cus_test123",
+ "status": "active",
+ "current_period_start": 1700000000,
+ "current_period_end": 1702592000,
+ "cancel_at_period_end": False,
+ "items": {
+ "data": [
+ {
+ "price": {
+ "id": "price_test_pro",
+ },
+ },
+ ],
+ },
+ }
+
+ # Temporarily set price mapping for test
+ global PRICE_TO_TIER
+ old_mapping = PRICE_TO_TIER.copy()
+ PRICE_TO_TIER["price_test_pro"] = "pro"
+
+ try:
+ _update_subscription_state(subscription)
+
+ # Verify user was updated
+ user = Core.Database.get_user_by_id(user_id)
+ self.assertIsNotNone(user)
+ assert user is not None
+ self.assertEqual(user["plan_tier"], "pro")
+ self.assertEqual(user["subscription_status"], "active")
+ self.assertEqual(user["stripe_subscription_id"], "sub_test123")
+ finally:
+ PRICE_TO_TIER = old_mapping
+
+ def test_webhook_missing_fields(self) -> None:
+ """Test handling webhook with missing required fields."""
+ # Create test user
+ user_id, _token = Core.Database.create_user("test@example.com")
+ Core.Database.set_user_stripe_customer(user_id, "cus_test456")
+
+ # Mock subscription with missing current_period_start
+ subscription = {
+ "id": "sub_test456",
+ "customer": "cus_test456",
+ "status": "active",
+ # Missing current_period_start and current_period_end
+ "cancel_at_period_end": False,
+ "items": {"data": []},
+ }
+
+ # Should not crash, just log warning and return
+ _update_subscription_state(subscription)
+
+ # User should remain on free tier
+ user = Core.Database.get_user_by_id(user_id)
+ self.assertIsNotNone(user)
+ assert user is not None
+ self.assertEqual(user["plan_tier"], "free")
+
+
+def main() -> None:
+ """Run tests."""
+ if len(sys.argv) > 1 and sys.argv[1] == "test":
+ os.environ["AREA"] = "Test"
+ Test.run(App.Area.Test, [TestWebhookHandling])
+ else:
+ logger.error("Usage: billing.py test")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Biz/PodcastItLater/STRIPE_TESTING.md b/Biz/PodcastItLater/STRIPE_TESTING.md
index 63cfdf6..1461c06 100644
--- a/Biz/PodcastItLater/STRIPE_TESTING.md
+++ b/Biz/PodcastItLater/STRIPE_TESTING.md
@@ -1,423 +1,114 @@
-# Stripe Testing Guide for PodcastItLater
+# Stripe Testing Guide
-This guide covers end-to-end Stripe billing integration testing.
+## Testing Stripe Integration Without Real Transactions
-## Prerequisites
+### 1. Use Stripe Test Mode
-1. Stripe account (sign up at stripe.com)
-2. Stripe CLI installed (`brew install stripe/stripe-cli/stripe` or download from stripe.com/docs/stripe-cli)
+Stripe provides test API keys that allow you to simulate payments without real money:
-## Setup Steps
-
-### 1. Get Stripe Test Mode API Keys
-
-1. Go to https://dashboard.stripe.com/test/apikeys
-2. Copy your test mode keys:
- - **Publishable key** (starts with `pk_test_`)
- - **Secret key** (starts with `sk_test_`)
-
-### 2. Create Products and Prices in Stripe Dashboard
-
-#### Personal Plan ($9/month)
-1. Go to https://dashboard.stripe.com/test/products
-2. Click "+ Add product"
-3. Fill in:
- - Name: `PodcastItLater Personal`
- - Description: `50 articles per month`
- - Pricing model: `Standard pricing`
- - Price: `$9.00`
- - Billing period: `Monthly`
- - Payment type: `Recurring`
-4. Click "Save product"
-5. **Copy the Price ID** (starts with `price_`) - you'll need this for `STRIPE_PRICE_ID_PERSONAL`
-
-#### Pro Plan ($29/month)
-1. Repeat above steps with:
- - Name: `PodcastItLater Pro`
- - Description: `Unlimited articles`
- - Price: `$29.00`
- - Billing period: `Monthly`
-2. **Copy the Price ID** - you'll need this for `STRIPE_PRICE_ID_PRO`
-
-### 3. Configure Environment Variables
-
-Create or update your `.envrc.local` file:
-
-```bash
-# Stripe Test Mode Keys
-export STRIPE_SECRET_KEY="sk_test_YOUR_SECRET_KEY_HERE"
-
-# Price IDs from Stripe dashboard
-export STRIPE_PRICE_ID_PERSONAL="price_YOUR_PERSONAL_PRICE_ID"
-export STRIPE_PRICE_ID_PRO="price_YOUR_PRO_PRICE_ID"
-
-# Other required vars (if not already set)
-export BASE_URL="http://localhost:8000"
-export SESSION_SECRET="dev-secret-key-for-testing"
-export SECRET_KEY="dev-secret-key-for-magic-links"
-export AREA="Test" # Important for test mode behavior
-```
-
-Reload environment:
-```bash
-direnv allow
-```
-
-### 4. Set Up Webhook Testing
-
-#### Option A: Stripe CLI (Recommended for local testing)
-
-1. Login to Stripe CLI:
- ```bash
- stripe login
- ```
-
-2. Start webhook forwarding:
+1. Get your test keys from https://dashboard.stripe.com/test/apikeys
+2. Set environment variables with test keys:
```bash
- stripe listen --forward-to http://localhost:8000/stripe/webhook
+ export STRIPE_SECRET_KEY="sk_test_..."
+ export STRIPE_WEBHOOK_SECRET="whsec_test_..."
+ export STRIPE_PRICE_ID_PRO="price_test_..."
```
-3. **Copy the webhook signing secret** shown in the output (starts with `whsec_`)
+### 2. Use Stripe Test Cards
-4. Add to `.envrc.local`:
- ```bash
- export STRIPE_WEBHOOK_SECRET="whsec_YOUR_WEBHOOK_SECRET"
- ```
-
-5. Reload environment:
- ```bash
- direnv allow
- ```
+In test mode, use these test card numbers:
+- **Success**: `4242 4242 4242 4242`
+- **Decline**: `4000 0000 0000 0002`
+- **3D Secure**: `4000 0025 0000 3155`
-#### Option B: Deploy and Use Stripe Dashboard Webhooks
+Any future expiry date and any 3-digit CVC will work.
-1. Deploy to production or staging environment
-2. Go to https://dashboard.stripe.com/test/webhooks
-3. Click "+ Add endpoint"
-4. Enter your webhook URL: `https://your-domain.com/stripe/webhook`
-5. Select events to listen for:
- - `checkout.session.completed`
- - `customer.subscription.created`
- - `customer.subscription.updated`
- - `customer.subscription.deleted`
- - `invoice.payment_failed`
-6. Copy the signing secret and add to your environment
+### 3. Trigger Test Webhooks
-### 5. Initialize Database
+Use Stripe CLI to trigger webhook events locally:
```bash
-# Make sure DATA_DIR is set (defaults to _/var/podcastitlater/)
-export DATA_DIR="_/var/podcastitlater/"
-mkdir -p $DATA_DIR
+# Install Stripe CLI
+# https://stripe.com/docs/stripe-cli
-# Start the web server to initialize database
-bild --time 0 Biz/PodcastItLater/Web.py
-python Biz/PodcastItLater/Web.py
-```
+# Login
+stripe login
-## Testing the Complete Flow
+# Forward webhooks to local server
+stripe listen --forward-to localhost:8000/stripe/webhook
-### Test 1: User Registration and Free Tier
-
-1. Start the web server:
- ```bash
- python Biz/PodcastItLater/Web.py
- ```
-
-2. Open http://localhost:8000 in your browser
-
-3. Login with `demo@example.com` (auto-approved in test mode)
-
-4. Verify:
- - ✓ Logged in as demo@example.com
- - ✓ Plan shows "Free"
- - ✓ Billing button visible
-
-5. Click "Billing" button
-
-6. Verify billing page shows:
- - ✓ Current Plan: Free
- - ✓ Usage: 0 / 10 articles
- - ✓ Period dates (current month)
- - ✓ Three pricing cards (Free, Personal, Pro)
- - ✓ "Upgrade" buttons on Personal and Pro plans
-
-### Test 2: Stripe Checkout Flow (Personal Plan)
-
-1. On billing page, click "Upgrade" button under Personal plan
-
-2. Verify redirected to Stripe Checkout page:
- - ✓ Shows "PodcastItLater Personal"
- - ✓ Shows $9.00/month
- - ✓ Can enter test card details
-
-3. Use Stripe test card:
- - Card number: `4242 4242 4242 4242`
- - Expiry: Any future date (e.g., `12/34`)
- - CVC: Any 3 digits (e.g., `123`)
- - Email: Use same email as logged in user
-
-4. Complete checkout
-
-5. Verify:
- - ✓ Redirected back to `/billing?status=success`
- - ✓ Success message shown
- - ✓ Plan updated to "Personal" (may take a few moments)
- - ✓ Usage shows "0 / 50 articles"
- - ✓ "Manage Subscription" button appears
- - ✓ "Current Plan" badge on Personal plan
-
-### Test 3: Webhook Events
-
-Check your terminal running `stripe listen` to verify webhook events received:
-
-```
-✓ checkout.session.completed [evt_xxx]
-✓ customer.subscription.created [evt_xxx]
-✓ customer.subscription.updated [evt_xxx]
+# Trigger specific events
+stripe trigger checkout.session.completed
+stripe trigger customer.subscription.created
+stripe trigger customer.subscription.updated
+stripe trigger invoice.payment_failed
```
-Check database to verify subscription data:
+### 4. Run Unit Tests
+
+The billing module includes unit tests that mock Stripe webhooks:
```bash
-sqlite3 _/var/podcastitlater/podcast.db
-```
+# Run billing tests
+AREA=Test python3 Biz/PodcastItLater/Billing.py test
-```sql
--- Check user subscription details
-SELECT id, email, plan_tier, subscription_status,
- stripe_customer_id, stripe_subscription_id
-FROM users WHERE email = 'demo@example.com';
-
--- Should show:
--- plan_tier: personal
--- subscription_status: active
--- stripe_customer_id: cus_xxx
--- stripe_subscription_id: sub_xxx
+# Or use bild
+bild --test Biz/PodcastItLater/Billing.py
```
-### Test 4: Billing Portal (Manage Subscription)
-
-1. On billing page (now showing Personal plan), click "Manage Subscription"
-
-2. Verify redirected to Stripe Billing Portal:
- - ✓ Shows current subscription: PodcastItLater Personal
- - ✓ Can update payment method
- - ✓ Can cancel subscription
- - ✓ Can view invoices
-
-3. Test cancellation:
- - Click "Cancel plan"
- - Select cancellation option (e.g., "Cancel at end of period")
- - Confirm
-
-4. Return to billing page
+### 5. Test Migration on Production
-5. Verify:
- - ✓ Subscription shows as active but set to cancel
- - ✓ Still can use service until period ends
+To fix the production database missing columns issue, you need to trigger the migration.
-### Test 5: Usage Limits Enforcement
+The migration runs automatically when `Database.init_db()` is called, but production may have an old database.
-1. Login as a free user (or create new user)
+**Option A: Restart the web service**
+The init_db() runs on startup, so restarting should apply migrations.
-2. Try to submit more than 10 articles in the current month
-
-3. On the 11th submission, verify:
- - ✓ Error message shown: "You've reached your limit of 10 articles per period. Upgrade to continue."
- - ✓ Submit button disabled or shows error
- - ✓ Upgrade prompt shown
-
-4. Upgrade to Personal or Pro plan
-
-5. Verify:
- - ✓ Can submit articles again
- - ✓ New usage limit applies
-
-### Test 6: Subscription Upgrade Flow
-
-1. Start with Personal plan
-
-2. Go to billing page
-
-3. Click "Upgrade" on Pro plan
-
-4. Complete checkout with test card
-
-5. Verify:
- - ✓ Plan upgraded to Pro
- - ✓ Usage shows "0 / ∞ articles"
- - ✓ Billing reflects pro-rated charges
-
-### Test 7: Payment Failure Handling
-
-1. Use Stripe test card that triggers payment failure:
- - Card number: `4000 0000 0000 0341` (charge fails)
-
-2. After first payment succeeds, wait for next billing cycle or trigger failure manually
-
-3. Verify:
- - ✓ Subscription status updates to "past_due"
- - ✓ User still has access during grace period
- - ✓ Webhook event processed: `invoice.payment_failed`
-
-### Test 8: Subscription Cancellation
-
-1. Cancel subscription from Billing Portal
-
-2. Wait for end of billing period OR manually expire in Stripe dashboard
-
-3. Verify:
- - ✓ Webhook event: `customer.subscription.deleted`
- - ✓ User downgraded to free tier
- - ✓ Usage limit reset to 10 articles/month
- - ✓ Stripe subscription data cleared
-
-## Common Test Cards
-
-| Card Number | Scenario |
-|-------------|----------|
-| 4242 4242 4242 4242 | Successful payment |
-| 4000 0000 0000 0341 | Charge fails |
-| 4000 0000 0000 9995 | Card declined |
-| 4000 0025 0000 3155 | Requires authentication (3D Secure) |
-
-Full list: https://stripe.com/docs/testing#cards
-
-## Troubleshooting
-
-### Webhooks not received
-
-- Check Stripe CLI is running: `stripe listen --forward-to ...`
-- Verify webhook secret matches in environment
-- Check web server logs for webhook processing errors
-- Verify web server is accessible at the forwarding URL
-
-### Database not updating
-
-- Check web server logs for errors
-- Verify webhook events are being processed (check stripe_events table)
-- Check database schema is up to date (run Web.py to trigger migrations)
-
-### Checkout session not creating
-
-- Verify STRIPE_SECRET_KEY is set and valid
-- Check STRIPE_PRICE_ID_PERSONAL and STRIPE_PRICE_ID_PRO are correct
-- Look for errors in web server logs
-- Verify price IDs exist in Stripe dashboard
-
-### User not upgrading after checkout
-
-- Verify webhooks are being received and processed
-- Check that customer email in checkout matches user email in database
-- Look for errors in webhook processing logs
-- Check stripe_events table for duplicate processing
-
-## Production Deployment
-
-### Prerequisites
-
-1. **Stripe Live Mode Setup:**
- - Create products/prices in live mode Stripe dashboard
- - Get live mode API keys (sk_live_...)
- - Set up webhook endpoint at: `https://podcastitlater.bensima.com/stripe/webhook`
- - Configure webhook events: checkout.session.completed, customer.subscription.*, invoice.payment_failed
- - Copy webhook signing secret (whsec_...)
-
-2. **Environment Variables:**
- Create `/run/podcastitlater/env` on production server with:
- ```bash
- # Authentication
- SECRET_KEY=<generate-with-openssl-rand-hex-32>
- SESSION_SECRET=<generate-with-openssl-rand-hex-32>
-
- # Email (for magic links)
- EMAIL_FROM=noreply@podcastitlater.bensima.com
- SMTP_SERVER=smtp.mailgun.org
- SMTP_PASSWORD=<mailgun-smtp-password>
-
- # Stripe (use test mode first, then switch to live)
- STRIPE_SECRET_KEY=sk_test_... # or sk_live_...
- STRIPE_WEBHOOK_SECRET=whsec_...
- STRIPE_PRICE_ID_PERSONAL=price_...
- STRIPE_PRICE_ID_PRO=price_...
- ```
-
-3. **Build and Deploy:**
- ```bash
- # On development machine, build the service
- bild --time 0 Biz/PodcastItLater/Web.py
-
- # Deploy to production server (method depends on your deployment setup)
- # The Web.nix service configuration handles:
- # - HTTPS via nginx with automatic SSL (Let's Encrypt)
- # - BASE_URL set to https://podcastitlater.bensima.com
- # - AREA=Live for production mode
- # - Data directory at /var/podcastitlater
- ```
-
-### Deployment Steps
-
-1. **Start with Test Mode:**
- - Use `STRIPE_SECRET_KEY=sk_test_...` initially
- - Test the full flow with test cards
- - Verify webhooks are received and processed
-
-2. **Switch to Live Mode:**
- - Update environment variables to use `sk_live_...`
- - Update price IDs to live mode prices
- - Update webhook secret to live mode webhook
- - Restart service
-
-3. **Verify Deployment:**
- - Visit https://podcastitlater.bensima.com
- - Test login flow
- - Check billing page loads
- - Try checkout flow (cancel before paying if testing)
- - Monitor logs for errors
-
-4. **Monitor Production:**
- - Check webhook events in database
- - Monitor subscription states
- - Watch for payment failures
- - Set up alerts for critical errors
+**Option B: Run migration manually**
+```bash
+# SSH to production
+# Run Python REPL with proper environment
+python3
+>>> import os
+>>> os.environ["AREA"] = "Live"
+>>> os.environ["DATA_DIR"] = "/var/podcastitlater"
+>>> import Biz.PodcastItLater.Core as Core
+>>> Core.Database.init_db()
+```
-## Monitoring
+### 6. Verify Database Schema
-### Check Webhook Events in Database
+Check that billing columns exist:
-```sql
-SELECT * FROM stripe_events
-ORDER BY created_at DESC
-LIMIT 10;
+```bash
+sqlite3 /var/podcastitlater/podcast.db
+.schema users
```
-### Check Subscription States
+Should show:
+- `stripe_customer_id TEXT`
+- `stripe_subscription_id TEXT`
+- `subscription_status TEXT`
+- `current_period_start TEXT`
+- `current_period_end TEXT`
+- `plan_tier TEXT NOT NULL DEFAULT 'free'`
+- `cancel_at_period_end INTEGER DEFAULT 0`
-```sql
-SELECT email, plan_tier, subscription_status,
- current_period_start, current_period_end
-FROM users
-WHERE plan_tier != 'free';
-```
+### 7. End-to-End Test Flow
-### Check Usage Stats
+1. Start in test mode: `AREA=Test PORT=8000 python3 Biz/PodcastItLater/Web.py`
+2. Login with test account
+3. Go to /billing
+4. Click "Upgrade Now"
+5. Use test card: 4242 4242 4242 4242
+6. Stripe CLI will forward webhook to your local server
+7. Verify subscription updated in database
-```sql
-SELECT u.email, u.plan_tier, COUNT(e.id) as articles_this_month
-FROM users u
-LEFT JOIN episodes e ON e.user_id = u.id
- AND e.created_at >= date('now', 'start of month')
-GROUP BY u.id, u.email, u.plan_tier;
-```
+### 8. Common Issues
-## Next Steps
+**KeyError in webhook**: Make sure you're using safe `.get()` access for all Stripe object fields, as the structure can vary.
-After successful testing:
+**Database column missing**: Run migrations by restarting the service or calling `Database.init_db()`.
-1. Mark task t-1pIV0ZF as done
-2. Update AGENTS.md with Stripe setup documentation
-3. Create production deployment checklist
-4. Set up error monitoring (Sentry)
-5. Configure email notifications for payment failures
-6. Add analytics for conversion tracking
+**Webhook signature verification fails**: Make sure `STRIPE_WEBHOOK_SECRET` matches your endpoint secret from Stripe dashboard.