Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [0.3.3] - 2026-03-19

- **Add plan provenance helpers**: `current_pricing_plan_resolution`, `current_pricing_plan_source`, and `PlanResolver.resolution_for(plan_owner)` now expose whether the effective plan comes from a manual assignment, a Pay subscription, or the default plan
- **Preserve underlying billing context**: resolution objects include the current subscription when present, even when a manual assignment overrides it for entitlements
- **Clarify effective plan vs billing state**: docs now explicitly distinguish the effective pricing plan from Pay/Stripe subscription status

## [0.3.2] - 2026-02-25

- **Fix stale grace warnings after plan upgrades**: Grace/blocked flags now auto-clear when usage drops below limit (self-healing state)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ The `pricing_plans` gem needs three new models in the schema in order to work: `
- `PricingPlans::Assignment` allow manual plan overrides independent of billing system (or before you wire up Stripe/Pay). Great for admin toggles, trials, demos.
- What: The arbitrary `plan_key` and a `source` label (default "manual"). Unique per plan_owner.
- How it's used: `PlanResolver` checks manual assignment → Pay → default plan. Manual assignments (admin overrides) take precedence over subscription-based plans. You can call `assign_pricing_plan!` and `remove_pricing_plan!` on the plan_owner.
- Provenance helpers: `current_pricing_plan_source` tells you whether the effective plan came from `:assignment`, `:subscription`, or `:default`, and `current_pricing_plan_resolution` exposes the assignment and current subscription objects when you need both entitlement and billing context.

- `PricingPlans::EnforcementState` tracks per-plan_owner per-limit enforcement state for persistent caps and per-period allowances (grace/warnings/block state) in a race-safe way.
- What: `exceeded_at`, `blocked_at`, last warning info, and a small JSON `data` column where we persist plan-derived parameters like grace period seconds.
Expand Down
25 changes: 24 additions & 1 deletion docs/03-model-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,32 @@ You can also use the top-level equivalents if you prefer: `PricingPlans.severity
You can also check and override the current pricing plan for any user, which comes handy as an admin:
```ruby
user.current_pricing_plan # => PricingPlans::Plan
user.current_pricing_plan_source # => :assignment, :subscription, :default
user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
user.assign_pricing_plan!(:pro) # manual assignment override
user.remove_pricing_plan! # remove manual override (fallback to default)
```

**Performance note:** Each call to `current_pricing_plan`, `current_pricing_plan_source`, or `current_pricing_plan_resolution` performs a fresh database lookup. If you need both the plan and its provenance, call `current_pricing_plan_resolution` once and read both values from that object — this avoids duplicate queries.

If you need the full provenance, use the resolution object:

```ruby
resolution = user.current_pricing_plan_resolution

resolution.plan.key # => :enterprise
resolution.source # => :assignment
resolution.assignment # => PricingPlans::Assignment | nil
resolution.assignment_source # => "admin" | "manual" | nil
resolution.subscription # => Pay subscription | nil
```

This distinction matters: the **effective pricing plan** is what controls entitlements and limits inside your app. The **Pay/Stripe subscription state** is billing-facing. A manual assignment may intentionally override the subscription-backed plan while still leaving the underlying subscription present for billing operations.

**Edge case:** `source` can be `:default` even when `subscription` is non-nil. This happens when a Pay subscription exists but its `processor_plan` (Stripe price ID) doesn't map to any plan in your registry. The subscription is preserved for billing context, but the effective plan falls back to your configured default.

`resolution.to_h` is handy for inspection and tests, but it preserves the raw `plan`, `assignment`, and `subscription` objects. If you need a JSON-safe payload, build one explicitly from the scalar fields you care about.

### Misc

```ruby
Expand All @@ -311,7 +333,8 @@ And finally, you get very thin convenient wrappers if you're using the `pay` gem
```ruby
# Pay (Stripe) convenience (returns false/nil when Pay is absent)
# Note: this is billing-facing state, distinct from our in-app
# enforcement grace which is tracked per-limit.
# enforcement grace which is tracked per-limit, and distinct from
# the effective plan resolved by current_pricing_plan.
user.pay_subscription_active? # => true/false
user.pay_on_trial? # => true/false
user.pay_on_grace_period? # => true/false
Expand Down
1 change: 1 addition & 0 deletions lib/pricing_plans.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class InvalidOperation < Error; end
autoload :Configuration, "pricing_plans/configuration"
autoload :Registry, "pricing_plans/registry"
autoload :Plan, "pricing_plans/plan"
autoload :PlanResolution, "pricing_plans/plan_resolution"
autoload :DSL, "pricing_plans/dsl"
autoload :IntegerRefinements, "pricing_plans/integer_refinements"
autoload :PlanResolver, "pricing_plans/plan_resolver"
Expand Down
6 changes: 4 additions & 2 deletions lib/pricing_plans/pay_support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def log_debug(message)
def subscription_active_for?(plan_owner)
return false unless plan_owner

