diff --git a/docs/plans/2026-03-06-dynamic-global-pricing-design.md b/docs/plans/2026-03-06-dynamic-global-pricing-design.md new file mode 100644 index 00000000..35e2e05d --- /dev/null +++ b/docs/plans/2026-03-06-dynamic-global-pricing-design.md @@ -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