towerops/docs/plans/2026-03-06-dynamic-global-pricing-design.md

7.2 KiB

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

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