usage_credits 0.1.1 → 0.2.0

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: '09e65a66af34a0f2bf35a72d59fdd2d9d9799aaeaa1ac8b7628f222f65e5beab'
4
- data.tar.gz: 2dc466a29bc36385aeee7c8bbb77833e521c2207e84ac6cf00543253d02af74e
3
+ metadata.gz: 0fcda02fd1ae61887b8a80d6fbf6c864843d363797d90dbbd66abafe2851ce8b
4
+ data.tar.gz: 6c1e67ec12f46bf5e33faa7d02ed723dc47926a29948a8140706392d1ffbe024
5
5
  SHA512:
6
- metadata.gz: c1a6c0fc9dab7e315d67b6071b38d5e00b386bc21830438636405296b32fbabb510f0f31a77d995ea8d03972b939ebcf25aba3baa7f03847a66d2ee55b4a6a62
7
- data.tar.gz: fd4c0225c47dfca3903249b9456d428c9012060acb4f8beceb4c577e5673a4f650cdfd0ba74035bae5b253284e8ccb73eeb87a11b815fed4b9626f00968bf84a
6
+ metadata.gz: cd9d8517d94b2c19d0b141d9b47f4362c65ee1bf9255bff400c2b55c34b14a87903cdd3f58987e92edad8c2116fbfe485f185ddc2e9696782ae912fc7f40402c
7
+ data.tar.gz: b9498add88988145a75e74abbdf24b086391fd76188d63719a108a97f4b1fa69af86d9ea9c941cdd45ac25a2d1e5319192b14d34361a2ea42058db333f6adb0e
data/.simplecov ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ SimpleCov.start 'rails' do
4
+ # Coverage directory
5
+ coverage_dir 'coverage'
6
+
7
+ # Enable branch coverage (must be before minimum_coverage)
8
+ enable_coverage :branch
9
+
10
+ # Set minimum coverage threshold to prevent coverage regression
11
+ # Current coverage: Line 84.38%, Branch 71.9%
12
+ # Note: Some paths (PostgreSQL JSON operators, error fallbacks) may not be exercised in SQLite tests
13
+ minimum_coverage line: 75, branch: 60
14
+
15
+ # Add custom groups for better organization
16
+ add_group 'Models', 'lib/usage_credits/models'
17
+ add_group 'Services', 'lib/usage_credits/services'
18
+ add_group 'Helpers', 'lib/usage_credits/helpers'
19
+ add_group 'Jobs', 'lib/usage_credits/jobs'
20
+ add_group 'Concerns', 'lib/usage_credits/models/concerns'
21
+ add_group 'DSL', ['lib/usage_credits/operation.rb', 'lib/usage_credits/credit_pack.rb', 'lib/usage_credits/credit_subscription_plan.rb']
22
+
23
+ # Filter out files we don't want to track
24
+ add_filter '/test/'
25
+ add_filter '/spec/'
26
+ add_filter '/config/'
27
+ add_filter '/db/'
28
+ add_filter '/vendor/'
29
+ add_filter '/bin/'
30
+
31
+ # Track all Ruby files in lib
32
+ track_files 'lib/**/*.rb'
33
+
34
+ # Disambiguate parallel test runs
35
+ command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
36
+
37
+ # Use different formatters for CI vs local
38
+ if ENV['CI']
39
+ # CI: Use simple formatter for console output
40
+ formatter SimpleCov::Formatter::SimpleFormatter
41
+ else
42
+ # Local: Use HTML formatter for detailed report
43
+ formatter SimpleCov::Formatter::HTMLFormatter
44
+ end
45
+
46
+ # Merge results from parallel runs
47
+ merge_timeout 3600
48
+ end
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI Agents (like OpenAI's Codex, Claude Code, etc) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
data/Appraisals ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test against Pay 8.3.x (minimum supported version)
4
+ appraise "pay-8.3" do
5
+ gem "pay", "~> 8.3.0"
6
+ gem "stripe", "~> 13.0"
7
+ end
8
+
9
+ # Test against Pay 9.0.x (current recommended version)
10
+ appraise "pay-9.0" do
11
+ gem "pay", "~> 9.0.0"
12
+ gem "stripe", "~> 13.0"
13
+ end
14
+
15
+ # Test against Pay 10.x (newly supported version)
16
+ appraise "pay-10.0" do
17
+ gem "pay", "~> 10.0.0"
18
+ gem "stripe", "~> 15.0"
19
+ end
20
+
21
+ # Test against Pay 11.x (latest version)
22
+ appraise "pay-11.0" do
23
+ gem "pay", "~> 11.0"
24
+ gem "stripe", "~> 18.0"
25
+ end
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.2.0] - 2025-12-29
2
+
3
+ - Add Claude Code GitHub Workflow by @rameerez in https://github.com/rameerez/usage_credits/pull/14
4
+ - Add test suite by @rameerez in https://github.com/rameerez/usage_credits/pull/15
5
+ - Update Pay gem dependency to support versions 8.3 to 9.x by @rameerez in https://github.com/rameerez/usage_credits/pull/16
6
+ - Update Pay gem dependency to support version 8.3 to < 10.0 by @kaka-ruto in https://github.com/rameerez/usage_credits/pull/10
7
+ - Add Pay version matrix testing with Appraisal by @rameerez in https://github.com/rameerez/usage_credits/pull/17
8
+ - Upgrade Pay dependency to support version 10.x by @rameerez in https://github.com/rameerez/usage_credits/pull/18
9
+ - Upgrade Pay dependency to support version 11.x by @rameerez in https://github.com/rameerez/usage_credits/pull/19
10
+ - Remove payment intent metadata from Subscription checkout session by @cole-robertson in https://github.com/rameerez/usage_credits/pull/2
11
+ - Handle subscription plan changes (upgrades & downgrades) by @rameerez in https://github.com/rameerez/usage_credits/pull/20
12
+ - Add configurable minimum fulfillment period for dev/test flexibility by @rameerez in https://github.com/rameerez/usage_credits/pull/21
13
+ - Add multi-period Stripe price support for subscription plans by @rameerez in https://github.com/rameerez/usage_credits/pull/22
14
+ - Fix a bug where very fast fulfillment periods would cause credits not to expire fast enough by @rameerez in https://github.com/rameerez/usage_credits/pull/23
15
+ - Fix incomplete fulfillment update on subscription plan upgrade by @rameerez in https://github.com/rameerez/usage_credits/pull/24
16
+
1
17
  ## [0.1.1] - 2025-01-14
