pricing_plans 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a94b5bd0d3211b4eb17838d133adbfcef2df64f1c36f5378c952d49e78a035d4
4
- data.tar.gz: f549e3f70a0ae506489204ba59c2da0996b62b4f514cf6f68276a9e8a1278d91
3
+ metadata.gz: 231aa688b5fb69cffde68df9bbab35af32634d8605930559378e15a841cb6aa0
4
+ data.tar.gz: 2fee172963111d141b39d9ad9335f1b84d3f9b6f46df139e63684103eecb7750
5
5
  SHA512:
6
- metadata.gz: 997d0a1039830243d8f5515197570fa84d07b3450482b448f7ef9acfb724accec12dad6fa0f60216162fc414afb26ba4f93169dd376ddb3590dec223d58c5eba
7
- data.tar.gz: 59da0f6040c935251fe2c47a635ce2738b373b8d91ae6349be6441ca04d31deb1c349cf1bf4161ed658f7abfa96cc202eedde1aa4b079ddd236545f1fa468f5e
6
+ metadata.gz: 99277aae15c2a137b0824a199097420a60613e9c5c20b6569036f11b574186997b303fdfe42d5a2d147a66d0b28a32812e8ca1706903d94c88f6e61a5e09bf27
7
+ data.tar.gz: 12aeee03c7911d19b7b4c04398946150534b0ef66d59aa50211992a52f3000908778e42e7eb51b2acc0bec85e23e51eff5996ae04870f948826485ebf80a1414
@@ -1,19 +1,18 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(mkdir:*)",
5
- "Bash(bundle install:*)",
6
- "Bash(bundle exec rake:*)",
7
- "Bash(bundle update:*)",
8
- "Bash(bundle exec ruby:*)",
9
4
  "Bash(sed:*)",
10
5
  "Bash(grep:*)",
11
- "Bash(ruby:*)",
12
6
  "Bash(find:*)",
13
7
  "mcp__context7__resolve-library-id",
14
8
  "mcp__context7__get-library-docs",
15
9
  "WebFetch(domain:github.com)",
16
- "WebSearch"
10
+ "WebSearch",
11
+ "Bash(bundle exec rake test:*)",
12
+ "Bash(bundle install:*)",
13
+ "Bash(bundle exec appraisal:*)",
14
+ "Bash(ls:*)",
15
+ "Bash(done)"
17
16
  ],
18
17
  "deny": []
19
18
  }
data/Appraisals ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ appraise "rails-7.2" do
4
+ gem "rails", "~> 7.2.3"
5
+ end
6
+
7
+ appraise "rails-8.1" do
8
+ gem "rails", "~> 8.1.2"
9
+ end
data/CHANGELOG.md CHANGED
@@ -1,9 +1,6 @@
1
- # Changelog
1
+ ## [0.2.1] - 2026-01-15
2
2
 
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3
+ - Added a `metadata` alias to plans, and documented its usage
7
4
 
8
5
  ## [0.2.0] - 2025-12-26
9
6
 
