docs: design for dynamic global pricing configuration

This commit is contained in:
Graham McIntire 2026-03-06 13:18:47 -06:00
parent 2ae7a2680a
commit ffb61641bb
No known key found for this signature in database

View file

@ -0,0 +1,224 @@
# Dynamic Global Pricing Configuration
**Date:** 2026-03-06
**Status:** Approved
**Author:** System Design
## Overview
Add superadmin UI to dynamically modify global billing defaults (free device count and price per device) with automatic Stripe Price creation and subscription migration. Update default pricing from $1 to $2/device/month.
## Goals
1. Allow superadmins to change global pricing without code deployment
2. Automatically create new Stripe Prices and migrate active subscriptions
3. Update marketing materials to reflect $2/device/month pricing
4. Maintain per-organization override capability
5. Provide audit trail and migration reporting
## Design Decisions
### Storage Strategy (Hybrid Approach)
**ApplicationSettings entries:**
- `default_free_devices` (integer): Number of free devices (default: 10)
- `default_price_per_device` (string): Price per device/month USD (default: "2.00")
- `stripe_price_id` (string): Current Stripe Price object ID
**Fallback behavior:**
- Check ApplicationSettings first
- Fall back to module attributes if not set
- Stripe Price ID falls back to env var `STRIPE_PRICE_ID`
### Stripe Integration
**Price changes trigger:**
1. Create new Stripe Price object at new unit amount
2. Update `stripe_price_id` in ApplicationSettings
3. Migrate all active subscriptions to new Price (best-effort)
4. Report successes/failures
**Migration behavior:**
- Update subscriptions with `proration_behavior: "none"` (change at next billing cycle)
- Best-effort: continue on individual failures, report at end
- Failed migrations logged for manual review
### UI Placement
**Location:** `/admin/organizations` page
**Component:** "Global Billing Defaults" card at top of organization list
**Workflow:**
1. Superadmin clicks "Edit Defaults"
2. Modal shows form with current values
3. On save → confirmation dialog: "This will update X active subscriptions. Continue?"
4. After confirmation → updates DB + Stripe + migrates subscriptions
5. Success message shows migration results (e.g., "Updated 45/47 subscriptions, 2 failed")
### Safety & Audit
**Confirmation dialog:**
- Shows current values vs new values
- Shows count of active subscriptions that will be affected
- Requires explicit confirmation before execution
**Audit logging:**
- Action: `"global_pricing_updated"`
- Metadata includes:
- Old/new values for free devices and price
- Migration results (succeeded/failed counts)
- Failed organization IDs and error reasons
- IP address and superuser ID captured
## Data Model
### New ApplicationSettings Records
```sql
INSERT INTO application_settings (id, key, value, value_type, description, inserted_at, updated_at)
VALUES
(gen_random_uuid(), 'default_free_devices', '10', 'integer',
'Number of free devices included in all subscriptions', NOW(), NOW()),
(gen_random_uuid(), 'default_price_per_device', '2.00', 'string',
'Price per device per month (USD) after free tier', NOW(), NOW());
```
Note: `stripe_price_id` will be populated on first price update via admin UI.
### Schema Changes
No schema changes required. Uses existing `application_settings` table.
## Implementation Components
### 1. Billing Module Updates
**New functions:**
- `default_free_devices/0` - Get from Settings or fallback
- `default_price_per_device/0` - Get from Settings or fallback
- `stripe_price_id/0` - Get from Settings or fallback to env var
- `migrate_all_subscriptions_to_price/1` - Migrate subscriptions with reporting
**Updated functions:**
- `effective_free_device_count/1` - Use `default_free_devices/0`
- `effective_price_per_device/1` - Use `default_price_per_device/0`
**Module attribute updates:**
- Change `@default_price_per_device` from `Decimal.new("1.00")` to `Decimal.new("2.00")`
### 2. StripeClient Updates
**New functions:**
- `create_price/1` - Create metered billing Price object
- `update_subscription_price/2` - Switch subscription to new Price
- `list_active_subscriptions/1` - Paginated list of active subs
**Price creation parameters:**
- `unit_amount_decimal`: e.g., "2.00" for $2/device/month
- `recurring.usage_type`: "metered"
- `recurring.aggregate_usage`: "max" (bill for max devices in period)
- `billing_scheme`: "per_unit"
### 3. Admin Context
**New function:**
- `Admin.update_global_pricing/3` - Orchestrates full update
**Flow:**
1. Validate new values (positive numbers, reasonable ranges)
2. Get count of active subscriptions for confirmation
3. If price changed: create new Stripe Price
4. Update ApplicationSettings
5. If price changed: migrate subscriptions
6. Create audit log with results
7. Return migration report
### 4. Admin LiveView UI
**Location:** `lib/towerops_web/live/admin/org_live/index.ex`
**New components:**
- Global Defaults card with current values
- Edit modal with form
- Confirmation dialog with impact preview
- Success/error messages with migration results
**Validation:**
- `default_free_devices`: integer, 1-10000
- `default_price_per_device`: decimal, 0.01-999.99
### 5. Marketing Updates
**Files to update:**
- `lib/towerops_web/controllers/page_html/home.html.heex`: Change $3 → $2
- `lib/towerops_web/controllers/user_registration_html/new.html.heex`: Update copy
## Error Handling
### Partial Migration Failures
**Scenario:** Stripe API fails for some subscriptions
**Behavior:**
- Continue migrating remaining subscriptions
- Collect failed org IDs and error messages
- Return summary: `%{total: 50, succeeded: 48, failed: 2, failures: [...]}`
- Display warning in UI with failure details
- Log failures in audit_logs for manual follow-up
**Failed subscriptions:**
- Remain on old price until manually updated
- No disruption to billing
- Superadmin can retry migration or update individually
### Complete Failures
**Scenario:** Stripe Price creation fails
**Behavior:**
- Abort entire operation
- Don't update ApplicationSettings
- Don't migrate any subscriptions
- Show error message with Stripe error details
- No audit log created (operation didn't complete)
## Testing Strategy
**Unit tests:**
- `Billing.default_free_devices/0` with/without ApplicationSetting
- `Billing.default_price_per_device/0` with/without ApplicationSetting
- `Billing.migrate_all_subscriptions_to_price/1` with mocked Stripe
**Integration tests:**
- `StripeClient.create_price/1` (mocked in test)
- `StripeClient.update_subscription_price/2` (mocked in test)
- Admin context with full flow (mocked Stripe)
**LiveView tests:**
- Render Global Defaults card
- Open edit modal
- Validation errors
- Confirmation dialog shows impact
- Success message with results
## Security Considerations
- Only accessible by superusers (existing `require_superuser` plug)
- Audit log captures all changes with IP and user ID
- Confirmation dialog prevents accidental mass updates
- Rate limiting already applied to `/admin/*` routes (100 req/min)
## Rollout Plan
1. Deploy code with $2 default in module attributes
2. Run migration to seed ApplicationSettings
3. Existing orgs continue using per-org overrides or new $2 default
4. Superadmin can adjust pricing via UI going forward
5. Marketing pages immediately show $2 pricing
## Future Enhancements
- Volume pricing tiers (e.g., 10% off at 500+ devices)
- Scheduled price changes (effective date in future)
- Price change preview/dry-run mode
- Email notification to affected org owners before price change