2
18
 
3
19
  - Rename `Wallet#subscriptions` to `Wallet.credit_subscriptions` so that it doesn’t override the Pay gem’s own subscriptions association on `User`
data/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
data/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # 💳✨ `usage_credits` - Add usage-based credits to your Rails app
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/usage_credits.svg)](https://badge.fury.io/rb/usage_credits)
3
+ [![Gem Version](https://badge.fury.io/rb/usage_credits.svg?v=0.1)](https://badge.fury.io/rb/usage_credits?v=0.1)
4
4
 
5
5
  Allow your users to have in-app credits / tokens they can use to perform operations.
6
6
 
7
7
  ✨ Perfect for SaaS, AI apps, games, and API products that want to implement usage-based pricing.
8
8
 
9
+ [ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ]
10
+
9
11
  Refill user credits with Stripe subscriptions, allow your users to top up by purchasing booster credit packs at any time, rollover unused credits to the next billing period, expire credits, implement PAYG (pay-as-you-go) billing, award free credits as bonuses (for referrals, giving feedback, etc.), get a detailed history and audit trail of every transaction for billing / reporting, and more!
10
12
 
11
13
  All with a simple DSL that reads just like English.
@@ -383,6 +385,31 @@ end
383
385
 
384
386
  For now, only Stripe subscriptions are supported (contribute to the codebase to help us add more payment processors!)
385
387
 
388
+ #### Subscriptions with a monthly + yearly price
389
+
390
+ For plans that offer multiple billing periods (e.g., monthly and yearly pricing), you can specify multiple Stripe price IDs using a hash:
391
+
392
+ ```ruby
393
+ subscription_plan :pro do
394
+ # Multi-period pricing: same plan, different billing frequencies
395
+ stripe_price month: "price_pro_monthly", year: "price_pro_yearly"
396
+
397
+ gives 10_000.credits.every(:month)
398
+ # ...
399
+ end
400
+ ```
401
+
402
+ The gem automatically handles plan matching for webhook events - when Stripe sends subscription events, the plan will be correctly identified regardless of which billing period was selected.
403
+
404
+ **Note:** Single-price plans (the traditional format) continue to work as before for backward compatibility:
405
+
406
+ ```ruby
407
+ subscription_plan :basic do
408
+ stripe_price "price_basic_monthly" # Single price ID (still works)
409
+ gives 1_000.credits.every(:month)
410
+ end
411
+ ```
412
+
386
413
  ### Specify a fulfillment period
387
414
 
388
415
  Next, specify how many credits a user subscribed to this plan gets, and when they get them.
@@ -413,6 +440,49 @@ subscription_plan :pro do
413
440
  end
414
441
  ```
415
442
 
443
+ ### Grace period for credit expiration
444
+
445
+ When credits expire (i.e., `unused_credits :expire`), there's a configurable **grace period** to ensure smooth transitions between fulfillment cycles. This prevents users from seeing zero credits between the moment old credits expire and new credits are fulfilled.
446
+
447
+ ```ruby
448
+ UsageCredits.configure do |config|
449
+ # Grace period for credit expiration (default: 5 minutes)
450
+ # Should match the frequency of your UsageCredits::FulfillmentJob runs
451
+ config.fulfillment_grace_period = 5.minutes
452
+ end
453
+ ```
454
+
455
+ > [!NOTE]
456
+ > **Automatic grace period capping:** If your fulfillment period is shorter than the grace period (e.g., credits every 15 seconds with a 5-minute grace period), the grace period is **automatically capped** to the fulfillment period. This prevents balance accumulation where credits pile up faster than they expire.
457
+ >
458
+ > For example, with `gives 100.credits.every(15.seconds)` and a 5-minute grace period, the effective grace period will be 15 seconds, not 5 minutes. A warning will be logged during initialization to alert you of this behavior.
459
+ >
460
+ > This is typically only relevant for development/testing with very short fulfillment periods. In production with monthly or daily fulfillment cycles, the default 5-minute grace period works perfectly.
461
+
462
+ ### Upgrades, downgrades, and plan changes
463
+
464
+ `usage_credits` reacts to plan changes (via the `pay` gem), and we handle automatically credit issuing for upgrades & downgrades:
465
+
466
+ - **Upgrades**: credits are granted immediately for the new plan. If the new plan expires credits, upgrade credits expire too.
467
+ - **Downgrades**: scheduled for the end of the current period; users keep current benefits until then.
468
+ - **Non-credit plan changes**: moving from credit → non-credit stops fulfillment at period end (no clawback).
469
+ - **Reactivation**: moving back to a credit plan reactivates fulfillment and grants credits (no signup bonus on reactivation).
470
+ - **Pending downgrades**: if a user returns to their current plan before the downgrade takes effect, we cancel the pending change and do **not** grant extra credits.
471
+ - **Credit gaming prevention**: we take measures to protect against user gaming the credit system by repeatedly upgrading/downgrading their subscription.
472
+
473
+ This happens automatically thanks to our Pay Subscription extension (changes to the `Subscription` model in the `pay` gem trigger `usage_credits` issuing the right credits based on the subscription change)
474
+
475
+ ### What we handle vs. what we don't (brief)
476
+
477
+ Handled:
478
+ - Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions
479
+ - Pending downgrade application on renewal
480
+ - Credit expiration and rollover
481
+
482
+ Not handled (yet):
483
+ - Plan changes while **trialing** (we only handle `status == "active"`)
484
+ - Paused subscriptions (see TODO in code)
485
+
416
486
  ## Transaction history & audit trail
417
487
 
418
488
  Every transaction (whether adding or deducting credits) is logged in the ledger, and automatically tracked with metadata:
@@ -469,6 +539,10 @@ Which will get you:
469
539
 
470
540
  It's useful if you want to name your credits something else (tokens, virtual currency, tasks, in-app gems, whatever) and you want the name to be consistent.
471
541
 
542
+ ## Demo Rails app
543
+
544
+ There's a demo Rails app showcasing the features in the `usage_credits` gem under `test/dummy`. It's currently deployed to `usagecredits.com`. If you want to run it yourself locally, you can just clone this repo, `cd` into the `test/dummy` folder, and then `bundle` and `rails s` to launch it. You can examine the code of the demo app to better understand the gem.
545
+
472
546
  ## Technical notes on architecture and how this gem is built
473
547
 
474
548
  Building a usage credits system is deceptively complex.
@@ -558,9 +632,7 @@ Real billing systems usually find edge cases when handling things like:
558
632
  Please help us by contributing to add tests to cover all critical paths!
559
633
 
560
634
  ## TODO
561
-
562
- - [ ] Write a comprehensive `minitest` test suite that covers all critical paths (both happy paths and weird edge cases)
563
- - [ ] Handle subscription upgrades and downgrades (upgrade immediately; downgrade at end of billing period? Cover all scenarios allowed by the Stripe Customer Portal?)
635
+ No open TODOs here right now. If you find an edge case, please open an issue or PR.
564
636
 
565
637
  ## Testing
566
638
 
@@ -574,8 +646,7 @@ To install this gem onto your local machine, run `bundle exec rake install`.
574
646
 
575
647
  ## Contributing
576
648
 
577
- Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/usage_credits. Our code of conduct is: just be nice and make your mom proud of what
578
- you do and post online.
649
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/usage_credits. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
579
650
 
580
651
  ## License
581
652
 
@@ -0,0 +1,29 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "mocha"
7
+ gem "simplecov", require: false
8
+ gem "standard"
9
+ gem "vcr"
10
+ gem "webmock"
11
+ gem "braintree", ">= 2.92.0"
12
+ gem "lemonsqueezy", "~> 1.0"
13
+ gem "paddle", "~> 2.6"
14
+ gem "stripe", "~> 15.0"
15
+ gem "prawn"
16
+ gem "receipts"
17
+ gem "sqlite3"
18
+ gem "pg"
19
+ gem "bootsnap", require: false
20
+ gem "puma"
21
+ gem "web-console", group: :development
22
+ gem "importmap-rails"
23
+ gem "sprockets-rails"
24
+ gem "stimulus-rails"
25
+ gem "turbo-rails"
26
+ gem "rdoc", ">= 7.0"
27
+ gem "pay", "~> 10.0.0"
28
+
29
+ gemspec path: "../"
@@ -0,0 +1,29 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "mocha"
7
+ gem "simplecov", require: false
8
+ gem "standard"
9
+ gem "vcr"
10
+ gem "webmock"
11
+ gem "braintree", ">= 2.92.0"
12
+ gem "lemonsqueezy", "~> 1.0"
13
+ gem "paddle", "~> 2.6"
14
+ gem "stripe", "~> 18.0"
15
+ gem "prawn"
16
+ gem "receipts"
17
+ gem "sqlite3"
18
+ gem "pg"
19
+ gem "bootsnap", require: false
20
+ gem "puma"
21
+ gem "web-console", group: :development
22
+ gem "importmap-rails"
23
+ gem "sprockets-rails"
24
+ gem "stimulus-rails"
25
+ gem "turbo-rails"
26
+ gem "rdoc", ">= 7.0"
27
+ gem "pay", "~> 11.0"
28
+
29
+ gemspec path: "../"
@@ -0,0 +1,29 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "mocha"
7
+ gem "simplecov", require: false
8
+ gem "standard"
9
+ gem "vcr"
10
+ gem "webmock"
11
+ gem "braintree", ">= 2.92.0"
12
+ gem "lemonsqueezy", "~> 1.0"
13
+ gem "paddle", "~> 2.6"
14
+ gem "stripe", "~> 13.0"
15
+ gem "prawn"
16
+ gem "receipts"
17
+ gem "sqlite3"
18
+ gem "pg"
19
+ gem "bootsnap", require: false
20
+ gem "puma"
21
+ gem "web-console", group: :development
22
+ gem "importmap-rails"
23
+ gem "sprockets-rails"
24
+ gem "stimulus-rails"
25
+ gem "turbo-rails"
26
+ gem "rdoc", ">= 7.0"
27
+ gem "pay", "~> 8.3.0"
28
+
29
+ gemspec path: "../"
@@ -0,0 +1,29 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "mocha"
7
+ gem "simplecov", require: false
8
+ gem "standard"
9
+ gem "vcr"
10
+ gem "webmock"
11
+ gem "braintree", ">= 2.92.0"
12
+ gem "lemonsqueezy", "~> 1.0"
13
+ gem "paddle", "~> 2.6"
14
+ gem "stripe", "~> 13.0"
15
+ gem "prawn"
16
+ gem "receipts"
17
+ gem "sqlite3"
18
+ gem "pg"
19
+ gem "bootsnap", require: false
20
+ gem "puma"
21
+ gem "web-console", group: :development
22
+ gem "importmap-rails"
23
+ gem "sprockets-rails"
24
+ gem "stimulus-rails"
25
+ gem "turbo-rails"
26
+ gem "rdoc", ">= 7.0"
27
+ gem "pay", "~> 9.0.0"
28
+
29
+ gemspec path: "../"
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  UsageCredits.configure do |config|
4
+ #
5
+ # Minimum fulfillment period for subscription plans (default: 1.day)
6
+ # In development/test, you can set this to a shorter period for faster testing:
7
+ #
8
+ # config.min_fulfillment_period = 2.seconds if Rails.env.development?
9
+ #
10
+ #
4
11
  #
5
12
  # Define your credit-consuming operations below
6
13
  #
@@ -55,6 +62,9 @@ UsageCredits.configure do |config|
55
62
  # gives 1000.credits.every(:month)
56
63
  # signup_bonus 100.credits
57
64
  # unused_credits :expire # Credits reset each month
65
+ #
66
+ # # Single price (backward compatible)
67
+ # stripe_price "price_basic_monthly"
58
68
  # end
59
69
  #
60
70
  # subscription_plan :pro do
@@ -62,6 +72,13 @@ UsageCredits.configure do |config|
62
72
  # signup_bonus 1_000.credits
63
73
  # trial_includes 500.credits
64
74
  # unused_credits :expire # Credits expire at the end of the fulfillment period (use :rollover to roll over to next period)
75
+ #
76
+ # # Multi-period pricing (monthly + yearly)
77
+ # stripe_price month: "price_pro_monthly", year: "price_pro_yearly"
78
+ #
79
+ # # When creating checkout sessions with multi-period plans, specify the period:
80
+ # # plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :month)
81
+ # # plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :year)
65
82
  # end
66
83
  #
67
84
  #
@@ -81,11 +98,22 @@ UsageCredits.configure do |config|
81
98
  #
82
99
  #
83
100
  #
84
- # For how long expiring credits from the previous fulfillment cycle will "overlap" the following fulfillment period.
101
+ # Grace period for credit expiration (default: 5.minutes)
102
+ #
103
+ # This is for how long expiring credits from the previous fulfillment cycle will "overlap" the following fulfillment period.
85
104
  # During this time, old credits from the previous period will erroneously count as available balance.
86
105
  # But if we set this to 0 or nil, user balance will show up as zero some time until the next fulfillment cycle hits.
87
106
  # A good default is to match the frequency of your UsageCredits::FulfillmentJob
88
- # fulfillment_grace_period = 5.minutes
107
+ #
108
+ # config.fulfillment_grace_period = 5.minutes
109
+ #
110
+ # NOTE: If your fulfillment period is shorter than the grace period (e.g., credits
111
+ # every 15 seconds with a 5-minute grace), the grace period is AUTOMATICALLY CAPPED
112
+ # to the fulfillment period. This prevents balance accumulation where credits pile
113
+ # up faster than they expire. A warning will be logged during initialization.
114
+ #
115
+ # This is typically only relevant for development/testing with very short periods.
116
+ # In production with monthly/daily fulfillment cycles, the default should work just fine.
89
117
  #
90
118
  #
91
119
  #
@@ -28,7 +28,12 @@ module UsageCredits
28
28
 
29
29
  attr_reader :credit_formatter
30
30
 
31
- attr_accessor :fulfillment_grace_period
31
+ attr_reader :fulfillment_grace_period
32
+
33
+ # Minimum allowed fulfillment period for subscription plans.
34
+ # Defaults to 1.day to prevent accidental 1-second refill loops in production.
35
+ # Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
36
+ attr_reader :min_fulfillment_period
32
37
 
33
38
  # =========================================
34
39
  # Low balance
@@ -56,10 +61,13 @@ module UsageCredits
56
61
  # This ensures smooth transition between fulfillment periods.
57
62
  # For this amount of time, old, already expired credits will be erroneously counted as available in the user's balance.
58
63
  # Keep it short enough that users don't notice they have the last period's credits still available, but
59
- # long enough that there's a smooth transition and users never get zero credits in between fullfillment periods
64
+ # long enough that there's a smooth transition and users never get zero credits in between fulfillment periods
60
65
  # A good setting is to match the frequency of your UsageCredits::FulfillmentJob runs
61
66
  @fulfillment_grace_period = 5.minutes # If you run your fulfillment job every 5 minutes, this should be enough
62
67
 
68
+ # Minimum fulfillment period - prevents accidental 1-second refill loops in production
69
+ @min_fulfillment_period = 1.day
70
+
63
71
  @allow_negative_balance = false
64
72
  @low_balance_threshold = nil
65
73
  @low_balance_callback = nil # Called when user hits low_balance_threshold
@@ -99,13 +107,20 @@ module UsageCredits
99
107
  plan = CreditSubscriptionPlan.new(name)
100
108
  plan.instance_eval(&block)
101
109
  plan.validate!
110
+
111
+ # Warn if fulfillment period is shorter than grace period (grace will be auto-capped)
112
+ warn_if_grace_period_exceeds_fulfillment(plan)
113
+
102
114
  @credit_subscription_plans[name] = plan
103
115
  end
104
116
 
105
117
  # Find a subscription plan by its processor-specific ID
118
+ # Works with both single-price and multi-period plans
119
+ # @param processor_id [String] The price ID to search for
120
+ # @return [CreditSubscriptionPlan, nil] The matching plan or nil
106
121
  def find_subscription_plan_by_processor_id(processor_id)
107
122
  @credit_subscription_plans.values.find do |plan|
108
- plan.processor_plan_ids.values.include?(processor_id)
123
+ plan.matches_processor_id?(processor_id)
109
124
  end
110
125
  end
111
126
 
@@ -153,6 +168,18 @@ module UsageCredits
153
168
  @fulfillment_grace_period = value
154
169
  end
155
170
 
171
+ def min_fulfillment_period=(value)
172
+ unless value.is_a?(ActiveSupport::Duration)
173
+ raise ArgumentError, "Minimum fulfillment period must be an ActiveSupport::Duration (e.g. 1.day, 2.seconds)"
174
+ end
175
+
176
+ if value < 1.second
177
+ raise ArgumentError, "Minimum fulfillment period must be at least 1 second"
178
+ end
179
+
180
+ @min_fulfillment_period = value
181
+ end
182
+
156
183
  # =========================================
157
184
  # Callback & Formatter Configuration
158
185
  # =========================================
@@ -200,5 +227,25 @@ module UsageCredits
200
227
  raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(', ')}"
201
228
  end
202
229
  end
230
+
231
+ # Warn developers when grace period exceeds fulfillment period
232
+ # In this case, the grace period will be automatically capped to the fulfillment period
233
+ # to prevent balance accumulation (credits piling up because they don't expire fast enough)
234
+ def warn_if_grace_period_exceeds_fulfillment(plan)
235
+ return unless plan.fulfillment_period.present?
236
+ return if plan.rollover_enabled # Grace period only matters for expiring credits
237
+
238
+ fulfillment_duration = plan.parsed_fulfillment_period
239
+
240
+ if @fulfillment_grace_period > fulfillment_duration
241
+ Rails.logger.warn(
242
+ "[UsageCredits] Subscription plan '#{plan.name}' has a fulfillment period " \
243
+ "(#{plan.fulfillment_period}) shorter than the configured grace period " \
244
+ "(#{@fulfillment_grace_period.inspect}). The grace period will be automatically " \
245
+ "capped to #{fulfillment_duration.inspect} for this plan to prevent balance accumulation. " \
246
+ "Consider adjusting config.fulfillment_grace_period if this is not intended."
247
+ )
248
+ end
249
+ end
203
250
  end
204
251
  end
@@ -7,6 +7,9 @@ module UsageCredits
7
7
 
8
8
  # Canonical periods and their aliases
9
9
  VALID_PERIODS = {
10
+ second: [:second, :seconds], # 1.second
11
+ minute: [:minute, :minutes], # 1.minute
12
+ hour: [:hour, :hours, :hourly], # 1.hour
10
13
  day: [:day, :daily], # 1.day
11
14
  week: [:week, :weekly], # 1.week
12
15
  month: [:month, :monthly], # 1.month
@@ -14,21 +17,35 @@ module UsageCredits
14
17
  year: [:year, :yearly, :annually] # 1.year
15
18
  }.freeze
16
19
 
17
- MIN_PERIOD = 1.day
20
+ MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead
18
21
 
19
22
  module_function
20
23
 
24
+ # Get the configured minimum fulfillment period
25
+ def min_fulfillment_period
26
+ # Use configured value if available, otherwise fall back to default
27
+ if defined?(UsageCredits) && UsageCredits.respond_to?(:configuration)
28
+ UsageCredits.configuration.min_fulfillment_period
29
+ else
30
+ MIN_PERIOD
31
+ end
32
+ end
33
+
21
34
  # Turns things like `:monthly` into `1.month` to always store consistent time periods
22
35
  def normalize_period(period)
23
36
  return nil unless period
24
37
 
25
38
  # Handle ActiveSupport::Duration objects directly
26
39
  if period.is_a?(ActiveSupport::Duration)
27
- raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if period < MIN_PERIOD
40
+ min_period = min_fulfillment_period
41
+ raise ArgumentError, "Period must be at least #{min_period.inspect}" if period < min_period
28
42
  period
29
43
  else
30
44
  # Convert symbols to canonical durations
31
- case period
45
+ duration = case period
46
+ when *VALID_PERIODS[:second] then 1.second
47
+ when *VALID_PERIODS[:minute] then 1.minute
48
+ when *VALID_PERIODS[:hour] then 1.hour
32
49
  when *VALID_PERIODS[:day] then 1.day
33
50
  when *VALID_PERIODS[:week] then 1.week
34
51
  when *VALID_PERIODS[:month] then 1.month
@@ -37,6 +54,10 @@ module UsageCredits
37
54
  else
38
55
  raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}"
39
56
  end
57
+
58
+ min_period = min_fulfillment_period
59
+ raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
60
+ duration
40
61
  end
41
62
  end
42
63
 
@@ -57,14 +78,31 @@ module UsageCredits
57
78
  raise ArgumentError, "Unsupported period unit: #{unit}. Supported units: #{valid_units.inspect}"
58
79
  end
59
80
 
60
- duration = amount.send(unit)
61
- raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if duration < MIN_PERIOD
81
+ # Map alias to canonical unit (e.g., :hourly -> :hour, :seconds -> :second)
82
+ canonical_unit = canonical_unit_for(unit)
83
+
84
+ duration = amount.send(canonical_unit)
85
+ min_period = min_fulfillment_period
86
+ raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
62
87
  duration
63
88
  else
64
89
  raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc."
65
90
  end
66
91
  end
67
92
 
93
+ # Map any alias to its canonical unit method name
94
+ # @param unit [Symbol] The unit symbol (e.g., :hourly, :seconds, :day)
95
+ # @return [Symbol] The canonical unit method (e.g., :hour, :second, :day)
96
+ def canonical_unit_for(unit)
97
+ # Find which canonical unit this alias belongs to
98
+ VALID_PERIODS.each do |canonical, aliases|
99
+ return canonical if aliases.include?(unit)
100
+ end
101
+
102
+ # Fallback to the unit itself if not found (shouldn't happen if validation passed)
103
+ unit
104
+ end
105
+
68
106
  # Validates that a period string matches the expected format and units
69
107
  def valid_period_format?(period_str)
70
108
  parse_period(period_str)
@@ -22,6 +22,8 @@ module UsageCredits
22
22
  private
23
23
 
24
24
  def allocation_does_not_exceed_remaining_amount
25
+ return if amount.blank? || source_transaction.blank?
26
+
25
27
  if source_transaction.remaining_amount < amount
26
28
  errors.add(:amount, "exceeds the remaining amount of the source transaction")
27
29
  end