log_debug "[PricingPlans::PaySupport] subscription_active_for? called for #{plan_owner.class.name}##{plan_owner.id}"
owner_id = plan_owner.respond_to?(:id) ? plan_owner.id : "N/A"
log_debug "[PricingPlans::PaySupport] subscription_active_for? called for #{plan_owner.class.name}##{owner_id}"

# Prefer Pay's official API on the payment_processor
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
Expand Down Expand Up @@ -66,7 +67,8 @@ def subscription_active_for?(plan_owner)
def current_subscription_for(plan_owner)
return nil unless pay_available?

log_debug "[PricingPlans::PaySupport] current_subscription_for called for #{plan_owner.class.name}##{plan_owner.id}"
owner_id = plan_owner.respond_to?(:id) ? plan_owner.id : "N/A"
log_debug "[PricingPlans::PaySupport] current_subscription_for called for #{plan_owner.class.name}##{owner_id}"

# Prefer Pay's payment_processor API
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
Expand Down
8 changes: 8 additions & 0 deletions lib/pricing_plans/plan_owner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ def current_pricing_plan
PlanResolver.effective_plan_for(self)
end

def current_pricing_plan_resolution
PlanResolver.resolution_for(self)
end

def current_pricing_plan_source
current_pricing_plan_resolution.source
end

def on_free_plan?
plan = current_pricing_plan || PricingPlans::Registry.default_plan
plan&.free? || false
Expand Down
46 changes: 46 additions & 0 deletions lib/pricing_plans/plan_resolution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

module PricingPlans
class PlanResolution < Struct.new(:plan, :source, :assignment, :subscription, keyword_init: true)
SOURCES = [:assignment, :subscription, :default].freeze

def initialize(**attributes)
super

unless SOURCES.include?(source)
raise ArgumentError, "Invalid source: #{source.inspect}. Must be one of: #{SOURCES.inspect}"
end

freeze
end

def assignment?
source == :assignment
end

def subscription?
source == :subscription
end

def default?
source == :default
end

def plan_key
plan&.key
end

def assignment_source
assignment&.source
end

# Extends Struct#to_h with derived fields.
# Note: this preserves the raw plan / assignment / subscription objects.
def to_h
super.merge(
plan_key: plan_key,
assignment_source: assignment_source
)
end
end
end
127 changes: 64 additions & 63 deletions lib/pricing_plans/plan_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,52 @@ def log_debug(message)
end

def effective_plan_for(plan_owner)
log_debug "[PricingPlans::PlanResolver] effective_plan_for called for #{plan_owner.class.name}##{plan_owner.respond_to?(:id) ? plan_owner.id : 'N/A'}"
resolution_for(plan_owner).plan
end

