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 +4 -4
- data/.simplecov +48 -0
- data/AGENTS.md +5 -0
- data/Appraisals +25 -0
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +5 -0
- data/README.md +77 -6
- data/gemfiles/pay_10.0.gemfile +29 -0
- data/gemfiles/pay_11.0.gemfile +29 -0
- data/gemfiles/pay_8.3.gemfile +29 -0
- data/gemfiles/pay_9.0.gemfile +29 -0
- data/lib/generators/usage_credits/templates/initializer.rb +30 -2
- data/lib/usage_credits/configuration.rb +50 -3
- data/lib/usage_credits/helpers/period_parser.rb +43 -5
- data/lib/usage_credits/models/allocation.rb +2 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +92 -44
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +376 -33
- data/lib/usage_credits/models/credit_subscription_plan.rb +115 -14
- data/lib/usage_credits/models/transaction.rb +1 -0
- data/lib/usage_credits/models/wallet.rb +15 -10
- data/lib/usage_credits/services/fulfillment_service.rb +15 -6
- data/lib/usage_credits/version.rb +1 -1
- metadata +119 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fcda02fd1ae61887b8a80d6fbf6c864843d363797d90dbbd66abafe2851ce8b
|
|
4
|
+
data.tar.gz: 6c1e67ec12f46bf5e33faa7d02ed723dc47926a29948a8140706392d1ffbe024
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://badge.fury.io/rb/usage_credits)
|
|
3
|
+
[](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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|