data/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # 💵 `pricing_plans` - Define and enforce pricing plan limits in your Rails app (SaaS entitlements)
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/pricing_plans.svg?x=1)](https://badge.fury.io/rb/pricing_plans)
3
+ [![Gem Version](https://badge.fury.io/rb/pricing_plans.svg)](https://badge.fury.io/rb/pricing_plans) [![Build Status](https://github.com/rameerez/pricing_plans/workflows/Tests/badge.svg)](https://github.com/rameerez/pricing_plans/actions)
4
4
 
5
- Enforce pricing plan limits with one-liners that read like plain English. Avoid scattering and entangling pricing logic everywhere in your Rails SaaS.
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks.
7
+
8
+ `pricing_plans` allows you to enforce pricing plan limits with one-liners that read like plain English. Avoid scattering and entangling pricing logic everywhere in your Rails SaaS.
6
9
 
7
10
  For example, this is how you define pricing plans and their entitlements:
8
11
  ```ruby
@@ -99,6 +102,16 @@ You can also display upgrade alerts to prompt users into upgrading to the next p
99
102
 
100
103
  ![pricing_plans Ruby on Rails gem - pricing plan upgrade prompt](/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg)
101
104
 
105
+ You can attach arbitrary plan `metadata` for UI/presentation needs (icons, colors, badges) directly in the initializer:
106
+
107
+ ```ruby
108
+ plan :hobby do
109
+ metadata icon: "rocket", color: "bg-red-500"
110
+ end
111
+
112
+ plan.metadata[:icon] # => "rocket"
113
+ ```
114
+
102
115
  You can also grandfather users into old plans (hidden to other users), assign plans manually without requiring a payment (for testing, gifts, or employees), and much more!
103
116
 
104
117
  ## 🤓 Read the docs!
@@ -300,6 +300,7 @@ PricingPlans.configure do |config|
300
300
  name "Free Plan" # optional, would default to "Free" as inferred from the :free key
301
301
  description "A plan to get you started"
302
302
  bullets "Basic features", "Community support"
303
+ metadata icon: "rocket", color: "bg-red-500"
303
304
 
304
305
  cta_text "Subscribe"
305
306
  # In initializers, prefer a string path/URL or set a global default CTA in config.
@@ -315,6 +316,18 @@ end
315
316
 
316
317
  You can also make a plan `default!`; and you can make a plan `highlighted!` to help you when building a pricing table.
317
318
 
319
+ ### Plan metadata for UI and presentation
320
+
321
+ You can attach arbitrary `metadata` to a plan for presentation needs (for example, per-card icons or colors on a pricing page). This keeps plan UI details co-located in the same DSL rather than scattered elsewhere:
322
+
323
+ ```ruby
324
+ plan :hobby do
325
+ metadata icon: "rocket", color: "bg-red-500"
326
+ end
327
+
328
+ plan.metadata[:icon] # => "rocket"
329
+ ```
330
+
318
331
  ### Hide plans from public lists
319
332
 
320
333
  You can mark a plan as `hidden!` to exclude it from public-facing plan lists (`PricingPlans.plans`, `PricingPlans.for_pricing`, `PricingPlans.view_models`). Hidden plans are still accessible internally and can be assigned to users.
@@ -328,6 +341,8 @@ You can mark a plan as `hidden!` to exclude it from public-facing plan lists (`P
328
341
  ```ruby
329
342
  PricingPlans.configure do |config|
330
343
  # Hidden default plan for users who haven't subscribed
344
+ # It won't appear on pricing page
345
+ # This is what users are on before they subscribe to any plan
331
346
  plan :unsubscribed do
332
347
  price 0
333
348
  hidden! # Won't appear on pricing page
@@ -411,4 +426,4 @@ plan :enterprise do
411
426
  unlimited :products
412
427
  allows :api_access, :premium_features
413
428
  end
414
- ```
429
+ ```
data/docs/04-views.md CHANGED
@@ -18,6 +18,7 @@ Each `PricingPlans::Plan` responds to:
18
18
  - `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this.
19
19
  - `plan.cta_text`
20
20
  - `plan.cta_url`
21
+ - `plan.metadata` → Optional hash for UI/presentation attributes (icons, colors, badges)
21
22
 
22
23
  ### Example: build a pricing page
23
24
 
@@ -118,4 +119,4 @@ Tip: you could also use `plan_limit_remaining(:projects)` and `plan_limit_percen
118
119
 
119
120
  ## Message customization
120
121
 
121
- - You can override copy globally via `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md), which is used across limit checks and features. Suggested signature: `(context:, **kwargs) -> string` with contexts `:over_limit`, `:grace`, `:feature_denied`, and `:overage_report`.
122
+ - You can override copy globally via `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md), which is used across limit checks and features. Suggested signature: `(context:, **kwargs) -> string` with contexts `:over_limit`, `:grace`, `:feature_denied`, and `:overage_report`.
@@ -1,12 +1,39 @@
1
1
  # Using `pricing_plans` with `pay` and/or `usage_credits`
2
2
 
3
- `pricing_plans` is designed to work seamlessly with other complementary popular gems like `pay` (to handle actual subscriptions and payments), and `usage_credits` (to handle credit-like spending and refills)
3
+ `pricing_plans` is designed to work seamlessly with other complementary popular gems like [`pay`](https://github.com/pay-rails/pay) (to handle actual subscriptions and payments), and `usage_credits` (to handle credit-like spending and refills)
4
4
 
5
- These gems are related but not overlapping. They're complementary. The boundaries are clear: billing is handled in Pay; metering (ledger-like) in usage_credits.
5
+ These gems are related but not overlapping. They're complementary. The boundaries are:
6
+ - [`pay`](https://github.com/pay-rails/pay) handles billing
7
+ - [`usage_credits`](https://github.com/rameerez/usage_credits/) handles user credits (metered usage through credits, ledger-like)
6
8
 
7
- The integration with `pay` should be seamless and is documented throughout the entire docs; however, here's a brief note about using `usage_credits` alongside `pricing_plans`.
9
+ ## `pay` gem
8
10
 
9
- ## Using `pricing_plans` with the `usage_credits` gem
11
+ The integration with the `pay` gem should be seamless and is documented throughout the entire docs; however, to make it explicit:
12
+
13
+ There's nothing to do on your end to make `pricing_plans` work with `pay`!
14
+
15
+ As long as your `pricing_plans` config (`config/initializers/pricing_plans.rb`) contains a plan with the correct `stripe_price` ID, whenever a subscription to that Stripe price ID is found through the `pay` gem, `pricing_plans` will understand the user is subscribed to that plan automatically, and will start enforcing the corresponding limits.
16
+
17
+ The way `pricing_plans` works doesn't require any data migration, or callback setup, or any manual action. You don't need to call `assign_pricing_plan!` at all at any point, unless you're trying to something like overriding a plan, gifting users access to plans without any payment, or things like that.
18
+
19
+ As long as a matching `stripe_price` is found in the `pricing_plans.rb` initializer, the gem will know a user subscribed to that Stripe price ID is under the corresponding plan. Essentially, the gem just looks at the current `pay` subscriptions of your user. If a matching price ID is found in the `pricing_plans` configuration file, it enforces the corresponding limits.
20
+
21
+ > [!TIP]
22
+ > To make your `pricing_plans` gem config work across environments (production, development, etc.) instead of defining price IDs statically like this in the config:
23
+ >
24
+ > ```ruby
25
+ > stripe_price month: "price_123", year: "price_456"
26
+ > ```
27
+ >
28
+ > Try instead defining them dynamically using `Rails.env`, so the corresponding plan for each environment gets loaded automatically. A simple solution would be to define your plans in the credentials file, and then doing something like this in the `pricing_plans` config:
29
+ >
30
+ > ```ruby
31
+ > stripe_price month: Rails.application.credentials.dig(Rails.env.to_sym, :stripe_plans, :plan_name, :monthly), year: Rails.application.credentials.dig(Rails.env.to_sym, :stripe_plans, :plan_name, :yearly)
32
+ > ```
33
+ >
34
+ > You can come up with similar solutions, like adding that config to a plaintext `.yml` file if you don't want to store this info in the credentials file, but this is the overall idea.
35
+
36
+ ## `usage_credits` gem
10
37
 
11
38
  In the SaaS world, pricing plans and usage credits are related in so far credits are usually a part of a pricing plan. A plan would give you, say, 100 credits a month along other features, and users would find that information usually documented in the pricing table itself.
12
39
 
@@ -0,0 +1,23 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.3"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "irb"
11
+ gem "rubocop", "~> 1.0"
12
+ gem "rubocop-minitest", "~> 0.35"
13
+ gem "rubocop-performance", "~> 1.0"
14
+ end
15
+
16
+ group :test do
17
+ gem "minitest", "~> 5.0"
18
+ gem "sqlite3", "~> 2.1"
19
+ gem "ostruct"
20
+ gem "simplecov", require: false
21
+ end
22
+
23
+ gemspec path: "../"
@@ -0,0 +1,23 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.2"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "irb"
11
+ gem "rubocop", "~> 1.0"
12
+ gem "rubocop-minitest", "~> 0.35"
13
+ gem "rubocop-performance", "~> 1.0"
14
+ end
15
+
16
+ group :test do
17
+ gem "minitest", "~> 5.0"
18
+ gem "sqlite3", "~> 2.1"
19
+ gem "ostruct"
20
+ gem "simplecov", require: false
21
+ end
22
+
23
+ gemspec path: "../"
@@ -12,11 +12,11 @@ module PricingPlans
12
12
  # Debug mode - set to true to enable debug output
13
13
  attr_accessor :debug
14
14
  # Global controller ergonomics
15
- # Optional global resolver for controller plan owner. Per-controller settings still win.
16
- # Accepts:
17
- # - Symbol: a controller helper to call (e.g., :current_organization)
18
- # - Proc: instance-exec'd in the controller (self is the controller)
19
- attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
15
+ # Optional global resolver for controller plan owner. Per-controller settings still win.
16
+ # Accepts:
17
+ # - Symbol: a controller helper to call (e.g., :current_organization)
18
+ # - Proc: instance-exec'd in the controller (self is the controller)
19
+ attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
20
20
  # When a limit check blocks, controllers can redirect to a global default target.
21
21
  # Accepts:
22
22
  # - Symbol: a controller helper to call (e.g., :pricing_path)
@@ -139,6 +139,9 @@ module PricingPlans
139
139
  end
140
140
  end
141
141
 
142
+ alias_method :set_metadata, :set_meta
143
+ alias_method :metadata, :meta
144
+
142
145
  # CTA helpers for pricing UI
143
146
  def set_cta_text(value)
144
147
  @cta_text = value&.to_s
@@ -169,7 +172,7 @@ module PricingPlans
169
172
  default = PricingPlans.configuration.default_cta_url
170
173
  return default if default
171
174
  # New default: if host app defines subscribe_path, prefer that
172
- if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
175
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
173
176
  return Rails.application.routes.url_helpers.subscribe_path(plan: key, interval: :month)
174
177
  end
175
178
  nil
@@ -480,6 +483,7 @@ module PricingPlans
480
483
  name: name,
481
484
  description: description,
482
485
  features: bullets, # alias in this gem
486
+ metadata: metadata.dup,
483
487
  highlighted: highlighted?,
484
488
  default: default?,
485
489
  free: free?,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/pricing_plans.rb CHANGED
@@ -135,11 +135,13 @@ module PricingPlans
135
135
  is_current = plan_owner ? (PlanResolver.effective_plan_for(plan_owner)&.key == plan.key) : false
136
136
  is_popular = Registry.highlighted_plan&.key == plan.key
137
137
  price_label = plan_price_label_for(plan)
138
+ # Duplicate metadata to avoid mutating plan internals from view-layer code.
138
139
  {
139
140
  key: plan.key,
140
141
  name: plan.name,
141
142
  description: plan.description,
142
143
  bullets: plan.bullets,
144
+ metadata: plan.metadata.dup,
143
145
  price_label: price_label,
144
146
  is_current: is_current,
145
147
  is_popular: is_popular,
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pricing_plans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-12-26 00:00:00.000000000 Z
10
+ date: 2026-01-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -49,104 +49,6 @@ dependencies:
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
52
- - !ruby/object:Gem::Dependency
53
- name: bundler
54
- requirement: !ruby/object:Gem::Requirement
55
- requirements:
56
- - - "~>"
57
- - !ruby/object:Gem::Version
58
- version: '2.0'
59
- type: :development
60
- prerelease: false
61
- version_requirements: !ruby/object:Gem::Requirement
62
- requirements:
63
- - - "~>"
64
- - !ruby/object:Gem::Version
65
- version: '2.0'
66
- - !ruby/object:Gem::Dependency
67
- name: rake
68
- requirement: !ruby/object:Gem::Requirement
69
- requirements:
70
- - - "~>"
71
- - !ruby/object:Gem::Version
72
- version: '13.0'
73
- type: :development
74
- prerelease: false
75
- version_requirements: !ruby/object:Gem::Requirement
76
- requirements:
77
- - - "~>"
78
- - !ruby/object:Gem::Version
79
- version: '13.0'
80
- - !ruby/object:Gem::Dependency
81
- name: minitest
82
- requirement: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - "~>"
85
- - !ruby/object:Gem::Version
86
- version: '5.0'
87
- type: :development
88
- prerelease: false
89
- version_requirements: !ruby/object:Gem::Requirement
90
- requirements:
91
- - - "~>"
92
- - !ruby/object:Gem::Version
93
- version: '5.0'
94
- - !ruby/object:Gem::Dependency
95
- name: sqlite3
96
- requirement: !ruby/object:Gem::Requirement
97
- requirements:
98
- - - "~>"
99
- - !ruby/object:Gem::Version
100
- version: '2.1'
101
- type: :development
102
- prerelease: false
103
- version_requirements: !ruby/object:Gem::Requirement
104
- requirements:
105
- - - "~>"
106
- - !ruby/object:Gem::Version
107
- version: '2.1'
108
- - !ruby/object:Gem::Dependency
109
- name: rubocop
110
- requirement: !ruby/object:Gem::Requirement
111
- requirements:
112
- - - "~>"
113
- - !ruby/object:Gem::Version
114
- version: '1.0'
115
- type: :development
116
- prerelease: false
117
- version_requirements: !ruby/object:Gem::Requirement
118
- requirements:
119
- - - "~>"
120
- - !ruby/object:Gem::Version
121
- version: '1.0'
122
- - !ruby/object:Gem::Dependency
123
- name: rubocop-minitest
124
- requirement: !ruby/object:Gem::Requirement
125
- requirements:
126
- - - "~>"
127
- - !ruby/object:Gem::Version
128
- version: '0.35'
129
- type: :development
130
- prerelease: false
131
- version_requirements: !ruby/object:Gem::Requirement
132
- requirements:
133
- - - "~>"
134
- - !ruby/object:Gem::Version
135
- version: '0.35'
136
- - !ruby/object:Gem::Dependency
137
- name: rubocop-performance
138
- requirement: !ruby/object:Gem::Requirement
139
- requirements:
140
- - - "~>"
141
- - !ruby/object:Gem::Version
142
- version: '1.0'
143
- type: :development
144
- prerelease: false
145
- version_requirements: !ruby/object:Gem::Requirement
146
- requirements:
147
- - - "~>"
148
- - !ruby/object:Gem::Version
149
- version: '1.0'
150
52
  description: Define and enforce pricing plan limits in your Rails SaaS (entitlements,
151
53
  quotas, feature gating). pricing_plans acts as your single source of truth for pricing
152
54
  plans. Define a pricing catalog with feature gating, persistent caps, per‑period
@@ -161,6 +63,7 @@ extra_rdoc_files: []
161
63
  files:
162
64
  - ".claude/settings.local.json"
163
65
  - ".rubocop.yml"
66
+ - Appraisals
164
67
  - CHANGELOG.md
165
68
  - CLAUDE.md
166
69
  - LICENSE.txt
@@ -176,6 +79,8 @@ files:
176
79
  - docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg
177
80
  - docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg
178
81
  - docs/images/product_creation_blocked.jpg
82
+ - gemfiles/rails_7.2.gemfile
83
+ - gemfiles/rails_8.1.gemfile
179
84
  - lib/generators/pricing_plans/install/install_generator.rb
180
85
  - lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb
181
86
  - lib/generators/pricing_plans/install/templates/initializer.rb