# 1. Check manual assignment FIRST (admin overrides take precedence)
log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
if plan_owner.respond_to?(:id)
assignment = Assignment.find_by(
plan_owner_type: plan_owner.class.name,
plan_owner_id: plan_owner.id
def plan_key_for(plan_owner)
resolution_for(plan_owner).plan_key
end

def resolution_for(plan_owner)
log_debug "[PricingPlans::PlanResolver] resolution_for called for #{plan_owner.class.name}##{plan_owner.respond_to?(:id) ? plan_owner.id : 'N/A'}"

assignment = assignment_for(plan_owner)
subscription = current_subscription_for(plan_owner)

if assignment
log_debug "[PricingPlans::PlanResolver] Returning assignment-backed resolution: #{assignment.plan_key}"
return PlanResolution.new(
plan: Registry.plan(assignment.plan_key),
source: :assignment,
assignment: assignment,
subscription: subscription
)
if assignment
log_debug "[PricingPlans::PlanResolver] Found manual assignment: #{assignment.plan_key}"
return Registry.plan(assignment.plan_key)
else
log_debug "[PricingPlans::PlanResolver] No manual assignment found"
end
end

# 2. Check Pay subscription status
pay_available = PaySupport.pay_available?
log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"
log_debug "[PricingPlans::PlanResolver] defined?(Pay) = #{defined?(Pay)}"

if pay_available
log_debug "[PricingPlans::PlanResolver] Calling resolve_plan_from_pay..."
plan_from_pay = resolve_plan_from_pay(plan_owner)
log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returned: #{plan_from_pay ? plan_from_pay.key : 'nil'}"
return plan_from_pay if plan_from_pay
if subscription
processor_plan = subscription.processor_plan
log_debug "[PricingPlans::PlanResolver] resolution_for subscription processor_plan = #{processor_plan.inspect}"

if processor_plan && (plan = plan_from_processor_plan(processor_plan))
log_debug "[PricingPlans::PlanResolver] Returning subscription-backed resolution: #{plan.key}"
return PlanResolution.new(
plan: plan,
source: :subscription,
assignment: nil,
subscription: subscription
)
end
end

# 3. Fall back to default plan
default = Registry.default_plan
log_debug "[PricingPlans::PlanResolver] Returning default plan: #{default ? default.key : 'nil'}"
default
end

def plan_key_for(plan_owner)
effective_plan_for(plan_owner)&.key
log_debug "[PricingPlans::PlanResolver] Returning default-backed resolution: #{default ? default.key : 'nil'}"
PlanResolution.new(
plan: default,
source: :default,
assignment: nil,
subscription: subscription
)
end

def assign_plan_manually!(plan_owner, plan_key, source: "manual")
Expand All @@ -62,46 +71,38 @@ def pay_available?
PaySupport.pay_available?
end

def resolve_plan_from_pay(plan_owner)
log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay: checking if plan_owner has payment_processor or Pay methods..."

# Check if plan_owner has payment_processor (preferred) or Pay methods directly (fallback)
has_payment_processor = plan_owner.respond_to?(:payment_processor)
has_pay_methods = plan_owner.respond_to?(:subscribed?) ||
plan_owner.respond_to?(:on_trial?) ||
plan_owner.respond_to?(:on_grace_period?) ||
plan_owner.respond_to?(:subscriptions)
def assignment_for(plan_owner)
log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
return nil unless plan_owner.respond_to?(:id)

log_debug "[PricingPlans::PlanResolver] has_payment_processor? #{has_payment_processor}"
log_debug "[PricingPlans::PlanResolver] has_pay_methods? #{has_pay_methods}"
assignment = Assignment.find_by(
plan_owner_type: plan_owner.class.name,
plan_owner_id: plan_owner.id
)

# PaySupport will handle both payment_processor and direct Pay methods
return nil unless has_payment_processor || has_pay_methods
if assignment
log_debug "[PricingPlans::PlanResolver] Found manual assignment: #{assignment.plan_key}"
else
log_debug "[PricingPlans::PlanResolver] No manual assignment found"
end

# Check if plan_owner has active subscription, trial, or grace period
log_debug "[PricingPlans::PlanResolver] Calling PaySupport.subscription_active_for?..."
is_active = PaySupport.subscription_active_for?(plan_owner)
log_debug "[PricingPlans::PlanResolver] subscription_active_for? returned: #{is_active}"
assignment
end

if is_active
log_debug "[PricingPlans::PlanResolver] Calling PaySupport.current_subscription_for..."
subscription = PaySupport.current_subscription_for(plan_owner)
log_debug "[PricingPlans::PlanResolver] current_subscription_for returned: #{subscription ? subscription.class.name : 'nil'}"
return nil unless subscription
def current_subscription_for(plan_owner)
return nil unless plan_owner

# Map processor plan to our plan
processor_plan = subscription.processor_plan
log_debug "[PricingPlans::PlanResolver] subscription.processor_plan = #{processor_plan.inspect}"
pay_available = pay_available?
log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"

if processor_plan
matched_plan = plan_from_processor_plan(processor_plan)
log_debug "[PricingPlans::PlanResolver] plan_from_processor_plan returned: #{matched_plan ? matched_plan.key : 'nil'}"
return matched_plan
end
end
return nil unless pay_available

log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returning nil"
nil
# This intentionally delegates the active/trial/grace filtering contract
# to PaySupport.current_subscription_for so resolution_for can preserve
# the same billing context wherever it is called.
subscription = PaySupport.current_subscription_for(plan_owner)
log_debug "[PricingPlans::PlanResolver] current_subscription_for returned: #{subscription ? subscription.class.name : 'nil'}"
subscription
end

def plan_from_processor_plan(processor_plan)
Expand Down
2 changes: 1 addition & 1 deletion lib/pricing_plans/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module PricingPlans
VERSION = "0.3.2"
VERSION = "0.3.3"
end
13 changes: 13 additions & 0 deletions test/pay_support_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

require "test_helper"

class PaySupportTest < ActiveSupport::TestCase
def test_subscription_active_for_handles_objects_without_id
assert_equal false, PricingPlans::PaySupport.subscription_active_for?(Object.new)
end

def test_current_subscription_for_handles_objects_without_id
assert_nil PricingPlans::PaySupport.current_subscription_for(Object.new)
end
end
Loading
Loading