diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e801dd..4e1796a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,15 @@ on: - "*.md" - "LICENSE.txt" +permissions: + contents: read + +# usage_credits 1.0 depends on wallets 0.3 before that version exists on +# RubyGems. Every CI lane resolves the exact reviewed Wallets candidate below; +# remove this source override only after the published dependency is available. +env: + WALLETS_PATH: ${{ github.workspace }}/wallets-core + jobs: # Main test suite - tests Ruby versions and Rails/Pay compatibility with SQLite sqlite: @@ -22,21 +31,48 @@ jobs: matrix: ruby_version: ["3.3", "3.4", "4.0"] gemfile: - - Gemfile - - gemfiles/rails_7.2.gemfile - - gemfiles/rails_8.1.gemfile - - gemfiles/pay_8.3.gemfile - - gemfiles/pay_9.0.gemfile - - gemfiles/pay_10.0.gemfile - - gemfiles/pay_11.0.gemfile + - path: Gemfile + label: default + - path: gemfiles/rails_7.2.gemfile + label: rails-7.2 + - path: gemfiles/rails_8.1.gemfile + label: rails-8.1 + - path: gemfiles/pay_minimum.gemfile + label: pay-minimum + include: + # Exercise both Rails boundaries on the minimum secure Ruby line. + - ruby_version: "3.2" + gemfile: + path: gemfiles/rails_7.2.gemfile + label: rails-7.2 + - ruby_version: "3.2" + gemfile: + path: gemfiles/rails_8.1.gemfile + label: rails-8.1 + # Also exercise the literal minimum runtime/dependency pair, not just + # each minimum independently in newer combinations. + - ruby_version: "3.2" + gemfile: + path: gemfiles/pay_minimum.gemfile + label: pay-minimum env: RAILS_ENV: test - BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile.path }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout reviewed wallets 0.3 candidate + uses: actions/checkout@v7 + with: + repository: rameerez/wallets + ref: 743f57e96a7b48e3bb790e63d5cba2e83f4f057b + path: wallets-core + persist-credentials: false - name: Set up Ruby ${{ matrix.ruby_version }} uses: ruby/setup-ruby@v1 @@ -44,17 +80,68 @@ jobs: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true - - name: Run tests - run: bundle exec rake test + - name: Prepare database and run tests + # Exercise the real migration path in SQLite too so dummy/test schema + # drift is caught before release, not only in adapter-specific jobs. + run: bundle exec rake db:migrate:reset test - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: test-results-sqlite-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile }} + name: test-results-sqlite-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile.label }} path: test/reports/ retention-days: 7 + security: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + gemfile: + - path: Gemfile + label: default + - path: gemfiles/rails_7.2.gemfile + label: rails-7.2 + - path: gemfiles/rails_8.1.gemfile + label: rails-8.1 + - path: gemfiles/pay_minimum.gemfile + label: pay-minimum + + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile.path }} + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout reviewed wallets 0.3 candidate + uses: actions/checkout@v7 + with: + repository: rameerez/wallets + ref: 743f57e96a7b48e3bb790e63d5cba2e83f4f057b + path: wallets-core + persist-credentials: false + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Audit locked dependencies + # ruby/setup-ruby may remove an untracked lock after populating its + # cache, and bundler-audit does not infer the appraisal lock path from + # BUNDLE_GEMFILE. Its --gemfile-lock value is relative to the scan root, + # so strip the absolute workspace prefix before auditing. + run: | + bundle lock + lockfile="${BUNDLE_GEMFILE#"$GITHUB_WORKSPACE/"}.lock" + bundle exec bundle-audit check --update --gemfile-lock "$lockfile" + # PostgreSQL compatibility tests postgres: runs-on: ubuntu-latest @@ -62,7 +149,17 @@ jobs: strategy: fail-fast: false matrix: - ruby_version: ["3.4"] + include: + - ruby_version: "3.4" + gemfile: + path: Gemfile + label: default + # Real-adapter coverage for the literal minimum supported + # Ruby/Rails/Pay boundary, not only the modern dependency graph. + - ruby_version: "3.2" + gemfile: + path: gemfiles/rails_7.2.gemfile + label: minimum services: postgres: @@ -82,10 +179,21 @@ jobs: env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432/usage_credits_test + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile.path }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout reviewed wallets 0.3 candidate + uses: actions/checkout@v7 + with: + repository: rameerez/wallets + ref: 743f57e96a7b48e3bb790e63d5cba2e83f4f057b + path: wallets-core + persist-credentials: false - name: Set up Ruby ${{ matrix.ruby_version }} uses: ruby/setup-ruby@v1 @@ -104,9 +212,9 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: test-results-postgres-ruby-${{ matrix.ruby_version }} + name: test-results-postgres-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile.label }} path: test/reports/ retention-days: 7 @@ -117,7 +225,17 @@ jobs: strategy: fail-fast: false matrix: - ruby_version: ["3.4"] + include: + - ruby_version: "3.4" + gemfile: + path: Gemfile + label: default + # Real-adapter coverage for the literal minimum supported + # Ruby/Rails/Pay boundary, not only the modern dependency graph. + - ruby_version: "3.2" + gemfile: + path: gemfiles/rails_7.2.gemfile + label: minimum services: mysql: @@ -136,10 +254,21 @@ jobs: env: RAILS_ENV: test DATABASE_URL: mysql2://root:root@127.0.0.1:3306/usage_credits_test + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile.path }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout reviewed wallets 0.3 candidate + uses: actions/checkout@v7 + with: + repository: rameerez/wallets + ref: 743f57e96a7b48e3bb790e63d5cba2e83f4f057b + path: wallets-core + persist-credentials: false - name: Set up Ruby ${{ matrix.ruby_version }} uses: ruby/setup-ruby@v1 @@ -158,8 +287,8 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: test-results-mysql-ruby-${{ matrix.ruby_version }} + name: test-results-mysql-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile.label }} path: test/reports/ retention-days: 7 diff --git a/.gitignore b/.gitignore index 0ca0f95..beafdd4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /_yardoc/ /coverage/ /doc/ +/rdoc/ /pkg/ /spec/reports/ /tmp/ @@ -33,4 +34,4 @@ deploy.yml .aux/ TODO -pay.md \ No newline at end of file +pay.md diff --git a/.simplecov b/.simplecov index 1c24814..a65fcb3 100644 --- a/.simplecov +++ b/.simplecov @@ -1,27 +1,35 @@ # frozen_string_literal: true -# SimpleCov configuration file (auto-loaded before test suite) -# This keeps test_helper.rb clean and follows best practices +# SimpleCov configuration file, auto-loaded when the test helper starts +# coverage. Keep startup in test_helper.rb so this file remains configuration-only. + +SimpleCov.configure do + # Allow release/CI verification to use an isolated result set. Reusing a + # developer's previous coverage directory can merge stale runs and hide a + # regression that would fail in a clean checkout. + coverage_dir ENV.fetch("COVERAGE_DIR", "coverage") -SimpleCov.start do # Use SimpleFormatter for terminal-only output (no HTML generation) formatter SimpleCov::Formatter::SimpleFormatter - # Track coverage for the lib directory (gem source code) - add_filter "/test/" - - # Track Ruby files in lib directory - track_files "lib/**/*.rb" + if respond_to?(:skip) + # SimpleCov 1.x vocabulary. + skip "/test/" + cover "lib/**/*.rb" + else + # Fallback vocabulary for SimpleCov 0.22. + add_filter "/test/" + track_files "lib/**/*.rb" + end # Enable branch coverage for more detailed metrics enable_coverage :branch - # Set minimum coverage threshold to prevent coverage regression - # Current coverage: Line 88.56%, Branch 81.17% + # Set minimum coverage thresholds to prevent coverage regressions. minimum_coverage line: 80, branch: 75 # Disambiguate parallel test runs - command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER'] + command_name "Job #{ENV["TEST_ENV_NUMBER"]}" if ENV["TEST_ENV_NUMBER"] end # Print coverage summary to terminal after tests complete diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..acc19ed --- /dev/null +++ b/.standard.yml @@ -0,0 +1,5 @@ +ruby_version: 3.1 + +ignore: + - test/dummy/bin/**/* + - test/dummy/db/*_schema.rb diff --git a/Appraisals b/Appraisals index 57cc332..4938034 100644 --- a/Appraisals +++ b/Appraisals @@ -1,43 +1,26 @@ # frozen_string_literal: true +# The generated gemfiles intentionally retain the root Gemfile's conditional +# WALLETS_PATH hook for coordinated pre-release CI. Appraisal cannot emit that +# runtime conditional itself, so preserve the postamble when regenerating. + # Test minimum supported Rails version (with latest Pay) appraise "rails-7.2" do gem "rails", "~> 7.2.0" - gem "pay", "~> 11.0" - gem "stripe", "~> 18.0" + gem "pay", ">= 11.6.2", "< 12.0" + gem "stripe", "~> 19.0" end # Test latest Rails version (with latest Pay) - this is the default/main Gemfile anyway appraise "rails-8.1" do gem "rails", "~> 8.1.0" - gem "pay", "~> 11.0" - gem "stripe", "~> 18.0" -end - -# Test minimum supported Pay version (with latest Rails) -appraise "pay-8.3" do - gem "pay", "~> 8.3.0" - gem "stripe", "~> 13.0" - gem "rails", "~> 8.1.0" -end - -# Test Pay 9.0 (popular stable version with latest Rails) -appraise "pay-9.0" do - gem "pay", "~> 9.0.0" - gem "stripe", "~> 13.0" - gem "rails", "~> 8.1.0" -end - -# Test Pay 10.0 (with latest Rails) -appraise "pay-10.0" do - gem "pay", "~> 10.0.0" - gem "stripe", "~> 15.0" - gem "rails", "~> 8.1.0" + gem "pay", ">= 11.6.2", "< 12.0" + gem "stripe", "~> 19.0" end -# Test latest Pay version (with latest Rails) -appraise "pay-11.0" do - gem "pay", "~> 11.0" - gem "stripe", "~> 18.0" +# Test the exact minimum secure supported Pay version (with latest Rails) +appraise "pay-minimum" do + gem "pay", "= 11.6.2" + gem "stripe", "~> 19.0" gem "rails", "~> 8.1.0" end diff --git a/CHANGELOG.md b/CHANGELOG.md index 10009d0..3681341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,57 @@ +## [1.0.0] - Unreleased + +`usage_credits` is now built on top of [`wallets`](https://github.com/rameerez/wallets), our append-oriented, multi-asset ledger core. The credits-focused DX you know is unchanged — same `has_credits`, `spend_credits_on`, `give_credits`, packs, subscriptions, and Pay integration — but the expiration-aware allocation ledger, balance math, row-level locking, and transfer machinery now live in a shared, independently tested core. + +### Added + +- New runtime dependency: `wallets` (`~> 0.3.0`), installed automatically with the gem +- Upgrade generator for existing installs: `rails generate usage_credits:upgrade` creates an in-place, re-runnable migration that preserves all existing ledger data. Before touching the schema it rejects duplicate owner wallets or fulfillment sources, partial polymorphic references, orphaned ledger or Pay sources, invalid ledger amounts/allocations, incompatible key types, reserved-index collisions, and interrupted transfer schemas with actionable diagnostics. +- Wallet-to-wallet credit transfers via the underlying wallets layer (`usage_credits_transfers` table), with expiration-preserving semantics by default +- `UsageCredits::Transfer` model, plus `transfer_in` / `transfer_out` transaction categories + +### Changed + +- **Schema** (handled by the upgrade migration for existing apps): wallets gain an `asset_code` column (default `"credits"`); one wallet per owner per asset is now enforced with a unique index; `balance` / `amount` / `credits_last_fulfillment` columns widen from `integer` to `bigint`; transactions gain a nullable `transfer_id` reference +- `UsageCredits::Wallet`, `Transaction`, and `Allocation` now subclass the `wallets` core models via its embeddability hooks (same tables as before, prefixed `usage_credits_`) +- Wallet creation now goes through the race-safe, idempotent `create_for_owner!` from the wallets core; an `initial_balance` is recorded as a proper ledger transaction (category `manual_adjustment`, reason `initial_balance`) instead of a bare column write, so initial balances are auditable +- Payment and subscription terms are snapshotted at checkout/fulfillment time, so delayed webhooks, refunds, renewals, and plan changes never depend on mutable initializer configuration. +- Credit-pack fulfillment/refunds and subscription fulfillment are serialized with row locks and database uniqueness constraints; successful callbacks are deferred until the outermost transaction commits. +- Pay fulfillment hooks run only after create/update commits, never after destroy commits; deleting an eligible but unfulfilled processor record cannot mint credits against a dangling source. +- Credit-pack refunds are cumulative, proportional, and idempotent under concurrent webhook delivery. If purchased credits were already spent, the refund records explicit credit debt instead of silently under-refunding; a never-fulfilled purchase cannot create debt. +- Operations evaluate dynamic costs exactly once under the wallet lock, round once at the final operation boundary, and normalize fixed/dynamic credit amounts through one strict validation path. Free operations remain ledger-free while preserving the established callback contract. +- `expire_after` now applies the snapshotted cancellation policy to credits created by that subscription, including after the configured plan is removed; unrelated credits are never shortened. The wallet owns this mutation and emits low-balance/depleted crossings when cancellation makes the expiration effective immediately. +- Persisted fulfillment cadence is parsed through the same strict duration parser as configuration and is never permitted below one second, preventing malformed metadata or a zero-period retry loop. +- Recurring Pay-backed fulfillment locks and re-checks the subscription before minting, fails closed for dangling Pay sources (including processor-specific STI type names) and unresolved plan transitions, and never awards while the processor subscription is trialing, paused, incomplete, or canceled. +- The locked subscription freshness check canonicalizes processor timestamps to the database column precision. Sub-microsecond values from processor SDKs can no longer make a just-committed callback look stale and silently suppress the initial credit award. +- Subscription lifecycle transactions now exit their blocks locally instead of using method-level `return`, preserving explicit commit semantics across the Rails 7.2-to-8.x behavior change; unrelated subscription updates also skip deferred-resume reconciliation queries. +- Credit-wallet lookup now delegates cold-cache lookup and race recovery to the wallets core's single `create_for_owner!` path instead of querying the same owner/asset association first. +- `transfer_to` and its backwards-compatible `transfer_credits_to` alias now share the complete expiration-policy signature and consistently translate core transfer failures into the `usage_credits` error hierarchy. +- Effective processor pauses use Pay's processor-specific lifecycle predicate rather than raw status (Stripe can remain `"active"` while paused). Plan changes made during a pause are snapshotted without minting, reconciled before the first resumed fulfillment even if an after-commit callback was interrupted, and rejected fail-closed if the persisted terms are inconsistent. +- Fresh and upgrade migrations add row-local ledger constraints and abort before schema changes when legacy rows violate amount, allocation, transfer, wallet, or fulfillment invariants. +- Rails 7.2's transaction callback API is now required to prevent rolled-back ledger events. +- Ruby 3.2 and Rails 7.2.3.1 are the minimum supported runtime versions. Current security-patched Rails dependency releases cannot be installed on Ruby 3.1, and earlier Rails 7.2 patch releases contain known vulnerabilities. +- Pay 11.6.2 is now the minimum supported version. Earlier Pay releases are excluded because [GHSA-mjgf-xj26-9qf9](https://github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9) permits forged Paddle Billing webhooks through non-constant-time signature comparison. + +### Preserved public surface + +- The established entry points remain: `credits`, `credit_history`, `give_credits`, `spend_credits_on`, `has_enough_credits_to?`, `estimate_credits_to`, `add_credits`, `deduct_credits`, callbacks, categories, scopes, and the Pay integration. +- Negative balances still floor to zero in `credits` (the wallets core can represent overdrafts, but `usage_credits` keeps its historical contract) +- `usage_credits` stays single-asset (`"credits"`) by design — multi-asset apps can use the `wallets` gem directly, side by side, including in the same app + +### Tests + +- The suite now contains 835 runs / 2,400 assertions, including adversarial coverage for concurrent fulfillment/refund delivery, stale processor records, processor timestamp precision, processor pauses/resumes, transfer API/error compatibility, cross-gem isolation, destroy callbacks, immutable commercial terms, cancellation expiration and threshold callbacks, malformed persisted cadence, centralized cost normalization, one-time compound rounding, interrupted upgrades, and database constraints. +- Compatibility coverage includes Ruby 3.2 across both the Rails 7.2 and Rails 8.1 boundaries, Ruby 3.3/3.4/4.0 across Rails 7.2/8.1 and both the Pay 11.6.2 security floor and latest compatible Pay release, plus clean migrations and the full suite on SQLite, PostgreSQL, and MySQL. PostgreSQL and MySQL each run both the default dependency graph and the literal Ruby 3.2 / Rails 7.2 / Pay 11.6.2 minimum boundary. +- CI audits every supported dependency bundle against the latest `ruby-advisory-db` before release. + +### Upgrade instructions + +1. Release or install `wallets` 0.3.x first; `usage_credits` 1.0 will not resolve against 0.2.x. +2. Update `usage_credits`, then run `rails generate usage_credits:upgrade`. +3. Review the generated migration and **back up your database** (the migration is not reversible). +4. Run the migration against a production snapshot, resolve every preflight failure, and measure its locking window before deployment. On PostgreSQL, locks acquired across all ledger DDL are held until the whole migration commits. +5. Deploy the gem update and `rails db:migrate` together — the 1.0 models expect the upgraded schema. + ## [0.5.0] - 2026-03-15 - Add configurable transaction categories via `config.additional_categories` for money-like wallet use cases (marketplaces, fintech) by @rameerez in https://github.com/rameerez/usage_credits/pull/29 diff --git a/Gemfile b/Gemfile index 6ceacf9..555328f 100644 --- a/Gemfile +++ b/Gemfile @@ -5,15 +5,23 @@ source "https://rubygems.org" # Runtime dependencies are specified in usage_credits.gemspec gemspec +# Ecosystem development can test an unreleased wallets version without +# weakening the runtime gemspec constraint. CI/release builds omit this and +# resolve the published dependency normally. +if ENV["WALLETS_PATH"] + gem "wallets", path: File.expand_path(ENV.fetch("WALLETS_PATH"), __dir__) +end + # Build & release tools gem "rake", "~> 13.0" group :development do gem "appraisal" + gem "bundler-audit", "~> 0.9" gem "web-console" # Code quality - gem "standard" + gem "standard", ">= 1.35.1" gem "rubocop", "~> 1.0" gem "rubocop-minitest", "~> 0.35" gem "rubocop-performance", "~> 1.0" @@ -37,7 +45,7 @@ group :test do gem "receipts" # Database adapters (for multi-database testing) - gem "sqlite3" + gem "sqlite3", ">= 2.9.5" gem "pg" gem "mysql2" diff --git a/README.md b/README.md index d878f5c..3a72524 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ `usage_credits` allows your users to have in-app credits / tokens they can use to perform operations. -✨ Perfect for SaaS, AI apps, games, API products, and **marketplace wallets** that want to implement usage-based pricing or track money-like balances. +✨ Perfect for SaaS, AI apps, games, API products, and **single-asset credit systems** that want to implement usage-based pricing or track money-like balances. -> **Not just for credits!** While the gem is called "usage_credits", it's built on a production-grade double-entry ledger with row-level locking, FIFO allocation, and full audit trails. You can use it for marketplace seller balances, in-app wallets, reward points, or any system that needs to track money-like assets with proper accounting. [See the "Beyond credits" section](#beyond-credits-using-this-gem-for-money-like-wallets-and-payouts) for examples. +> **Built on top of [`wallets`](https://github.com/rameerez/wallets).** As of `usage_credits` 1.0, `usage_credits` uses `wallets` as its ledger core underneath. If your main problem is multi-asset wallets, transfers, in-game resources, or general balances, use `wallets` directly. Use `usage_credits` when you want the opinionated DX for credits, operations, subscriptions, packs, and payments. [ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ] @@ -19,8 +19,10 @@ All with a simple DSL that reads just like English. **Requirements** +- Ruby 3.2+ and Rails 7.2.3.1–8.x. Ruby 3.2 is the security floor because current patched versions of transitive Rails dependencies no longer support Ruby 3.1. - An ActiveJob backend (Sidekiq, `solid_queue`, etc.) for subscription credit fulfillment -- [`pay`](https://github.com/pay-rails/pay) gem for Stripe/PayPal/Lemon Squeezy integration (sell credits, refill subscriptions) +- [`pay`](https://github.com/pay-rails/pay) 11.6.2–11.x for payment integration. Pay 11.6.2 is the security floor because it fixes [GHSA-mjgf-xj26-9qf9](https://github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9). +- [`wallets`](https://github.com/rameerez/wallets) 0.3.x (installed automatically as the ledger core) ## 👨‍💻 Example @@ -97,6 +99,30 @@ rails generate usage_credits:install rails db:migrate ``` +### Upgrading from pre-1.0 + +If you're upgrading an existing app from pre-1.0 `usage_credits`, run this instead of the install generator: +```bash +rails generate usage_credits:upgrade +rails db:migrate +``` + +The upgrade migration moves your existing ledger onto the [`wallets`](https://github.com/rameerez/wallets) core schema **in place** — all balances, transactions, allocations, and fulfillments are preserved exactly as they are. It: + +- Adds an `asset_code` column to wallets (defaults to `"credits"`, so nothing changes for you) +- Enforces one wallet per owner with a unique index (it first checks your data and aborts with step-by-step instructions if any owner somehow has duplicate wallets, before touching anything) +- Widens integer amount columns to `bigint` +- Creates the `usage_credits_transfers` table that powers wallet-to-wallet transfers + +A few production notes: + +- Back up your database first. The migration is intentionally irreversible; rollback means restoring that backup. +- Rehearse the migration against a recent production snapshot and measure it before choosing a deployment window. +- On PostgreSQL, Rails runs the migration in one DDL transaction. The unique-index build, four `bigint` conversions, constraints, foreign keys, and transfer-table changes therefore hold their acquired table locks until the whole migration commits. Writes to **all** `usage_credits_*` ledger tables may be blocked for the combined duration, not only while each individual `bigint` conversion is executing. Use a maintenance window for a large or write-heavy ledger. A genuinely lower-lock rollout requires a DBA-reviewed, application-specific staged migration (including concurrent indexes and separately validated constraints); do not add `disable_ddl_transaction!` to the generated migration casually, because that trades atomicity for partial-schema exposure. +- MySQL DDL commits step by step. Every mutation is independently guarded, so an interrupted run can be fixed and safely re-run. +- Deploy the gem update and migration together: the 1.0 models expect the upgraded schema. +- Fresh installs use descriptive `usage_credits_*` index names. Upgrades deliberately preserve safe legacy index names rather than dropping and rebuilding equivalent indexes, so schema dumps from fresh and upgraded databases can differ by index name while remaining structurally equivalent. + Add `has_credits` your user model (or any model that needs to have credits): ```ruby class User < ApplicationRecord @@ -422,6 +448,13 @@ subscription_plan :pro do end ``` +Cancellation terms are snapshotted when the subscription is fulfilled, so a +later initializer change or plan removal cannot rewrite the customer's policy. +`expire_after` only shortens credits minted by that subscription; manual grants, +credit packs, and other subscriptions are untouched. When cancellation makes +those credits expire immediately, any resulting low-balance or depleted +crossing is dispatched after the cancellation transaction commits. + The first thing to understand is that **credit fulfillment** is decoupled from **billing periods**: ### Credit fulfillment cycles @@ -538,11 +571,11 @@ This happens automatically thanks to our Pay Subscription extension (changes to Handled: - Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions - Pending downgrade application on renewal +- Processor pauses and resumes: no credits are minted during an effective pause; scheduled pauses follow Pay's processor lifecycle semantics; plan changes made while paused are atomically reconciled without a bonus before service resumes minting - Credit expiration and rollover Not handled (yet): - Plan changes while **trialing** (we only handle `status == "active"`) -- Paused subscriptions (see TODO in code) ## Transaction history & audit trail @@ -629,9 +662,17 @@ Which will get you: 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. -## Beyond credits: using this gem for money-like wallets and payouts +## Beyond credits: wallet-like balances on top of a credits product layer + +While this gem is called `usage_credits`, the underlying architecture is still a **production-grade append-oriented ledger API** with row-level locking, expiration-aware allocation, and full transaction trails. That means you can use it for more than just API credits when the product still fits a **single-asset credits model**. -While this gem is called `usage_credits`, the underlying architecture is a **production-grade double-entry ledger** with row-level locking, FIFO allocation, and full audit trails. This makes it suitable for more than just API credits — you can use it as a wallet system for **money-like assets**, **marketplace payouts**, **in-app balances**, and more. +Good fits here: +- marketplace seller balances in cents +- internal store credit +- cashback / reward points +- telecom-style balances where acquisition/refill matters more than multi-asset modeling + +If the real problem is **multi-asset wallets**, **player inventories**, or **wallet-to-wallet transfers as a primary feature**, use [`wallets`](https://github.com/rameerez/wallets) directly instead. ### Custom transaction categories @@ -665,9 +706,7 @@ class User < ApplicationRecord has_credits # Each user gets a wallet def request_payout(amount_cents) - # In production, wrap in wallet.with_lock { } to prevent race conditions - raise "Insufficient balance" if credits < amount_cents - + # deduct_credits locks and checks the wallet atomically. wallet.deduct_credits( amount_cents, category: :payout_requested, @@ -729,29 +768,78 @@ Now you have: end ``` -### Why this works for money +### Wallet-level transfers + +Because `usage_credits` uses `wallets` underneath, the underlying wallet object also supports low-level wallet operations like transfers: + +```ruby +seller.credit_wallet.transfer_to( + buyer.credit_wallet, + 500, + category: :refund, + metadata: { order_id: 42 } +) +``` + +Transfers preserve expiration buckets by default because they run through the underlying `wallets` ledger. If you need cash-like behavior instead, you can still opt into evergreen receive-side credits at the wallet layer: + +```ruby +seller.credit_wallet.transfer_to( + buyer.credit_wallet, + 500, + category: :refund, + expiration_policy: :none, + metadata: { order_id: 42 } +) +``` + +`transfer_credits_to` is a backwards-compatible alias for `transfer_to`; both +accept the same expiration options and return a `UsageCredits::Transfer`. +Transfer domain failures stay inside the `usage_credits` error hierarchy: +`UsageCredits::InvalidTransfer` for invalid endpoints and +`UsageCredits::InsufficientCredits` for insufficient balance. -The gem's architecture gives you everything you'd need for a money-handling system: +This is intentionally a **wallet-level API**, not the main `usage_credits` DSL. The main product surface of `usage_credits` is still: +- `give_credits` +- `spend_credits_on` +- credit packs +- subscription fulfillment +- Pay integration + +If transfers, multi-asset balances, and wallet movement are central to your app, that is usually a sign you should use [`wallets`](https://github.com/rameerez/wallets) directly. + +### Why this still works for money-like balances + +The ledger architecture gives you everything you'd want from a serious internal balance system: | Feature | How it helps | |---------|--------------| -| Double-entry ledger | Every credit has a corresponding debit source tracked via allocations | -| Immutable transactions | Append-only — no edits, only new entries (required for financial audit) | +| Allocation-backed ledger | Every spend records exactly which credit buckets it consumed | +| Append-oriented operations | Public wallet operations record new transaction rows instead of editing balances in place | | Row-level locking | Prevents race conditions and double-spending | -| FIFO allocation | When spending, oldest credits are used first (important for expiring balances) | +| Expiration-aware allocation | Soonest-expiring credits are spent first, with oldest-first ties | | Balance snapshots | Each transaction records balance before/after for reconciliation | | Rich metadata | Store order IDs, user IDs, payment references — whatever you need for audit | +The public API is append-oriented, not tamper-proof: code with model or SQL access can still modify ledger rows, and destroying an owner intentionally cascades through that owner's credit history. Use soft deletion or an application-level destroy restriction when records must be retained, keep database backups, reconcile payment-processor events, and apply the audit controls appropriate to your risk model. + ### A note on multi-currency -Currently, the gem uses a single currency per installation (configured via `config.default_currency`). All amounts are stored as integers (cents) to avoid floating-point issues. +`usage_credits` is intentionally **single-asset**: every owner gets exactly one credits wallet (asset code `"credits"`). All amounts are stored as integers (for money, usually cents) to avoid floating-point issues. -If you need multi-currency support, you could: -1. Store amounts in the smallest unit of each currency (cents, pence, etc.) -2. Use metadata to track the currency per transaction -3. Handle conversion at the application layer +If you need one wallet per currency or asset, use [`wallets`](https://github.com/rameerez/wallets) — the dedicated gem for multi-asset support, and the same ledger core `usage_credits` runs on. Both gems coexist cleanly in the same app, each with its own tables. Put `has_wallets` (from `wallets`) on the models that need multi-asset balances, and `has_credits` (from `usage_credits`) on the models that need credits: -Multi-currency wallets (one wallet per currency per user) is on the roadmap for a future version. For now, if you need this, you'd run separate wallet instances or handle it at the application level. +```ruby +class User < ApplicationRecord + has_credits # user.credits, user.spend_credits_on(...) +end + +class Team < ApplicationRecord + has_wallets # team.wallet(:eur), team.wallet(:usd), team.wallet(:wood) +end +``` + +One caveat: avoid putting both `has_credits` and `has_wallets` on the *same* model — both define a `wallet` method (the credits wallet vs. the multi-asset lookup), so whichever you include last wins. If you ever do need both on one model, use the unambiguous `credit_wallet` for credits and `find_wallet(:asset)` for the rest. ### Naming your "credits" @@ -783,13 +871,13 @@ That results in a plethora of bugs as soon as time starts rolling and customers That only gets you so far. -One problem is the discrepancy between billing periods and fulfillment cycles (you may want to charge your users up front for a whole year if they have a yearly subscription, but you may not want to refill all their credits up front, but month by month) Then if you want expiring credits (so that unused credits don't roll over to the next period), credit packs, etc. you essentially end up needing to build a double-entry ledger system. You need to keep track of every credit-giving and credit-spending operation. The ledger should be immutable by design (append-only), transactions should happen on row-level locks to prevent double-spending, operations should be atomic, etc. +One problem is the discrepancy between billing periods and fulfillment cycles (you may want to charge your users up front for a whole year if they have a yearly subscription, but you may not want to refill all their credits up front, but month by month). Once you add expiring credits, credit packs, and refunds, you need an allocation-backed transaction ledger that tracks every grant and spend. Writes must be atomic and serialized with row-level locks to prevent double-spending. That's exactly what I ended up building: - `Wallet` is the root of all functionality. All users have a wallet that centralizes everything and keeps track of the available balance – and all credit operations (add/deduct credits) are performed on the wallet. - `Transaction` - operations get logged as transactions. The Transaction model is the basis for the ledger system. - `Fulfillment` represents a credit-giving action (wether recurring or not). Subscriptions are tied to a Fulfillment record that orchestrates when the actual credit fulfillment should happen, and how often. A Fulfillment object will create one or many positive Transactions. -- `Allocation` is the basis for our bucket-based FIFO credit spending system. It's what solves the [dragging cost problem](https://x.com/rameerez/status/1884246492837302759) and allows for expiring credits. +- `Allocation` is the basis for our bucket-based, first-expiring-first-out credit spending system. It's what solves the [dragging cost problem](https://x.com/rameerez/status/1884246492837302759) and allows for expiring credits. - `CreditPack` and `CreditSubscriptionPlan` are POROs that model credit-giving objects (one-time purchases for credit packs; recurring subscriptions for subscription plans). They allow for easy configuration through the DSL and store all information on memory. - `Operation` represents a credit-spending operation. @@ -800,7 +888,7 @@ Heads up: we acquire a row-level lock when spending credits, to avoid concurrenc ### Summary of features **Core ledger:** -- Immutable ledger design (transactions are append-only) +- Append-oriented wallet operations with a complete transaction trail - Row-level locks to prevent double-spending even with concurrent usage - Secure credit spending (credits will not be deducted if the operation fails) - Audit trail / transaction logs (each transaction has metadata on how the credits were spent, and what "credit bucket" they drew from) @@ -817,7 +905,7 @@ Heads up: we acquire a row-level lock when spending credits, to avoid concurrenc - Credits can be expired - Credits can be rolled over to the next period - Prevents double-fulfillment of credits -- FIFO bucketed ledger approach for credit spending +- First-expiring-first-out bucket allocation, with oldest-first ties ### Numeric extensions @@ -848,12 +936,12 @@ This gem _pollutes_ a bit the `Kernel` namespace by defining 3 top-level methods Billing systems are extremely complex and full of edge cases. This is a new gem, and it may be missing some edge cases. -Real billing systems usually find edge cases when handling things like: +Production integrations should still define and test their product policy for things like: - Prorated changes - Different pricing tiers - Usage rollups and aggregation - Upgrading and downgrading subscriptions -- Pausing and resuming subscriptions (especially at edge times) +- Processor-specific billing/proration modes around subscription transitions - Re-activating subscriptions - Refunds and credits - Failed payments @@ -862,7 +950,8 @@ Real billing systems usually find edge cases when handling things like: Please help us by contributing to add tests to cover all critical paths! ## TODO -No open TODOs here right now. If you find an edge case, please open an issue or PR. + +- Add a first-class reversal/refund helper on top of wallet-level transfers if transfers become a documented primary use case ## Testing @@ -870,7 +959,7 @@ Run the test suite with `bundle exec rake test` ## Development -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. diff --git a/Rakefile b/Rakefile index 9d88a57..b1df4cc 100644 --- a/Rakefile +++ b/Rakefile @@ -10,7 +10,7 @@ require "rdoc/task" RDoc::Task.new(:rdoc) do |rdoc| rdoc.rdoc_dir = "rdoc" - rdoc.title = "Pay" + rdoc.title = "UsageCredits" rdoc.options << "--line-numbers" rdoc.rdoc_files.include("README.md") rdoc.rdoc_files.include("lib/**/*.rb") @@ -19,8 +19,6 @@ end APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) load "rails/tasks/engine.rake" -load "rails/tasks/statistics.rake" - require "rake/testtask" Rake::TestTask.new(:test) do |t| diff --git a/gemfiles/pay_11.0.gemfile b/gemfiles/pay_11.0.gemfile deleted file mode 100644 index f1e0e34..0000000 --- a/gemfiles/pay_11.0.gemfile +++ /dev/null @@ -1,41 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rake", "~> 13.0" -gem "pay", "~> 11.0" -gem "stripe", "~> 18.0" -gem "rails", "~> 8.1.0" - -group :development do - gem "appraisal" - gem "web-console" - gem "standard" - gem "rubocop", "~> 1.0" - gem "rubocop-minitest", "~> 0.35" - gem "rubocop-performance", "~> 1.0" -end - -group :test do - gem "minitest", "~> 5.0" - gem "mocha" - gem "simplecov", require: false - gem "vcr" - gem "webmock" - gem "braintree", ">= 2.92.0" - gem "lemonsqueezy", "~> 1.0" - gem "paddle", "~> 2.6" - gem "prawn" - gem "receipts" - gem "sqlite3" - gem "pg" - gem "bootsnap", require: false - gem "puma" - gem "importmap-rails" - gem "sprockets-rails" - gem "stimulus-rails" - gem "turbo-rails" - gem "rdoc", ">= 7.0" -end - -gemspec path: "../" diff --git a/gemfiles/pay_8.3.gemfile b/gemfiles/pay_8.3.gemfile deleted file mode 100644 index c1fa9ec..0000000 --- a/gemfiles/pay_8.3.gemfile +++ /dev/null @@ -1,41 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rake", "~> 13.0" -gem "pay", "~> 8.3.0" -gem "stripe", "~> 13.0" -gem "rails", "~> 8.1.0" - -group :development do - gem "appraisal" - gem "web-console" - gem "standard" - gem "rubocop", "~> 1.0" - gem "rubocop-minitest", "~> 0.35" - gem "rubocop-performance", "~> 1.0" -end - -group :test do - gem "minitest", "~> 5.0" - gem "mocha" - gem "simplecov", require: false - gem "vcr" - gem "webmock" - gem "braintree", ">= 2.92.0" - gem "lemonsqueezy", "~> 1.0" - gem "paddle", "~> 2.6" - gem "prawn" - gem "receipts" - gem "sqlite3" - gem "pg" - gem "bootsnap", require: false - gem "puma" - gem "importmap-rails" - gem "sprockets-rails" - gem "stimulus-rails" - gem "turbo-rails" - gem "rdoc", ">= 7.0" -end - -gemspec path: "../" diff --git a/gemfiles/pay_9.0.gemfile b/gemfiles/pay_9.0.gemfile deleted file mode 100644 index 51dedf5..0000000 --- a/gemfiles/pay_9.0.gemfile +++ /dev/null @@ -1,41 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rake", "~> 13.0" -gem "pay", "~> 9.0.0" -gem "stripe", "~> 13.0" -gem "rails", "~> 8.1.0" - -group :development do - gem "appraisal" - gem "web-console" - gem "standard" - gem "rubocop", "~> 1.0" - gem "rubocop-minitest", "~> 0.35" - gem "rubocop-performance", "~> 1.0" -end - -group :test do - gem "minitest", "~> 5.0" - gem "mocha" - gem "simplecov", require: false - gem "vcr" - gem "webmock" - gem "braintree", ">= 2.92.0" - gem "lemonsqueezy", "~> 1.0" - gem "paddle", "~> 2.6" - gem "prawn" - gem "receipts" - gem "sqlite3" - gem "pg" - gem "bootsnap", require: false - gem "puma" - gem "importmap-rails" - gem "sprockets-rails" - gem "stimulus-rails" - gem "turbo-rails" - gem "rdoc", ">= 7.0" -end - -gemspec path: "../" diff --git a/gemfiles/pay_10.0.gemfile b/gemfiles/pay_minimum.gemfile similarity index 59% rename from gemfiles/pay_10.0.gemfile rename to gemfiles/pay_minimum.gemfile index e5187ed..6681253 100644 --- a/gemfiles/pay_10.0.gemfile +++ b/gemfiles/pay_minimum.gemfile @@ -3,14 +3,15 @@ source "https://rubygems.org" gem "rake", "~> 13.0" -gem "pay", "~> 10.0.0" -gem "stripe", "~> 15.0" +gem "pay", "= 11.6.2" +gem "stripe", "~> 19.0" gem "rails", "~> 8.1.0" group :development do gem "appraisal" + gem "bundler-audit", "~> 0.9" gem "web-console" - gem "standard" + gem "standard", ">= 1.35.1" gem "rubocop", "~> 1.0" gem "rubocop-minitest", "~> 0.35" gem "rubocop-performance", "~> 1.0" @@ -27,8 +28,9 @@ group :test do gem "paddle", "~> 2.6" gem "prawn" gem "receipts" - gem "sqlite3" + gem "sqlite3", ">= 2.9.5" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" @@ -39,3 +41,10 @@ group :test do end gemspec path: "../" + +# Appraisal does not carry the root Gemfile's conditional path dependency into +# generated gemfiles. Keep coordinated pre-release CI on the exact Wallets +# source checkout without weakening the runtime gemspec constraint. +if ENV["WALLETS_PATH"] + gem "wallets", path: File.expand_path(ENV.fetch("WALLETS_PATH"), __dir__) +end diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index f49aa3b..4cc3520 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -4,13 +4,14 @@ source "https://rubygems.org" gem "rake", "~> 13.0" gem "rails", "~> 7.2.0" -gem "pay", "~> 11.0" -gem "stripe", "~> 18.0" +gem "pay", ">= 11.6.2", "< 12.0" +gem "stripe", "~> 19.0" group :development do gem "appraisal" + gem "bundler-audit", "~> 0.9" gem "web-console" - gem "standard" + gem "standard", ">= 1.35.1" gem "rubocop", "~> 1.0" gem "rubocop-minitest", "~> 0.35" gem "rubocop-performance", "~> 1.0" @@ -27,8 +28,9 @@ group :test do gem "paddle", "~> 2.6" gem "prawn" gem "receipts" - gem "sqlite3" + gem "sqlite3", ">= 2.9.5" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" @@ -39,3 +41,10 @@ group :test do end gemspec path: "../" + +# Appraisal does not carry the root Gemfile's conditional path dependency into +# generated gemfiles. Keep coordinated pre-release CI on the exact Wallets +# source checkout without weakening the runtime gemspec constraint. +if ENV["WALLETS_PATH"] + gem "wallets", path: File.expand_path(ENV.fetch("WALLETS_PATH"), __dir__) +end diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile index b0f7428..08aab8b 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -4,13 +4,14 @@ source "https://rubygems.org" gem "rake", "~> 13.0" gem "rails", "~> 8.1.0" -gem "pay", "~> 11.0" -gem "stripe", "~> 18.0" +gem "pay", ">= 11.6.2", "< 12.0" +gem "stripe", "~> 19.0" group :development do gem "appraisal" + gem "bundler-audit", "~> 0.9" gem "web-console" - gem "standard" + gem "standard", ">= 1.35.1" gem "rubocop", "~> 1.0" gem "rubocop-minitest", "~> 0.35" gem "rubocop-performance", "~> 1.0" @@ -27,8 +28,9 @@ group :test do gem "paddle", "~> 2.6" gem "prawn" gem "receipts" - gem "sqlite3" + gem "sqlite3", ">= 2.9.5" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" @@ -39,3 +41,10 @@ group :test do end gemspec path: "../" + +# Appraisal does not carry the root Gemfile's conditional path dependency into +# generated gemfiles. Keep coordinated pre-release CI on the exact Wallets +# source checkout without weakening the runtime gemspec constraint. +if ENV["WALLETS_PATH"] + gem "wallets", path: File.expand_path(ENV.fetch("WALLETS_PATH"), __dir__) +end diff --git a/lib/generators/usage_credits/install_generator.rb b/lib/generators/usage_credits/install_generator.rb index 8e00cba..e18f8f9 100644 --- a/lib/generators/usage_credits/install_generator.rb +++ b/lib/generators/usage_credits/install_generator.rb @@ -19,7 +19,15 @@ def create_migration_file end def create_initializer - template "initializer.rb", "config/initializers/usage_credits.rb" + destination = "config/initializers/usage_credits.rb" + if File.exist?(File.expand_path(destination, destination_root)) + # Never clobber a configured app. Thor's interactive conflict prompt + # is not a safety net in non-TTY runs (scripts, CI, AI agents), and + # this file is where apps define their whole credits catalog. + say_status :skip, "#{destination} already exists — keeping your configuration (delete it and re-run to regenerate the template)", :yellow + return + end + template "initializer.rb", destination end def display_post_install_message diff --git a/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb b/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb index 5a6cf3d..5e84437 100644 --- a/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +++ b/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb @@ -6,17 +6,33 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> create_table :usage_credits_wallets, id: primary_key_type do |t| t.references :owner, polymorphic: true, null: false, type: foreign_key_type - t.integer :balance, null: false, default: 0 + t.string :asset_code, null: false, default: "credits" + t.bigint :balance, null: false, default: 0 + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :usage_credits_wallets, [ :owner_type, :owner_id, :asset_code ], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + + create_table :usage_credits_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end create_table :usage_credits_transactions, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type - t.integer :amount, null: false + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.bigint :amount, null: false t.string :category, null: false t.datetime :expires_at + t.references :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers } t.references :fulfillment, type: foreign_key_type t.send(json_column_type, :metadata, null: false, default: json_column_default) @@ -24,9 +40,12 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> end create_table :usage_credits_fulfillments, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type - t.references :source, polymorphic: true, type: foreign_key_type - t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.references :source, + polymorphic: true, + type: foreign_key_type, + index: { unique: true, name: "index_usage_credits_fulfillments_on_source" } + t.bigint :credits_last_fulfillment, null: false # Credits given in last fulfillment t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription) t.datetime :last_fulfilled_at # When last fulfilled t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed) @@ -37,36 +56,57 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> t.timestamps end - # Allocations are the basis for the bucket-based, FIFO with expiration inventory-like system + add_foreign_key :usage_credits_transactions, + :usage_credits_fulfillments, + column: :fulfillment_id + + # Allocations are the basis for the bucket-based, first-expiring-first-out inventory system create_table :usage_credits_allocations, id: primary_key_type do |t| # The "spend" transaction (negative) that is *using* credits t.references :transaction, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_transaction_id" } + index: { name: "index_usage_credits_allocations_on_transaction_id" } # The "source" transaction (positive) from which the credits are drawn t.references :source_transaction, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_source_transaction_id" } + index: { name: "index_usage_credits_allocations_on_source_tx_id" } # How many credits were allocated from that particular source - t.integer :amount, null: false + t.bigint :amount, null: false t.timestamps end - # Add indexes + add_check_constraint :usage_credits_transfers, + "amount > 0", + name: "check_usage_credits_transfers_amount_positive" + add_check_constraint :usage_credits_transfers, + "from_wallet_id <> to_wallet_id", + name: "check_usage_credits_transfers_distinct_wallets" + add_check_constraint :usage_credits_transactions, + "amount <> 0", + name: "check_usage_credits_transactions_amount_nonzero" + add_check_constraint :usage_credits_allocations, + "amount > 0", + name: "check_usage_credits_allocations_amount_positive" + add_check_constraint :usage_credits_fulfillments, + "credits_last_fulfillment >= 0", + name: "check_usage_credits_fulfillments_credits_nonnegative" + + # Transaction indexes add_index :usage_credits_transactions, :category add_index :usage_credits_transactions, :expires_at + add_index :usage_credits_transactions, [ :expires_at, :id ], name: "index_usage_credits_transactions_on_expires_at_and_id" + add_index :usage_credits_transactions, [ :wallet_id, :amount ], name: "index_usage_credits_transactions_on_wallet_id_and_amount" - # Composite index on (expires_at, id) for efficient ordering when calculating balances - add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id' - - # Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts - add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount' + # Allocation indexes + add_index :usage_credits_allocations, [ :transaction_id, :source_transaction_id ], name: "index_usage_credits_allocations_on_tx_and_source_tx" - add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx" + # Transfer indexes + add_index :usage_credits_transfers, [ :from_wallet_id, :to_wallet_id, :asset_code ], name: "index_usage_credits_transfers_on_wallets_and_asset" + # Fulfillment indexes add_index :usage_credits_fulfillments, :next_fulfillment_at add_index :usage_credits_fulfillments, :fulfillment_type end @@ -75,10 +115,10 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> def primary_and_foreign_key_types config = Rails.configuration.generators - setting = config.options[config.orm][:primary_key_type] + setting = config.options[config.orm][ :primary_key_type ] primary_key_type = setting || :primary_key foreign_key_type = setting || :bigint - [primary_key_type, foreign_key_type] + [ primary_key_type, foreign_key_type ] end def json_column_type diff --git a/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb b/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb new file mode 100644 index 0000000..82c9d70 --- /dev/null +++ b/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb @@ -0,0 +1,592 @@ +# frozen_string_literal: true + +# Upgrades a pre-1.0 usage_credits install to the wallets-backed ledger core. +# +# Every DDL step is independently guarded so this migration can resume after +# an interrupted MySQL deployment. Data-integrity preflights run before the +# first schema mutation. The migration is intentionally irreversible: restore +# the required pre-deploy backup to roll back this release. +# +# PostgreSQL runs this migration inside one DDL transaction. Locks acquired by +# the index build, bigint conversions, constraints, foreign keys, and transfer +# schema are held until the entire migration commits. Rehearse on a production +# snapshot and schedule a maintenance window for a large/write-heavy ledger. +# Do not casually add disable_ddl_transaction!: a lower-lock rollout needs an +# application-specific staged plan for concurrent indexes and later constraint +# validation, and necessarily exposes a partially upgraded schema between steps. +class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_version %> + REQUIRED_TABLES = %i[ + usage_credits_wallets + usage_credits_transactions + usage_credits_allocations + usage_credits_fulfillments + ].freeze + + OWNER_ASSET_INDEX = "index_usage_credits_wallets_on_owner_and_asset" + FULFILLMENT_SOURCE_INDEX = "index_usage_credits_fulfillments_on_source" + TRANSFER_WALLETS_INDEX = "index_usage_credits_transfers_on_wallets_and_asset" + TRANSACTION_TRANSFER_INDEX = "index_usage_credits_transactions_on_transfer_id" + PAY_SOURCE_TABLES = { + "Pay::Charge" => :pay_charges, + "Pay::Subscription" => :pay_subscriptions + }.freeze + + def up + ensure_usage_credits_installed! + ensure_existing_transfer_schema! + ensure_reserved_index_names! + ensure_no_duplicate_wallets! + ensure_no_duplicate_fulfillment_sources! + ensure_complete_fulfillment_sources! + ensure_no_orphaned_ledger_references! + ensure_no_orphaned_pay_sources! + ensure_ledger_invariants! + + wallet_key_type = primary_key_type_for(:usage_credits_wallets) + ensure_ledger_reference_types!(wallet_key_type) + + unless column_exists?(:usage_credits_wallets, :asset_code) + add_column :usage_credits_wallets, :asset_code, :string, null: false, default: "credits" + end + + ensure_unique_index!( + :usage_credits_wallets, + %i[owner_type owner_id asset_code], + name: OWNER_ASSET_INDEX + ) + + ensure_bigint_column!(:usage_credits_wallets, :balance, null: false, default: 0) + ensure_bigint_column!(:usage_credits_transactions, :amount, null: false) + ensure_bigint_column!(:usage_credits_allocations, :amount, null: false) + ensure_bigint_column!(:usage_credits_fulfillments, :credits_last_fulfillment, null: false) + + ensure_check_constraint!( + :usage_credits_transactions, + "amount <> 0", + name: "check_usage_credits_transactions_amount_nonzero" + ) + ensure_check_constraint!( + :usage_credits_allocations, + "amount > 0", + name: "check_usage_credits_allocations_amount_positive" + ) + ensure_check_constraint!( + :usage_credits_fulfillments, + "credits_last_fulfillment >= 0", + name: "check_usage_credits_fulfillments_credits_nonnegative" + ) + + ensure_ledger_foreign_keys!(wallet_key_type) + ensure_transfers_table!(wallet_key_type) + ensure_transfer_reference! + + ensure_unique_index!( + :usage_credits_fulfillments, + %i[source_type source_id], + name: FULFILLMENT_SOURCE_INDEX + ) + end + + def down + raise ActiveRecord::IrreversibleMigration, + "usage_credits 1.0 cannot be downgraded in place. Restore the backup taken before upgrading." + end + + private + + def ensure_usage_credits_installed! + missing = REQUIRED_TABLES.reject { |table| table_exists?(table) } + return if missing.empty? + + if missing == REQUIRED_TABLES + raise <<~MESSAGE + No usage_credits tables found. This migration upgrades an existing pre-1.0 + install. For new apps, run `rails generate usage_credits:install` instead. + MESSAGE + end + + raise <<~MESSAGE + Cannot upgrade an incomplete usage_credits schema. Missing table(s): + #{missing.join(', ')} + + Restore the missing pre-1.0 tables (and their data) from backup before + retrying. No schema changes have been applied. + MESSAGE + end + + def ensure_no_duplicate_wallets! + grouping = %w[owner_type owner_id] + grouping << "asset_code" if column_exists?(:usage_credits_wallets, :asset_code) + + duplicates = connection.select_rows(<<~SQL.squish) + SELECT #{grouping.join(', ')}, COUNT(*) + FROM usage_credits_wallets + GROUP BY #{grouping.join(', ')} + HAVING COUNT(*) > 1 + ORDER BY #{grouping.join(', ')} + SQL + + return if duplicates.empty? + + listed = duplicates.first(10).map do |row| + owner_type, owner_id, asset_code, count = + if grouping.include?("asset_code") + row + else + [row[0], row[1], nil, row[2]] + end + asset = asset_code ? " / #{asset_code}" : "" + " - #{owner_type}##{owner_id}#{asset} (#{count} wallets)" + end + listed << " ...and #{duplicates.size - 10} more" if duplicates.size > 10 + + raise <<~MESSAGE + Cannot upgrade: #{duplicates.size} owner(s) have more than one usage_credits wallet: + + #{listed.join("\n")} + + These are financially ambiguous and must be reviewed before adding the + unique owner/asset constraint. For each owner, choose the canonical wallet, + move its transactions and fulfillments, recompute the cached balance from + the merged ledger, verify allocations, and only then delete the duplicate. + + No schema changes have been applied yet. Back up the database, resolve the + duplicates in a reviewed script, and run `rails db:migrate` again. + MESSAGE + end + + def ensure_no_duplicate_fulfillment_sources! + duplicates = connection.select_rows(<<~SQL.squish) + SELECT source_type, source_id, COUNT(*) + FROM usage_credits_fulfillments + WHERE source_type IS NOT NULL AND source_id IS NOT NULL + GROUP BY source_type, source_id + HAVING COUNT(*) > 1 + ORDER BY source_type, source_id + SQL + + return if duplicates.empty? + + listed = duplicates.first(10).map { |type, id, count| " - #{type}##{id} (#{count} fulfillments)" } + listed << " ...and #{duplicates.size - 10} more" if duplicates.size > 10 + + raise <<~MESSAGE + Cannot upgrade: #{duplicates.size} payment source(s) have duplicate fulfillments: + + #{listed.join("\n")} + + One payment source may mint credits only once. Reconcile each source against + its linked ledger transactions and processor record, retain the correct + fulfillment, and remove only proven duplicates. No schema changes have been + applied; run `rails db:migrate` again after reconciliation. + MESSAGE + end + + def ensure_complete_fulfillment_sources! + count = connection.select_value(<<~SQL.squish).to_i + SELECT COUNT(*) + FROM usage_credits_fulfillments + WHERE (source_type IS NULL AND source_id IS NOT NULL) + OR (source_type IS NOT NULL AND source_id IS NULL) + SQL + return if count.zero? + + raise <<~MESSAGE + Cannot upgrade: #{count} fulfillment(s) have an incomplete polymorphic + source (only source_type or source_id is present). Reconcile these rows + before retrying. No schema changes have been applied. + MESSAGE + end + + def ensure_no_orphaned_ledger_references! + checks = [ + [ :usage_credits_transactions, :wallet_id, :usage_credits_wallets ], + [ :usage_credits_fulfillments, :wallet_id, :usage_credits_wallets ], + [ :usage_credits_transactions, :fulfillment_id, :usage_credits_fulfillments ], + [ :usage_credits_allocations, :transaction_id, :usage_credits_transactions ], + [ :usage_credits_allocations, :source_transaction_id, :usage_credits_transactions ] + ] + if table_exists?(:usage_credits_transfers) + checks << [ :usage_credits_transfers, :from_wallet_id, :usage_credits_wallets ] + checks << [ :usage_credits_transfers, :to_wallet_id, :usage_credits_wallets ] + end + if table_exists?(:usage_credits_transfers) && column_exists?(:usage_credits_transactions, :transfer_id) + checks << [ :usage_credits_transactions, :transfer_id, :usage_credits_transfers ] + end + + orphaned = checks.filter_map do |child_table, foreign_key, parent_table| + count = orphan_count(child_table, foreign_key, parent_table) + " - #{child_table}.#{foreign_key}: #{count}" if count.positive? + end + return if orphaned.empty? + + raise <<~MESSAGE + Cannot upgrade because orphaned ledger references were found: + + #{orphaned.join("\n")} + + Restore or reconcile the missing parent rows before retrying. The upgrade + adds foreign keys to make this corruption impossible going forward. No + schema changes have been applied. + MESSAGE + end + + def ensure_no_orphaned_pay_sources! + orphaned = PAY_SOURCE_TABLES.filter_map do |source_type, parent_table| + source_count = scalar_count(<<~SQL) + SELECT COUNT(*) + FROM usage_credits_fulfillments + WHERE source_type = #{connection.quote(source_type)} + AND source_id IS NOT NULL + SQL + next if source_count.zero? + + unless table_exists?(parent_table) + next " - #{source_type}: #{source_count} (missing #{parent_table})" + end + + count = polymorphic_orphan_count(source_type, parent_table) + " - #{source_type}: #{count}" if count.positive? + end + return if orphaned.empty? + + raise <<~MESSAGE + Cannot upgrade because fulfillment payment sources are missing: + + #{orphaned.join("\n")} + + Restore or reconcile each Pay charge/subscription before retrying. A + dangling subscription source could otherwise mint credits without a + processor record to prove that the customer is still entitled to them. + No schema changes have been applied. + MESSAGE + end + + def ensure_ledger_invariants! + violations = { + "zero-amount transactions" => scalar_count(<<~SQL), + SELECT COUNT(*) FROM usage_credits_transactions WHERE amount = 0 + SQL + "non-positive allocations" => scalar_count(<<~SQL), + SELECT COUNT(*) FROM usage_credits_allocations WHERE amount <= 0 + SQL + "allocations with invalid debit/credit direction or different wallets" => scalar_count(<<~SQL), + SELECT COUNT(*) + FROM usage_credits_allocations allocations + INNER JOIN usage_credits_transactions spends + ON spends.id = allocations.transaction_id + INNER JOIN usage_credits_transactions sources + ON sources.id = allocations.source_transaction_id + WHERE spends.amount >= 0 + OR sources.amount <= 0 + OR spends.wallet_id <> sources.wallet_id + SQL + "over-allocated credit sources" => scalar_count(<<~SQL), + SELECT COUNT(*) + FROM ( + SELECT allocations.source_transaction_id + FROM usage_credits_allocations allocations + INNER JOIN usage_credits_transactions sources + ON sources.id = allocations.source_transaction_id + GROUP BY allocations.source_transaction_id, sources.amount + HAVING SUM(allocations.amount) > sources.amount + ) ledger_violations + SQL + "over-allocated debit transactions" => scalar_count(<<~SQL), + SELECT COUNT(*) + FROM ( + SELECT allocations.transaction_id + FROM usage_credits_allocations allocations + INNER JOIN usage_credits_transactions spends + ON spends.id = allocations.transaction_id + GROUP BY allocations.transaction_id, spends.amount + HAVING SUM(allocations.amount) > -spends.amount + ) ledger_violations + SQL + "negative fulfillment credit snapshots" => scalar_count(<<~SQL) + SELECT COUNT(*) + FROM usage_credits_fulfillments + WHERE credits_last_fulfillment < 0 + SQL + } + + if table_exists?(:usage_credits_transfers) + violations["invalid interrupted transfers"] = scalar_count(<<~SQL) + SELECT COUNT(*) + FROM usage_credits_transfers + WHERE amount <= 0 OR from_wallet_id = to_wallet_id + SQL + end + + violations.delete_if { |_description, count| count.zero? } + return if violations.empty? + + listed = violations.map { |description, count| " - #{description}: #{count}" } + raise <<~MESSAGE + Cannot upgrade because the existing ledger violates wallets accounting invariants: + + #{listed.join("\n")} + + Reconcile every listed row against the surrounding transactions and payment + records before retrying. Automatically coercing these values could create or + destroy customer credit. No schema changes have been applied. + MESSAGE + end + + def scalar_count(sql) + connection.select_value(sql.squish).to_i + end + + def orphan_count(child_table, foreign_key, parent_table) + child = connection.quote_table_name(child_table) + parent = connection.quote_table_name(parent_table) + foreign_column = connection.quote_column_name(foreign_key) + parent_key = connection.quote_column_name(connection.primary_key(parent_table)) + + connection.select_value(<<~SQL.squish).to_i + SELECT COUNT(*) + FROM #{child} child + LEFT JOIN #{parent} parent ON parent.#{parent_key} = child.#{foreign_column} + WHERE child.#{foreign_column} IS NOT NULL + AND parent.#{parent_key} IS NULL + SQL + end + + def polymorphic_orphan_count(source_type, parent_table) + fulfillments = connection.quote_table_name(:usage_credits_fulfillments) + parent = connection.quote_table_name(parent_table) + source_id = connection.quote_column_name(:source_id) + parent_key = connection.quote_column_name(connection.primary_key(parent_table)) + + connection.select_value(<<~SQL.squish).to_i + SELECT COUNT(*) + FROM #{fulfillments} fulfillments + LEFT JOIN #{parent} parent ON parent.#{parent_key} = fulfillments.#{source_id} + WHERE fulfillments.source_type = #{connection.quote(source_type)} + AND fulfillments.#{source_id} IS NOT NULL + AND parent.#{parent_key} IS NULL + SQL + end + + def ensure_bigint_column!(table, column, **options) + current = column_for(table, column) + raise "Cannot upgrade: missing #{table}.#{column}" unless current + return if current.sql_type.to_s.downcase.include?("bigint") + + change_column table, column, :bigint, **options + end + + def ensure_existing_transfer_schema! + transaction_reference_exists = column_exists?(:usage_credits_transactions, :transfer_id) + unless table_exists?(:usage_credits_transfers) + return unless transaction_reference_exists + + raise <<~MESSAGE.squish + Cannot resume upgrade: usage_credits_transactions.transfer_id exists but + usage_credits_transfers does not. Restore or remove the incomplete + reference before retrying; no new schema changes have been applied. + MESSAGE + end + + required_columns = %w[ + id from_wallet_id to_wallet_id asset_code amount category + expiration_policy metadata created_at updated_at + ] + missing = required_columns.reject { |column| column_exists?(:usage_credits_transfers, column) } + return if missing.empty? + + raise <<~MESSAGE.squish + Cannot resume upgrade: usage_credits_transfers is incomplete (missing: + #{missing.join(', ')}). Repair or remove the interrupted table before + retrying; no new schema changes have been applied. + MESSAGE + end + + def ensure_reserved_index_names! + expectations = [ + [:usage_credits_wallets, OWNER_ASSET_INDEX, %w[owner_type owner_id asset_code]], + [:usage_credits_fulfillments, FULFILLMENT_SOURCE_INDEX, %w[source_type source_id]] + ] + if table_exists?(:usage_credits_transfers) + expectations << [:usage_credits_transfers, TRANSFER_WALLETS_INDEX, %w[from_wallet_id to_wallet_id asset_code]] + end + if column_exists?(:usage_credits_transactions, :transfer_id) + expectations << [:usage_credits_transactions, TRANSACTION_TRANSFER_INDEX, %w[transfer_id]] + end + + expectations.each do |table, name, expected_columns| + index = connection.indexes(table).find { |candidate| candidate.name == name } + next if index.nil? || index.columns == expected_columns + + raise <<~MESSAGE.squish + Cannot upgrade: index #{name} on #{table} is reserved for + (#{expected_columns.join(', ')}) but currently covers + (#{index.columns.join(', ')}). Rename or remove the conflicting index + before retrying; no schema changes have been applied. + MESSAGE + end + end + + def ensure_ledger_reference_types!(wallet_key_type) + ensure_reference_type!(:usage_credits_transactions, :wallet_id, wallet_key_type) + ensure_reference_type!(:usage_credits_fulfillments, :wallet_id, wallet_key_type) + + transaction_key_type = primary_key_type_for(:usage_credits_transactions) + ensure_reference_type!(:usage_credits_allocations, :transaction_id, transaction_key_type) + ensure_reference_type!(:usage_credits_allocations, :source_transaction_id, transaction_key_type) + + fulfillment_key_type = primary_key_type_for(:usage_credits_fulfillments) + ensure_reference_type!(:usage_credits_transactions, :fulfillment_id, fulfillment_key_type) + + return unless table_exists?(:usage_credits_transfers) + + ensure_reference_type!(:usage_credits_transfers, :from_wallet_id, wallet_key_type) + ensure_reference_type!(:usage_credits_transfers, :to_wallet_id, wallet_key_type) + if column_exists?(:usage_credits_transactions, :transfer_id) + ensure_reference_type!( + :usage_credits_transactions, + :transfer_id, + primary_key_type_for(:usage_credits_transfers) + ) + end + end + + def ensure_ledger_foreign_keys!(wallet_key_type) + ensure_foreign_key!(:usage_credits_transactions, :usage_credits_wallets, column: :wallet_id) + ensure_foreign_key!(:usage_credits_fulfillments, :usage_credits_wallets, column: :wallet_id) + + ensure_foreign_key!(:usage_credits_allocations, :usage_credits_transactions, column: :transaction_id) + ensure_foreign_key!(:usage_credits_allocations, :usage_credits_transactions, column: :source_transaction_id) + + ensure_foreign_key!(:usage_credits_transactions, :usage_credits_fulfillments, column: :fulfillment_id) + end + + def ensure_transfers_table!(wallet_key_type) + unless table_exists?(:usage_credits_transfers) + create_table :usage_credits_transfers, id: wallet_key_type do |t| + t.references :from_wallet, null: false, type: wallet_key_type + t.references :to_wallet, null: false, type: wallet_key_type + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + end + + ensure_reference_type!(:usage_credits_transfers, :from_wallet_id, wallet_key_type) + ensure_reference_type!(:usage_credits_transfers, :to_wallet_id, wallet_key_type) + + ensure_check_constraint!( + :usage_credits_transfers, + "amount > 0", + name: "check_usage_credits_transfers_amount_positive" + ) + ensure_check_constraint!( + :usage_credits_transfers, + "from_wallet_id <> to_wallet_id", + name: "check_usage_credits_transfers_distinct_wallets" + ) + + ensure_foreign_key!( + :usage_credits_transfers, + :usage_credits_wallets, + column: :from_wallet_id + ) + ensure_foreign_key!( + :usage_credits_transfers, + :usage_credits_wallets, + column: :to_wallet_id + ) + + unless index_exists?(:usage_credits_transfers, %i[from_wallet_id to_wallet_id asset_code]) + add_index :usage_credits_transfers, + %i[from_wallet_id to_wallet_id asset_code], + name: TRANSFER_WALLETS_INDEX + end + end + + def ensure_transfer_reference! + transfer_key_type = primary_key_type_for(:usage_credits_transfers) + + unless column_exists?(:usage_credits_transactions, :transfer_id) + add_column :usage_credits_transactions, :transfer_id, transfer_key_type + end + + ensure_reference_type!(:usage_credits_transactions, :transfer_id, transfer_key_type) + + unless index_exists?(:usage_credits_transactions, :transfer_id) + add_index :usage_credits_transactions, :transfer_id, name: TRANSACTION_TRANSFER_INDEX + end + + ensure_foreign_key!( + :usage_credits_transactions, + :usage_credits_transfers, + column: :transfer_id + ) + end + + def ensure_unique_index!(table, columns, name:) + expected_columns = columns.map(&:to_s) + existing = connection.indexes(table).find { |index| index.columns == expected_columns } + return if existing&.unique + + remove_index table, name: existing.name if existing + add_index table, columns, unique: true, name: name + end + + def ensure_foreign_key!(from_table, to_table, column:) + return if foreign_key_exists?(from_table, to_table, column: column) + + add_foreign_key from_table, to_table, column: column + end + + def ensure_check_constraint!(table, expression, name:) + return if check_constraint_exists?(table, name: name) + + add_check_constraint table, expression, name: name + end + + def ensure_reference_type!(table, column, expected_type) + actual_type = reference_type_for(column_for(table, column)) + return if actual_type == expected_type + + raise <<~MESSAGE.squish + Cannot upgrade: #{table}.#{column} is #{actual_type.inspect}, but the + referenced primary key is #{expected_type.inspect}. Correct this type + mismatch before retrying; no automatic cast is safe for ledger keys. + MESSAGE + end + + def primary_key_type_for(table) + primary_key = connection.primary_key(table) + column = column_for(table, primary_key) + raise "Cannot determine primary key type for #{table}" unless column + + reference_type_for(column) + end + + def reference_type_for(column) + return :uuid if column.type == :uuid + return :bigint if column.sql_type.to_s.downcase.include?("bigint") + return :integer if column.type == :integer + + column.type + end + + def column_for(table, column) + connection.columns(table).find { |candidate| candidate.name == column.to_s } + end + + def json_column_type + return :jsonb if connection.adapter_name.downcase.include?("postgresql") + :json + end + + def json_column_default + return nil if connection.adapter_name.downcase.include?("mysql") + {} + end +end diff --git a/lib/generators/usage_credits/upgrade_generator.rb b/lib/generators/usage_credits/upgrade_generator.rb new file mode 100644 index 0000000..e9c0526 --- /dev/null +++ b/lib/generators/usage_credits/upgrade_generator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails/generators/base" +require "rails/generators/active_record" + +module UsageCredits + module Generators + class UpgradeGenerator < Rails::Generators::Base + include ActiveRecord::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + def self.next_migration_number(dir) + ActiveRecord::Generators::Base.next_migration_number(dir) + end + + def create_migration_file + migration_template "upgrade_usage_credits_to_wallets_core.rb.erb", File.join(db_migrate_path, "upgrade_usage_credits_to_wallets_core.rb") + end + + def display_post_upgrade_message + say "\nUsageCredits 1.0 upgrade migration has been generated!", :green + say "\nThis migration will:" + say " - Check for duplicate owner wallets first, and abort with instructions if any exist" + say " - Add 'asset_code' column to wallets (default: 'credits')" + say " - Enforce one wallet per owner per asset with a unique index" + say " - Change integer columns to bigint for larger balance support" + say " - Create 'usage_credits_transfers' table for wallet transfers" + say " - Add 'transfer_id' column to transactions" + say " - Upgrade pre-1.0 installs to the wallets-backed ledger core" + say "\nEvery step is guarded, so the migration is safe to re-run if interrupted." + say "\nTo complete the upgrade:" + say " 1. Review the migration file in db/migrate/" + say " 2. Back up your database (this migration is not reversible)" + say " 3. Run 'rails db:migrate'" + say "\n" + end + + private + + def migration_version + "[#{ActiveRecord::VERSION::STRING.to_f}]" + end + end + end +end diff --git a/lib/usage_credits.rb b/lib/usage_credits.rb index a7186cc..b2bd23b 100644 --- a/lib/usage_credits.rb +++ b/lib/usage_credits.rb @@ -5,14 +5,25 @@ require "rails" require "active_record" +require "active_job" require "pay" +require "wallets" require "active_support/all" +module UsageCredits + # usage_credits deliberately exposes one asset. Keep its persisted value in + # one runtime constant so association lookup and race-safe creation cannot + # drift apart. + DEFAULT_ASSET_CODE = "credits" +end + # Load order matters! Dependencies are loaded in this specific order: # # 1. Core helpers require "usage_credits/helpers/credit_calculator" # Centralized credit rounding require "usage_credits/helpers/period_parser" # Parse fulfillment periods like `:monthly` to `1.month` +require "usage_credits/helpers/processor_metadata" # Payment metadata limits and serialization +require "usage_credits/subscription_terms" # Immutable processor subscription snapshots require "usage_credits/core_ext/numeric" # Numeric extension to write `10.credits` in our DSL # 2. Cost calculation @@ -43,17 +54,19 @@ class ApplicationJob < ActiveJob::Base end # 6. Models (order matters for dependencies) +# These extend Wallets::* classes, so wallets gem must be loaded first require "usage_credits/models/wallet" require "usage_credits/models/transaction" require "usage_credits/models/allocation" +require "usage_credits/models/transfer" require "usage_credits/models/operation" require "usage_credits/models/fulfillment" require "usage_credits/models/credit_pack" require "usage_credits/models/credit_subscription_plan" # 7. Jobs -require "usage_credits/services/fulfillment_service.rb" -require "usage_credits/jobs/fulfillment_job.rb" +require "usage_credits/services/fulfillment_service" +require "usage_credits/jobs/fulfillment_job" # Main module that serves as the primary interface to the gem. # Most methods here delegate to Configuration, which is the single source of truth for all config in the initializer @@ -62,6 +75,7 @@ module UsageCredits class Error < StandardError; end class InsufficientCredits < Error; end class InvalidOperation < Error; end + class InvalidTransfer < Error; end class << self attr_writer :configuration @@ -157,7 +171,6 @@ def handle_event(event, **params) notify_low_balance(params[:wallet]&.owner) end end - end end diff --git a/lib/usage_credits/callbacks.rb b/lib/usage_credits/callbacks.rb index 38fc695..8286fd8 100644 --- a/lib/usage_credits/callbacks.rb +++ b/lib/usage_credits/callbacks.rb @@ -1,67 +1,22 @@ # frozen_string_literal: true module UsageCredits - # Centralized callback dispatch module - # Handles executing callbacks with error isolation + # usage_credits adapter for wallets' shared callback dispatcher. module Callbacks - module_function - - # Dispatch a callback event with error isolation - # Callbacks should never break the main operation - # - # @param event [Symbol] The event type (e.g., :credits_added, :low_balance_reached) - # @param context_data [Hash] Data to pass to the callback via CallbackContext - def dispatch(event, **context_data) - config = UsageCredits.configuration - callback = config.public_send(:"on_#{event}_callback") - - return unless callback.is_a?(Proc) - - context = CallbackContext.new(event: event, **context_data) + extend Wallets::CallbackDispatcher - execute_safely(callback, context) - end - - # Execute callback with error isolation and arity handling - # - # @param callback [Proc] The callback to execute - # @param context [CallbackContext] The context to pass - def execute_safely(callback, context) - case callback.arity - when 1, -1, -2 # Accepts one arg or variable args - callback.call(context) - when 0 - callback.call - else - log_warn "[UsageCredits] Callback has unexpected arity (#{callback.arity}). Expected 0 or 1." - end - rescue StandardError => e - # Log but don't re-raise - callbacks should never break credit operations - log_error "[UsageCredits] Callback error for #{context.event}: #{e.class}: #{e.message}" - log_debug e.backtrace.join("\n") - end + module_function - # Safe logging that works with or without Rails - def log_error(message) - if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger - Rails.logger.error(message) - else - warn message - end + def callback_configuration + UsageCredits.configuration end - def log_warn(message) - if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger - Rails.logger.warn(message) - else - warn message - end + def callback_context_class + UsageCredits::CallbackContext end - def log_debug(message) - if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug? - Rails.logger.debug(message) - end + def callback_log_prefix + "[UsageCredits]" end end end diff --git a/lib/usage_credits/configuration.rb b/lib/usage_credits/configuration.rb index da048c6..85da073 100644 --- a/lib/usage_credits/configuration.rb +++ b/lib/usage_credits/configuration.rb @@ -33,6 +33,12 @@ class Configuration # Custom transaction categories that extend the default set attr_reader :additional_categories + # Table prefix for usage_credits tables (for wallets gem compatibility) + # Note: usage_credits uses fixed table names, so this is always "usage_credits_" + def table_prefix + "usage_credits_" + end + # Minimum allowed fulfillment period for subscription plans. # Defaults to 1.day to prevent accidental 1-second refill loops in production. # Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration. @@ -53,12 +59,12 @@ class Configuration # ========================================= attr_reader :on_credits_added_callback, - :on_credits_deducted_callback, - :on_low_balance_reached_callback, - :on_balance_depleted_callback, - :on_insufficient_credits_callback, - :on_subscription_credits_awarded_callback, - :on_credit_pack_purchased_callback + :on_credits_deducted_callback, + :on_low_balance_reached_callback, + :on_balance_depleted_callback, + :on_insufficient_credits_callback, + :on_subscription_credits_awarded_callback, + :on_credit_pack_purchased_callback def initialize # Initialize empty data stores @@ -159,15 +165,18 @@ def find_subscription_plan_by_processor_id(processor_id) def default_currency=(value) value = value.to_s.downcase.to_sym unless VALID_CURRENCIES.include?(value) - raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}" + raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(", ")}" end @default_currency = value end - # Set low balance threshold with validation + # Set low balance threshold with validation. Accepts plain integers and + # the DSL form the initializer template shows (`100.credits`, a + # Cost::Fixed) — anything else fails WholeNumber.parse loudly. def low_balance_threshold=(value) if value - value = value.to_i + value = value.to_i if value.is_a?(UsageCredits::Cost::Fixed) + value = Wallets::WholeNumber.parse(value, name: "Low balance threshold", allow_string: true) raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative? end @low_balance_threshold = value @@ -183,7 +192,10 @@ def rounding_strategy=(strategy) end def fulfillment_grace_period=(value) - if value.nil? || value&.to_i == 0 + # Only actual nil/numeric zero select the safe one-second fallback. + # String coercion previously made arbitrary garbage ("nope".to_i == 0) + # silently valid configuration; non-Duration strings now fail closed. + if value.nil? || value == 0 @fulfillment_grace_period = 1.second return end @@ -296,19 +308,19 @@ def validate! def validate_currency! raise ArgumentError, "Default currency can't be blank" if default_currency.blank? unless VALID_CURRENCIES.include?(default_currency.to_s.downcase.to_sym) - raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}" + raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(", ")}" end end def validate_threshold! - if @low_balance_threshold && @low_balance_threshold.negative? + if @low_balance_threshold&.negative? raise ArgumentError, "Low balance threshold must be greater than or equal to zero" end end def validate_rounding_strategy! unless VALID_ROUNDING_STRATEGIES.include?(@rounding_strategy) - raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(', ')}" + raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(", ")}" end end diff --git a/lib/usage_credits/core_ext/numeric.rb b/lib/usage_credits/core_ext/numeric.rb index c9d1c4a..52b983a 100644 --- a/lib/usage_credits/core_ext/numeric.rb +++ b/lib/usage_credits/core_ext/numeric.rb @@ -7,28 +7,24 @@ # (Cost::Base, Cost::Fixed, Cost::Variable, Cost::Compound, etc.) class Numeric def credits - raise ArgumentError, "Credit amount must be a whole number (decimals are not allowed)" unless self == self.to_i - raise ArgumentError, "Credit amount cannot be negative" if self.negative? - UsageCredits::Cost::Fixed.new(self.to_i) + UsageCredits::Cost::Fixed.new(self) end alias_method :credit, :credits def credits_per(unit) - raise ArgumentError, "Credit cost rate must be a whole number (decimals are not allowed)" unless self == self.to_i - # Convert common units to their base unit unit = case unit.to_s.downcase - when "mb", "megabyte", "megabytes" - :mb - when "kb", "kilobyte", "kilobytes" - :kb - when "gb", "gigabyte", "gigabytes" - :gb - when "unit", "units" - :units - else - unit.to_sym - end + when "mb", "megabyte", "megabytes" + :mb + when "kb", "kilobyte", "kilobytes" + :kb + when "gb", "gigabyte", "gigabytes" + :gb + when "unit", "units" + :units + else + unit.to_sym + end UsageCredits::Cost::Variable.new(self, unit) end diff --git a/lib/usage_credits/cost/base.rb b/lib/usage_credits/cost/base.rb index 289a1d4..9ec3f1f 100644 --- a/lib/usage_credits/cost/base.rb +++ b/lib/usage_credits/cost/base.rb @@ -31,12 +31,7 @@ def to_i protected def validate_amount!(amount) - unless amount == amount.to_i - raise ArgumentError, "Credit amount must be a whole number (got: #{amount})" - end - if amount.negative? - raise ArgumentError, "Credit amount cannot be negative (got: #{amount})" - end + CreditCalculator.normalize_credit_amount(amount) end end end diff --git a/lib/usage_credits/cost/compound.rb b/lib/usage_credits/cost/compound.rb index e43f68d..9a8b9b6 100644 --- a/lib/usage_credits/cost/compound.rb +++ b/lib/usage_credits/cost/compound.rb @@ -12,8 +12,9 @@ def initialize(*costs) end def calculate(params = {}) - total = costs.sum { |cost| cost.calculate(params) } - CreditCalculator.apply_rounding(total) + # Components stay unrounded until Operation#calculate_cost applies the + # configured strategy once to the final sum. + costs.sum { |cost| cost.calculate(params) } end def +(other) diff --git a/lib/usage_credits/cost/fixed.rb b/lib/usage_credits/cost/fixed.rb index 32676ae..fcfacbd 100644 --- a/lib/usage_credits/cost/fixed.rb +++ b/lib/usage_credits/cost/fixed.rb @@ -7,7 +7,7 @@ class Fixed < Base attr_reader :period def initialize(amount) - @amount = amount + super @period = nil # Will default to 1.month in CreditSubscriptionPlan end @@ -18,7 +18,6 @@ def calculate(params = {}) value.calculate(params) else validate_amount!(value) - value.to_i end end diff --git a/lib/usage_credits/cost/variable.rb b/lib/usage_credits/cost/variable.rb index 1c70f69..99b9442 100644 --- a/lib/usage_credits/cost/variable.rb +++ b/lib/usage_credits/cost/variable.rb @@ -4,37 +4,52 @@ module UsageCredits module Cost # Variable credit cost based on units (e.g., 1 credit per MB) class Variable < Base + SUPPORTED_UNITS = %i[kb mb gb units].freeze + attr_reader :unit def initialize(amount, unit) super(amount) @unit = unit.to_sym + raise ArgumentError, "Unknown unit: #{unit}" unless SUPPORTED_UNITS.include?(@unit) end def calculate(params = {}) + # Return the raw composable cost. Operation#calculate_cost is the one + # rounding boundary, preventing nested Variable/Compound calculators + # from rounding the same business cost more than once. size = extract_size(params) - raw_cost = amount * size - CreditCalculator.apply_rounding(raw_cost) + amount * size end private def extract_size(params) case unit - when :mb + when :kb, :mb, :gb # First check for direct MB value if params[:mb] - CreditCalculator.apply_rounding(params[:mb].to_f) + convert_megabytes(params[:mb]) # Then check for bytes that need conversion elsif params[:size] - CreditCalculator.apply_rounding(params[:size].to_f / 1.megabyte) + params[:size].to_f / bytes_per_unit else 0 end when :units - CreditCalculator.apply_rounding(params.fetch(:units, 0).to_f) - else - raise ArgumentError, "Unknown unit: #{unit}" + params.fetch(:units, 0) + end + end + + def convert_megabytes(megabytes) + (megabytes.to_f * 1.megabyte) / bytes_per_unit + end + + def bytes_per_unit + case unit + when :kb then 1.kilobyte + when :mb then 1.megabyte + when :gb then 1.gigabyte end end end diff --git a/lib/usage_credits/engine.rb b/lib/usage_credits/engine.rb index caa31fb..a5c5ee8 100644 --- a/lib/usage_credits/engine.rb +++ b/lib/usage_credits/engine.rb @@ -5,16 +5,9 @@ module UsageCredits class Engine < ::Rails::Engine isolate_namespace UsageCredits - # Ensure our models load first - config.autoload_paths << File.expand_path("../models", __dir__) - config.autoload_paths << File.expand_path("../models/concerns", __dir__) - - # Set up autoloading paths - initializer "usage_credits.autoload", before: :set_autoload_paths do |app| - app.config.autoload_paths << root.join("lib") - app.config.autoload_paths << root.join("lib/usage_credits/models") - app.config.autoload_paths << root.join("lib/usage_credits/models/concerns") - end + # All gem code is required eagerly by lib/usage_credits.rb. Adding these + # directories to the host app's autoloaders would make Zeitwerk claim + # common top-level constants such as ::Wallet and ::Operation. # Add has_credits method to ActiveRecord::Base initializer "usage_credits.active_record" do @@ -30,8 +23,11 @@ class Engine < ::Rails::Engine end end - initializer "usage_credits.configs" do - # Initialize any config settings + initializer "usage_credits.action_view" do + ActiveSupport.on_load :action_view do + require "usage_credits/helpers/credits_helper" + include UsageCredits::CreditsHelper + end end end end diff --git a/lib/usage_credits/helpers/credit_calculator.rb b/lib/usage_credits/helpers/credit_calculator.rb index 9047fdf..9f01e6a 100644 --- a/lib/usage_credits/helpers/credit_calculator.rb +++ b/lib/usage_credits/helpers/credit_calculator.rb @@ -21,6 +21,24 @@ def apply_rounding(amount) end end + # Normalize every configured or dynamically calculated credit cost through + # one strict boundary. Keeping parse-error translation here means fixed + # values and Proc results cannot match Wallets error-message text in + # separate places and drift apart. + def normalize_credit_amount(amount) + number = begin + Wallets::WholeNumber.parse(amount, name: "Credit amount") + rescue ArgumentError + raise ArgumentError, "Credit amount must be a whole number (got: #{amount})" + end + + if number.negative? + raise ArgumentError, "Credit amount cannot be negative (got: #{amount})" + end + + number + end + # Convert a monetary amount to credits def money_to_credits(cents, exchange_rate) apply_rounding(cents * exchange_rate / 100.0) diff --git a/lib/usage_credits/helpers/credits_helper.rb b/lib/usage_credits/helpers/credits_helper.rb index 50f11bf..b2e2ada 100644 --- a/lib/usage_credits/helpers/credits_helper.rb +++ b/lib/usage_credits/helpers/credits_helper.rb @@ -17,20 +17,19 @@ def format_credit_price(cents, currency = nil) # Credit pack purchase button def credit_pack_button(pack, options = {}) button_to options[:path] || credit_pack_purchase_path(pack), - class: options[:class] || "credit-pack-button", - method: :post, - data: { - turbo: false, - pack_name: pack.name, - credits: pack.credits, - bonus_credits: pack.bonus_credits, - price: pack.price_cents - } do + class: options[:class] || "credit-pack-button", + method: :post, + data: { + turbo: false, + pack_name: pack.name, + credits: pack.credits, + bonus_credits: pack.bonus_credits, + price: pack.price_cents + } do render_credit_pack_button_content(pack) end end - private def render_credit_pack_button_content(pack) @@ -40,6 +39,5 @@ def render_credit_pack_button_content(pack) content_tag(:span, format_credit_price(pack.price_cents, pack.price_currency), class: "price") ].compact, " ") end - end end diff --git a/lib/usage_credits/helpers/period_parser.rb b/lib/usage_credits/helpers/period_parser.rb index 7f34d99..135ad8b 100644 --- a/lib/usage_credits/helpers/period_parser.rb +++ b/lib/usage_credits/helpers/period_parser.rb @@ -4,7 +4,6 @@ module UsageCredits # Handles parsing and normalization of time periods throughout the gem. # Converts strings like "1.month" or symbols like :monthly into ActiveSupport::Duration objects. module PeriodParser - # Canonical periods and their aliases VALID_PERIODS = { second: [:second, :seconds], # 1.second @@ -17,6 +16,7 @@ module PeriodParser year: [:year, :yearly, :annually] # 1.year }.freeze + ABSOLUTE_MIN_PERIOD = 1.second MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead module_function @@ -37,8 +37,7 @@ def normalize_period(period) # Handle ActiveSupport::Duration objects directly if period.is_a?(ActiveSupport::Duration) - min_period = min_fulfillment_period - raise ArgumentError, "Period must be at least #{min_period.inspect}" if period < min_period + validate_minimum!(period) period else # Convert symbols to canonical durations @@ -55,8 +54,7 @@ def normalize_period(period) raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}" end - min_period = min_fulfillment_period - raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period + validate_minimum!(duration) duration end end @@ -65,8 +63,11 @@ def normalize_period(period) # @param period_str [String, ActiveSupport::Duration] A string like "1.month" or "1 month" or an existing duration # @return [ActiveSupport::Duration] The parsed duration # @raise [ArgumentError] If the period string is invalid - def parse_period(period_str) - return period_str if period_str.is_a?(ActiveSupport::Duration) + def parse_period(period_str, enforce_minimum: true) + if period_str.is_a?(ActiveSupport::Duration) + validate_minimum!(period_str, persisted: !enforce_minimum) + return period_str + end if period_str.to_s =~ /\A(\d+)[.\s](\w+)\z/ amount = $1.to_i @@ -82,14 +83,19 @@ def parse_period(period_str) canonical_unit = canonical_unit_for(unit) duration = amount.send(canonical_unit) - min_period = min_fulfillment_period - raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period + validate_minimum!(duration, persisted: !enforce_minimum) duration else raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc." end end + # Persisted commercial schedules remain valid if an operator later raises + # the minimum allowed for newly configured plans. + def parse_persisted_period(period_str) + parse_period(period_str, enforce_minimum: false) + end + # Map any alias to its canonical unit method name # @param unit [Symbol] The unit symbol (e.g., :hourly, :seconds, :day) # @return [Symbol] The canonical unit method (e.g., :hour, :second, :day) @@ -111,5 +117,21 @@ def valid_period_format?(period_str) false end + def valid_persisted_period_format?(period_str) + parse_persisted_period(period_str) + true + rescue ArgumentError + false + end + + def validate_minimum!(duration, persisted: false) + # Persisted commercial terms must survive a later operator-configured + # minimum increase, but they can never bypass the gem's hard safety + # floor. A zero cadence remains perpetually due and can mint on every job + # run, so one second is the absolute minimum for all schedules. + min_period = persisted ? ABSOLUTE_MIN_PERIOD : min_fulfillment_period + raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period + end + private_class_method :validate_minimum! end end diff --git a/lib/usage_credits/helpers/processor_metadata.rb b/lib/usage_credits/helpers/processor_metadata.rb new file mode 100644 index 0000000..5394fcc --- /dev/null +++ b/lib/usage_credits/helpers/processor_metadata.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module UsageCredits + # Normalizes and validates metadata before it crosses a payment-processor + # boundary. Stripe stores metadata as at most 50 string key/value pairs; + # structured Ruby values are encoded as one JSON string instead of becoming + # unsupported nested form parameters. + module ProcessorMetadata + MAX_PAIRS = 50 + MAX_KEY_LENGTH = 40 + MAX_VALUE_LENGTH = 500 + + module_function + + def normalize(value) + hash = value.nil? ? {} : value.to_h + raise ArgumentError, "Processor metadata cannot contain more than #{MAX_PAIRS} keys" if hash.size > MAX_PAIRS + + hash.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(key, raw_value), normalized| + normalized_key = key.to_s + validate_key!(normalized_key) + + normalized_value = structured?(raw_value) ? ActiveSupport::JSON.encode(raw_value) : raw_value.to_s + if normalized_value.length > MAX_VALUE_LENGTH + raise ArgumentError, "Processor metadata value for #{normalized_key.inspect} exceeds #{MAX_VALUE_LENGTH} characters" + end + + normalized[normalized_key] = normalized_value + end + rescue NoMethodError, TypeError + raise ArgumentError, "Processor metadata must be hash-like" + end + + def validate_key!(key) + if key.empty? || key.length > MAX_KEY_LENGTH || key.match?(/[\[\]]/) + raise ArgumentError, "Invalid processor metadata key: #{key.inspect}" + end + end + private_class_method :validate_key! + + def structured?(value) + value.is_a?(Hash) || value.is_a?(Array) + end + private_class_method :structured? + end +end diff --git a/lib/usage_credits/jobs/fulfillment_job.rb b/lib/usage_credits/jobs/fulfillment_job.rb index 8022256..0885356 100644 --- a/lib/usage_credits/jobs/fulfillment_job.rb +++ b/lib/usage_credits/jobs/fulfillment_job.rb @@ -17,7 +17,7 @@ def perform end Rails.logger.info "Completed processing #{count} fulfillments in #{formatted_time}" - rescue StandardError => e + rescue => e Rails.logger.error "Error processing credit fulfillments: #{e.message}" raise # Re-raise to trigger job retry end diff --git a/lib/usage_credits/models/allocation.rb b/lib/usage_credits/models/allocation.rb index 96ca4ed..2a246a6 100644 --- a/lib/usage_credits/models/allocation.rb +++ b/lib/usage_credits/models/allocation.rb @@ -5,29 +5,24 @@ module UsageCredits # to a *positive* (credit) transaction, indicating how many # credits were taken from that specific credit source. # - # Allocations are the basis for the bucket-based, FIFO-with-expiration inventory-like system - # This is critical for calculating balances when there are mixed expiring and non-expiring credits - # Otherwise, balance calculations will always be wrong because negative transactions get dragged forever - # More info: https://x.com/rameerez/status/1884246492837302759 - class Allocation < ApplicationRecord - self.table_name = "usage_credits_allocations" + # This class extends Wallets::Allocation with usage_credits table configuration. - belongs_to :spend_transaction, class_name: "UsageCredits::Transaction", foreign_key: "transaction_id" - belongs_to :source_transaction, class_name: "UsageCredits::Transaction" + class Allocation < Wallets::AllocationBase + # ========================================= + # Embeddability Configuration + # ========================================= - validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 } + self.embedded_table_name = "usage_credits_allocations" + self.config_provider = -> { UsageCredits.configuration } - validate :allocation_does_not_exceed_remaining_amount - - private - - def allocation_does_not_exceed_remaining_amount - return if amount.blank? || source_transaction.blank? - - if source_transaction.remaining_amount < amount - errors.add(:amount, "exceeds the remaining amount of the source transaction") - end - end + # ========================================= + # Re-declare Associations with Correct Classes + # ========================================= + belongs_to :spend_transaction, + class_name: "UsageCredits::Transaction", + foreign_key: "transaction_id", + optional: false + belongs_to :source_transaction, class_name: "UsageCredits::Transaction", optional: false end end diff --git a/lib/usage_credits/models/concerns/has_wallet.rb b/lib/usage_credits/models/concerns/has_wallet.rb index ea636c9..80cac08 100644 --- a/lib/usage_credits/models/concerns/has_wallet.rb +++ b/lib/usage_credits/models/concerns/has_wallet.rb @@ -6,11 +6,19 @@ module HasWallet extend ActiveSupport::Concern included do + # Filter to the default "credits" asset_code for backwards compatibility has_one :credit_wallet, - class_name: "UsageCredits::Wallet", - as: :owner, - dependent: :destroy - + -> { where(asset_code: UsageCredits::DEFAULT_ASSET_CODE) }, + class_name: "UsageCredits::Wallet", + as: :owner, + dependent: :destroy + + # NOTE on the alias dance below: `credits_wallet` and `wallet` are aliased + # to the raw has_one reader *before* `credit_wallet` is redefined to + # auto-create missing wallets (see `define_method(:credit_wallet)` further + # down). So `user.credit_wallet` auto-creates, while `user.wallet` / + # `user.credits_wallet` just read. This asymmetry is the pre-1.0 contract, + # kept as-is for backwards compatibility. alias_method :credits_wallet, :credit_wallet alias_method :wallet, :credit_wallet @@ -18,13 +26,13 @@ module HasWallet # More intuitive delegations delegate :credits, - :credit_history, - :has_enough_credits_to?, - :estimate_credits_to, - :spend_credits_on, - :give_credits, - to: :ensure_credit_wallet, - allow_nil: false # Never return nil for these methods + :credit_history, + :has_enough_credits_to?, + :estimate_credits_to, + :spend_credits_on, + :give_credits, + to: :ensure_credit_wallet, + allow_nil: false # Never return nil for these methods # Fix recursion by properly aliasing the original method alias_method :original_credit_wallet, :credit_wallet @@ -44,7 +52,6 @@ def credit_subscriptions .map { |f| UsageCredits.find_subscription_plan_by_processor_id(f.metadata["plan"]) } .compact end - end # Class methods added to the model @@ -52,15 +59,17 @@ def credit_subscriptions def has_credits(**options) include UsageCredits::HasWallet unless included_modules.include?(UsageCredits::HasWallet) - # Initialize class instance variable instead of class variable - @credit_options = options - - # Ensure wallet is created by default unless explicitly disabled - @credit_options[:auto_create] = true if @credit_options[:auto_create].nil? + # Each class owns an immutable options snapshot. Subclasses inherit it + # until they explicitly call `has_credits`, without sharing a mutable + # hash with their parent. + @credit_options = {auto_create: true}.merge(options.symbolize_keys).freeze end def credit_options - @credit_options ||= { auto_create: true } + return @credit_options if instance_variable_defined?(:@credit_options) + return superclass.credit_options if superclass.respond_to?(:credit_options) + + {auto_create: true} end end @@ -75,16 +84,30 @@ def should_create_wallet? end def ensure_credit_wallet - return original_credit_wallet if original_credit_wallet.present? - return unless should_create_wallet? - - if persisted? - build_credit_wallet( - balance: credit_options[:initial_balance] || 0 - ).tap(&:save!) - else - raise "Cannot create wallet for unsaved owner" + credit_wallet_association = association(:credit_wallet) + cached_wallet = credit_wallet_association.target if credit_wallet_association.loaded? + return cached_wallet if cached_wallet&.persisted? + + # With auto-creation disabled, retain the ordinary has_one reader and + # refresh a cached miss so a concurrently-created wallet is still seen. + # With it enabled, create_for_owner! is the one lookup/creation primitive: + # it returns an existing row or creates it race-safely. This avoids + # querying once through the association and then repeating the same + # owner/asset lookup in create_for_owner! on a cold cache. + unless should_create_wallet? + return original_credit_wallet unless credit_wallet_association.loaded? + return credit_wallet_association.reload.target end + raise "Cannot create wallet for unsaved owner" unless persisted? + + wallet = UsageCredits::Wallet.create_for_owner!( + owner: self, + asset_code: UsageCredits::DEFAULT_ASSET_CODE, + initial_balance: credit_options[:initial_balance] + ) + + self.credit_wallet = wallet + wallet end def create_credit_wallet diff --git a/lib/usage_credits/models/concerns/pay_charge_extension.rb b/lib/usage_credits/models/concerns/pay_charge_extension.rb index 52167f4..189c4d0 100644 --- a/lib/usage_credits/models/concerns/pay_charge_extension.rb +++ b/lib/usage_credits/models/concerns/pay_charge_extension.rb @@ -11,7 +11,7 @@ module PayChargeExtension included do after_initialize :init_metadata - after_commit :fulfill_credit_pack! + after_commit :fulfill_credit_pack!, on: [:create, :update] after_commit :handle_refund!, on: :update, if: :refund_needed? end @@ -40,9 +40,9 @@ def succeeded? return amount_captured == amount.to_i && amount.to_i.positive? end - # For non-Stripe charges, we assume Pay only creates charges after successful payment - # This is a reasonable assumption based on Pay gem's behavior - # TODO: Implement for more payment processors if needed + # Pay's non-Stripe adapters persist Pay::Charge rows only after their + # processor-specific success event (for example, Paddle Billing ignores + # every transaction whose status is not "completed"). true end @@ -95,6 +95,45 @@ def pack_identifier metadata["pack_name"] end + # Checkout metadata is the immutable commercial snapshot: it records what + # the customer bought at payment time. Runtime configuration may be edited + # or the pack may be removed before a delayed webhook/refund arrives, so it + # must only be a legacy fallback, never the source of truth for quantities. + def credit_pack_snapshot + pack_name = pack_identifier.to_s + pack = UsageCredits.find_pack(pack_name.to_sym) + + credits = strict_metadata_integer("credits", fallback: pack&.credits) + bonus_credits = strict_metadata_integer("bonus_credits", fallback: pack&.bonus_credits || 0) + return if credits.nil? || bonus_credits.nil? || !credits.positive? || bonus_credits.negative? + + snapshot = { + pack_name: pack_name, + credits: credits, + bonus_credits: bonus_credits, + total_credits: credits + bonus_credits, + price_cents: strict_metadata_integer("price_cents", fallback: pack&.price_cents), + price_currency: (metadata["price_currency"].presence || pack&.price_currency || currency).to_s.upcase + } + + if pack && (pack.credits != credits || pack.bonus_credits != bonus_credits) + Rails.logger.warn "Credit pack #{pack_name} changed after charge #{id}; honoring checkout snapshot (#{credits} + #{bonus_credits} credits)" + end + + snapshot + rescue ArgumentError => e + Rails.logger.error "Invalid credit pack metadata for charge #{id}: #{e.message}" + nil + end + + def strict_metadata_integer(key, fallback: nil) + value = metadata[key] + value = fallback if value.nil? || (value.respond_to?(:empty?) && value.empty?) + return nil if value.nil? + + Wallets::WholeNumber.parse(value, name: key, allow_string: true) + end + def credits_already_fulfilled? # First check if there's a fulfillment record for this charge return true if UsageCredits::Fulfillment.exists?(source: self) @@ -106,10 +145,10 @@ def credits_already_fulfilled? return false unless transactions.present? begin - adapter = ActiveRecord::Base.connection.adapter_name.downcase + adapter = transactions.connection.adapter_name.downcase if adapter.include?("postgres") # PostgreSQL supports the @> JSON containment operator. - transactions.exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json]) + transactions.exists?(["metadata @> ?", {purchase_charge_id: id, credits_fulfilled: true}.to_json]) elsif adapter.include?("mysql") # MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison transactions.exists?([ @@ -128,7 +167,11 @@ def credits_already_fulfilled? if tx.metadata.is_a?(Hash) tx.metadata else - JSON.parse(tx.metadata) rescue {} + begin + JSON.parse(tx.metadata) + rescue + {} + end end data["purchase_charge_id"].to_i == id.to_i && data["credits_fulfilled"].to_s == "true" end @@ -137,7 +180,7 @@ def credits_already_fulfilled? def fulfill_credit_pack! return unless is_credit_pack_purchase? - return unless pack_identifier + return unless pack_identifier.present? return unless has_valid_wallet? return unless succeeded? return if refunded? @@ -145,76 +188,79 @@ def fulfill_credit_pack! Rails.logger.info "Starting to process charge #{id} to fulfill credits" - pack_name = pack_identifier.to_sym - pack = UsageCredits.find_pack(pack_name) - - unless pack - Rails.logger.error "Credit pack not found: #{pack_name} for charge #{id}" + snapshot = credit_pack_snapshot + unless snapshot + Rails.logger.error "Credit pack snapshot is missing or invalid for charge #{id}" return end - # Validate that the pack details match if they're provided in metadata - if metadata["credits"].present? - expected_credits = metadata["credits"].to_i - if expected_credits != pack.credits - Rails.logger.error "Credit pack mismatch: expected #{expected_credits} credits but pack #{pack_name} provides #{pack.credits}" - return - end - end - begin + wallet = credit_wallet credit_transaction = nil - - # Wrap in transaction to ensure atomicity - if Fulfillment.create! fails, - # the credits should NOT be added. This is critical for money handling. - ActiveRecord::Base.transaction do - # Add credits to the user's wallet - credit_transaction = credit_wallet.add_credits( - pack.total_credits, - category: "credit_pack_purchase", - metadata: { + fulfillment = nil + fulfilled = false + + # The wallet lock serializes duplicate webhook deliveries for the same + # owner. Re-check idempotency inside that lock; the database's unique + # source index is the final guard against duplicate fulfillment rows. + wallet.with_lock do + next if UsageCredits::Fulfillment.exists?(source: self) + + fulfilled_at = Time.current + fulfillment = Fulfillment.create!( + wallet: wallet, + source: self, + fulfillment_type: "credit_pack", + credits_last_fulfillment: snapshot.fetch(:total_credits), + last_fulfilled_at: fulfilled_at, + next_fulfillment_at: nil, + metadata: snapshot.merge( purchase_charge_id: id, - purchased_at: created_at, - credits_fulfilled: true, - fulfilled_at: Time.current, - **pack.base_metadata - } + purchased_at: created_at + ) ) - # Also create a one-time fulfillment record for audit and consistency - # This Fulfillment record won't get picked up by the fulfillment job because `next_fulfillment_at` is nil - Fulfillment.create!( - wallet: credit_wallet, - source: self, # the Pay::Charge - fulfillment_type: "credit_pack", - credits_last_fulfillment: pack.total_credits, - last_fulfilled_at: Time.current, - next_fulfillment_at: nil, # so it doesn't get re-processed + credit_transaction = wallet.add_credits( + snapshot.fetch(:total_credits), + category: "credit_pack_purchase", + fulfillment: fulfillment, metadata: { purchase_charge_id: id, purchased_at: created_at, - **pack.base_metadata + credits_fulfilled: true, + fulfilled_at: fulfilled_at, + **snapshot } ) + fulfilled = true end + return unless fulfilled + # Dispatch credit_pack_purchased callback after successful fulfillment # Note: credits_added callback was already fired by add_credits - UsageCredits::Callbacks.dispatch(:credit_pack_purchased, - wallet: credit_wallet, - amount: pack.total_credits, - transaction: credit_transaction, - metadata: { - credit_pack_name: pack_name, - credit_pack: pack, - pay_charge: self, - price_cents: pack.price_cents - } - ) - - Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}" - rescue StandardError => e - Rails.logger.error "Failed to fulfill credit pack #{pack_name} for charge #{id}: #{e.message}" + ActiveRecord.after_all_transactions_commit do + UsageCredits::Callbacks.dispatch(:credit_pack_purchased, + wallet: wallet, + amount: snapshot.fetch(:total_credits), + transaction: credit_transaction, + metadata: { + credit_pack_name: snapshot.fetch(:pack_name).to_sym, + credit_pack: UsageCredits.find_pack(snapshot.fetch(:pack_name).to_sym), + pay_charge: self, + fulfillment: fulfillment, + price_cents: snapshot[:price_cents] + }) + end + + Rails.logger.info "Successfully fulfilled credit pack #{snapshot.fetch(:pack_name)} for charge #{id}" + rescue ActiveRecord::RecordNotUnique + # A concurrent delivery committed the unique source row first. Treat + # that committed fulfillment as the idempotent winner. + return if UsageCredits::Fulfillment.exists?(source: self) + raise + rescue => e + Rails.logger.error "Failed to fulfill credit pack #{pack_identifier} for charge #{id}: #{e.message}" raise end end @@ -227,29 +273,29 @@ def credits_previously_refunded # Try database-level filtering first (more efficient) begin - adapter = ActiveRecord::Base.connection.adapter_name.downcase - if adapter.include?("postgres") - # PostgreSQL supports the @> JSON containment operator - filtered = transactions.where( - "metadata @> ?", - { refunded_purchase_charge_id: id, credits_refunded: true }.to_json - ) - return filtered.sum { |tx| -tx.amount } - elsif adapter.include?("mysql") - # MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison - filtered = transactions.where( - "JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)", - id - ) - return filtered.sum { |tx| -tx.amount } - else - # SQLite: json_extract returns SQL values (true becomes 1) - filtered = transactions.where( - "json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?", - id, 1 - ) - return filtered.sum { |tx| -tx.amount } - end + adapter = transactions.connection.adapter_name.downcase + filtered = + if adapter.include?("postgres") + # PostgreSQL supports the @> JSON containment operator + transactions.where( + "metadata @> ?", + {refunded_purchase_charge_id: id, credits_refunded: true}.to_json + ) + elsif adapter.include?("mysql") + # MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison + transactions.where( + "JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)", + id + ) + else + # SQLite: json_extract returns SQL values (true becomes 1) + transactions.where( + "json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?", + id, 1 + ) + end + + return filtered.sum { |tx| -tx.amount } rescue ActiveRecord::StatementInvalid => e Rails.logger.warn "JSON query failed, falling back to Ruby filtering: #{e.message}" end @@ -257,7 +303,11 @@ def credits_previously_refunded # Fallback: filter in Ruby (for databases without JSON support) # Sum in a single pass to avoid multiple iterations transactions.sum do |tx| - data = tx.metadata.is_a?(Hash) ? tx.metadata : (JSON.parse(tx.metadata) rescue {}) + data = tx.metadata.is_a?(Hash) ? tx.metadata : begin + JSON.parse(tx.metadata) + rescue + {} + end if data["refunded_purchase_charge_id"].to_i == id.to_i && data["credits_refunded"].to_s == "true" -tx.amount else @@ -266,30 +316,29 @@ def credits_previously_refunded end end - def credits_already_refunded? - # Check if any refund was already processed for this charge - credits_previously_refunded > 0 - end - - def fully_refunded? - # Check if a full refund (100%) has already been processed - pack = UsageCredits.find_pack(pack_identifier&.to_sym) - return false unless pack - credits_previously_refunded >= pack.total_credits - end - def handle_refund! # Guard clauses for required data and state return unless refunded? - return unless pack_identifier + return unless pack_identifier.present? return unless has_valid_wallet? return unless amount.is_a?(Numeric) && amount.positive? - pack_name = pack_identifier.to_sym - pack = UsageCredits.find_pack(pack_name) + fulfillment = UsageCredits::Fulfillment.find_by(source: self) + # Processor metadata proves what a charge was intended to buy, not that + # the corresponding credits were ever issued. A refund can arrive for a + # charge whose fulfillment failed (or whose create webhook was never + # delivered); clawing that metadata snapshot back would manufacture + # credit debt for value the customer never received. Keep the legacy + # transaction lookup for pre-1.0 purchases that predate Fulfillment rows. + unless fulfillment || credits_already_fulfilled? + Rails.logger.error "Cannot refund credits for charge #{id}: no completed credit fulfillment exists" + return + end - unless pack - Rails.logger.error "Credit pack not found for refund: #{pack_name} for charge #{id}" + snapshot = fulfillment&.metadata&.symbolize_keys || credit_pack_snapshot + total_purchased_credits = fulfillment&.credits_last_fulfillment || snapshot&.fetch(:total_credits, nil) + unless snapshot && total_purchased_credits&.positive? + Rails.logger.error "Original credit pack fulfillment is missing for refund on charge #{id}" return end @@ -299,51 +348,56 @@ def handle_refund! return end - # Calculate total credits that SHOULD be refunded based on current refund amount - refund_ratio = amount_refunded.to_f / amount.to_f - total_credits_to_refund = (pack.total_credits * refund_ratio).ceil - - # Calculate credits already refunded (for incremental/partial refunds) - already_refunded = credits_previously_refunded - - # Only deduct the INCREMENTAL amount (difference between what should be refunded and what's already refunded) - credits_to_remove = total_credits_to_refund - already_refunded - - # Skip if nothing new to refund - if credits_to_remove <= 0 - Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)" - return - end - begin - Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (incremental from #{already_refunded} to #{total_credits_to_refund})" - - credit_wallet.deduct_credits( - credits_to_remove, - category: "credit_pack_refund", - metadata: { - refunded_purchase_charge_id: id, - credits_refunded: true, - refunded_at: Time.current, - refund_percentage: refund_ratio, - refund_amount_cents: amount_refunded, - incremental_credits: credits_to_remove, - total_credits_refunded: total_credits_to_refund, - **pack.base_metadata - } - ) + wallet = credit_wallet + refund_transaction = nil + + # Both the cumulative-refund query and the new debit live under the + # wallet lock. Concurrent partial/full refund webhooks therefore apply + # only the remaining delta, never the same clawback twice. + wallet.with_lock do + already_refunded = credits_previously_refunded + total_credits_to_refund = divide_rounding_up( + total_purchased_credits * amount_refunded.to_i, + amount.to_i + ) + credits_to_remove = total_credits_to_refund - already_refunded + + if credits_to_remove <= 0 + Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)" + next + end + + refund_ratio = amount_refunded.to_f / amount.to_f + Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (incremental from #{already_refunded} to #{total_credits_to_refund})" + + refund_transaction = wallet.send( + :deduct_refunded_credits, + credits_to_remove, + fulfillment: fulfillment, + metadata: snapshot.merge( + refunded_purchase_charge_id: id, + credits_refunded: true, + refunded_at: Time.current, + refund_percentage: refund_ratio, + refund_amount_cents: amount_refunded, + incremental_credits: credits_to_remove, + total_credits_refunded: total_credits_to_refund + ) + ) + end + + return unless refund_transaction Rails.logger.info "Successfully processed refund for charge #{id}" - rescue UsageCredits::InsufficientCredits => e - Rails.logger.error "Insufficient credits for refund on charge #{id}: #{e.message}" - # If negative balance not allowed and user has used credits, - # we'll let the error propagate - raise - rescue StandardError => e + rescue => e Rails.logger.error "Failed to process refund for charge #{id}: #{e.message}" raise end end + def divide_rounding_up(numerator, denominator) + numerator.div(denominator) + (numerator.remainder(denominator).zero? ? 0 : 1) + end end end diff --git a/lib/usage_credits/models/concerns/pay_subscription_extension.rb b/lib/usage_credits/models/concerns/pay_subscription_extension.rb index 352e926..b2e3fd9 100644 --- a/lib/usage_credits/models/concerns/pay_subscription_extension.rb +++ b/lib/usage_credits/models/concerns/pay_subscription_extension.rb @@ -31,13 +31,14 @@ module PaySubscriptionExtension included do # For initial setup and fulfillment, we can't do after_create or on: :create because the subscription first may # get created with status "incomplete" and only get updated to status "active" when the payment is cleared - after_commit :handle_initial_award_and_fulfillment_setup - - after_commit :update_fulfillment_on_renewal, if: :subscription_renewed? - after_commit :update_fulfillment_on_cancellation, if: :subscription_canceled? - after_commit :handle_plan_change_wrapper - - # TODO: handle paused subscriptions (may still have an "active" status?) + after_commit :handle_initial_award_and_fulfillment_setup, on: [:create, :update] + + after_commit :update_fulfillment_on_renewal, on: :update, if: :subscription_renewed? + after_commit :update_fulfillment_on_cancellation, on: :update, if: :subscription_canceled? + after_commit :handle_plan_change_wrapper, on: :update + after_commit :apply_deferred_plan_change_after_resume, + on: :update, + if: :usage_credit_resume_state_changed? end # Identify the usage_credits plan object @@ -47,11 +48,41 @@ def credit_subscription_plan end def provides_credits? - credit_subscription_plan.present? + subscription_terms.present? end def fulfillment_should_stop_at - (ends_at || current_period_end) + ends_at || current_period_end + end + + # Pay's processor-specific #active? implementation is the source of truth + # for grace periods and effective pauses. Pay::Subscription itself does not + # implement #paused?, however, so legacy/base-class rows need the equivalent + # lifecycle check without calling Pay's otherwise unsafe base #active?. + # In particular, Stripe keeps the raw status as "active" while a void pause + # is in effect. + def eligible_for_usage_credit_fulfillment?(include_trial: false) + processor_active = if status == "on_trial" + respond_to?(:on_trial?) && on_trial? + elsif respond_to?(:paused?) + active? + else + ["trialing", "active"].include?(status) && !ended? + end + + return false unless processor_active + return false if paused_for_usage_credits? + + include_trial || !trialing_for_credits? + end + + # Reconcile the initial/trial award state on demand. The recurring service + # uses this at lifecycle boundaries for processors (notably Braintree, + # whose status can remain `active` throughout a trial) and to finish a + # deferred plan change before any recurring credits can be minted. + def sync_usage_credit_fulfillment! + handle_initial_award_and_fulfillment_setup + apply_deferred_plan_change_after_resume end private @@ -64,16 +95,14 @@ def has_valid_wallet? end def credits_already_fulfilled? - # TODO: There's a race condition where Pay actually updates the subscription two times on initial creation, - # leading to us triggering handle_initial_award_and_fulfillment_setup twice too. - # Since no Fulfillment record has been created yet, both callbacks will try to create the same Fulfillment object - # at about the same time, thus making this check useless (there's nothing written to the DB yet) - # For now, we handle it by adding a validation to the Fulfillment model so that there's no two Fulfillment objects - # with the same source_id -- so whichever of the two callbacks gets processed first wins, the other just fails. - # That's how we prevent double credit awarding for now, but this race condition should be handled more elegantly. fulfillment = UsageCredits::Fulfillment.find_by(source: self) return false unless fulfillment + # Deferred terms own resume reconciliation. Treat the fulfillment as + # initialized until those terms are atomically installed so the generic + # reactivation path cannot mint an unintended extra first cycle. + return true if fulfillment.metadata.key?("deferred_plan_change") + # A stopped fulfillment (stops_at in the past) should NOT prevent reactivation # This handles: credit → non-credit → credit transitions (after stop date) return false if fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current @@ -82,7 +111,27 @@ def credits_already_fulfilled? # should also allow reactivation - user changed their mind before the stop took effect return false if fulfillment.metadata["stopped_reason"].present? - true + initial_award_completed?(fulfillment) + end + + def initial_award_completed?(fulfillment) + award_state = fulfillment.metadata["initial_award_state"] + + if trialing_for_credits? + # Any existing trial/active initial award makes a trial callback + # idempotent. Active subscriptions never move backwards into trial. + true + elsif status == "active" + return true if award_state == "active" + return false if award_state == "trial" || fulfillment.metadata["trial"] + + # Pre-1.0 fulfillments do not carry initial_award_state. Conservatively + # treat a non-trial legacy row as already active to avoid over-crediting + # existing customers during upgrade. + true + else + false + end end # Returns an existing fulfillment that is stopped or scheduled to stop @@ -102,7 +151,8 @@ def reactivatable_fulfillment end def subscription_renewed? - (saved_change_to_ends_at? || saved_change_to_current_period_end?) && status == "active" + (saved_change_to_ends_at? || saved_change_to_current_period_end?) && + eligible_for_usage_credit_fulfillment? end # This doesn't get called the exact moment the user cancels its subscription, but at the end of the period, @@ -114,7 +164,8 @@ def subscription_canceled? end def plan_changed? - return false unless saved_change_to_processor_plan? && status == "active" + return false unless saved_change_to_processor_plan? + return false unless eligible_for_usage_credit_fulfillment? || paused_for_usage_credits? # The old plan ID must be present (not nil) - otherwise this is initial subscription creation # not a plan change. Initial subscription is handled by handle_initial_award_and_fulfillment_setup. @@ -125,7 +176,8 @@ def plan_changed? # If old plan wasn't a credit plan (not in config), then handle_initial_award_and_fulfillment_setup # will handle the "fresh start" case - we don't want to double-award credits. old_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(old_plan_id) - return false unless old_plan.present? + fulfillment = UsageCredits::Fulfillment.find_by(source: self) + return false unless old_plan.present? || fulfillment.present? # At this point, old plan provided credits. We handle: # - Credit → Credit (upgrade/downgrade) @@ -139,69 +191,119 @@ def plan_changed? # Immediate awarding of first cycle + set up Fulfillment object for subsequent periods def handle_initial_award_and_fulfillment_setup - return unless provides_credits? + plan = subscription_terms + return unless plan return unless has_valid_wallet? - # We only do immediate awarding if the subscription is trialing or active - return unless ["trialing", "active"].include?(status) - - # Check if we need to reactivate a stopped fulfillment (credit → non-credit → credit scenario) - existing_reactivatable_fulfillment = reactivatable_fulfillment - is_reactivation = existing_reactivatable_fulfillment.present? + # Pay normalizes trials and pauses differently per processor. Trust its + # active predicate instead of the raw status, which remains "active" for + # an effective Stripe void pause. + return unless eligible_for_usage_credit_fulfillment?(include_trial: true) # Skip if we already have an ACTIVE fulfillment record return if credits_already_fulfilled? - plan = credit_subscription_plan wallet = customer.owner.credit_wallet # Calculate credit expiration using the shared helper credits_expire_at = calculate_credit_expiration(plan, current_period_start) - Rails.logger.info "Fulfilling #{is_reactivation ? 'reactivation' : 'initial'} credits for subscription #{id}" - Rails.logger.info " Status: #{status}" - Rails.logger.info " Plan: #{plan}" - # Variables to track for callback dispatch after transaction commits total_credits_awarded = 0 last_credit_transaction = nil + is_reactivation = false # Transaction for atomic awarding + fulfillment creation/reactivation # Callback is dispatched AFTER this block to ensure credits are persisted - ActiveRecord::Base.transaction do - transaction_ids = [] - - # 1) If this is a trial and not an active subscription: award trial credits, if any - if status == "trialing" && plan.trial_credits.positive? + self.class.transaction do + # Lock order for every subscription mutation is subscription → + # fulfillment → wallet. Serializing on the Pay row closes duplicate + # webhook races and prevents lock-order deadlocks with recurring jobs. + next unless lock_current_subscription_version + next unless eligible_for_usage_credit_fulfillment?(include_trial: true) + existing_fulfillment = UsageCredits::Fulfillment.lock.find_by(source: self) + wallet.lock! + next if existing_fulfillment&.metadata&.key?("deferred_plan_change") + next if existing_fulfillment && initial_award_completed?(existing_fulfillment) && !reactivatable_record?(existing_fulfillment) + + is_reactivation = existing_fulfillment.present? && reactivatable_record?(existing_fulfillment) + is_trial_activation = existing_fulfillment.present? && status == "active" && !trialing_for_credits? && !is_reactivation + + Rails.logger.info "Fulfilling #{is_reactivation ? "reactivation" : "initial"} credits for subscription #{id}" + Rails.logger.info " Status: #{status}" + Rails.logger.info " Plan: #{plan}" + + # Create or reactivate the fulfillment before minting so every ledger + # row is linked at INSERT time. The enclosing transaction keeps the + # temporary zero amount invisible and rolls everything back together. + fulfilled_at = Time.current + next_fulfillment_at = next_subscription_fulfillment_at(plan) + award_state = trialing_for_credits? ? "trial" : "active" + + fulfillment_record = if is_reactivation || is_trial_activation + existing_fulfillment.tap do |record| + record.update!( + credits_last_fulfillment: 0, + fulfillment_period: plan.fulfillment_period_display, + last_fulfilled_at: fulfilled_at, + next_fulfillment_at: next_fulfillment_at, + stops_at: fulfillment_should_stop_at, + metadata: record.metadata + .except( + "trial", "stopped_reason", "stopped_at", "stopped_plan", + "pending_plan_change", "pending_plan_snapshot", "plan_change_at" + ) + .merge(plan_snapshot_metadata(plan)) + .merge( + "subscription_id" => id, + "initial_award_state" => award_state, + (is_reactivation ? "reactivated_at" : "activated_at") => fulfilled_at + ) + ) + end + else + UsageCredits::Fulfillment.create!( + wallet: wallet, + source: self, + fulfillment_type: "subscription", + credits_last_fulfillment: 0, + fulfillment_period: plan.fulfillment_period_display, + last_fulfilled_at: fulfilled_at, + next_fulfillment_at: next_fulfillment_at, + stops_at: fulfillment_should_stop_at, + metadata: { + "subscription_id" => id, + "initial_award_state" => award_state, + "trial" => trialing_for_credits? + }.merge(plan_snapshot_metadata(plan)) + ) + end - # Immediate awarding of trial credits + # If this is a trial and not an active subscription, award trial credits. + if trialing_for_credits? && plan.trial_credits.positive? last_credit_transaction = wallet.add_credits(plan.trial_credits, category: "subscription_trial", expires_at: trial_ends_at, + fulfillment: fulfillment_record, metadata: { subscription_id: id, reason: is_reactivation ? "reactivation_trial_credits" : "initial_trial_credits", plan: processor_plan, - fulfilled_at: Time.current - } - ) - transaction_ids << last_credit_transaction.id + fulfilled_at: fulfilled_at + }) total_credits_awarded += plan.trial_credits - elsif status == "active" - # Awarding of signup bonus, if any (only on initial setup, not reactivation) if plan.signup_bonus_credits.positive? && !is_reactivation bonus_transaction = wallet.add_credits(plan.signup_bonus_credits, category: "subscription_signup_bonus", + fulfillment: fulfillment_record, metadata: { subscription_id: id, reason: "signup_bonus", plan: processor_plan, - fulfilled_at: Time.current - } - ) - transaction_ids << bonus_transaction.id + fulfilled_at: fulfilled_at + }) total_credits_awarded += plan.signup_bonus_credits last_credit_transaction = bonus_transaction end @@ -210,110 +312,74 @@ def handle_initial_award_and_fulfillment_setup if plan.credits_per_period.positive? credits_transaction = wallet.add_credits(plan.credits_per_period, category: "subscription_credits", - expires_at: credits_expire_at, # This will be nil if credit rollover is enabled + expires_at: credits_expire_at, + fulfillment: fulfillment_record, metadata: { subscription_id: id, reason: is_reactivation ? "reactivation" : "first_cycle", plan: processor_plan, - fulfilled_at: Time.current - } - ) - transaction_ids << credits_transaction.id + fulfilled_at: fulfilled_at + }) total_credits_awarded += plan.credits_per_period last_credit_transaction = credits_transaction end end - # 2) Create or reactivate Fulfillment record for subsequent awarding - # Use current_period_start as the base time, falling back to Time.current - period_start = if trial_ends_at && status == "trialing" - trial_ends_at - else - current_period_start || Time.current - end - - # Ensure next_fulfillment_at is in the future - next_fulfillment_at = period_start + plan.parsed_fulfillment_period - next_fulfillment_at = Time.current + plan.parsed_fulfillment_period if next_fulfillment_at <= Time.current - - if is_reactivation - # Reactivate the existing stopped/scheduled-to-stop fulfillment - # Merge metadata to preserve any custom keys while updating core fields - # Use string keys consistently to avoid duplicates after JSON serialization - existing_reactivatable_fulfillment.update!( - credits_last_fulfillment: total_credits_awarded, - fulfillment_period: plan.fulfillment_period_display, - last_fulfilled_at: Time.current, - next_fulfillment_at: next_fulfillment_at, - stops_at: fulfillment_should_stop_at, - metadata: existing_reactivatable_fulfillment.metadata - .except("stopped_reason", "stopped_at", "pending_plan_change", "plan_change_at") - .merge( - "subscription_id" => id, - "plan" => processor_plan, - "reactivated_at" => Time.current - ) - ) - - Rails.logger.info "Reactivated fulfillment #{existing_reactivatable_fulfillment.id} for subscription #{id}" - else - # Create new fulfillment - # Use string keys consistently to avoid duplicates after JSON serialization - UsageCredits::Fulfillment.create!( - wallet: wallet, - source: self, - fulfillment_type: "subscription", - credits_last_fulfillment: total_credits_awarded, - fulfillment_period: plan.fulfillment_period_display, - last_fulfilled_at: Time.current, - next_fulfillment_at: next_fulfillment_at, - stops_at: fulfillment_should_stop_at, # Pre-emptively set when the fulfillment will stop, in case we miss a future event (like sub cancellation) - metadata: { - "subscription_id" => id, - "plan" => processor_plan, - } - ) - - Rails.logger.info "Initial fulfillment for subscription #{id} finished" - end - - # Link created transactions to the fulfillment object for traceability - fulfillment_record = UsageCredits::Fulfillment.find_by(source: self) - UsageCredits::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment_record&.id) if transaction_ids.any? + fulfillment_record.update!(credits_last_fulfillment: total_credits_awarded) + Rails.logger.info "Fulfillment #{fulfillment_record.id} updated for subscription #{id}" end # Dispatch callback AFTER transaction commits - ensures credits are persisted if total_credits_awarded > 0 - UsageCredits::Callbacks.dispatch(:subscription_credits_awarded, - wallet: wallet, - amount: total_credits_awarded, - transaction: last_credit_transaction, - metadata: { - subscription_plan_name: plan.name, - subscription: plan, - pay_subscription: self, - fulfillment_period: plan.fulfillment_period_display, - is_reactivation: is_reactivation, - status: status - } - ) + ActiveRecord.after_all_transactions_commit do + UsageCredits::Callbacks.dispatch(:subscription_credits_awarded, + wallet: wallet, + amount: total_credits_awarded, + transaction: last_credit_transaction, + metadata: { + subscription_plan_name: plan.name, + subscription: plan.callback_plan, + pay_subscription: self, + fulfillment_period: plan.fulfillment_period_display, + is_reactivation: is_reactivation, + status: status + }) + end end - rescue => e Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}" raise end + def reactivatable_record?(fulfillment) + (fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current) || + fulfillment.metadata["stopped_reason"].present? + end + + def next_subscription_fulfillment_at(plan) + # Trial credits hand off at the actual processor trial boundary. Once + # active, credit cadence is intentionally independent of billing cadence + # (for example a monthly charge can grant credits daily). + trial_boundary = trial_ends_at || current_period_end + return trial_boundary if trialing_for_credits? && trial_boundary.present? && trial_boundary > Time.current + + period_start = [current_period_start || Time.current, Time.current].max + candidate = period_start + plan.parsed_fulfillment_period + (candidate > Time.current) ? candidate : Time.current + plan.parsed_fulfillment_period + end + # Handle subscription renewal (we received a new payment for another billing period) # Each time the subscription renews and ends_at moves forward, # we keep awarding credits because Fulfillment#stops_at also moves forward def update_fulfillment_on_renewal - return unless provides_credits? && has_valid_wallet? + return unless has_valid_wallet? fulfillment = UsageCredits::Fulfillment.find_by(source: self) return unless fulfillment - ActiveRecord::Base.transaction do + self.class.transaction do + next unless lock_current_subscription_version + fulfillment.lock! # Check if there's a pending plan change to apply if fulfillment.metadata["pending_plan_change"].present? apply_pending_plan_change(fulfillment) @@ -324,32 +390,53 @@ def update_fulfillment_on_renewal Rails.logger.info "Fulfillment #{fulfillment.id} stops_at updated to #{fulfillment.stops_at}" rescue => e Rails.logger.error "Failed to extend fulfillment period for subscription #{id}: #{e.message}" - raise ActiveRecord::Rollback + raise end end - # If the subscription is canceled, let's set the Fulfillment's stops_at so that the job won't keep awarding def update_fulfillment_on_cancellation - plan = credit_subscription_plan - return unless plan && has_valid_wallet? + return unless has_valid_wallet? fulfillment = UsageCredits::Fulfillment.find_by(source: self) return unless fulfillment - ActiveRecord::Base.transaction do + self.class.transaction do + next unless lock_current_subscription_version + fulfillment.lock! + wallet = fulfillment.wallet + wallet.lock! + + active_plan_id = fulfillment.metadata["plan"] + configured_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(active_plan_id) + terms = terms_from_fulfillment( + fulfillment, + expected_plan_id: active_plan_id, + configured_plan: configured_plan + ) || UsageCredits::SubscriptionTerms.from_plan(configured_plan, processor_plan_id: active_plan_id) + # Subscription cancelled, so stop awarding credits in the future - fulfillment.update!(stops_at: fulfillment_should_stop_at) + fulfillment_attributes = {stops_at: fulfillment_should_stop_at} + if terms&.expire_credits_on_cancel + expires_at = cancellation_credit_expiration_at(terms) + wallet.expire_fulfillment_credits!(fulfillment: fulfillment, expires_at: expires_at) + fulfillment_attributes[:metadata] = fulfillment.metadata.merge( + "cancellation_credit_expiration_at" => expires_at, + "cancellation_credit_expiration_applied_at" => Time.current + ) + end + + fulfillment.update!(fulfillment_attributes) Rails.logger.info "Fulfillment #{fulfillment.id} stops_at set to #{fulfillment.stops_at} due to cancellation" rescue => e Rails.logger.error "Failed to stop credit fulfillment for subscription #{id}: #{e.message}" - raise ActiveRecord::Rollback + raise end + end - # TODO: we can also expire already awarded credits here (without making the ledger mutable – we'll need to - # check if the plan expires credits or not, and if rollover we may need to add a negative transaction to offset - # the remaining balance) - + def cancellation_credit_expiration_at(terms) + effective_cancellation_at = fulfillment_should_stop_at || Time.current + effective_cancellation_at + terms.credit_expiration_period_seconds.seconds end # Wrapper to check condition and call handle_plan_change @@ -389,28 +476,41 @@ def handle_plan_change Rails.logger.info " Looking up current plan: #{current_plan_id}" Rails.logger.info " Looking up new plan: #{new_plan_id}" - current_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(current_plan_id) + current_plan = terms_from_fulfillment(fulfillment, expected_plan_id: current_plan_id) || + terms_from_config(current_plan_id) new_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(new_plan_id) Rails.logger.info " Current plan found: #{current_plan&.name} (#{current_plan&.credits_per_period} credits)" Rails.logger.info " New plan found: #{new_plan&.name} (#{new_plan&.credits_per_period} credits)" + # A processor can change plans while service is paused. Persist the new + # immutable terms, but never mint an upgrade until service resumes. A + # later resume callback atomically installs the deferred terms; the next + # normal fulfillment then uses them. + if paused_for_usage_credits? + defer_plan_change_until_resume(fulfillment, new_plan) + return + end + # Handle downgrade to a non-credit plan: schedule fulfillment stop for end of period - if new_plan.nil? && current_plan.present? + if new_plan.nil? && current_plan_id.present? handle_downgrade_to_non_credit_plan(fulfillment) return end return unless new_plan # Neither current nor new plan provides credits - nothing to do - ActiveRecord::Base.transaction do + self.class.transaction do + next unless lock_current_subscription_version + next unless eligible_for_usage_credit_fulfillment? + fulfillment.lock! # FIRST: Check if returning to current plan (canceling a pending change) # This must come first! Returning to current plan = no credits, just clear pending # This matches Stripe's billing: no new charge means no new credits if current_plan_id == new_plan_id Rails.logger.info " Action: Returning to current plan (clearing pending change)" clear_pending_plan_change(fulfillment) - return + next end # Now compare credits to determine upgrade vs downgrade @@ -430,12 +530,12 @@ def handle_plan_change else # Same credits amount, different plan - update metadata immediately Rails.logger.info " Action: Same credits, different plan - updating metadata only" - update_fulfillment_plan_metadata(fulfillment, new_plan_id) + update_fulfillment_plan_metadata(fulfillment, new_plan) end rescue => e Rails.logger.error "Failed to handle plan change for subscription #{id}: #{e.message}" Rails.logger.error e.backtrace.join("\n") - raise ActiveRecord::Rollback + raise end Rails.logger.info " Plan change completed successfully" @@ -451,9 +551,9 @@ def handle_plan_upgrade(new_plan, fulfillment) Rails.logger.info " [UPGRADE] New plan period: #{new_plan.fulfillment_period_display}" # Calculate expiration using shared helper (uses current_period_end for upgrades) - credits_expire_at = calculate_credit_expiration(new_plan, current_period_end) + credits_expire_at = calculate_credit_expiration(new_plan, Time.current) - Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || 'never (rollover enabled)'}" + Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || "never (rollover enabled)"}" # Calculate next fulfillment time based on the NEW plan's period # This ensures the fulfillment schedule matches the new plan's cadence @@ -463,13 +563,14 @@ def handle_plan_upgrade(new_plan, fulfillment) # The callback should only fire after ALL operations succeed upgrade_transaction = nil - ActiveRecord::Base.transaction do + fulfillment.class.transaction do # Grant full new plan credits immediately # Use string keys consistently to avoid duplicates after JSON serialization upgrade_transaction = wallet.add_credits( new_plan.credits_per_period, category: "subscription_upgrade", expires_at: credits_expire_at, + fulfillment: fulfillment, metadata: { "subscription_id" => id, "plan" => processor_plan, @@ -489,27 +590,30 @@ def handle_plan_upgrade(new_plan, fulfillment) # to ensure future fulfillments happen on the correct schedule # Use string keys consistently to avoid duplicates after JSON serialization fulfillment.update!( + credits_last_fulfillment: new_plan.credits_per_period, + last_fulfilled_at: Time.current, fulfillment_period: new_plan.fulfillment_period_display, next_fulfillment_at: next_fulfillment_at, metadata: fulfillment.metadata - .except("pending_plan_change", "plan_change_at") - .merge("plan" => processor_plan) + .except("pending_plan_change", "pending_plan_snapshot", "plan_change_at") + .merge(plan_snapshot_metadata(new_plan)) ) end # Dispatch callback AFTER transaction commits - ensures credits are persisted - UsageCredits::Callbacks.dispatch(:subscription_credits_awarded, - wallet: wallet, - amount: new_plan.credits_per_period, - transaction: upgrade_transaction, - metadata: { - subscription_plan_name: new_plan.name, - subscription: new_plan, - pay_subscription: self, - fulfillment_period: new_plan.fulfillment_period_display, - reason: "plan_upgrade" - } - ) + ActiveRecord.after_all_transactions_commit do + UsageCredits::Callbacks.dispatch(:subscription_credits_awarded, + wallet: wallet, + amount: new_plan.credits_per_period, + transaction: upgrade_transaction, + metadata: { + subscription_plan_name: new_plan.name, + subscription: new_plan, + pay_subscription: self, + fulfillment_period: new_plan.fulfillment_period_display, + reason: "plan_upgrade" + }) + end Rails.logger.info " [UPGRADE] Credits awarded successfully" Rails.logger.info " [UPGRADE] New balance: #{wallet.reload.balance}" @@ -529,6 +633,7 @@ def handle_plan_downgrade(new_plan, fulfillment) fulfillment.update!( metadata: fulfillment.metadata.merge( "pending_plan_change" => processor_plan, + "pending_plan_snapshot" => plan_snapshot_metadata(new_plan), "plan_change_at" => schedule_time ) ) @@ -543,13 +648,16 @@ def handle_downgrade_to_non_credit_plan(fulfillment) # Ensure schedule_time is never in the past schedule_time = [current_period_end || Time.current, Time.current].max - ActiveRecord::Base.transaction do + self.class.transaction do + next unless lock_current_subscription_version + fulfillment.lock! # Use string keys consistently to avoid duplicates after JSON serialization fulfillment.update!( stops_at: schedule_time, metadata: fulfillment.metadata.merge( "stopped_reason" => "downgrade_to_non_credit_plan", - "stopped_at" => schedule_time + "stopped_at" => schedule_time, + "stopped_plan" => processor_plan ) ) @@ -557,15 +665,21 @@ def handle_downgrade_to_non_credit_plan(fulfillment) rescue => e Rails.logger.error "Failed to handle downgrade to non-credit plan for subscription #{id}: #{e.message}" Rails.logger.error e.backtrace.join("\n") - raise ActiveRecord::Rollback + raise end end - def update_fulfillment_plan_metadata(fulfillment, new_plan_id) - # Use string keys consistently to avoid duplicates after JSON serialization - fulfillment.update!( - metadata: fulfillment.metadata.merge("plan" => new_plan_id) - ) + def update_fulfillment_plan_metadata(fulfillment, new_plan) + attributes = { + metadata: fulfillment.metadata.merge(plan_snapshot_metadata(new_plan)) + } + + if fulfillment.fulfillment_period != new_plan.fulfillment_period_display + attributes[:fulfillment_period] = new_plan.fulfillment_period_display + attributes[:next_fulfillment_at] = Time.current + new_plan.parsed_fulfillment_period + end + + fulfillment.update!(attributes) end # Clear any pending plan change metadata @@ -574,32 +688,108 @@ def clear_pending_plan_change(fulfillment) return unless fulfillment.metadata["pending_plan_change"].present? fulfillment.update!( - metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at") + metadata: fulfillment.metadata.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at") ) Rails.logger.info "Subscription #{id} pending plan change cleared (returned to current plan)" end + def defer_plan_change_until_resume(fulfillment, new_plan) + self.class.transaction do + next unless lock_current_subscription_version + next unless paused_for_usage_credits? + fulfillment.lock! + + if new_plan.nil? + fulfillment.update!( + stops_at: Time.current, + metadata: fulfillment.metadata + .except("deferred_plan_change", "deferred_plan_snapshot") + .merge( + "stopped_reason" => "paused_change_to_non_credit_plan", + "stopped_at" => Time.current, + "stopped_plan" => processor_plan + ) + ) + elsif fulfillment.metadata["plan"] == processor_plan && !reactivatable_record?(fulfillment) + fulfillment.update!( + metadata: fulfillment.metadata.except("deferred_plan_change", "deferred_plan_snapshot") + ) + else + fulfillment.update!( + metadata: fulfillment.metadata.merge( + "deferred_plan_change" => processor_plan, + "deferred_plan_snapshot" => plan_snapshot_metadata(new_plan) + ) + ) + end + end + end + + def apply_deferred_plan_change_after_resume + return unless eligible_for_usage_credit_fulfillment? + + fulfillment = UsageCredits::Fulfillment.find_by(source: self) + return unless fulfillment&.metadata&.key?("deferred_plan_change") + + self.class.transaction do + next unless lock_current_subscription_version + next unless eligible_for_usage_credit_fulfillment? + fulfillment.lock! + + plan_id = fulfillment.metadata["deferred_plan_change"] + snapshot = fulfillment.metadata["deferred_plan_snapshot"] + unless plan_id.to_s == processor_plan.to_s && snapshot&.fetch("plan", nil).to_s == plan_id.to_s + raise UsageCredits::InvalidOperation, + "Deferred plan terms for Pay::Subscription #{id} do not match its current processor plan" + end + + fulfillment.update!( + stops_at: fulfillment_should_stop_at, + fulfillment_period: snapshot.fetch("fulfillment_period"), + metadata: fulfillment.metadata + .except( + "deferred_plan_change", + "deferred_plan_snapshot", + "pending_plan_change", + "pending_plan_snapshot", + "plan_change_at", + "stopped_reason", + "stopped_at", + "stopped_plan" + ) + .merge(snapshot) + ) + end + end + def apply_pending_plan_change(fulfillment) pending_plan = fulfillment.metadata["pending_plan_change"] + pending_snapshot = fulfillment.metadata["pending_plan_snapshot"] + pending_snapshot = nil unless pending_snapshot&.fetch("plan", nil) == pending_plan + configured_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(pending_plan) # Validate that the pending plan still exists in configuration # This handles the edge case where an admin removes a plan after a user scheduled a downgrade - unless UsageCredits.configuration.find_subscription_plan_by_processor_id(pending_plan) + unless configured_plan || pending_snapshot.present? Rails.logger.error "Cannot apply pending plan change for subscription #{id}: plan '#{pending_plan}' not found in configuration" # Clear the invalid pending change to prevent repeated failures fulfillment.update!( - metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at") + metadata: fulfillment.metadata.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at") ) return end - # Update to the new plan and clear the pending change - # Use string keys consistently to avoid duplicates after JSON serialization + snapshot = pending_snapshot.presence || plan_snapshot_metadata(configured_plan, plan_id: pending_plan) + period = snapshot.fetch("fulfillment_period") + + # Update all cadence and quantity fields, not only the display metadata. fulfillment.update!( + fulfillment_period: period, + next_fulfillment_at: Time.current + UsageCredits::PeriodParser.parse_persisted_period(period), metadata: fulfillment.metadata - .except("pending_plan_change", "plan_change_at") - .merge("plan" => pending_plan) + .except("pending_plan_change", "pending_plan_snapshot", "plan_change_at") + .merge(snapshot) ) Rails.logger.info "Applied pending plan change for subscription #{id}: now on #{pending_plan}" @@ -631,5 +821,131 @@ def calculate_credit_expiration(plan, base_time = nil) effective_base + fulfillment_period + effective_grace end + def lock_current_subscription_version + current = self.class.lock.find_by(id: id) + return false unless current + + %i[ + updated_at status processor_plan current_period_start current_period_end + trial_ends_at ends_at pause_starts_at pause_behavior pause_resumes_at metadata + ].all? do |attribute| + !has_attribute?(attribute) || database_equivalent_attribute?( + attribute, + current.public_send(attribute), + public_send(attribute) + ) + end + end + + # Processor timestamps can carry nanoseconds while Rails' timestamp + # columns persist microseconds. Comparing the raw callback object against + # the locked row would therefore reject the exact state that was just + # saved on adapters/platforms that leave the extra nanoseconds in memory. + # Preserve strict comparisons for every non-temporal value, and compare + # temporal values at the column's effective database precision. + def database_equivalent_attribute?(attribute, persisted_value, callback_value) + return true if persisted_value == callback_value + + type = self.class.type_for_attribute(attribute.to_s) + return false unless [:datetime, :time].include?(type.type) + return false if persisted_value.nil? || callback_value.nil? + + column_precision = self.class.columns_hash.fetch(attribute.to_s).precision + precision = (column_precision || 6).clamp(0, 9) + scale = 10**precision + + (persisted_value.to_r * scale).floor == (callback_value.to_r * scale).floor + end + + # Deferred terms only need callback-time reconciliation when a processor + # lifecycle field may have moved a subscription out of a pause. The + # recurring service still calls the reconciliation method directly as a + # recovery path, so unrelated subscription updates can skip its lookup. + def usage_credit_resume_state_changed? + %w[status pause_behavior pause_starts_at pause_resumes_at].any? do |attribute| + has_attribute?(attribute) && saved_change_to_attribute?(attribute) + end + end + + def paused_for_usage_credits? + return true if status == "paused" + + pause_started = respond_to?(:pause_starts_at) && pause_starts_at.present? && pause_starts_at <= Time.current + return true if pause_started + + respond_to?(:paused?) && paused? && + (!respond_to?(:on_grace_period?) || !on_grace_period?) + end + + def trialing_for_credits? + return true if ["on_trial", "trialing"].include?(status) + + transitioned_to_active = saved_change_to_status? && ["on_trial", "trialing"].include?(status_before_last_save) + return false if transitioned_to_active + return on_trial? if respond_to?(:on_trial?) + + false + end + + def plan_snapshot_metadata(plan, plan_id: processor_plan) + cancellation_expiration_seconds = + if plan.respond_to?(:credit_expiration_period_seconds) + plan.credit_expiration_period_seconds + else + plan.credit_expiration_period&.to_i || 0 + end + + { + "plan" => plan_id, + "plan_name" => plan.name, + "credits_per_period" => plan.credits_per_period, + "signup_bonus_credits" => plan.signup_bonus_credits, + "trial_credits" => plan.trial_credits, + "fulfillment_period" => plan.fulfillment_period_display, + "rollover_enabled" => plan.rollover_enabled, + "expire_credits_on_cancel" => plan.expire_credits_on_cancel, + "credit_expiration_period" => cancellation_expiration_seconds + } + end + + def subscription_terms + configured_plan = credit_subscription_plan + fulfillment = UsageCredits::Fulfillment.find_by(source: self) + + terms = terms_from_fulfillment(fulfillment, expected_plan_id: processor_plan, configured_plan: configured_plan) + return terms if terms + + data = (metadata || {}).with_indifferent_access + metadata_plan = data[:processor_plan].presence + if data[:purchase_type] == "credit_subscription" && (metadata_plan.nil? || metadata_plan.to_s == processor_plan.to_s) + terms = terms_from_metadata(data, configured_plan: configured_plan) + return terms if terms + end + + UsageCredits::SubscriptionTerms.from_plan(configured_plan, processor_plan_id: processor_plan) + end + + def terms_from_fulfillment(fulfillment, expected_plan_id:, configured_plan: nil) + return unless fulfillment&.fulfillment_type == "subscription" + return unless fulfillment.metadata["plan"].to_s == expected_plan_id.to_s + + terms_from_metadata(fulfillment.metadata, configured_plan: configured_plan, processor_plan_id: expected_plan_id) + end + + def terms_from_config(plan_id) + plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(plan_id) + UsageCredits::SubscriptionTerms.from_plan(plan, processor_plan_id: plan_id) + end + + def terms_from_metadata(data, configured_plan:, processor_plan_id: processor_plan) + UsageCredits::SubscriptionTerms.from_metadata( + data, + processor_plan_id: processor_plan_id, + configured_plan: configured_plan + ) + rescue ArgumentError => e + Rails.logger.error "Invalid subscription terms for Pay::Subscription #{id}: #{e.message}" + nil + end end end diff --git a/lib/usage_credits/models/credit_pack.rb b/lib/usage_credits/models/credit_pack.rb index b0f6048..30dc176 100644 --- a/lib/usage_credits/models/credit_pack.rb +++ b/lib/usage_credits/models/credit_pack.rb @@ -9,11 +9,10 @@ module UsageCredits # # @see PayChargeExtension for the actual payment processing, credit pack fulfilling and refund handling class CreditPack - attr_reader :name, - :credits, :bonus_credits, - :price_cents, :price_currency, - :metadata + :credits, :bonus_credits, + :price_cents, :price_currency, + :metadata def initialize(name) @name = name @@ -30,17 +29,17 @@ def initialize(name) # Set the base number of credits def gives(amount) - @credits = amount.to_i + @credits = normalize_whole_number!(amount, "Credits", minimum: 1) end # Set bonus credits (e.g., for promotions) def bonus(amount) - @bonus_credits = amount.to_i + @bonus_credits = normalize_whole_number!(amount, "Bonus credits", minimum: 0) end # Set the price in cents (e.g., 4900 for $49.00) def costs(cents) - @price_cents = cents + @price_cents = normalize_whole_number!(cents, "Price cents", minimum: 1) end alias_method :cost, :costs @@ -48,7 +47,7 @@ def costs(cents) def currency(currency) currency = currency.to_s.downcase.to_sym unless UsageCredits::Configuration::VALID_CURRENCIES.include?(currency) - raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(', ')}" + raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(", ")}" end @price_currency = currency.to_s.upcase end @@ -158,20 +157,22 @@ def create_checkout_session(user, **options) raise ArgumentError, "User must have a payment processor" unless user.respond_to?(:payment_processor) && user.payment_processor # Merge custom metadata with base_metadata (base_metadata takes precedence for critical fields) - custom_metadata = options.delete(:metadata) || {} - merged_metadata = custom_metadata.merge(base_metadata) + custom_metadata = merge_hash_options!(options, :metadata) + merged_metadata = UsageCredits::ProcessorMetadata.normalize(custom_metadata.merge(base_metadata)) # Handle payment_intent_data specially to preserve metadata # We dup to avoid mutating the caller's original hash - custom_payment_intent_data = (options.delete(:payment_intent_data) || {}).dup - custom_pi_metadata = custom_payment_intent_data.delete(:metadata) || {} + custom_payment_intent_data = merge_hash_options!(options, :payment_intent_data) + custom_pi_metadata = merge_hash_options!(custom_payment_intent_data, :metadata) merged_payment_intent_data = custom_payment_intent_data.merge( - metadata: custom_pi_metadata.merge(base_metadata) + metadata: UsageCredits::ProcessorMetadata.normalize(custom_pi_metadata.merge(base_metadata)) ) # Remove protected parameters that could break credit fulfillment options.delete(:mode) + options.delete("mode") options.delete(:line_items) + options.delete("line_items") user.payment_processor.checkout( mode: "payment", @@ -202,5 +203,25 @@ def base_metadata price_currency: price_currency } end + + private + + def merge_hash_options!(options, key) + values = [options.delete(key.to_s), options.delete(key)].compact + values.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |value, merged| + unless value.respond_to?(:to_h) + raise ArgumentError, "#{key} must be hash-like" + end + + merged.merge!(value.to_h.with_indifferent_access) + end + end + + def normalize_whole_number!(amount, name, minimum:) + amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed) + value = Wallets::WholeNumber.parse(amount, name: name) + raise ArgumentError, "#{name} must be at least #{minimum}" if value < minimum + value + end end end diff --git a/lib/usage_credits/models/credit_subscription_plan.rb b/lib/usage_credits/models/credit_subscription_plan.rb index 4306c93..585c0c6 100644 --- a/lib/usage_credits/models/credit_subscription_plan.rb +++ b/lib/usage_credits/models/credit_subscription_plan.rb @@ -10,12 +10,12 @@ module UsageCredits # @see PaySubscriptionExtension for the actual credit fulfillment logic class CreditSubscriptionPlan attr_reader :name, - :processor_plan_ids, - :fulfillment_period, :credits_per_period, - :signup_bonus_credits, :trial_credits, - :rollover_enabled, - :expire_credits_on_cancel, :credit_expiration_period, - :metadata + :processor_plan_ids, + :fulfillment_period, :credits_per_period, + :signup_bonus_credits, :trial_credits, + :rollover_enabled, + :expire_credits_on_cancel, :credit_expiration_period, + :metadata attr_writer :fulfillment_period @@ -41,23 +41,23 @@ def initialize(name) # Set base credits given each period def gives(amount) if amount.is_a?(UsageCredits::Cost::Fixed) - @credits_per_period = amount.amount + @credits_per_period = normalize_whole_number!(amount.amount, "Credits per period", minimum: 0) @fulfillment_period = UsageCredits::PeriodParser.normalize_period(amount.period || 1.month) self else - @credits_per_period = amount.to_i + @credits_per_period = normalize_whole_number!(amount, "Credits per period", minimum: 0) CreditGiver.new(self) end end # One-time signup bonus credits def signup_bonus(amount) - @signup_bonus_credits = amount.to_i + @signup_bonus_credits = normalize_whole_number!(amount, "Signup bonus credits", minimum: 0) end # Credits given during trial period def trial_includes(amount) - @trial_credits = amount.to_i + @trial_credits = normalize_whole_number!(amount, "Trial credits", minimum: 0) end # Configure whether unused credits roll over between periods @@ -75,6 +75,12 @@ def unused_credits(behavior) # @param duration [ActiveSupport::Duration, nil] Grace period before credits expire # @return [void] def expire_after(duration) + unless duration.nil? + seconds = duration.is_a?(ActiveSupport::Duration) ? duration.value : duration + seconds = Wallets::WholeNumber.parse(seconds, name: "Credit expiration period") + raise ArgumentError, "Credit expiration period cannot be negative" if seconds.negative? + end + @expire_credits_on_cancel = true @credit_expiration_period = duration end @@ -170,7 +176,7 @@ def stripe_prices ids = plan_id_for(:stripe) return {} if ids.nil? return ids if ids.is_a?(Hash) - { default: ids } # Wrap single ID in hash for consistency + {default: ids} # Wrap single ID in hash for consistency end # Check if this plan matches a given processor price ID @@ -180,7 +186,7 @@ def stripe_prices def matches_processor_id?(processor_id) processor_plan_ids.values.any? do |ids| if ids.is_a?(Hash) - ids.values.include?(processor_id) + ids.value?(processor_id) else ids == processor_id end @@ -202,15 +208,18 @@ def matches_processor_id?(processor_id) # plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :year) def create_checkout_session(user, success_url:, cancel_url:, processor: :stripe, period: nil) raise ArgumentError, "User must respond to payment_processor" unless user.respond_to?(:payment_processor) + raise ArgumentError, "User must have a payment processor" unless user.payment_processor raise ArgumentError, "No fulfillment period configured for plan: #{name}" unless fulfillment_period + processor = processor.to_sym + plan_ids = plan_id_for(processor) raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless plan_ids # Determine which price ID to use plan_id = if plan_ids.is_a?(Hash) # Multi-period plan: period is required - raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(', ')}). Please specify period: parameter (e.g., period: :month)" if period.nil? + raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(", ")}). Please specify period: parameter (e.g., period: :month)" if period.nil? plan_ids[period.to_sym] || raise(ArgumentError, "Period #{period.inspect} not found. Available periods: #{plan_ids.keys.inspect}") else # Single-price plan: use the ID directly @@ -251,6 +260,10 @@ def parsed_fulfillment_period end def create_stripe_checkout_session(user, plan_id, success_url, cancel_url) + processor_metadata = UsageCredits::ProcessorMetadata.normalize( + base_metadata.merge(processor_plan: plan_id) + ) + user.payment_processor.checkout( mode: "subscription", line_items: [{ @@ -259,7 +272,7 @@ def create_stripe_checkout_session(user, plan_id, success_url, cancel_url) }], success_url: success_url, cancel_url: cancel_url, - subscription_data: { metadata: base_metadata } + subscription_data: {metadata: processor_metadata} ) end @@ -278,6 +291,15 @@ def base_metadata } end + private + + def normalize_whole_number!(amount, name, minimum:) + amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed) + value = Wallets::WholeNumber.parse(amount, name: name) + raise ArgumentError, "#{name} must be at least #{minimum}" if value < minimum + value + end + # ========================================= # DSL Helper Classes # ========================================= @@ -300,6 +322,5 @@ def every(period) @plan end end - end end diff --git a/lib/usage_credits/models/fulfillment.rb b/lib/usage_credits/models/fulfillment.rb index 7dff734..ffecad5 100644 --- a/lib/usage_credits/models/fulfillment.rb +++ b/lib/usage_credits/models/fulfillment.rb @@ -5,51 +5,38 @@ module UsageCredits # including credit pack purchases and subscriptions. # Some of this credit-giving actions are repeating in nature (i.e.: subscriptions), some are not (one-time purchases) class Fulfillment < ApplicationRecord + include Wallets::HasMetadata + self.table_name = "usage_credits_fulfillments" belongs_to :wallet belongs_to :source, polymorphic: true, optional: true validates :wallet, presence: true - validates :source_id, uniqueness: { scope: :source_type }, if: :source_id? - validates :credits_last_fulfillment, presence: true, numericality: { only_integer: true } - validates :fulfillment_type, presence: true + validates :source_id, uniqueness: {scope: :source_type}, if: :source_id? + validates :source_type, presence: true, if: :source_id? + validates :source_id, presence: true, if: :source_type? + validates :credits_last_fulfillment, + presence: true, + numericality: {only_integer: true, greater_than_or_equal_to: 0} + validates :fulfillment_type, + presence: true, + inclusion: {in: %w[subscription credit_pack manual]} validate :valid_fulfillment_period_format, if: :fulfillment_period? - validates :next_fulfillment_at, comparison: { greater_than: :last_fulfilled_at }, + validates :next_fulfillment_at, comparison: {greater_than: :last_fulfilled_at}, if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? } - # ========================================= - # Metadata Handling - # ========================================= - - # Sync in-place modifications to metadata before saving - before_save :sync_metadata_cache - - # Get metadata with indifferent access (string/symbol keys) - # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults) - def metadata - @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) - end - - # Set metadata, ensuring consistent storage format - def metadata=(hash) - @indifferent_metadata = nil # Clear cache - super(hash.is_a?(Hash) ? hash.to_h : {}) - end - - # Clear metadata cache on reload to ensure fresh data from database - def reload(*) - @indifferent_metadata = nil - super - end - # Only get fulfillments that are due AND not stopped scope :due_for_fulfillment, -> { - where("next_fulfillment_at <= ?", Time.current) - .where("stops_at IS NULL OR stops_at > ?", Time.current) - .where("last_fulfilled_at IS NULL OR next_fulfillment_at > last_fulfilled_at") + table = arel_table + where(table[:next_fulfillment_at].lteq(Time.current)) + .where(table[:stops_at].eq(nil).or(table[:stops_at].gt(Time.current))) + .where(table[:last_fulfilled_at].eq(nil).or(table[:next_fulfillment_at].gt(table[:last_fulfilled_at]))) + } + scope :active, -> { + table = arel_table + where(table[:stops_at].eq(nil).or(table[:stops_at].gt(Time.current))) } - scope :active, -> { where("stops_at IS NULL OR stops_at > ?", Time.current) } # Alias for backward compatibility - will be removed in next version scope :pending, -> { due_for_fulfillment } @@ -83,26 +70,15 @@ def calculate_next_fulfillment # we use current time as the base to avoid scheduling multiple rapid fulfillments. # This ensures smooth recovery from missed fulfillments by scheduling the next one # from the current time rather than the missed fulfillment time. - base_time = next_fulfillment_at > Time.current ? next_fulfillment_at : Time.current + base_time = (next_fulfillment_at > Time.current) ? next_fulfillment_at : Time.current - base_time + UsageCredits::PeriodParser.parse_period(fulfillment_period) + base_time + UsageCredits::PeriodParser.parse_persisted_period(fulfillment_period) end private - # Sync in-place modifications to the cached metadata back to the attribute - # This ensures changes like `metadata["key"] = "value"` are persisted on save - # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults) - def sync_metadata_cache - if @indifferent_metadata - write_attribute(:metadata, @indifferent_metadata.to_h) - elsif read_attribute(:metadata).nil? - write_attribute(:metadata, {}) - end - end - def valid_fulfillment_period_format - unless UsageCredits::PeriodParser.valid_period_format?(fulfillment_period) + unless UsageCredits::PeriodParser.valid_persisted_period_format?(fulfillment_period) errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units") end end @@ -122,6 +98,5 @@ def validate_fulfillment_schedule errors.add(:next_fulfillment_at, "should be nil for non-recurring fulfillments") unless new_record? end end - end end diff --git a/lib/usage_credits/models/operation.rb b/lib/usage_credits/models/operation.rb index 92c026c..469c1ea 100644 --- a/lib/usage_credits/models/operation.rb +++ b/lib/usage_credits/models/operation.rb @@ -3,11 +3,10 @@ module UsageCredits # A DSL to define an operation that consumes credits when performed. class Operation - attr_reader :name, # Operation identifier (e.g., :process_video) - :cost_calculator, # Lambda or Fixed that calculates credit cost - :validation_rules, # Array of [condition, message] pairs - :metadata # Custom data for your app's use + :cost_calculator, # Lambda or Fixed that calculates credit cost + :validation_rules, # Array of [condition, message] pairs + :metadata # Custom data for your app's use def initialize(name, &block) @name = name @@ -61,16 +60,9 @@ def calculate_cost(params = {}) normalized_params = normalize_params(params) validate!(normalized_params) # Ensure params are valid before calculating - # Calculate raw cost - total = case cost_calculator - when Proc - result = cost_calculator.call(normalized_params) - raise ArgumentError, "Credit amount must be a whole number (got: #{result})" unless result == result.to_i - raise ArgumentError, "Credit amount cannot be negative (got: #{result})" if result.negative? - result - else - cost_calculator.calculate(normalized_params) - end + # `costs` wraps every configured value (including a Proc) in a Cost + # object, so there is one calculation/validation path here. + total = cost_calculator.calculate(normalized_params) # Apply configured rounding strategy CreditCalculator.apply_rounding(total) @@ -92,7 +84,7 @@ def validate!(params = {}) begin result = condition.call(normalized) raise InvalidOperation, message unless result - rescue StandardError => e + rescue => e raise InvalidOperation, "Validation error: #{e.message}" end end @@ -103,10 +95,12 @@ def validate!(params = {}) # ========================================= # Create an audit record of this operation - def to_audit_hash(params = {}) + def to_audit_hash(params = nil, cost: nil, **keyword_params) + params = (params || {}).merge(keyword_params) + { operation: name, - cost: calculate_cost(params), + cost: cost.nil? ? calculate_cost(params) : cost, params: params, metadata: metadata, executed_at: Time.current, @@ -128,19 +122,23 @@ def normalize_params(params) # Handle different size specifications size = if params[:mb] - params[:mb].to_f - elsif params[:size_mb] - params[:size_mb].to_f - elsif params[:size_megabytes] - params[:size_megabytes].to_f - elsif params[:size] - params[:size].to_f / 1.megabyte - else - 0.0 - end + normalize_quantity(params[:mb], :mb) + elsif params[:kb] + normalize_quantity(params[:kb], :kb) / 1024.0 + elsif params[:gb] + normalize_quantity(params[:gb], :gb) * 1024.0 + elsif params[:size_mb] + normalize_quantity(params[:size_mb], :size_mb) + elsif params[:size_megabytes] + normalize_quantity(params[:size_megabytes], :size_megabytes) + elsif params[:size] + normalize_quantity(params[:size], :size) / 1.megabyte + else + 0.0 + end # Handle generic unit-based operations - units = params[:units].to_f if params[:units] + units = normalize_quantity(params[:units], :units) if params[:units] params.merge( size: (size * 1.megabyte).to_i, # Raw bytes @@ -149,5 +147,15 @@ def normalize_params(params) ) end + def normalize_quantity(value, name) + number = Float(value) + unless number.finite? && !number.negative? + raise ArgumentError, "#{name} must be a finite, non-negative number" + end + + number + rescue ArgumentError, TypeError + raise ArgumentError, "#{name} must be a finite, non-negative number" + end end end diff --git a/lib/usage_credits/models/transaction.rb b/lib/usage_credits/models/transaction.rb index 4c752e9..d62aa9b 100644 --- a/lib/usage_credits/models/transaction.rb +++ b/lib/usage_credits/models/transaction.rb @@ -3,146 +3,88 @@ module UsageCredits # Records all credit changes in a wallet (additions, deductions, expirations). # - # Each transaction represents a single credit operation and includes: - # - amount: How many credits (positive for additions, negative for deductions) - # - category: What kind of operation (subscription fulfillment, pack purchase, etc) - # - metadata: Additional details about the operation - # - expires_at: When these credits expire (optional) - class Transaction < ApplicationRecord - self.table_name = "usage_credits_transactions" + # This class extends Wallets::Transaction with usage_credits-specific features: + # - Fulfillment tracking for subscription/pack credits + # - Usage-credits specific transaction categories + # - Operation charge descriptions and formatting + + class Transaction < Wallets::TransactionBase + # ========================================= + # Embeddability Configuration + # ========================================= + + self.embedded_table_name = "usage_credits_transactions" + self.config_provider = -> { UsageCredits.configuration } # ========================================= # Transaction Categories # ========================================= - # Default transaction types, grouped by purpose: + # Override base categories with usage_credits-specific ones DEFAULT_CATEGORIES = [ # Bonus credits - "signup_bonus", # Initial signup bonus - "referral_bonus", # Referral reward bonus - "bonus", # Generic bonus + "signup_bonus", + "referral_bonus", + "bonus", # Subscription-related - "subscription_credits", # Generic subscription credits - "subscription_trial", # Trial period credits - "subscription_signup_bonus", # Bonus for subscribing - "subscription_upgrade", # Plan upgrade credits + "subscription_credits", + "subscription_trial", + "subscription_signup_bonus", + "subscription_upgrade", # One-time purchases - "credit_pack", # Generic credit pack - "credit_pack_purchase", # Credit pack bought - "credit_pack_refund", # Credit pack refunded + "credit_pack", + "credit_pack_purchase", + "credit_pack_refund", # Credit usage & management - "operation_charge", # Credits spent on operation - "manual_adjustment", # Manual admin adjustment - "credit_added", # Generic addition - "credit_deducted" # Generic deduction + "operation_charge", + "manual_adjustment", + "credit_added", + "credit_deducted", + + # Transfer categories (from wallets) + "transfer_in", + "transfer_out" ].freeze - # All valid categories: defaults + any custom categories added via config - # @return [Array] Combined list of valid category names + # Kept as an immutable compatibility snapshot for callers that referenced + # the historical constant. Runtime validation uses .categories so configured + # additions remain dynamic without mutating this public constant. + CATEGORIES = DEFAULT_CATEGORIES + def self.categories - (DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq + (DEFAULT_CATEGORIES + resolved_config.additional_categories).uniq end - # Backwards compatibility: CATEGORIES constant still works - # but prefer using Transaction.categories for dynamic lookup - CATEGORIES = DEFAULT_CATEGORIES - # ========================================= - # Associations & Validations + # Additional Associations # ========================================= - belongs_to :wallet - - belongs_to :fulfillment, optional: true + belongs_to :wallet, class_name: "UsageCredits::Wallet", inverse_of: :transactions, optional: false + belongs_to :transfer, class_name: "UsageCredits::Transfer", optional: true + belongs_to :fulfillment, class_name: "UsageCredits::Fulfillment", optional: true + # Re-declare allocation associations with correct classes has_many :outgoing_allocations, - class_name: "UsageCredits::Allocation", - foreign_key: :transaction_id, - dependent: :destroy + class_name: "UsageCredits::Allocation", + foreign_key: :transaction_id, + dependent: :destroy has_many :incoming_allocations, - class_name: "UsageCredits::Allocation", - foreign_key: :source_transaction_id, - dependent: :destroy - - validates :amount, presence: true, numericality: { only_integer: true } - validates :category, presence: true, inclusion: { in: ->(record) { Transaction.categories } } - - validate :remaining_amount_cannot_be_negative + class_name: "UsageCredits::Allocation", + foreign_key: :source_transaction_id, + dependent: :destroy # ========================================= - # Scopes + # Backwards Compatibility Scopes # ========================================= - scope :credits_added, -> { where("amount > 0") } - scope :credits_deducted, -> { where("amount < 0") } - scope :by_category, ->(category) { where(category: category) } - scope :recent, -> { order(created_at: :desc) } + scope :credits_added, -> { where(arel_table[:amount].gt(0)) } + scope :credits_deducted, -> { where(arel_table[:amount].lt(0)) } scope :operation_charges, -> { where(category: :operation_charge) } - # A transaction is not expired if: - # 1. It has no expiration date, OR - # 2. Its expiration date is in the future - scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } - scope :expired, -> { where("expires_at < ?", Time.current) } - - - # ========================================= - # Helpers - # ========================================= - - # Get the owner of the wallet these credits belong to - def owner - wallet.owner - end - - # Have these credits expired? - def expired? - expires_at.present? && expires_at < Time.current - end - - # Is this transaction a positive credit or a negative (spend)? - def credit? - amount > 0 - end - - def debit? - amount < 0 - end - - # How many credits from this transaction have already been allocated (spent)? - # Only applies if this transaction is positive. - def allocated_amount - incoming_allocations.sum(:amount) - end - - # How many credits remain unused in this positive transaction? - # If negative, this will effectively be 0. - def remaining_amount - return 0 unless credit? - amount - allocated_amount - end - - # ========================================= - # Balance After Transaction - # ========================================= - - # Get the balance after this transaction was applied - # Returns nil for transactions created before this feature was added - def balance_after - metadata[:balance_after] - end - - # Get the balance before this transaction was applied - # Returns the stored value if available, otherwise nil - # Note: For transactions created before this feature, returns nil - def balance_before - metadata[:balance_before] - end - # ========================================= # Display Formatting # ========================================= @@ -154,7 +96,6 @@ def formatted_amount end # Format the balance after for display (e.g., "500 credits") - # Returns nil if balance_after is not stored def formatted_balance_after return nil unless balance_after UsageCredits.configuration.credit_formatter.call(balance_after) @@ -162,54 +103,13 @@ def formatted_balance_after # Get a human-readable description of what this transaction represents def description - # Custom description takes precedence return self[:description] if self[:description].present? - - # Operation charges have dynamic descriptions return operation_description if category == "operation_charge" - - # Use predefined description or fallback to titleized category category.titleize end - # ========================================= - # Metadata Handling - # ========================================= - - # Sync in-place modifications to metadata before saving - before_save :sync_metadata_cache - - # Get metadata with indifferent access (string/symbol keys) - # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults) - def metadata - @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) - end - - # Set metadata, ensuring consistent storage format - def metadata=(hash) - @indifferent_metadata = nil # Clear cache - super(hash.is_a?(Hash) ? hash.to_h : {}) - end - - # Clear metadata cache on reload to ensure fresh data from database - def reload(*) - @indifferent_metadata = nil - super - end - private - # Sync in-place modifications to the cached metadata back to the attribute - # This ensures changes like `metadata["key"] = "value"` are persisted on save - # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults) - def sync_metadata_cache - if @indifferent_metadata - write_attribute(:metadata, @indifferent_metadata.to_h) - elsif read_attribute(:metadata).nil? - write_attribute(:metadata, {}) - end - end - # Format operation charge descriptions (e.g., "Process Video (-10 credits)") def operation_description operation = metadata["operation"]&.to_s&.titleize @@ -220,12 +120,5 @@ def operation_description "#{operation} (-#{cost} credits)" end - - def remaining_amount_cannot_be_negative - if credit? && remaining_amount < 0 - errors.add(:base, "Allocated amount exceeds transaction amount") - end - end - end end diff --git a/lib/usage_credits/models/transfer.rb b/lib/usage_credits/models/transfer.rb new file mode 100644 index 0000000..deaf16a --- /dev/null +++ b/lib/usage_credits/models/transfer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module UsageCredits + # A transfer records an internal movement of credits between two wallets. + # The actual balance impact lives in the linked transactions on each side + # so the transaction history remains explicit. + # + # This class extends Wallets::Transfer with usage_credits table configuration. + + class Transfer < Wallets::TransferBase + # ========================================= + # Embeddability Configuration + # ========================================= + + self.embedded_table_name = "usage_credits_transfers" + self.config_provider = -> { UsageCredits.configuration } + self.transaction_class_name = "UsageCredits::Transaction" + + # ========================================= + # Re-declare Associations with Correct Classes + # ========================================= + + belongs_to :from_wallet, + class_name: "UsageCredits::Wallet", + inverse_of: :outgoing_transfers, + optional: false + belongs_to :to_wallet, + class_name: "UsageCredits::Wallet", + inverse_of: :incoming_transfers, + optional: false + has_many :transactions, + class_name: "UsageCredits::Transaction", + foreign_key: :transfer_id, + inverse_of: :transfer, + dependent: :nullify + end +end diff --git a/lib/usage_credits/models/wallet.rb b/lib/usage_credits/models/wallet.rb index aa79b47..d361b63 100644 --- a/lib/usage_credits/models/wallet.rb +++ b/lib/usage_credits/models/wallet.rb @@ -3,106 +3,118 @@ module UsageCredits # A Wallet manages credit balance and transactions for a user/owner. # - # It's responsible for: - # 1. Tracking credit balance - # 2. Performing credit operations (add/deduct) - # 3. Managing credit expiration - # 4. Handling low balance alerts - - class Wallet < ApplicationRecord - self.table_name = "usage_credits_wallets" + # This class extends Wallets::Wallet with usage_credits-specific features: + # - Operation-based spending (spend_credits_on) + # - Human-friendly API (give_credits, credits, credit_history) + # - Fulfillment tracking for subscriptions and credit packs + # - Usage-credits specific callbacks (credits_added, credits_deducted, etc.) + class Wallet < Wallets::WalletBase # ========================================= - # Associations & Validations + # Embeddability Configuration # ========================================= - belongs_to :owner, polymorphic: true - has_many :transactions, class_name: "UsageCredits::Transaction", dependent: :destroy - has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy - has_many :outbound_allocations, through: :transactions, source: :outgoing_allocations - has_many :inbound_allocations, through: :transactions, source: :incoming_allocations - has_many :allocations, ->(wallet) { unscope(:where).where("usage_credits_allocations.transaction_id IN (?) OR usage_credits_allocations.source_transaction_id IN (?)", wallet.transaction_ids, wallet.transaction_ids) }, class_name: "UsageCredits::Allocation", dependent: :destroy - - validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance? + self.embedded_table_name = "usage_credits_wallets" + self.config_provider = -> { UsageCredits.configuration } + self.callbacks_module = UsageCredits::Callbacks + self.transaction_class_name = "UsageCredits::Transaction" + self.allocation_class_name = "UsageCredits::Allocation" + self.transfer_class_name = "UsageCredits::Transfer" + self.additional_transaction_attribute_names = %i[fulfillment].freeze + + # Map base wallet events to usage_credits-specific event names + self.callback_event_map = { + credited: :credits_added, + debited: :credits_deducted, + insufficient: :insufficient_credits, + low_balance: :low_balance_reached, + depleted: :balance_depleted, + transfer_completed: nil + }.freeze # ========================================= - # Metadata Handling + # Re-declare Associations with Correct Classes # ========================================= - # Sync in-place modifications to metadata before saving - before_save :sync_metadata_cache - - # Get metadata with indifferent access (string/symbol keys) - # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults) - def metadata - @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) - end + # Override parent associations to use UsageCredits classes + has_many :transactions, + class_name: "UsageCredits::Transaction", + inverse_of: :wallet, + dependent: :destroy + has_many :outgoing_transfers, + class_name: "UsageCredits::Transfer", + foreign_key: :from_wallet_id, + dependent: :destroy, + inverse_of: :from_wallet + has_many :incoming_transfers, + class_name: "UsageCredits::Transfer", + foreign_key: :to_wallet_id, + dependent: :destroy, + inverse_of: :to_wallet + + # UsageCredits-specific associations + has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy - # Set metadata, ensuring consistent storage format - def metadata=(hash) - @indifferent_metadata = nil # Clear cache - super(hash.is_a?(Hash) ? hash.to_h : {}) - end + class << self + private - # Clear metadata cache on reload to ensure fresh data from database - def reload(*) - @indifferent_metadata = nil - super + def initial_balance_credit_attributes + { + category: :manual_adjustment, + metadata: {reason: "initial_balance"} + } + end end # ========================================= - # Credit Balance & History + # Backwards Compatibility API # ========================================= - # Get current credit balance + # Get current credit balance (alias for balance) # - # The first naive approach was to compute this as a sum of all non-expired transactions like: - # transactions.not_expired.sum(:amount) - # but that fails when we mix expiring and non-expiring credits: https://x.com/rameerez/status/1884246492837302759 - # - # So we needed to introduce the Allocation model - # - # Now to calculate current balance, instead of summing: - # we sum only unexpired positive transactions’ remaining_amount + # usage_credits historically floors negative balances to zero even when + # allow_negative_balance is enabled. Keep that contract for backwards + # compatibility, even though the shared wallets core can represent unbacked + # negative debits explicitly. def credits - # Sum the leftover in all *positive* transactions that haven't expired - transactions - .where("amount > 0") - .where("expires_at IS NULL OR expires_at > ?", Time.current) - .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM usage_credits_allocations WHERE source_transaction_id = usage_credits_transactions.id)") - .yield_self { |sum| [sum, 0].max }.to_i + balance end - # Get transaction history (oldest first) + def current_balance + # Wallets::Wallet#balance delegates dynamically to current_balance, so + # this override is the active public balance implementation (not a dead + # helper). Refund debits remain explicit ledger debt while the Usage + # Credits compatibility view stays floored at zero. + refund_debits = transactions.debits.where(category: "credit_pack_refund") + [positive_remaining_balance - unbacked_negative_balance(refund_debits), 0].max + end + + # Get transaction history (oldest first) - alias for history def credit_history - transactions.order(created_at: :asc) + history end # ========================================= - # Credit Operations + # Credit Operations (High-Level API) # ========================================= # Check if wallet has enough credits for an operation def has_enough_credits_to?(operation_name, **params) - operation = find_and_validate_operation(operation_name, params) - - # Then check if we actually have enough credits + operation = find_operation(operation_name) credits >= operation.calculate_cost(params) - rescue InvalidOperation => e - raise e - rescue StandardError => e + rescue InvalidOperation + raise + rescue => e raise InvalidOperation, "Error checking credits: #{e.message}" end # Calculate how many credits an operation would cost def estimate_credits_to(operation_name, **params) - operation = find_and_validate_operation(operation_name, params) - - # Then calculate the cost + operation = find_operation(operation_name) operation.calculate_cost(params) - rescue InvalidOperation => e - raise e - rescue StandardError => e + rescue InvalidOperation + raise + rescue => e raise InvalidOperation, "Error estimating cost: #{e.message}" end @@ -111,74 +123,59 @@ def estimate_credits_to(operation_name, **params) # @param params [Hash] Parameters for the operation # @yield Optional block that must succeed before credits are deducted def spend_credits_on(operation_name, **params) - operation = find_and_validate_operation(operation_name, params) - + operation = find_operation(operation_name) cost = operation.calculate_cost(params) - # Check if user has enough credits - unless has_enough_credits_to?(operation_name, **params) - # Fire insufficient_credits callback before raising - UsageCredits::Callbacks.dispatch(:insufficient_credits, - wallet: self, - amount: cost, - operation_name: operation_name, - metadata: { - available: credits, - required: cost, - params: params - } - ) - raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" - end - # Create audit trail - # Stringify keys from audit_data to avoid duplicate key warnings in JSON - audit_data = operation.to_audit_hash(params).deep_stringify_keys + audit_data = operation.to_audit_hash(params, cost: cost).deep_stringify_keys deduct_params = { - metadata: audit_data.merge(operation.metadata.deep_stringify_keys).merge( - "executed_at" => Time.current, - "gem_version" => UsageCredits::VERSION - ), + metadata: audit_data, category: :operation_charge } - if block_given? - # If block given, only deduct credits if it succeeds - ActiveRecord::Base.transaction do - lock! # Row-level lock for concurrency safety + # The affordability check and the protected operation must happen while + # holding the same wallet lock. Otherwise another request can consume the + # balance after the pre-check, causing this block's side effects to run + # even though its eventual debit fails. + with_lock do + available = credits + if cost > available + UsageCredits::Callbacks.dispatch(:insufficient_credits, + wallet: self, + amount: cost, + operation_name: operation_name, + metadata: { + available: available, + required: cost, + params: params + }) + raise InsufficientCredits, "Insufficient credits (#{available} < #{cost})" + end - yield # Perform the operation first + yield if block_given? - deduct_credits(cost, **deduct_params) # Deduct credits only if the block was successful - end - else - deduct_credits(cost, **deduct_params) + # Free operations are a supported part of the DSL. There is no valid + # zero-amount ledger transaction to record, so execute the block and + # return nil without calling the strictly-positive debit primitive. + cost.zero? ? nil : deduct_credits(cost, **deduct_params) end - rescue StandardError => e - raise e end # Give credits to the wallet with optional reason and expiration date # @param amount [Integer] Number of credits to give - # @param reason [String, nil] Optional reason for giving credits (for auditing / trail purposes) + # @param reason [String, nil] Optional reason for giving credits # @param expires_at [DateTime, nil] Optional expiration date for the credits def give_credits(amount, reason: nil, expires_at: nil) - raise ArgumentError, "Amount is required" if amount.nil? - raise ArgumentError, "Cannot give negative credits" if amount.to_i.negative? - raise ArgumentError, "Credit amount must be a whole number" unless amount == amount.to_i - raise ArgumentError, "Expiration date must be a valid datetime" if expires_at && !expires_at.respond_to?(:to_datetime) - raise ArgumentError, "Expiration date must be in the future" if expires_at && expires_at <= Time.current - category = case reason&.to_s - when "signup" then :signup_bonus - when "referral" then :referral_bonus - when /bonus/i then :bonus - else :manual_adjustment - end + when "signup" then :signup_bonus + when "referral" then :referral_bonus + when /bonus/i then :bonus + else :manual_adjustment + end add_credits( - amount.to_i, - metadata: { reason: reason }, + amount, + metadata: {reason: reason}, category: category, expires_at: expires_at ) @@ -188,185 +185,89 @@ def give_credits(amount, reason: nil, expires_at: nil) # Credit Management (Internal API) # ========================================= - # Add credits to the wallet (internal method) + # Add credits to the wallet (wraps parent's credit method) + # Maintains backwards compatibility with fulfillment parameter def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil) - with_lock do - amount = amount.to_i - raise ArgumentError, "Cannot add non-positive credits" if amount <= 0 - - previous_balance = credits # Capture BEFORE creating transaction - - transaction = transactions.create!( - amount: amount, - category: category, - expires_at: expires_at, - metadata: metadata, - fulfillment: fulfillment - ) - - # Sync the wallet's `balance` column - self.balance = credits - save! - - # Store balance information in transaction metadata for audit trail. - # Note: This update! is in the same DB transaction as the create! above (via with_lock), - # so if this fails, the entire transaction rolls back - no orphaned records possible. - # We intentionally overwrite any user-supplied balance_before/balance_after keys - # to ensure system-set values are authoritative. - transaction.update!(metadata: transaction.metadata.merge( - balance_before: previous_balance, - balance_after: balance - )) - - # Dispatch callback with full context - UsageCredits::Callbacks.dispatch(:credits_added, - wallet: self, - amount: amount, - category: category, - transaction: transaction, - previous_balance: previous_balance, - new_balance: balance, - metadata: metadata - ) - - # To finish, let's return the transaction that has been just created so we can reference it in parts of the code - # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension - # after the credits have been awarded and the Fulfillment object has been created, we need to store it - return transaction - end + credit( + amount, + metadata: metadata, + category: category, + expires_at: expires_at, + fulfillment: fulfillment + ) end - # Remove credits from the wallet (Internal method) - # - # After implementing the expiring FIFO inventory-like system through the Allocation model, - # we no longer just create one -X transaction. Now we also allocate that spend across whichever - # positive transactions still have leftover. - # - # TODO: This code enumerates all unexpired positive transactions each time. - # That's fine if usage scale is moderate. We're already indexing this. - # If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently. - def deduct_credits(amount, metadata: {}, category: :credit_deducted) - with_lock do - amount = amount.to_i - raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0 - - # Capture previous balance for low_balance check - previous_balance = credits - - # Figure out how many credits are available right now - available = previous_balance - if amount > available && !allow_negative_balance? - raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})" - end - - # Create the negative transaction that represents the spend - spend_tx = transactions.create!( - amount: -amount, - category: category, - metadata: metadata - ) # We'll attach allocations to it next. - - # We now allocate from oldest/soonest-expiring positive transactions - remaining_to_deduct = amount - - # 1) Gather all unexpired positives with leftover, order by expire time (soonest first), - # then fallback to any with no expiry (which should come last). - positive_txs = transactions - .where("amount > 0") - .where("expires_at IS NULL OR expires_at > ?", Time.current) - .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC")) - .lock("FOR UPDATE") - .select(:id, :amount, :expires_at) - .to_a - - positive_txs.each do |pt| - # Calculate leftover amount for this transaction - allocated = pt.incoming_allocations.sum(:amount) - leftover = pt.amount - allocated - next if leftover <= 0 - - allocate_amount = [leftover, remaining_to_deduct].min - - # Create allocation - Allocation.create!( - spend_transaction: spend_tx, - source_transaction: pt, - amount: allocate_amount - ) + # Remove credits from the wallet (wraps parent's debit method) + # Converts Wallets::InsufficientBalance to InsufficientCredits for backwards compatibility + def deduct_credits(amount, metadata: {}, category: :credit_deducted, fulfillment: nil) + debit(amount, metadata: metadata, category: category, fulfillment: fulfillment) + rescue Wallets::InsufficientBalance => e + raise InsufficientCredits, e.message + end - remaining_to_deduct -= allocate_amount - break if remaining_to_deduct <= 0 + # Shorten the lifetime of credits minted by one fulfillment and reconcile + # the wallet through the same balance/callback internals as core mutations. + # This keeps Pay lifecycle code out of Wallets' private implementation and + # makes immediate cancellation expiry observable through low/depleted + # callbacks after the surrounding transaction commits. + def expire_fulfillment_credits!(fulfillment:, expires_at:) + unless expires_at.respond_to?(:to_datetime) + raise ArgumentError, "Expiration date must respond to to_datetime" end - # If anything’s still left to deduct (and we allow negative?), we just leave it unallocated - # TODO: implement this edge case; typically we'd create an unbacked negative record. - if remaining_to_deduct.positive? && allow_negative_balance? - # The spend_tx already has -amount, so effectively user goes negative - # with no “source bucket” to allocate from. That is an edge case the end user's business logic must handle. - elsif remaining_to_deduct.positive? - # We shouldn’t get here if InsufficientCredits is raised earlier, but just in case: - raise InsufficientCredits, "Not enough credit buckets to cover the deduction" + expiration = begin + expires_at.to_datetime + rescue + raise ArgumentError, "Expiration date must be a valid date or time" end - # Keep the `balance` column in sync - self.balance = credits - save! - - # Store balance information in transaction metadata for audit trail. - # Note: This update! is in the same DB transaction as the create! above (via with_lock), - # so if this fails, the entire transaction rolls back - no orphaned records possible. - # We intentionally overwrite any user-supplied balance_before/balance_after keys - # to ensure system-set values are authoritative. - spend_tx.update!(metadata: spend_tx.metadata.merge( - balance_before: previous_balance, - balance_after: balance - )) - - # Dispatch credits_deducted callback - UsageCredits::Callbacks.dispatch(:credits_deducted, - wallet: self, - amount: amount, - category: category, - transaction: spend_tx, - previous_balance: previous_balance, - new_balance: balance, - metadata: metadata - ) - - # Check for low balance threshold crossing - if !was_low_balance?(previous_balance) && low_balance? - UsageCredits::Callbacks.dispatch(:low_balance_reached, - wallet: self, - threshold: UsageCredits.configuration.low_balance_threshold, - previous_balance: previous_balance, - new_balance: balance - ) - end + with_lock do + previous_balance = balance + transactions_to_expire = transactions.credits.where(fulfillment: fulfillment) + expiry = transactions_to_expire.klass.arel_table[:expires_at] + updated_count = transactions_to_expire + .where(expiry.eq(nil).or(expiry.gt(expiration))) + .update_all(expires_at: expiration, updated_at: Time.current) + + if updated_count.positive? + refresh_cached_balance! + dispatch_balance_threshold_callbacks!(previous_balance) + end - # Check for balance depletion (balance reaches exactly zero) - if previous_balance > 0 && balance == 0 - UsageCredits::Callbacks.dispatch(:balance_depleted, - wallet: self, - previous_balance: previous_balance, - new_balance: 0 - ) + updated_count end + end - spend_tx - end + # Keep the inherited wallet primitive inside usage_credits' public error + # hierarchy. Both transfer entry points share this implementation and the + # complete wallets transfer surface, including expiration overrides. + def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil) + super + rescue Wallets::InvalidTransfer => e + raise InvalidTransfer, e.message + rescue Wallets::InsufficientBalance => e + raise InsufficientCredits, e.message + rescue Wallets::Error => e + raise UsageCredits::Error, e.message end + alias_method :transfer_credits_to, :transfer_to private - # Sync in-place modifications to the cached metadata back to the attribute - # This ensures changes like `metadata["key"] = "value"` are persisted on save - # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults) - def sync_metadata_cache - if @indifferent_metadata - write_attribute(:metadata, @indifferent_metadata.to_h) - elsif read_attribute(:metadata).nil? - write_attribute(:metadata, {}) + # Payment refunds must be represented even after the purchased credits + # have been consumed. The unbacked debit remains ledger debt; the public + # balance stays floored at zero until later credits repay that debt. + def deduct_refunded_credits(amount, metadata:, fulfillment:) + with_lock do + apply_debit( + amount, + metadata: metadata, + category: :credit_pack_refund, + transfer: nil, + extra_attributes: {fulfillment: fulfillment}, + allow_unbacked: true + ) end end @@ -374,41 +275,12 @@ def sync_metadata_cache # Helper Methods # ========================================= - # Find an operation and validate its parameters - # @param name [Symbol] Operation name - # @param params [Hash] Operation parameters to validate - # @return [Operation] The validated operation - # @raise [InvalidOperation] If operation not found or validation fails - def find_and_validate_operation(name, params) + # Find an operation. `Operation#calculate_cost` owns parameter validation, + # keeping validation and user-supplied cost code single-evaluation. + def find_operation(name) operation = UsageCredits.operations[name.to_sym] raise InvalidOperation, "Operation not found: #{name}" unless operation - operation.validate!(params) operation end - - def insufficient_credits?(amount) - !allow_negative_balance? && amount > credits - end - - def allow_negative_balance? - UsageCredits.configuration.allow_negative_balance - end - - # ========================================= - # Balance Threshold Helpers - # ========================================= - - def low_balance? - threshold = UsageCredits.configuration.low_balance_threshold - return false if threshold.nil? || threshold.negative? - credits <= threshold - end - - def was_low_balance?(previous_balance) - threshold = UsageCredits.configuration.low_balance_threshold - return false if threshold.nil? || threshold.negative? - previous_balance <= threshold - end end - end diff --git a/lib/usage_credits/railtie.rb b/lib/usage_credits/railtie.rb index fa199d4..759d728 100644 --- a/lib/usage_credits/railtie.rb +++ b/lib/usage_credits/railtie.rb @@ -1,17 +1,8 @@ # frozen_string_literal: true -module UsageCredits - # Railtie for Rails integration - class Railtie < Rails::Railtie - railtie_name :usage_credits - - # Set up action view helpers if needed - initializer "usage_credits.action_view" do - ActiveSupport.on_load :action_view do - require "usage_credits/helpers/credits_helper" - include UsageCredits::CreditsHelper - end - end +require "usage_credits" unless defined?(UsageCredits::Engine) - end +module UsageCredits + # Compatibility constant for applications that require this historical path. + Railtie = Engine unless const_defined?(:Railtie, false) end diff --git a/lib/usage_credits/services/fulfillment_service.rb b/lib/usage_credits/services/fulfillment_service.rb index bc2a87f..6fa8522 100644 --- a/lib/usage_credits/services/fulfillment_service.rb +++ b/lib/usage_credits/services/fulfillment_service.rb @@ -6,15 +6,12 @@ def self.process_pending_fulfillments failed = 0 Fulfillment.due_for_fulfillment.find_each do |fulfillment| - begin - new(fulfillment).process - count += 1 - rescue StandardError => e - failed += 1 - Rails.logger.error "Failed to process fulfillment #{fulfillment.id}: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - next # Continue with next fulfillment - end + count += 1 if new(fulfillment).process + rescue => e + failed += 1 + Rails.logger.error "Failed to process fulfillment #{fulfillment.id}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + next # Continue with next fulfillment end Rails.logger.info "Processed #{count} fulfillments (#{failed} failed)" @@ -27,26 +24,111 @@ def initialize(fulfillment) end def process - ActiveRecord::Base.transaction do + credit_transaction = nil + + reconcile_subscription_source! + + @fulfillment.class.transaction do + lock_subscription_source! @fulfillment.lock! # row lock to avoid double awarding # re-check if it's still due, in case time changed or another process already updated it - return unless @fulfillment.due_for_fulfillment? + next unless @fulfillment.due_for_fulfillment? + next unless subscription_source_eligible? credits = calculate_credits - give_credits(credits) + unless credits.is_a?(Integer) && credits.positive? + raise UsageCredits::Error, "Fulfillment credits must be a positive whole number" + end + + credit_transaction = give_credits(credits) update_fulfillment(credits) end + + dispatch_subscription_callback(credit_transaction) if credit_transaction + credit_transaction rescue UsageCredits::Error => e Rails.logger.error "Usage credits error processing fulfillment #{@fulfillment.id}: #{e.message}" raise - rescue StandardError => e + rescue => e Rails.logger.error "Unexpected error processing fulfillment #{@fulfillment.id}: #{e.message}" raise end private + def reconcile_subscription_source! + return unless @fulfillment.fulfillment_type == "subscription" + return unless @fulfillment.metadata["initial_award_state"] == "trial" || + @fulfillment.metadata.key?("deferred_plan_change") + + source = @fulfillment.source + return unless source.is_a?(Pay::Subscription) + return unless source.eligible_for_usage_credit_fulfillment? + + source.sync_usage_credit_fulfillment! + @fulfillment.reload + end + + def lock_subscription_source! + @pay_subscription_source = false + @locked_subscription_source = nil + return unless @fulfillment.fulfillment_type == "subscription" + + # A persisted polymorphic type means this fulfillment was explicitly + # tied to Pay. Do not reinterpret a dangling reference as a legacy + # source-less/custom subscription and continue minting credits. + source_class = @fulfillment.source_type&.safe_constantize + @pay_subscription_source = source_class && source_class <= Pay::Subscription + source = @fulfillment.source + return unless @pay_subscription_source || source.is_a?(Pay::Subscription) + + unless source + raise UsageCredits::Error, + "Pay subscription #{@fulfillment.source_id} for fulfillment #{@fulfillment.id} no longer exists" + end + + # All subscription writers use subscription → fulfillment → wallet lock + # order. Taking the processor row first makes the status check and credit + # mint linearizable with cancellation/activation webhooks. + @pay_subscription_source = true + @locked_subscription_source = source.class.lock.find_by(id: source.id) + unless @locked_subscription_source + raise UsageCredits::Error, + "Pay subscription #{source.id} for fulfillment #{@fulfillment.id} no longer exists" + end + end + + def subscription_source_eligible? + return true unless @fulfillment.fulfillment_type == "subscription" + return true unless @pay_subscription_source + + # A stale/delayed job must never keep minting recurring credits while the + # processor subscription is trialing, canceled, paused, or incomplete. + # Pay's processor-specific predicate handles Stripe pauses whose raw + # status remains "active" until the subscription is resumed. + source = @locked_subscription_source + return false unless source&.eligible_for_usage_credit_fulfillment? + + if @fulfillment.metadata.key?("deferred_plan_change") + raise UsageCredits::InvalidOperation, + "Deferred plan change for fulfillment #{@fulfillment.id} was not reconciled" + end + + source_plan = source.processor_plan.to_s + active_plan = @fulfillment.metadata["plan"].to_s + resolved_transition = source_plan == active_plan || + @fulfillment.metadata["pending_plan_change"].to_s == source_plan || + @fulfillment.metadata["stopped_plan"].to_s == source_plan + + unless resolved_transition + raise UsageCredits::InvalidOperation, + "Pay subscription #{source.id} plan transition has not been reconciled for fulfillment #{@fulfillment.id}" + end + + true + end + def validate_fulfillment! raise UsageCredits::Error, "No fulfillment provided" if @fulfillment.nil? raise UsageCredits::Error, "Invalid fulfillment type" unless ["subscription", "credit_pack", "manual"].include?(@fulfillment.fulfillment_type) @@ -57,7 +139,8 @@ def validate_fulfillment! when "subscription" raise UsageCredits::Error, "No plan specified in metadata" unless @fulfillment.metadata["plan"].present? when "credit_pack" - raise UsageCredits::Error, "No pack specified in metadata" unless @fulfillment.metadata["pack"].present? + pack_name = @fulfillment.metadata["pack"] || @fulfillment.metadata["pack_name"] + raise UsageCredits::Error, "No pack specified in metadata" unless pack_name.present? else raise UsageCredits::Error, "No credits amount specified in metadata" unless @fulfillment.metadata["credits"].present? end @@ -73,6 +156,26 @@ def give_credits(credits) ) end + def dispatch_subscription_callback(transaction) + return unless @fulfillment.fulfillment_type == "subscription" + + ActiveRecord.after_all_transactions_commit do + UsageCredits::Callbacks.dispatch( + :subscription_credits_awarded, + wallet: @fulfillment.wallet, + amount: transaction.amount, + transaction: transaction, + metadata: { + fulfillment: @fulfillment, + subscription_plan_name: @plan&.name, + pay_subscription: @fulfillment.source, + fulfillment_period: @fulfillment.fulfillment_period, + reason: "fulfillment_cycle" + } + ) + end + end + def update_fulfillment(credits) @fulfillment.update!( last_fulfilled_at: Time.current, @@ -85,24 +188,37 @@ def calculate_credits case @fulfillment.fulfillment_type when "subscription" @plan = UsageCredits.find_subscription_plan_by_processor_id(@fulfillment.metadata["plan"]) - raise UsageCredits::InvalidOperation, "No subscription plan found for processor ID #{@fulfillment.metadata["plan"]}" unless @plan - @plan.credits_per_period + snapshot_credits = @fulfillment.metadata["credits_per_period"] + if @fulfillment.metadata.key?("credits_per_period") + strict_positive_integer(snapshot_credits, "credits_per_period") + elsif @plan + @plan.credits_per_period + else + raise UsageCredits::InvalidOperation, "No subscription plan found for processor ID #{@fulfillment.metadata["plan"]} and no persisted credit snapshot" + end when "credit_pack" - pack = UsageCredits.find_credit_pack(@fulfillment.metadata["pack"]) - raise UsageCredits::InvalidOperation, "No credit pack named #{@fulfillment.metadata["pack"]}" unless pack + pack_name = @fulfillment.metadata["pack"] || @fulfillment.metadata["pack_name"] + pack = UsageCredits.find_credit_pack(pack_name) + raise UsageCredits::InvalidOperation, "No credit pack named #{pack_name}" unless pack pack.total_credits else - @fulfillment.metadata["credits"].to_i + strict_positive_integer(@fulfillment.metadata["credits"], "credits") end end def calculate_expiration - return nil unless @fulfillment.fulfillment_type == "subscription" && @plan - return nil if @plan.rollover_enabled + return nil unless @fulfillment.fulfillment_type == "subscription" + + rollover = if @fulfillment.metadata.key?("rollover_enabled") + ActiveModel::Type::Boolean.new.cast(@fulfillment.metadata["rollover_enabled"]) + else + @plan&.rollover_enabled + end + return nil if rollover # Cap the grace period to the fulfillment period to prevent balance accumulation # when fulfillment_period << grace_period (e.g., 15 seconds vs 5 minutes) - fulfillment_period = @plan.parsed_fulfillment_period + fulfillment_period = UsageCredits::PeriodParser.parse_persisted_period(@fulfillment.fulfillment_period) effective_grace = [ UsageCredits.configuration.fulfillment_grace_period, fulfillment_period @@ -111,6 +227,14 @@ def calculate_expiration @fulfillment.calculate_next_fulfillment + effective_grace end + def strict_positive_integer(value, name) + number = Wallets::WholeNumber.parse(value, name: name, allow_string: true) + raise UsageCredits::Error, "#{name} must be positive" unless number.positive? + number + rescue ArgumentError + raise UsageCredits::Error, "#{name} must be a positive whole number" + end + def fulfillment_category case @fulfillment.fulfillment_type when "subscription" then "subscription_credits" diff --git a/lib/usage_credits/subscription_terms.rb b/lib/usage_credits/subscription_terms.rb new file mode 100644 index 0000000..8fbbe5f --- /dev/null +++ b/lib/usage_credits/subscription_terms.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module UsageCredits + # Immutable commercial terms used to fulfill one processor subscription. + # Terms may come from checkout metadata, an existing fulfillment snapshot, + # or (for legacy records) the current Ruby configuration. + class SubscriptionTerms + TRUE_VALUES = [true, 1, "1", "true"].freeze + FALSE_VALUES = [false, 0, "0", "false"].freeze + + attr_reader :name, + :processor_plan_id, + :credits_per_period, + :signup_bonus_credits, + :trial_credits, + :fulfillment_period_display, + :rollover_enabled, + :expire_credits_on_cancel, + :credit_expiration_period_seconds, + :configured_plan + + def self.from_plan(plan, processor_plan_id:) + return if plan.nil? + + new( + name: plan.name, + processor_plan_id: processor_plan_id, + credits_per_period: plan.credits_per_period, + signup_bonus_credits: plan.signup_bonus_credits, + trial_credits: plan.trial_credits, + fulfillment_period: plan.fulfillment_period_display, + rollover_enabled: plan.rollover_enabled, + expire_credits_on_cancel: plan.expire_credits_on_cancel, + credit_expiration_period: plan.credit_expiration_period&.to_i, + configured_plan: plan + ) + end + + def self.from_metadata(metadata, processor_plan_id:, configured_plan: nil) + data = (metadata || {}).with_indifferent_access + return unless data.key?(:credits_per_period) && data.key?(:fulfillment_period) && data.key?(:rollover_enabled) + + new( + name: data[:plan_name].presence || data[:subscription_name].presence || configured_plan&.name || processor_plan_id, + processor_plan_id: processor_plan_id, + credits_per_period: data[:credits_per_period], + signup_bonus_credits: data.fetch(:signup_bonus_credits, 0), + trial_credits: data.fetch(:trial_credits, 0), + fulfillment_period: data[:fulfillment_period], + rollover_enabled: parse_boolean(data[:rollover_enabled], "rollover_enabled"), + expire_credits_on_cancel: boolean_from_metadata( + data, + :expire_credits_on_cancel, + fallback: configured_plan&.expire_credits_on_cancel || false + ), + credit_expiration_period: value_from_metadata( + data, + :credit_expiration_period, + fallback: configured_plan&.credit_expiration_period&.to_i + ), + configured_plan: configured_plan, + allow_string_numbers: true + ) + end + + def self.parse_boolean(value, name) + return true if TRUE_VALUES.include?(value) + return false if FALSE_VALUES.include?(value) + + raise ArgumentError, "#{name} must be true or false" + end + private_class_method :parse_boolean + + def self.boolean_from_metadata(data, key, fallback:) + value = value_from_metadata(data, key, fallback: fallback) + parse_boolean(value, key) + end + private_class_method :boolean_from_metadata + + def self.value_from_metadata(data, key, fallback:) + value = data[key] + (value.nil? || value == "") ? fallback : value + end + private_class_method :value_from_metadata + + def initialize(name:, processor_plan_id:, credits_per_period:, signup_bonus_credits:, trial_credits:, + fulfillment_period:, rollover_enabled:, expire_credits_on_cancel: false, credit_expiration_period: nil, + configured_plan: nil, allow_string_numbers: false) + @name = name.respond_to?(:to_sym) ? name.to_sym : name + @processor_plan_id = processor_plan_id.to_s + @credits_per_period = parse_positive(credits_per_period, "credits_per_period", allow_string_numbers) + @signup_bonus_credits = parse_non_negative(signup_bonus_credits, "signup_bonus_credits", allow_string_numbers) + @trial_credits = parse_non_negative(trial_credits, "trial_credits", allow_string_numbers) + @fulfillment_period_display = fulfillment_period.to_s + @parsed_fulfillment_period = UsageCredits::PeriodParser.parse_persisted_period(@fulfillment_period_display) + @rollover_enabled = rollover_enabled == true + @expire_credits_on_cancel = expire_credits_on_cancel == true + @credit_expiration_period_seconds = parse_expiration_period(credit_expiration_period, allow_string_numbers) + @configured_plan = configured_plan + freeze + end + + attr_reader :parsed_fulfillment_period + + def callback_plan + configured_plan || self + end + + private + + def parse_non_negative(value, name, allow_string) + number = Wallets::WholeNumber.parse(value, name: name, allow_string: allow_string) + raise ArgumentError, "#{name} cannot be negative" if number.negative? + + number + end + + def parse_positive(value, name, allow_string) + number = parse_non_negative(value, name, allow_string) + raise ArgumentError, "#{name} must be positive" unless number.positive? + + number + end + + def parse_expiration_period(value, allow_string) + return 0 if value.nil? || value == "" + + parse_non_negative(value, "credit_expiration_period", allow_string) + end + end +end diff --git a/lib/usage_credits/version.rb b/lib/usage_credits/version.rb index 49911e1..b82655c 100644 --- a/lib/usage_credits/version.rb +++ b/lib/usage_credits/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module UsageCredits - VERSION = "0.5.0" + VERSION = "1.0.0" end diff --git a/test/dummy/Gemfile b/test/dummy/Gemfile index 0c629a6..c4f45db 100644 --- a/test/dummy/Gemfile +++ b/test/dummy/Gemfile @@ -4,11 +4,11 @@ source "https://rubygems.org" gemspec path: "../.." # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 8.0.1" +gem "rails", "~> 8.0.4", ">= 8.0.4.1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use sqlite3 as the database for Active Record -gem "sqlite3", ">= 2.1" +gem "sqlite3", ">= 2.9.5" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] @@ -24,7 +24,7 @@ gem "jbuilder" # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[ windows jruby ] +gem "tzinfo-data", platforms: %i[windows jruby] # Use the database-backed adapters for Rails.cache and Active Job gem "solid_cache" @@ -42,7 +42,7 @@ gem "thruster", require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + gem "debug", platforms: %i[mri windows], require: "debug/prelude" # Static analysis for security vulnerabilities [https://brakemanscanner.org/] gem "brakeman", require: false diff --git a/test/dummy/Gemfile.docker b/test/dummy/Gemfile.docker index 9332436..429e68e 100644 --- a/test/dummy/Gemfile.docker +++ b/test/dummy/Gemfile.docker @@ -1,7 +1,7 @@ source "https://rubygems.org" -gem "usage_credits", "~> 0.1.1" -gem "pay", "~> 8.3" +gem "usage_credits", "~> 0.5.0" +gem "pay", ">= 11.6.2", "< 12.0" # When making changes to the regular Gemfile, we need to copy here everything below `gemspec path: "../.."` # This is because parent directories to this Rails app are not available to Docker when compiling the app @@ -14,7 +14,7 @@ gem "pay", "~> 8.3" # !!!!!!!!!!!!! # After editing this Gemfile, we need to run this: # -# BUNDLE_GEMFILE=Gemfile.docker bundle instal +# BUNDLE_GEMFILE=Gemfile.docker bundle install # # to compile the Gemfile.lock file for Docker @@ -25,11 +25,11 @@ gem "pay", "~> 8.3" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 8.0.1" +gem "rails", "~> 8.0.4", ">= 8.0.4.1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use sqlite3 as the database for Active Record -gem "sqlite3", ">= 2.1" +gem "sqlite3", ">= 2.9.5" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] diff --git a/test/dummy/Gemfile.docker.lock b/test/dummy/Gemfile.docker.lock index 0576c2d..9dd89d4 100644 --- a/test/dummy/Gemfile.docker.lock +++ b/test/dummy/Gemfile.docker.lock @@ -1,29 +1,29 @@ GEM remote: https://rubygems.org/ specs: - actioncable (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + actioncable (8.0.5) + actionpack (= 8.0.5) + activesupport (= 8.0.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actionmailbox (8.0.5) + actionpack (= 8.0.5) + activejob (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) mail (>= 2.8.0) - actionmailer (8.0.1) - actionpack (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activesupport (= 8.0.1) + actionmailer (8.0.5) + actionpack (= 8.0.5) + actionview (= 8.0.5) + activejob (= 8.0.5) + activesupport (= 8.0.5) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.1) - actionview (= 8.0.1) - activesupport (= 8.0.1) + actionpack (8.0.5) + actionview (= 8.0.5) + activesupport (= 8.0.5) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -31,35 +31,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.1) - actionpack (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actiontext (8.0.5) + actionpack (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.1) - activesupport (= 8.0.1) + actionview (8.0.5) + activesupport (= 8.0.5) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.1) - activesupport (= 8.0.1) + activejob (8.0.5) + activesupport (= 8.0.5) globalid (>= 0.3.6) - activemodel (8.0.1) - activesupport (= 8.0.1) - activerecord (8.0.1) - activemodel (= 8.0.1) - activesupport (= 8.0.1) + activemodel (8.0.5) + activesupport (= 8.0.5) + activerecord (8.0.5) + activemodel (= 8.0.5) + activesupport (= 8.0.5) timeout (>= 0.4.0) - activestorage (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activesupport (= 8.0.1) + activestorage (8.0.5) + actionpack (= 8.0.5) + activejob (= 8.0.5) + activerecord (= 8.0.5) + activesupport (= 8.0.5) marcel (~> 1.0) - activesupport (8.0.1) + activesupport (8.0.5) base64 benchmark (>= 0.3) bigdecimal @@ -72,242 +72,256 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - ast (2.4.2) + ast (2.4.3) awesome_print (1.9.2) - base64 (0.2.0) - bcrypt_pbkdf (1.1.1) - bcrypt_pbkdf (1.1.1-arm64-darwin) - benchmark (0.4.0) - bigdecimal (3.1.9) + base64 (0.3.0) + bcrypt_pbkdf (1.1.2) + benchmark (0.5.0) + bigdecimal (4.1.2) bindex (0.8.1) - bootsnap (1.18.4) + bootsnap (1.24.6) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (8.0.5) racc builder (3.3.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.0) - crass (1.0.6) - date (3.4.1) - debug (1.10.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) - dotenv (3.1.4) - drb (2.2.1) - ed25519 (1.3.0) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.4) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + fugit (1.13.0) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + globalid (1.4.0) activesupport (>= 6.1) - i18n (1.14.7) + i18n (1.15.2) concurrent-ruby (~> 1.0) - importmap-rails (2.1.0) + importmap-rails (2.2.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - io-console (0.8.0) - irb (1.14.3) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.13.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) - json (2.9.1) - kamal (2.3.0) + jbuilder (2.15.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.21.1) + kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) concurrent-ruby (~> 1.2) dotenv (~> 3.1) - ed25519 (~> 1.2) + ed25519 (~> 1.4) net-ssh (~> 7.3) sshkit (>= 1.23.0, < 2.0) thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) - language_server-protocol (3.17.0.3) - logger (1.6.5) - loofah (2.24.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.1) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.2.1) mini_mime (1.1.5) - minitest (5.25.4) - msgpack (1.8.0) - net-imap (0.5.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.3) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-scp (4.0.0) + net-scp (4.1.0) net-ssh (>= 2.6.5, < 8.0.0) net-sftp (4.0.0) net-ssh (>= 5.0.0, < 8.0.0) - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol - net-ssh (7.3.0) - nio4r (2.7.4) - nokogiri (1.18.2-arm64-darwin) + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.2-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - ostruct (0.6.1) - parallel (1.26.3) - parser (3.3.7.0) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - pay (8.3.0) - rails (>= 6.0.0) - propshaft (1.1.0) + pay (11.6.2) + rails (>= 7.0.0) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) - psych (5.2.3) - date - stringio - puma (6.5.0) + puma (8.0.2) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.8) - rack-session (2.1.0) + rack (3.2.6) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (8.0.1) - actioncable (= 8.0.1) - actionmailbox (= 8.0.1) - actionmailer (= 8.0.1) - actionpack (= 8.0.1) - actiontext (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activemodel (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + rails (8.0.5) + actioncable (= 8.0.5) + actionmailbox (= 8.0.5) + actionmailer (= 8.0.5) + actionpack (= 8.0.5) + actiontext (= 8.0.5) + actionview (= 8.0.5) + activejob (= 8.0.5) + activemodel (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) bundler (>= 1.15.0) - railties (= 8.0.1) - rails-dom-testing (2.2.0) + railties (= 8.0.5) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + railties (8.0.5) + actionpack (= 8.0.5) + activesupport (= 8.0.5) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) - rdoc (6.11.0) - psych (>= 4.0.0) - regexp_parser (2.10.0) - reline (0.6.0) + rake (13.4.2) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) io-console (~> 0.5) - rubocop (1.70.0) + rubocop (1.88.2) json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) - parser (>= 3.3.1.0) - rubocop-minitest (0.36.0) - rubocop (>= 1.61, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-performance (1.23.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.29.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.36.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.52.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails-omakase (1.0.0) - rubocop - rubocop-minitest - rubocop-performance - rubocop-rails + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) securerandom (0.4.1) - solid_cable (3.0.7) + solid_cable (4.0.0) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_cache (1.0.6) + solid_cache (1.0.10) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.1.0) + solid_queue (1.4.0) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) - fugit (~> 1.11.0) + fugit (~> 1.11) railties (>= 7.1) - thor (~> 1.3.1) - sqlite3 (2.5.0-arm64-darwin) - sqlite3 (2.5.0-x86_64-linux-gnu) - sshkit (1.23.2) + thor (>= 1.3.1) + sqlite3 (2.9.5-arm64-darwin) + sqlite3 (2.9.5-x86_64-linux-gnu) + sshkit (1.25.0) base64 + logger net-scp (>= 1.1.2) net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.2) - thor (1.3.2) - thruster (0.1.9-arm64-darwin) - thruster (0.1.9-x86_64-linux) - timeout (0.4.3) - turbo-rails (2.0.11) - actionpack (>= 6.0.0) - railties (>= 6.0.0) + thor (1.5.0) + thruster (0.1.22-arm64-darwin) + thruster (0.1.22-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.2) - usage_credits (0.1.1) - pay (~> 8.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + usage_credits (0.5.0) + pay (>= 8.3, < 12.0) rails (>= 6.1) useragent (0.16.11) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) + web-console (4.3.0) + actionview (>= 8.0.0) bindex (>= 0.4.0) - railties (>= 6.0.0) - websocket-driver (0.7.7) + railties (>= 8.0.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.7.1) + zeitwerk (2.8.2) PLATFORMS arm64-darwin @@ -321,21 +335,21 @@ DEPENDENCIES importmap-rails jbuilder kamal - pay (~> 8.3) + pay (>= 11.6.2, < 12.0) propshaft puma (>= 5.0) - rails (~> 8.0.1) + rails (~> 8.0.4, >= 8.0.4.1) rubocop-rails-omakase solid_cable solid_cache solid_queue - sqlite3 (>= 2.1) + sqlite3 (>= 2.9.5) stimulus-rails thruster turbo-rails tzinfo-data - usage_credits (~> 0.1.1) + usage_credits (~> 0.5.0) web-console BUNDLED WITH - 2.6.2 + 2.5.16 diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb index 122b628..7562ad5 100644 --- a/test/dummy/app/controllers/application_controller.rb +++ b/test/dummy/app/controllers/application_controller.rb @@ -12,7 +12,7 @@ def current_user def reset_demo! if current_user # First delete the Pay::Customer which will cascade delete all Pay-related records - if pay_customer = current_user.payment_processor + if (pay_customer = current_user.payment_processor) pay_customer.destroy end diff --git a/test/dummy/app/controllers/credits_controller.rb b/test/dummy/app/controllers/credits_controller.rb index 56e8425..8e15e04 100644 --- a/test/dummy/app/controllers/credits_controller.rb +++ b/test/dummy/app/controllers/credits_controller.rb @@ -23,7 +23,7 @@ def perform_operation flash[:alert] = "Not enough credits: #{e.message}" rescue UsageCredits::InvalidOperation => e flash[:alert] = "Invalid operation: #{e.message}" - rescue StandardError => e + rescue => e flash[:alert] = "Operation failed: #{e.message}" end @@ -32,7 +32,7 @@ def perform_operation def checkout # Mock `pay` payment processor, so instead of creating a checkout session, just create a charge directly - current_user.payment_processor.charge(@pack.price_cents, metadata: @pack.base_metadata ) + current_user.payment_processor.charge(@pack.price_cents, metadata: @pack.base_metadata) # Redirect to success page redirect_to root_path, notice: "Successfully purchased #{@pack.credits} credits!" @@ -47,8 +47,8 @@ def checkout_subscription current_user.payment_processor.subscribe(plan: @credits_subscription_plan.plan_id_for(:fake_processor), metadata: @credits_subscription_plan.base_metadata) redirect_to root_path, notice: "Successfully subscribed!" - rescue Pay::Error => e - redirect_to root_path, alert: e.message + rescue Pay::Error => e + redirect_to root_path, alert: e.message end def award_bonus @@ -82,7 +82,7 @@ def award_bonus current_user.give_credits(amount, reason: reason, expires_at: expires_at) - redirect_to root_path, notice: "Successfully awarded a bonus of #{amount} credits with reason: #{reason}#{expires_at ? " (expires on #{expires_at.strftime("%B %d, %Y at %I:%M %p")})" : ""}" + redirect_to root_path, notice: "Successfully awarded a bonus of #{amount} credits with reason: #{reason}#{" (expires on #{expires_at.strftime("%B %d, %Y at %I:%M %p")})" if expires_at}" end private diff --git a/test/dummy/app/models/team.rb b/test/dummy/app/models/team.rb new file mode 100644 index 0000000..47411e1 --- /dev/null +++ b/test/dummy/app/models/team.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Model for testing coexistence of wallets and usage_credits gems +# This model uses has_wallets directly from the wallets gem, +# while User model uses has_credits from usage_credits gem. +class Team < ApplicationRecord + include Wallets::HasWallets + + has_wallets default_asset: :points +end diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb index 4cc21c4..4ba93a0 100644 --- a/test/dummy/config/environments/development.rb +++ b/test/dummy/config/environments/development.rb @@ -20,7 +20,7 @@ if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true - config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"} else config.action_controller.perform_caching = false end @@ -38,7 +38,7 @@ config.action_mailer.perform_caching = false # Set localhost to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + config.action_mailer.default_url_options = {host: "localhost", port: 3000} # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/test/dummy/config/environments/production.rb b/test/dummy/config/environments/production.rb index 2bcbc11..65b3a4b 100644 --- a/test/dummy/config/environments/production.rb +++ b/test/dummy/config/environments/production.rb @@ -16,7 +16,7 @@ config.action_controller.perform_caching = true # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"} # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" @@ -34,8 +34,8 @@ # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to STDOUT with the current request id as a default log tag. - config.log_tags = [ :request_id ] - config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + config.log_tags = [:request_id] + config.logger = ActiveSupport::TaggedLogging.logger($stdout) # Change to "debug" to log everything (including potentially personally-identifiable information!) config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") @@ -51,14 +51,14 @@ # Replace the default in-process and non-durable queuing backend for Active Job. config.active_job.queue_adapter = :solid_queue - config.solid_queue.connects_to = { database: { writing: :queue } } + config.solid_queue.connects_to = {database: {writing: :queue}} # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "usagecredits.com" } + config.action_mailer.default_url_options = {host: "usagecredits.com"} # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. # config.action_mailer.smtp_settings = { @@ -77,7 +77,7 @@ config.active_record.dump_schema_after_migration = false # Only use :id for inspections in production. - config.active_record.attributes_for_inspect = [ :id ] + config.active_record.attributes_for_inspect = [:id] # Enable DNS rebinding protection and other `Host` header attacks. # config.hosts = [ diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb index c2095b1..0fb6d30 100644 --- a/test/dummy/config/environments/test.rb +++ b/test/dummy/config/environments/test.rb @@ -16,7 +16,7 @@ config.eager_load = ENV["CI"].present? # Configure public file server for tests with cache-control for performance. - config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + config.public_file_server.headers = {"cache-control" => "public, max-age=3600"} # Show full error reports. config.consider_all_requests_local = true @@ -37,7 +37,7 @@ config.action_mailer.delivery_method = :test # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "example.com" } + config.action_mailer.default_url_options = {host: "example.com"} # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/test/dummy/config/initializers/usage_credits.rb b/test/dummy/config/initializers/usage_credits.rb index 3330be6..20a3658 100644 --- a/test/dummy/config/initializers/usage_credits.rb +++ b/test/dummy/config/initializers/usage_credits.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true UsageCredits.configure do |config| - # Default test operations operation :small_operation do costs 1.credit @@ -20,7 +19,6 @@ costs 42.credits end - # Define test credit packs credit_pack :tiny do gives 10.credits @@ -39,7 +37,6 @@ currency :usd end - # Define subscriptions subscription_plan :test_plan do processor_plan(:fake_processor, "abcdef123456") @@ -56,5 +53,4 @@ # # Send notification to user when their balance drops below the threshold # ApplicationMailer.generic_email(to: user.email, body: "Heads up! You're low on credits.", subject: "Low credits alert").deliver_now # end - end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 30bbce0..90c1abf 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -3,7 +3,7 @@ # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check + get "up" => "rails/health#show", :as => :rails_health_check # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest @@ -15,11 +15,11 @@ root "credits#index" get "/credits" => "credits#index" - post "/credits/perform/:operation" => "credits#perform_operation", as: :credits_perform_operation - get "/credits/purchase/:pack" => "credits#checkout", as: :credit_packs_checkout - get "/credits/subscribe" => "credits#checkout_subscription", as: :credit_subscription_checkout - get "/credits/bonus/:bonus_amount/:bonus_reason" => "credits#award_bonus", as: :credit_award_bonus + post "/credits/perform/:operation" => "credits#perform_operation", :as => :credits_perform_operation + get "/credits/purchase/:pack" => "credits#checkout", :as => :credit_packs_checkout + get "/credits/subscribe" => "credits#checkout_subscription", :as => :credit_subscription_checkout + get "/credits/bonus/:bonus_amount/:bonus_reason" => "credits#award_bonus", :as => :credit_award_bonus # Allow visitors to reset their demo state - get "/reset" => "application#reset_demo!", as: :reset_demo + get "/reset" => "application#reset_demo!", :as => :reset_demo end diff --git a/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb b/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb index 57887b2..b0c04c5 100644 --- a/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb +++ b/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb @@ -6,17 +6,33 @@ def change create_table :usage_credits_wallets, id: primary_key_type do |t| t.references :owner, polymorphic: true, null: false, type: foreign_key_type - t.integer :balance, null: false, default: 0 + t.string :asset_code, null: false, default: "credits" + t.bigint :balance, null: false, default: 0 + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + + create_table :usage_credits_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :usage_credits_wallets} + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :usage_credits_wallets} + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end create_table :usage_credits_transactions, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type - t.integer :amount, null: false + t.references :wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :usage_credits_wallets} + t.bigint :amount, null: false t.string :category, null: false t.datetime :expires_at + t.references :transfer, type: foreign_key_type, foreign_key: {to_table: :usage_credits_transfers} t.references :fulfillment, type: foreign_key_type t.send(json_column_type, :metadata, null: false, default: json_column_default) @@ -24,9 +40,12 @@ def change end create_table :usage_credits_fulfillments, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type - t.references :source, polymorphic: true, type: foreign_key_type - t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment + t.references :wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :usage_credits_wallets} + t.references :source, + polymorphic: true, + type: foreign_key_type, + index: {unique: true, name: "index_usage_credits_fulfillments_on_source"} + t.bigint :credits_last_fulfillment, null: false # Credits given in last fulfillment t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription) t.datetime :last_fulfilled_at # When last fulfilled t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed) @@ -36,37 +55,57 @@ def change t.timestamps end + add_foreign_key :usage_credits_transactions, + :usage_credits_fulfillments, + column: :fulfillment_id - # Allocations are the basis for the bucket-based, FIFO with expiration inventory-like system + # Allocations are the basis for the bucket-based, first-expiring-first-out inventory system create_table :usage_credits_allocations, id: primary_key_type do |t| # The "spend" transaction (negative) that is *using* credits t.references :transaction, null: false, type: foreign_key_type, - foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_transaction_id" } + foreign_key: {to_table: :usage_credits_transactions}, + index: {name: "index_usage_credits_allocations_on_transaction_id"} # The "source" transaction (positive) from which the credits are drawn t.references :source_transaction, null: false, type: foreign_key_type, - foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_source_transaction_id" } + foreign_key: {to_table: :usage_credits_transactions}, + index: {name: "index_usage_credits_allocations_on_source_tx_id"} # How many credits were allocated from that particular source - t.integer :amount, null: false + t.bigint :amount, null: false t.timestamps end - # Add indexes + add_check_constraint :usage_credits_transfers, + "amount > 0", + name: "check_usage_credits_transfers_amount_positive" + add_check_constraint :usage_credits_transfers, + "from_wallet_id <> to_wallet_id", + name: "check_usage_credits_transfers_distinct_wallets" + add_check_constraint :usage_credits_transactions, + "amount <> 0", + name: "check_usage_credits_transactions_amount_nonzero" + add_check_constraint :usage_credits_allocations, + "amount > 0", + name: "check_usage_credits_allocations_amount_positive" + add_check_constraint :usage_credits_fulfillments, + "credits_last_fulfillment >= 0", + name: "check_usage_credits_fulfillments_credits_nonnegative" + + # Transaction indexes add_index :usage_credits_transactions, :category add_index :usage_credits_transactions, :expires_at + add_index :usage_credits_transactions, [:expires_at, :id], name: "index_usage_credits_transactions_on_expires_at_and_id" + add_index :usage_credits_transactions, [:wallet_id, :amount], name: "index_usage_credits_transactions_on_wallet_id_and_amount" - # Composite index on (expires_at, id) for efficient ordering when calculating balances - add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id' - - # Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts - add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount' + # Allocation indexes + add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_usage_credits_allocations_on_tx_and_source_tx" - add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx" + # Transfer indexes + add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset" + # Fulfillment indexes add_index :usage_credits_fulfillments, :next_fulfillment_at add_index :usage_credits_fulfillments, :fulfillment_type end @@ -82,7 +121,7 @@ def primary_and_foreign_key_types end def json_column_type - return :jsonb if connection.adapter_name.downcase.include?('postgresql') + return :jsonb if connection.adapter_name.downcase.include?("postgresql") :json end @@ -90,7 +129,7 @@ def json_column_type # Returns an empty hash default for SQLite/PostgreSQL, nil for MySQL. # Models handle nil metadata gracefully by defaulting to {} in their accessors. def json_column_default - return nil if connection.adapter_name.downcase.include?('mysql') + return nil if connection.adapter_name.downcase.include?("mysql") {} end end diff --git a/test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb b/test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb new file mode 100644 index 0000000..3d4aaba --- /dev/null +++ b/test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class CreateWalletsCoexistenceTables < ActiveRecord::Migration[7.2] + def change + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :teams, id: primary_key_type do |t| + t.string :name + + t.timestamps + end + + create_table :wallets_wallets, id: primary_key_type do |t| + t.references :owner, polymorphic: true, null: false, type: foreign_key_type + t.string :asset_code, null: false + t.bigint :balance, null: false, default: 0 + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :wallets_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_wallets_on_owner_and_asset_code" + + create_table :wallets_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :wallets_wallets} + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :wallets_wallets} + t.string :asset_code, null: false + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + create_table :wallets_transactions, id: primary_key_type do |t| + t.references :wallet, null: false, type: foreign_key_type, foreign_key: {to_table: :wallets_wallets} + t.bigint :amount, null: false + t.string :category, null: false + t.datetime :expires_at + t.references :transfer, type: foreign_key_type, foreign_key: {to_table: :wallets_transfers} + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + create_table :wallets_allocations, id: primary_key_type do |t| + t.references :transaction, null: false, type: foreign_key_type, + foreign_key: {to_table: :wallets_transactions}, + index: {name: "index_wallets_allocations_on_transaction_id"} + t.references :source_transaction, null: false, type: foreign_key_type, + foreign_key: {to_table: :wallets_transactions}, + index: {name: "index_wallets_allocations_on_source_transaction_id"} + t.bigint :amount, null: false + + t.timestamps + end + + add_index :wallets_transactions, :category + add_index :wallets_transactions, :expires_at + add_index :wallets_transactions, [:wallet_id, :amount], name: "index_wallets_transactions_on_wallet_id_and_amount" + add_index :wallets_transactions, [:expires_at, :id], name: "index_wallets_transactions_on_expires_at_and_id" + add_index :wallets_allocations, [:transaction_id, :source_transaction_id], name: "index_wallets_allocations_on_tx_and_source_tx" + add_index :wallets_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_wallets_transfers_on_wallets_and_asset" + end + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end + + def json_column_type + return :jsonb if connection.adapter_name.downcase.include?("postgresql") + + :json + end + + def json_column_default + return nil if connection.adapter_name.downcase.include?("mysql") + + {} + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 55b05a1..4b7bdf3 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_04_16_000000) do +ActiveRecord::Schema[7.2].define(version: 2025_04_17_000000) do create_table "pay_charges", force: :cascade do |t| t.bigint "customer_id", null: false t.bigint "subscription_id" @@ -110,22 +110,29 @@ t.datetime "updated_at", null: false end + create_table "teams", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "usage_credits_allocations", force: :cascade do |t| t.bigint "transaction_id", null: false t.bigint "source_transaction_id", null: false - t.integer "amount", null: false + t.bigint "amount", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["source_transaction_id"], name: "index_allocations_on_source_transaction_id" - t.index ["transaction_id", "source_transaction_id"], name: "index_allocations_on_tx_and_source_tx" - t.index ["transaction_id"], name: "index_allocations_on_transaction_id" + t.index ["source_transaction_id"], name: "index_usage_credits_allocations_on_source_tx_id" + t.index ["transaction_id", "source_transaction_id"], name: "index_usage_credits_allocations_on_tx_and_source_tx" + t.index ["transaction_id"], name: "index_usage_credits_allocations_on_transaction_id" + t.check_constraint "amount > 0", name: "check_usage_credits_allocations_amount_positive" end create_table "usage_credits_fulfillments", force: :cascade do |t| t.bigint "wallet_id", null: false t.string "source_type" t.bigint "source_id" - t.integer "credits_last_fulfillment", null: false + t.bigint "credits_last_fulfillment", null: false t.string "fulfillment_type", null: false t.datetime "last_fulfilled_at" t.datetime "next_fulfillment_at" @@ -136,34 +143,57 @@ t.datetime "updated_at", null: false t.index ["fulfillment_type"], name: "index_usage_credits_fulfillments_on_fulfillment_type" t.index ["next_fulfillment_at"], name: "index_usage_credits_fulfillments_on_next_fulfillment_at" - t.index ["source_type", "source_id"], name: "index_usage_credits_fulfillments_on_source" + t.index ["source_type", "source_id"], name: "index_usage_credits_fulfillments_on_source", unique: true t.index ["wallet_id"], name: "index_usage_credits_fulfillments_on_wallet_id" + t.check_constraint "credits_last_fulfillment >= 0", name: "check_usage_credits_fulfillments_credits_nonnegative" end create_table "usage_credits_transactions", force: :cascade do |t| t.bigint "wallet_id", null: false - t.integer "amount", null: false + t.bigint "amount", null: false t.string "category", null: false t.datetime "expires_at" + t.bigint "transfer_id" t.bigint "fulfillment_id" t.json "metadata", default: {}, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["category"], name: "index_usage_credits_transactions_on_category" - t.index ["expires_at", "id"], name: "index_transactions_on_expires_at_and_id" + t.index ["expires_at", "id"], name: "index_usage_credits_transactions_on_expires_at_and_id" t.index ["expires_at"], name: "index_usage_credits_transactions_on_expires_at" t.index ["fulfillment_id"], name: "index_usage_credits_transactions_on_fulfillment_id" - t.index ["wallet_id", "amount"], name: "index_transactions_on_wallet_id_and_amount" + t.index ["transfer_id"], name: "index_usage_credits_transactions_on_transfer_id" + t.index ["wallet_id", "amount"], name: "index_usage_credits_transactions_on_wallet_id_and_amount" t.index ["wallet_id"], name: "index_usage_credits_transactions_on_wallet_id" + t.check_constraint "amount <> 0", name: "check_usage_credits_transactions_amount_nonzero" + end + + create_table "usage_credits_transfers", force: :cascade do |t| + t.bigint "from_wallet_id", null: false + t.bigint "to_wallet_id", null: false + t.string "asset_code", default: "credits", null: false + t.bigint "amount", null: false + t.string "category", default: "transfer", null: false + t.string "expiration_policy", default: "preserve", null: false + t.json "metadata", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["from_wallet_id", "to_wallet_id", "asset_code"], name: "index_usage_credits_transfers_on_wallets_and_asset" + t.index ["from_wallet_id"], name: "index_usage_credits_transfers_on_from_wallet_id" + t.index ["to_wallet_id"], name: "index_usage_credits_transfers_on_to_wallet_id" + t.check_constraint "amount > 0", name: "check_usage_credits_transfers_amount_positive" + t.check_constraint "from_wallet_id <> to_wallet_id", name: "check_usage_credits_transfers_distinct_wallets" end create_table "usage_credits_wallets", force: :cascade do |t| t.string "owner_type", null: false t.bigint "owner_id", null: false - t.integer "balance", default: 0, null: false + t.string "asset_code", default: "credits", null: false + t.bigint "balance", default: 0, null: false t.json "metadata", default: {}, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "asset_code"], name: "index_usage_credits_wallets_on_owner_and_asset", unique: true t.index ["owner_type", "owner_id"], name: "index_usage_credits_wallets_on_owner" end @@ -174,10 +204,77 @@ t.datetime "updated_at", null: false end + create_table "wallets_allocations", force: :cascade do |t| + t.bigint "transaction_id", null: false + t.bigint "source_transaction_id", null: false + t.bigint "amount", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["source_transaction_id"], name: "index_wallets_allocations_on_source_transaction_id" + t.index ["transaction_id", "source_transaction_id"], name: "index_wallets_allocations_on_tx_and_source_tx" + t.index ["transaction_id"], name: "index_wallets_allocations_on_transaction_id" + end + + create_table "wallets_transactions", force: :cascade do |t| + t.bigint "wallet_id", null: false + t.bigint "amount", null: false + t.string "category", null: false + t.datetime "expires_at" + t.bigint "transfer_id" + t.json "metadata", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["category"], name: "index_wallets_transactions_on_category" + t.index ["expires_at", "id"], name: "index_wallets_transactions_on_expires_at_and_id" + t.index ["expires_at"], name: "index_wallets_transactions_on_expires_at" + t.index ["transfer_id"], name: "index_wallets_transactions_on_transfer_id" + t.index ["wallet_id", "amount"], name: "index_wallets_transactions_on_wallet_id_and_amount" + t.index ["wallet_id"], name: "index_wallets_transactions_on_wallet_id" + end + + create_table "wallets_transfers", force: :cascade do |t| + t.bigint "from_wallet_id", null: false + t.bigint "to_wallet_id", null: false + t.string "asset_code", null: false + t.bigint "amount", null: false + t.string "category", default: "transfer", null: false + t.string "expiration_policy", default: "preserve", null: false + t.json "metadata", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["from_wallet_id", "to_wallet_id", "asset_code"], name: "index_wallets_transfers_on_wallets_and_asset" + t.index ["from_wallet_id"], name: "index_wallets_transfers_on_from_wallet_id" + t.index ["to_wallet_id"], name: "index_wallets_transfers_on_to_wallet_id" + end + + create_table "wallets_wallets", force: :cascade do |t| + t.string "owner_type", null: false + t.bigint "owner_id", null: false + t.string "asset_code", null: false + t.bigint "balance", default: 0, null: false + t.json "metadata", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "asset_code"], name: "index_wallets_on_owner_and_asset_code", unique: true + t.index ["owner_type", "owner_id"], name: "index_wallets_wallets_on_owner" + end + add_foreign_key "pay_charges", "pay_customers", column: "customer_id" add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id" add_foreign_key "pay_payment_methods", "pay_customers", column: "customer_id" add_foreign_key "pay_subscriptions", "pay_customers", column: "customer_id" add_foreign_key "usage_credits_allocations", "usage_credits_transactions", column: "source_transaction_id" add_foreign_key "usage_credits_allocations", "usage_credits_transactions", column: "transaction_id" + add_foreign_key "usage_credits_fulfillments", "usage_credits_wallets", column: "wallet_id" + add_foreign_key "usage_credits_transactions", "usage_credits_fulfillments", column: "fulfillment_id" + add_foreign_key "usage_credits_transactions", "usage_credits_transfers", column: "transfer_id" + add_foreign_key "usage_credits_transactions", "usage_credits_wallets", column: "wallet_id" + add_foreign_key "usage_credits_transfers", "usage_credits_wallets", column: "from_wallet_id" + add_foreign_key "usage_credits_transfers", "usage_credits_wallets", column: "to_wallet_id" + add_foreign_key "wallets_allocations", "wallets_transactions", column: "source_transaction_id" + add_foreign_key "wallets_allocations", "wallets_transactions", column: "transaction_id" + add_foreign_key "wallets_transactions", "wallets_transfers", column: "transfer_id" + add_foreign_key "wallets_transactions", "wallets_wallets", column: "wallet_id" + add_foreign_key "wallets_transfers", "wallets_wallets", column: "from_wallet_id" + add_foreign_key "wallets_transfers", "wallets_wallets", column: "to_wallet_id" end diff --git a/test/fixtures/teams.yml b/test/fixtures/teams.yml new file mode 100644 index 0000000..bfcc134 --- /dev/null +++ b/test/fixtures/teams.yml @@ -0,0 +1,12 @@ +# Team fixtures for coexistence testing +alpha_team: + id: 1 + name: Alpha Team + created_at: <%= 10.days.ago %> + updated_at: <%= 10.days.ago %> + +beta_team: + id: 2 + name: Beta Team + created_at: <%= 5.days.ago %> + updated_at: <%= 5.days.ago %> diff --git a/test/fixtures/usage_credits/wallets.yml b/test/fixtures/usage_credits/wallets.yml index 97472cd..2311103 100644 --- a/test/fixtures/usage_credits/wallets.yml +++ b/test/fixtures/usage_credits/wallets.yml @@ -3,6 +3,7 @@ rich_wallet: id: 1 owner_type: User owner_id: 1 + asset_code: credits balance: 1000 metadata: {} created_at: <%= 30.days.ago %> @@ -13,6 +14,7 @@ poor_wallet: id: 2 owner_type: User owner_id: 2 + asset_code: credits balance: 5 metadata: {} created_at: <%= 15.days.ago %> @@ -23,6 +25,7 @@ subscribed_wallet: id: 3 owner_type: User owner_id: 4 + asset_code: credits balance: 500 metadata: { subscription_tier: "pro" } created_at: <%= 60.days.ago %> @@ -33,6 +36,7 @@ expiry_wallet: id: 4 owner_type: User owner_id: 5 + asset_code: credits balance: 300 metadata: {} created_at: <%= 90.days.ago %> @@ -43,6 +47,7 @@ trial_wallet: id: 5 owner_type: User owner_id: 6 + asset_code: credits balance: 500 metadata: {} created_at: <%= 7.days.ago %> @@ -53,6 +58,7 @@ cancelled_wallet: id: 6 owner_type: User owner_id: 7 + asset_code: credits balance: 50 metadata: {} created_at: <%= 90.days.ago %> @@ -63,17 +69,8 @@ refund_wallet: id: 7 owner_type: User owner_id: 8 + asset_code: credits balance: 1000 metadata: {} created_at: <%= 20.days.ago %> updated_at: <%= 1.day.ago %> - -# Empty wallet (for testing zero balance) -empty_wallet: - id: 8 - owner_type: User - owner_id: 3 - balance: 0 - metadata: {} - created_at: <%= 1.day.ago %> - updated_at: <%= 1.day.ago %> diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 5bcdb34..d634302 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -61,3 +61,11 @@ refund_user: name: Refund User created_at: <%= 20.days.ago %> updated_at: <%= 1.day.ago %> + +# User truly without any wallet (for wallet creation tests) +walletless_user: + id: 9 + email: walletless@example.com + name: Walletless User + created_at: <%= 1.day.ago %> + updated_at: <%= 1.day.ago %> diff --git a/test/fixtures/wallets/wallets.yml b/test/fixtures/wallets/wallets.yml new file mode 100644 index 0000000..140823b --- /dev/null +++ b/test/fixtures/wallets/wallets.yml @@ -0,0 +1,22 @@ +# Wallets gem wallets (for coexistence testing) +# These use the wallets_wallets table, separate from usage_credits_wallets + +alpha_points_wallet: + id: 1 + owner_type: Team + owner_id: 1 + asset_code: points + balance: 500 + metadata: {} + created_at: <%= 10.days.ago %> + updated_at: <%= 1.day.ago %> + +beta_points_wallet: + id: 2 + owner_type: Team + owner_id: 2 + asset_code: points + balance: 200 + metadata: {} + created_at: <%= 5.days.ago %> + updated_at: <%= 1.day.ago %> diff --git a/test/helpers/credit_calculator_test.rb b/test/helpers/credit_calculator_test.rb index 7d5c8e5..a98cb3c 100644 --- a/test/helpers/credit_calculator_test.rb +++ b/test/helpers/credit_calculator_test.rb @@ -29,6 +29,37 @@ class CreditCalculatorTest < ActiveSupport::TestCase UsageCredits.reset! end + # ======================================== + # CREDIT AMOUNT NORMALIZATION + # ======================================== + + test "normalize_credit_amount returns canonical non-negative integers" do + assert_equal 0, UsageCredits::CreditCalculator.normalize_credit_amount(0) + assert_equal 12, UsageCredits::CreditCalculator.normalize_credit_amount(12) + assert_equal 12, UsageCredits::CreditCalculator.normalize_credit_amount(12.0) + assert_equal 12, UsageCredits::CreditCalculator.normalize_credit_amount(BigDecimal("12.0")) + end + + test "normalize_credit_amount rejects negative values with a stable error" do + [-1, -1.0, BigDecimal("-1")].each do |value| + error = assert_raises(ArgumentError) do + UsageCredits::CreditCalculator.normalize_credit_amount(value) + end + + assert_equal "Credit amount cannot be negative (got: #{value})", error.message + end + end + + test "normalize_credit_amount rejects non-whole values with a stable error" do + [nil, 1.5, Float::NAN, Float::INFINITY, "12", :twelve].each do |value| + error = assert_raises(ArgumentError) do + UsageCredits::CreditCalculator.normalize_credit_amount(value) + end + + assert_equal "Credit amount must be a whole number (got: #{value})", error.message + end + end + # ======================================== # ROUNDING STRATEGIES # ======================================== @@ -198,7 +229,7 @@ class CreditCalculatorTest < ActiveSupport::TestCase money = UsageCredits::CreditCalculator.credits_to_money(credits, 10) # Should be close (may not be exact due to rounding) - assert money >= 990 && money <= 1010 + assert money.between?(990, 1010) end test "never undercharges with default ceil strategy" do diff --git a/test/helpers/period_parser_test.rb b/test/helpers/period_parser_test.rb index c0f388c..5bc879f 100644 --- a/test/helpers/period_parser_test.rb +++ b/test/helpers/period_parser_test.rb @@ -231,6 +231,26 @@ class UsageCredits::PeriodParserTest < ActiveSupport::TestCase assert_includes error.message, "Period must be at least" end + test "parse_persisted_period is independent of a later minimum increase" do + UsageCredits.configuration.min_fulfillment_period = 1.month + + assert_equal 1.week, UsageCredits::PeriodParser.parse_persisted_period("1.week") + assert UsageCredits::PeriodParser.valid_persisted_period_format?("1.week") + refute UsageCredits::PeriodParser.valid_period_format?("1.week") + end + + test "parse_persisted_period enforces the absolute safety floor" do + assert_raises(ArgumentError) do + UsageCredits::PeriodParser.parse_persisted_period("0.seconds") + end + assert_raises(ArgumentError) do + UsageCredits::PeriodParser.parse_persisted_period(0.seconds) + end + + refute UsageCredits::PeriodParser.valid_persisted_period_format?("0.seconds") + assert_equal 1.second, UsageCredits::PeriodParser.parse_persisted_period("1.second") + end + test "parse_period with new time units when configured" do UsageCredits.configuration.min_fulfillment_period = 1.second diff --git a/test/helpers/processor_metadata_test.rb b/test/helpers/processor_metadata_test.rb new file mode 100644 index 0000000..d4ae0ee --- /dev/null +++ b/test/helpers/processor_metadata_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProcessorMetadataTest < ActiveSupport::TestCase + test "normalizes keys and serializes structured values as JSON strings" do + metadata = UsageCredits::ProcessorMetadata.normalize( + purchase_type: "credit_pack", + credits: 100, + rollover: false, + details: {tier: "pro"}, + tags: %w[one two] + ) + + assert_equal "credit_pack", metadata["purchase_type"] + assert_equal "100", metadata["credits"] + assert_equal "false", metadata["rollover"] + assert_equal({"tier" => "pro"}, ActiveSupport::JSON.decode(metadata["details"])) + assert_equal %w[one two], ActiveSupport::JSON.decode(metadata["tags"]) + end + + test "enforces processor key and value limits before checkout" do + assert_raises(ArgumentError) do + UsageCredits::ProcessorMetadata.normalize("x" * 41 => "value") + end + assert_raises(ArgumentError) do + UsageCredits::ProcessorMetadata.normalize(value: "x" * 501) + end + assert_raises(ArgumentError) do + UsageCredits::ProcessorMetadata.normalize((1..51).to_h { |index| ["key_#{index}", index] }) + end + end + + test "rejects non hash metadata" do + error = assert_raises(ArgumentError) do + UsageCredits::ProcessorMetadata.normalize("not metadata") + end + + assert_includes error.message, "hash-like" + end +end diff --git a/test/integration/coexistence_test.rb b/test/integration/coexistence_test.rb new file mode 100644 index 0000000..805e3a2 --- /dev/null +++ b/test/integration/coexistence_test.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require "test_helper" + +# This test verifies that the wallets gem and usage_credits gem can coexist +# in the same Rails application without conflicts. +# +# Plan requirements tested: +# - One model uses direct has_wallets (Team) +# - One model uses has_credits (User) +# - Direct wallets write to wallets_* tables +# - Usage credits write to usage_credits_* tables +# - Cross-transfer between gems is rejected +class CoexistenceTest < ActiveSupport::TestCase + # Load both wallets gem fixtures and usage_credits fixtures + fixtures :teams, :users + + setup do + # Ensure clean state for wallets gem tables + Wallets::Wallet.where(owner_type: "Team").delete_all + end + + test "Team model uses has_wallets from wallets gem" do + team = teams(:alpha_team) + + # Should be able to create a wallet via the wallets gem + wallet = team.wallet(:points) + + assert_instance_of Wallets::Wallet, wallet + assert_equal "points", wallet.asset_code + assert_equal "Team", wallet.owner_type + assert_equal team.id, wallet.owner_id + end + + test "User model uses has_credits from usage_credits gem" do + user = users(:rich_user) + wallet = user.credit_wallet + + assert_instance_of UsageCredits::Wallet, wallet + assert_equal "credits", wallet.asset_code + assert_equal "User", wallet.owner_type + assert_equal user.id, wallet.owner_id + end + + test "wallets gem writes to wallets_wallets table" do + team = teams(:alpha_team) + team.wallet(:points).credit(100, category: :reward) + + # Verify data is in wallets_wallets table + wallet_record = Wallets::Wallet.find_by(owner_type: "Team", owner_id: team.id, asset_code: "points") + assert_not_nil wallet_record + assert_equal 100, wallet_record.balance + + # Verify NOT in usage_credits_wallets table + uc_record = UsageCredits::Wallet.find_by(owner_type: "Team", owner_id: team.id) + assert_nil uc_record + end + + test "usage_credits gem writes to usage_credits_wallets table" do + user = User.create!(email: "coexist-#{SecureRandom.hex(4)}@example.com", name: "Coexist User") + user.give_credits(100, reason: "test") + + # Verify data is in usage_credits_wallets table + uc_record = UsageCredits::Wallet.find_by(owner_type: "User", owner_id: user.id, asset_code: "credits") + assert_not_nil uc_record + assert_equal 100, uc_record.balance + + # Verify NOT in wallets_wallets table + wallet_record = Wallets::Wallet.find_by(owner_type: "User", owner_id: user.id) + assert_nil wallet_record + end + + test "wallets gem transfers stay within wallets_* tables" do + team1 = teams(:alpha_team) + team2 = teams(:beta_team) + + team1.wallet(:points).credit(100, category: :reward) + team2.wallet(:points) # Ensure wallet exists + + transfer = nil + + assert_no_difference -> { UsageCredits::Transfer.count } do + assert_no_difference -> { UsageCredits::Transaction.count } do + assert_no_difference -> { UsageCredits::Allocation.count } do + transfer = team1.wallet(:points).transfer_to(team2.wallet(:points), 30, category: :gift) + end + end + end + + # Verify transfer is in wallets_transfers table + assert_instance_of Wallets::Transfer, transfer + assert_equal 30, transfer.amount + assert_equal 70, team1.wallet(:points).reload.balance + assert_equal 30, team2.wallet(:points).reload.balance + end + + test "usage_credits gem transfers stay within usage_credits_* tables" do + user1 = User.create!(email: "sender-coex-#{SecureRandom.hex(4)}@example.com", name: "Sender") + user2 = User.create!(email: "recipient-coex-#{SecureRandom.hex(4)}@example.com", name: "Recipient") + + user1.give_credits(100, reason: "test") + + transfer = nil + + assert_difference -> { UsageCredits::Transfer.count }, 1 do + assert_difference -> { UsageCredits::Transaction.count }, 2 do + assert_no_difference -> { Wallets::Transfer.count } do + assert_no_difference -> { Wallets::Transaction.count } do + assert_no_difference -> { Wallets::Allocation.count } do + transfer = user1.credit_wallet.transfer_to(user2.credit_wallet, 30, category: :gift) + end + end + end + end + end + + # Verify transfer is in usage_credits_transfers table + assert_instance_of UsageCredits::Transfer, transfer + assert_equal 30, transfer.amount + assert_equal 70, user1.credits + assert_equal 30, user2.credits + assert_instance_of UsageCredits::Transaction, transfer.outbound_transaction + assert_equal [UsageCredits::Transaction], transfer.inbound_transactions.map(&:class).uniq + assert_equal 1, transfer.inbound_transactions.count + assert_equal "preserve", transfer.expiration_policy + end + + test "destroying a usage credits wallet preserves its counterparty ledger" do + sender = User.create!(email: "sender-destroy-#{SecureRandom.hex(4)}@example.com", name: "Sender") + recipient = User.create!(email: "recipient-destroy-#{SecureRandom.hex(4)}@example.com", name: "Recipient") + sender.give_credits(100, reason: "test") + transfer = sender.credit_wallet.transfer_to(recipient.credit_wallet, 30, category: :gift) + recipient_transaction = transfer.inbound_transaction + transfer_id = transfer.id + sender_wallet_id = sender.credit_wallet.id + sender_id = sender.id + + sender.destroy! + + assert_nil UsageCredits::Transfer.find_by(id: transfer_id) + assert_equal 30, recipient.credit_wallet.reload.credits + assert_nil recipient_transaction.reload.transfer_id + assert_equal transfer_id, recipient_transaction.metadata["transfer_id"] + assert_equal sender_wallet_id, recipient_transaction.metadata["counterparty_wallet_id"] + assert_equal sender_id, recipient_transaction.metadata["counterparty_owner_id"] + assert_equal "User", recipient_transaction.metadata["counterparty_owner_type"] + end + + test "destroying a receiving usage credits wallet preserves the sender ledger" do + sender = User.create!(email: "sender-receiver-destroy-#{SecureRandom.hex(4)}@example.com", name: "Sender") + recipient = User.create!(email: "recipient-receiver-destroy-#{SecureRandom.hex(4)}@example.com", name: "Recipient") + sender.give_credits(100, reason: "test") + transfer = sender.credit_wallet.transfer_to(recipient.credit_wallet, 30, category: :gift) + sender_transaction = transfer.outbound_transaction + + recipient.destroy! + + assert_nil UsageCredits::Transfer.find_by(id: transfer.id) + assert_equal 70, sender.credit_wallet.reload.credits + assert_nil sender_transaction.reload.transfer_id + assert_equal transfer.id, sender_transaction.metadata["transfer_id"] + end + + test "cross-gem transfers are rejected" do + team = teams(:alpha_team) + user = User.create!(email: "cross-#{SecureRandom.hex(4)}@example.com", name: "Cross User") + + # Use same asset code for both to test class mismatch specifically + # (asset mismatch check happens before class check in transfer_to) + team.wallet(:credits).credit(100, category: :reward) + user.give_credits(100, reason: "test") + + # Attempting to transfer from wallets gem wallet to usage_credits gem wallet + # should fail because the wallet classes are different + error = assert_raises(Wallets::InvalidTransfer) do + team.wallet(:credits).transfer_to(user.credit_wallet, 30, category: :gift) + end + assert_equal "Wallet classes must match", error.message + + # Reverse direction should also fail + error = assert_raises(UsageCredits::InvalidTransfer) do + user.credit_wallet.transfer_to(team.wallet(:credits), 30, category: :gift) + end + assert_equal "Wallet classes must match", error.message + end + + test "transactions use correct classes and tables per gem" do + team = teams(:alpha_team) + user = User.create!(email: "tx-#{SecureRandom.hex(4)}@example.com", name: "TX User") + + team.wallet(:points).credit(100, category: :reward) + user.give_credits(100, reason: "test") + + # Wallets gem transactions + team_transactions = team.wallet(:points).transactions + assert team_transactions.all? { |tx| tx.is_a?(Wallets::Transaction) } + + # Usage credits transactions + user_transactions = user.credit_wallet.transactions + assert user_transactions.all? { |tx| tx.is_a?(UsageCredits::Transaction) } + end + + test "callbacks are isolated between gems" do + wallets_callback_fired = false + usage_credits_callback_fired = false + + original_wallets_callback = Wallets.configuration.instance_variable_get(:@on_balance_credited_callback) + original_uc_callback = UsageCredits.configuration.instance_variable_get(:@on_credits_added_callback) + + begin + Wallets.configure do |config| + config.on_balance_credited { |_ctx| wallets_callback_fired = true } + end + + UsageCredits.configure do |config| + config.on_credits_added { |_ctx| usage_credits_callback_fired = true } + end + + # Credit via wallets gem + team = teams(:alpha_team) + team.wallet(:points).credit(50, category: :reward) + + assert wallets_callback_fired, "Wallets gem callback should have fired" + assert_not usage_credits_callback_fired, "Usage credits callback should NOT have fired for wallets gem operation" + + # Reset flags + wallets_callback_fired = false + usage_credits_callback_fired = false + + # Credit via usage_credits gem + user = User.create!(email: "callback-#{SecureRandom.hex(4)}@example.com", name: "Callback User") + user.give_credits(50, reason: "test") + + assert usage_credits_callback_fired, "Usage credits callback should have fired" + assert_not wallets_callback_fired, "Wallets gem callback should NOT have fired for usage_credits operation" + ensure + # Configuration is global process state. Always restore it, even when an + # assertion or ledger operation fails, so later tests cannot be polluted. + Wallets.configuration.instance_variable_set(:@on_balance_credited_callback, original_wallets_callback) + UsageCredits.configuration.instance_variable_set(:@on_credits_added_callback, original_uc_callback) + end + end +end diff --git a/test/integration/wallet_callbacks_test.rb b/test/integration/wallet_callbacks_test.rb index 27cbdbe..8a3b884 100644 --- a/test/integration/wallet_callbacks_test.rb +++ b/test/integration/wallet_callbacks_test.rb @@ -215,9 +215,8 @@ class WalletCallbacksIntegrationTest < ActiveSupport::TestCase current_period_end: 1.month.from_now ) - # Wait for initial setup callback + # Ignore the initial setup callback; this assertion targets the upgrade. events.clear - initial_balance = @user.credit_wallet.reload.credits # Check if fulfillment was created fulfillment = UsageCredits::Fulfillment.find_by(source: subscription) diff --git a/test/models/concerns/has_wallet_test.rb b/test/models/concerns/has_wallet_test.rb index 012108c..c38354d 100644 --- a/test/models/concerns/has_wallet_test.rb +++ b/test/models/concerns/has_wallet_test.rb @@ -40,6 +40,10 @@ class HasWalletTest < ActiveSupport::TestCase # AUTOMATIC WALLET CREATION # ======================================== + test "default asset code is the single credits asset" do + assert_equal "credits", UsageCredits::DEFAULT_ASSET_CODE + end + test "wallet is automatically created on user creation" do user = User.create!(email: "autowallet@example.com", name: "Auto Wallet User") @@ -62,6 +66,23 @@ def self.name assert_equal false, test_class.credit_options[:auto_create] end + test "auto-create disabled reads an existing wallet after a cached miss" do + test_class = Class.new(User) do + def self.name + "TestUserNoAutoWalletWithExistingRow" + end + + has_credits auto_create: false + end + + user = test_class.create!(email: "no-auto-#{SecureRandom.hex(4)}@example.com", name: "No Auto") + assert_nil user.credit_wallet + + wallet = UsageCredits::Wallet.create_for_owner!(owner: user, asset_code: "credits") + + assert_equal wallet, user.credit_wallet + end + test "wallet is created with default balance of zero" do user = User.create!(email: "defaultbal@example.com", name: "Default Balance User") @@ -82,6 +103,75 @@ def self.name assert_equal 100, test_class.credit_options[:initial_balance] end + test "initial_balance is applied through a manual_adjustment transaction" do + test_class = Class.new(User) do + def self.name + "TestUserWithInitialBalanceLedgerBootstrap" + end + + has_credits initial_balance: 100 + end + + user = test_class.create!(email: "initial-balance-#{SecureRandom.hex(4)}@example.com", name: "Initial Balance User") + wallet = user.credit_wallet + + assert_equal 100, user.credits + assert_equal 1, wallet.transactions.count + assert_equal "manual_adjustment", wallet.transactions.first.category + assert_equal "initial_balance", wallet.transactions.first.metadata["reason"] + end + + test "fractional initial_balance is rejected instead of truncated" do + test_class = Class.new(User) do + def self.name + "TestUserWithFractionalInitialBalance" + end + + has_credits initial_balance: 10.5 + end + + email = "fractional-#{SecureRandom.hex(4)}@example.com" + error = assert_raises(ArgumentError) do + test_class.create!(email: email, name: "Fractional") + end + + assert_includes error.message, "whole number" + assert_nil User.find_by(email: email) + end + + test "credit options are immutable snapshots" do + test_class = Class.new(User) do + self.table_name = "users" + has_credits initial_balance: 25 + end + + assert_predicate test_class.credit_options, :frozen? + assert_raises(FrozenError) { test_class.credit_options[:initial_balance] = 100 } + end + + test "usage credits wallet create_for_owner applies initial_balance via manual_adjustment once" do + wallet = UsageCredits::Wallet.create_for_owner!( + owner: users(:new_user), + asset_code: :credits, + initial_balance: 60 + ) + + assert_no_difference -> { UsageCredits::Wallet.where(owner: users(:new_user), asset_code: "credits").count } do + same_wallet = UsageCredits::Wallet.create_for_owner!( + owner: users(:new_user), + asset_code: "CREDITS", + initial_balance: 999 + ) + + assert_equal wallet.id, same_wallet.id + end + + assert_equal 60, wallet.reload.balance + assert_equal 1, wallet.transactions.count + assert_equal "manual_adjustment", wallet.transactions.sole.category + assert_equal "initial_balance", wallet.transactions.sole.metadata["reason"] + end + # ======================================== # ASSOCIATIONS # ======================================== @@ -125,6 +215,12 @@ def self.name assert_equal user.credit_wallet, user.wallet end + test "does not expose plural credit_wallets association" do + user = users(:rich_user) + + refute_respond_to user, :credit_wallets + end + # ======================================== # WALLET AUTO-CREATION (ensure_credit_wallet) # ======================================== @@ -163,6 +259,32 @@ def self.name assert_nil user.original_credit_wallet end + test "ensure_credit_wallet reuses an existing wallet through the core lookup without querying the association first" do + test_class = Class.new(User) do + def self.name + "TestUserWithExistingWalletLookup" + end + + has_credits initial_balance: 80 + end + + user = test_class.create!(email: "lookup-#{SecureRandom.hex(4)}@example.com", name: "Lookup User") + existing_wallet = user.credit_wallet + + user.association(:credit_wallet).reset + user.expects(:original_credit_wallet).never + + assert_no_difference -> { UsageCredits::Wallet.where(owner: user, asset_code: "credits").count } do + wallet = user.send(:ensure_credit_wallet) + + assert_equal existing_wallet.id, wallet.id + end + + assert_equal 80, existing_wallet.reload.balance + assert_equal 1, existing_wallet.transactions.count + assert_equal "manual_adjustment", existing_wallet.transactions.sole.category + end + # ======================================== # METHOD DELEGATION # ======================================== @@ -298,6 +420,28 @@ def self.name assert_equal 500, options[:initial_balance] end + test "credit options are inherited and can be overridden by subclasses" do + parent_class = Class.new(User) do + def self.name + "TestUserCreditOptionsParent" + end + + has_credits auto_create: false, initial_balance: 500 + end + child_class = Class.new(parent_class) do + def self.name + "TestUserCreditOptionsChild" + end + end + + assert_equal({auto_create: false, initial_balance: 500}, child_class.credit_options) + + child_class.has_credits initial_balance: 25 + + assert_equal({auto_create: true, initial_balance: 25}, child_class.credit_options) + assert_equal({auto_create: false, initial_balance: 500}, parent_class.credit_options) + end + # ======================================== # EDGE CASES # ======================================== diff --git a/test/models/concerns/pay_charge_extension_test.rb b/test/models/concerns/pay_charge_extension_test.rb index 1c1f446..47454f7 100644 --- a/test/models/concerns/pay_charge_extension_test.rb +++ b/test/models/concerns/pay_charge_extension_test.rb @@ -75,7 +75,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "status" => "succeeded", "amount_captured" => 4900 }, + object: {"status" => "succeeded", "amount_captured" => 4900}, data: {} # Empty data to simulate Pay 10+ behavior ) @@ -86,7 +86,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "status" => "failed", "amount_captured" => 0 }, + object: {"status" => "failed", "amount_captured" => 0}, data: {} ) @@ -98,7 +98,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase type: "Pay::Stripe::Charge", amount: 4900, object: {}, # Empty object - data: { "status" => "succeeded", "amount_captured" => 4900 } + data: {"status" => "succeeded", "amount_captured" => 4900} ) assert charge.succeeded? @@ -109,8 +109,8 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "status" => "failed", "amount_captured" => 0 }, - data: { "status" => "succeeded", "amount_captured" => 4900 } # Legacy data should be ignored + object: {"status" => "failed", "amount_captured" => 0}, + data: {"status" => "succeeded", "amount_captured" => 4900} # Legacy data should be ignored ) # Should use object (failed), not data (succeeded) @@ -121,7 +121,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - data: { amount_captured: 4900 } + data: {amount_captured: 4900} ) assert charge.succeeded? @@ -136,8 +136,8 @@ class PayChargeExtensionTest < ActiveSupport::TestCase test "charge_object_data returns object when present (Pay 10+)" do charge = Pay::Charge.new( - object: { "status" => "succeeded", "id" => "ch_123" }, - data: { "status" => "failed" } + object: {"status" => "succeeded", "id" => "ch_123"}, + data: {"status" => "failed"} ) result = charge.send(:charge_object_data) @@ -148,7 +148,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase test "charge_object_data returns data when object is empty (legacy Pay)" do charge = Pay::Charge.new( object: {}, - data: { "status" => "succeeded", "id" => "ch_456" } + data: {"status" => "succeeded", "id" => "ch_456"} ) result = charge.send(:charge_object_data) @@ -167,7 +167,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase # Some older Pay versions might have nil instead of empty hash charge = Pay::Charge.new( object: nil, - data: { "status" => "succeeded", "id" => "ch_nil_object" } + data: {"status" => "succeeded", "id" => "ch_nil_object"} ) result = charge.send(:charge_object_data) @@ -185,7 +185,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "status" => "pending", "amount_captured" => 0 }, + object: {"status" => "pending", "amount_captured" => 0}, data: {} ) @@ -196,7 +196,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "status" => "canceled", "amount_captured" => 0 }, + object: {"status" => "canceled", "amount_captured" => 0}, data: {} ) @@ -208,7 +208,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase type: "Pay::Stripe::Charge", amount: 4900, object: {}, - data: { "status" => "pending", "amount_captured" => 0 } + data: {"status" => "pending", "amount_captured" => 0} ) assert_not charge.succeeded? @@ -219,7 +219,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "amount_captured" => 4900 }, # No status field + object: {"amount_captured" => 4900}, # No status field data: {} ) @@ -230,7 +230,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "amount_captured" => 0 }, # No status, no capture + object: {"amount_captured" => 0}, # No status, no capture data: {} ) @@ -242,7 +242,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase charge = Pay::Charge.new( type: "Pay::Stripe::Charge", amount: 4900, - object: { "amount_captured" => 2000 }, # Only partially captured + object: {"amount_captured" => 2000}, # Only partially captured data: {} ) @@ -452,6 +452,41 @@ class PayChargeExtensionTest < ActiveSupport::TestCase assert_equal 1000, wallet.reload.credits end + test "destroying an eligible unfulfilled charge never awards credits" do + user = User.create!(email: "destroy-charge-#{SecureRandom.hex(4)}@example.com", name: "Destroy Charge") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_destroy_charge_#{SecureRandom.hex(4)}" + ) + charge = Pay::Charge.create!( + customer: customer, + type: "Pay::Charge", + processor_id: "ch_destroy_charge_#{SecureRandom.hex(4)}", + amount: 4900, + currency: "usd", + amount_refunded: 0, + metadata: {}, + data: {status: "succeeded"} + ) + + # Simulate processor metadata arriving without callbacks, then removal of + # the Pay row. A destroy commit must never become a fulfillment trigger. + charge.update_columns( + metadata: { + purchase_type: "credit_pack", + pack_name: "starter", + credits: 1000, + bonus_credits: 0 + } + ) + + assert_no_difference [-> { wallet.reload.credits }, -> { UsageCredits::Fulfillment.count }] do + charge.destroy! + end + end + test "charge creates fulfillment record" do # Create a fresh user to avoid fixture wallet conflicts user = User.create!(email: "fulfill2_test@example.com", name: "Fulfill2 Test User") @@ -477,7 +512,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase credits: 1000, price_cents: 4900 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) fulfillment = UsageCredits::Fulfillment.find_by(source: charge) @@ -485,6 +520,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase assert_equal "credit_pack", fulfillment.fulfillment_type assert_equal 1000, fulfillment.credits_last_fulfillment assert_nil fulfillment.next_fulfillment_at # One-time, not recurring + assert_equal fulfillment, user.credit_wallet.transactions.find_by!(category: "credit_pack_purchase").fulfillment end end @@ -513,7 +549,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase bonus_credits: 100, price_cents: 2900 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) end end @@ -541,7 +577,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase credits: 1000, price_cents: 4900 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) transaction = wallet.transactions.find_by(category: "credit_pack_purchase") @@ -574,8 +610,8 @@ class PayChargeExtensionTest < ActiveSupport::TestCase processor_id: "ch_nonpack_test", amount: 1999, currency: "usd", - metadata: { product: "other_product" }, - data: { status: "succeeded" } + metadata: {product: "other_product"}, + data: {status: "succeeded"} ) end end @@ -598,8 +634,8 @@ class PayChargeExtensionTest < ActiveSupport::TestCase processor_id: "ch_no_pack_name", amount: 4900, currency: "usd", - metadata: { purchase_type: "credit_pack" }, # Missing pack_name - data: { status: "succeeded" } + metadata: {purchase_type: "credit_pack"}, # Missing pack_name + data: {status: "succeeded"} ) end end @@ -631,7 +667,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) end end @@ -659,7 +695,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "failed", amount_captured: 0 } + data: {status: "failed", amount_captured: 0} ) end end @@ -688,7 +724,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded", refunded: true } + data: {status: "succeeded", refunded: true} ) end end @@ -720,7 +756,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # Credits should now be 1000 @@ -754,7 +790,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # After creation, should be fulfilled @@ -772,7 +808,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase # PACK VALIDATION # ======================================== - test "charge with unknown pack name is ignored" do + test "charge is fulfilled from its checkout snapshot after pack removal" do # Use fixture user with existing wallet user = users(:new_user) wallet = user.credit_wallet @@ -783,7 +819,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase processor_id: "cus_test_unknown_pack" ) - assert_no_difference -> { wallet.reload.credits } do + assert_difference -> { wallet.reload.credits }, 1000 do Pay::Charge.create!( customer: customer, type: "Pay::Charge", @@ -793,14 +829,17 @@ class PayChargeExtensionTest < ActiveSupport::TestCase metadata: { purchase_type: "credit_pack", pack_name: "nonexistent_pack", - credits: 1000 + credits: 1000, + bonus_credits: 0, + price_cents: 4900, + price_currency: "USD" }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) end end - test "charge with mismatched credits is ignored" do + test "charge honors checkout quantities when the configured pack later changes" do # Use fixture user with existing wallet user = users(:new_user) wallet = user.credit_wallet @@ -811,7 +850,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase processor_id: "cus_test_mismatch" ) - assert_no_difference -> { wallet.reload.credits } do + assert_difference -> { wallet.reload.credits }, 999 do Pay::Charge.create!( customer: customer, type: "Pay::Charge", @@ -821,13 +860,21 @@ class PayChargeExtensionTest < ActiveSupport::TestCase metadata: { purchase_type: "credit_pack", pack_name: "starter", - credits: 999 # Starter pack should give 1000 + credits: 999, + bonus_credits: 0 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) end end + test "fractional numeric checkout quantities are rejected instead of truncated" do + charge = pay_charges(:completed_charge) + charge.metadata["credits"] = 10.5 + + assert_nil charge.send(:credit_pack_snapshot) + end + # ======================================== # REFUND HANDLING - FULL REFUND # ======================================== @@ -856,7 +903,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) assert_equal 1000, wallet.reload.credits @@ -890,7 +937,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) charge.update!(amount_refunded: 4900) @@ -902,6 +949,76 @@ class PayChargeExtensionTest < ActiveSupport::TestCase assert_equal true, refund_tx.metadata["credits_refunded"] assert_equal 1.0, refund_tx.metadata["refund_percentage"] assert_equal 4900, refund_tx.metadata["refund_amount_cents"] + assert_equal UsageCredits::Fulfillment.find_by!(source: charge), refund_tx.fulfillment + end + + test "refund uses the purchased fulfillment snapshot after configuration changes" do + user = User.create!(email: "refund_snapshot@example.com", name: "Refund Snapshot User") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_refund_snapshot" + ) + charge = Pay::Charge.create!( + customer: customer, + type: "Pay::Charge", + processor_id: "ch_refund_snapshot", + amount: 4900, + currency: "usd", + metadata: { + purchase_type: "credit_pack", + pack_name: "starter", + credits: 1000, + bonus_credits: 0, + price_cents: 4900 + }, + data: {status: "succeeded"} + ) + + UsageCredits.configure do |config| + config.credit_pack :starter do + gives 5000.credits + costs 199.dollars + end + end + + assert_difference -> { wallet.reload.credits }, -1000 do + charge.update!(amount_refunded: 4900) + end + end + + test "refund does not create debt when the purchase was never fulfilled" do + user = User.create!(email: "unfulfilled_refund@example.com", name: "Unfulfilled Refund User") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_unfulfilled_refund" + ) + + charge = Pay::Charge.create!( + customer: customer, + type: "Pay::Stripe::Charge", + processor_id: "ch_unfulfilled_refund", + amount: 4900, + currency: "usd", + amount_refunded: 0, + metadata: { + purchase_type: "credit_pack", + pack_name: "starter", + credits: 1000, + bonus_credits: 0 + }, + object: {status: "failed", amount_captured: 0}, + data: {} + ) + + assert_not UsageCredits::Fulfillment.exists?(source: charge) + assert_no_difference -> { wallet.reload.transactions.count } do + charge.update!(amount_refunded: 4900) + end + assert_equal 0, wallet.reload.credits end # ======================================== @@ -931,7 +1048,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) assert_equal 1000, wallet.reload.credits @@ -966,7 +1083,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # Refund 30% (1470 cents) should deduct ceil(1000 * 0.3) = 300 credits @@ -998,7 +1115,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) initial_credits = wallet.reload.credits @@ -1016,7 +1133,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase # REFUND HANDLING - EDGE CASES # ======================================== - test "refund when credits already spent raises InsufficientCredits" do + test "refund after credits are spent records debt that future credits repay" do # Create a fresh user to avoid fixture wallet conflicts user = User.create!(email: "insufficient@example.com", name: "Insufficient User") wallet = user.credit_wallet # Auto-created wallet @@ -1039,7 +1156,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # Verify credits were added @@ -1049,10 +1166,20 @@ class PayChargeExtensionTest < ActiveSupport::TestCase wallet.deduct_credits(1000, category: "operation_charge", metadata: {}) assert_equal 0, wallet.reload.credits - # Try to refund - should raise InsufficientCredits - assert_raises(UsageCredits::InsufficientCredits) do - charge.update!(amount_refunded: 4900) - end + # The cash refund has already happened at the processor. Its ledger + # reversal must therefore persist even if the purchased credits were used. + assert_nothing_raised { charge.update!(amount_refunded: 4900) } + + refund_transaction = wallet.transactions.find_by!(category: "credit_pack_refund") + assert_equal(-1000, refund_transaction.amount) + assert_equal 1000, refund_transaction.unbacked_amount + assert_equal 0, wallet.reload.credits + + wallet.give_credits(600, reason: "later_grant") + assert_equal 0, wallet.reload.credits + + wallet.give_credits(500, reason: "later_grant") + assert_equal 100, wallet.reload.credits end test "refund without pack_name is ignored" do @@ -1074,8 +1201,8 @@ class PayChargeExtensionTest < ActiveSupport::TestCase amount: 4900, currency: "usd", amount_refunded: 0, - metadata: { purchase_type: "credit_pack" }, # Missing pack_name - data: { status: "succeeded" } + metadata: {purchase_type: "credit_pack"}, # Missing pack_name + data: {status: "succeeded"} ) # Try to refund - should be ignored @@ -1108,7 +1235,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # Try to refund - should be ignored (no wallet to deduct from) @@ -1140,7 +1267,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # Verify credits were added @@ -1179,7 +1306,7 @@ class PayChargeExtensionTest < ActiveSupport::TestCase pack_name: "starter", credits: 1000 }, - data: { status: "succeeded" } + data: {status: "succeeded"} ) # Process refund @@ -1194,40 +1321,6 @@ class PayChargeExtensionTest < ActiveSupport::TestCase end end - test "credits_already_refunded? detects processed refund" do - # Create a fresh user to avoid fixture wallet conflicts - user = User.create!(email: "refund_check@example.com", name: "Refund Check User") - user.credit_wallet # Ensure wallet exists - - customer = Pay::Customer.create!( - owner: user, - processor: :fake_processor, - processor_id: "cus_test_refund_check" - ) - - charge = Pay::Charge.create!( - customer: customer, - type: "Pay::Charge", - processor_id: "ch_refund_check", - amount: 4900, - currency: "usd", - amount_refunded: 0, - metadata: { - purchase_type: "credit_pack", - pack_name: "starter", - credits: 1000 - }, - data: { status: "succeeded" } - ) - - # Before refund - assert_not charge.send(:credits_already_refunded?) - - # After refund - charge.update!(amount_refunded: 4900) - assert charge.send(:credits_already_refunded?) - end - # ======================================== # INTEGRATION WITH EXISTING FIXTURES # ======================================== diff --git a/test/models/concerns/pay_subscription_extension_test.rb b/test/models/concerns/pay_subscription_extension_test.rb index e3d6128..db665de 100644 --- a/test/models/concerns/pay_subscription_extension_test.rb +++ b/test/models/concerns/pay_subscription_extension_test.rb @@ -49,6 +49,27 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase signup_bonus 200.credits unused_credits :expire end + + config.subscription_plan :stripe_pause_pro do + processor_plan(:stripe, "stripe_pause_pro") + gives 500.credits.every(:month) + signup_bonus 100.credits + unused_credits :expire + end + + config.subscription_plan :stripe_pause_premium do + processor_plan(:stripe, "stripe_pause_premium") + gives 2000.credits.every(:month) + signup_bonus 200.credits + unused_credits :expire + end + + config.subscription_plan :lemon_trial do + processor_plan(:lemon_squeezy, "lemon_trial") + gives 500.credits.every(:month) + trial_includes 75.credits + unused_credits :expire + end end end @@ -135,6 +156,182 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase end end + test "effective Stripe pause blocks initial credits even though raw status is active" do + wallet, customer = stripe_subscription_context("initial-paused") + subscription = nil + + assert_no_difference [-> { wallet.reload.credits }, -> { UsageCredits::Fulfillment.count }] do + subscription = create_stripe_subscription( + customer, + processor_plan: "stripe_pause_pro", + pause_behavior: "void", + pause_starts_at: 1.minute.ago + ) + end + + assert_equal "active", subscription.status + assert_not subscription.active? + assert_not subscription.eligible_for_usage_credit_fulfillment?(include_trial: true) + end + + test "scheduled Stripe pause remains eligible until its effective time" do + wallet, customer = stripe_subscription_context("scheduled-pause") + subscription = nil + + assert_difference -> { wallet.reload.credits }, 600 do + subscription = create_stripe_subscription( + customer, + processor_plan: "stripe_pause_pro", + pause_behavior: "void", + pause_starts_at: 1.day.from_now + ) + end + + assert subscription.active? + assert subscription.eligible_for_usage_credit_fulfillment? + assert UsageCredits::Fulfillment.exists?(source: subscription) + end + + test "processor on_trial status receives only its trial credits" do + token = SecureRandom.hex(4) + user = User.create!(email: "lemon-trial-#{token}@example.com", name: "Lemon Trial") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :lemon_squeezy, + processor_id: "cus_lemon_trial_#{token}" + ) + subscription = nil + + assert_difference -> { wallet.reload.credits }, 75 do + subscription = Pay::LemonSqueezy::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_lemon_trial_#{token}", + processor_plan: "lemon_trial", + status: "on_trial", + quantity: 1, + trial_ends_at: 1.week.from_now + ) + end + + assert subscription.eligible_for_usage_credit_fulfillment?(include_trial: true) + assert_not subscription.eligible_for_usage_credit_fulfillment? + assert_equal "trial", UsageCredits::Fulfillment.find_by!(source: subscription).metadata["initial_award_state"] + assert_equal 0, wallet.transactions.where(category: "subscription_credits").count + end + + test "plan change during Stripe pause is deferred without minting and applied on resume" do + wallet, customer = stripe_subscription_context("paused-plan-change") + subscription = create_stripe_subscription(customer, processor_plan: "stripe_pause_pro") + fulfillment = UsageCredits::Fulfillment.find_by!(source: subscription) + + assert_equal 600, wallet.reload.credits + + subscription.update_columns( + pause_behavior: "void", + pause_starts_at: 1.minute.ago, + updated_at: Time.current + ) + subscription.reload + + assert_no_difference -> { wallet.reload.credits } do + subscription.update!(processor_plan: "stripe_pause_premium") + end + + fulfillment.reload + assert_equal "stripe_pause_pro", fulfillment.metadata["plan"] + assert_equal "stripe_pause_premium", fulfillment.metadata["deferred_plan_change"] + assert_equal 0, wallet.transactions.where(category: "subscription_upgrade").count + + assert_no_difference -> { wallet.reload.credits } do + subscription.update!(pause_behavior: nil, pause_starts_at: nil) + end + + fulfillment.reload + assert_equal "stripe_pause_premium", fulfillment.metadata["plan"] + assert_equal 2000, fulfillment.metadata["credits_per_period"] + assert_nil fulfillment.metadata["deferred_plan_change"] + assert_equal 0, wallet.transactions.where(category: "subscription_upgrade").count + + fulfillment.update_columns( + last_fulfilled_at: 1.month.ago, + next_fulfillment_at: 1.second.ago + ) + assert subscription.reload.eligible_for_usage_credit_fulfillment? + assert fulfillment.reload.due_for_fulfillment? + assert_difference -> { wallet.reload.credits }, 2000 do + UsageCredits::FulfillmentService.new(fulfillment.reload).process + end + end + + test "multiple plan changes while paused preserve one deferred source of truth" do + wallet, customer = stripe_subscription_context("paused-multiple-plan-changes") + subscription = create_stripe_subscription(customer, processor_plan: "stripe_pause_pro") + fulfillment = UsageCredits::Fulfillment.find_by!(source: subscription) + + subscription.update_columns( + pause_behavior: "void", + pause_starts_at: 1.minute.ago, + updated_at: Time.current + ) + + assert_no_difference -> { wallet.reload.credits } do + subscription.update!(processor_plan: "non_credit_plan") + subscription.update!(processor_plan: "stripe_pause_premium") + end + + fulfillment.reload + assert fulfillment.stopped? + assert_equal "stripe_pause_premium", fulfillment.metadata["deferred_plan_change"] + assert_equal "stripe_pause_premium", fulfillment.metadata.dig("deferred_plan_snapshot", "plan") + + assert_no_difference -> { wallet.reload.credits } do + subscription.update!(pause_behavior: nil, pause_starts_at: nil) + end + + fulfillment.reload + assert_not fulfillment.stopped? + assert_equal "stripe_pause_premium", fulfillment.metadata["plan"] + assert_equal 2000, fulfillment.metadata["credits_per_period"] + assert_nil fulfillment.metadata["deferred_plan_change"] + assert_nil fulfillment.metadata["stopped_reason"] + assert_nil fulfillment.metadata["stopped_plan"] + end + + test "unrelated subscription updates skip deferred resume reconciliation" do + _wallet, customer = stripe_subscription_context("unrelated-update") + subscription = create_stripe_subscription(customer, processor_plan: "stripe_pause_pro") + + subscription.expects(:apply_deferred_plan_change_after_resume).never + subscription.update!(quantity: 2) + end + + test "destroying a subscription does not run credit lifecycle callbacks" do + user = User.create!(email: "destroy-subscription-#{SecureRandom.hex(4)}@example.com", name: "Destroy Subscription") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_destroy_subscription_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_destroy_subscription_#{SecureRandom.hex(4)}", + processor_plan: "pro_plan_monthly", + status: "incomplete", + quantity: 1 + ) + + callback = -> { raise "credit lifecycle callback ran after destroy" } + subscription.stub(:handle_initial_award_and_fulfillment_setup, callback) do + assert_no_difference [-> { wallet.reload.credits }, -> { UsageCredits::Fulfillment.count }] do + subscription.destroy! + end + end + end + test "active subscription awards signup bonus separately" do # Create a fresh user to avoid fixture wallet conflicts user = User.create!(email: "sub_bonus@example.com", name: "Sub Bonus User") @@ -187,6 +384,71 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase assert_equal 500, sub_credit_tx.amount end + test "active subscription honors validated checkout terms after plan removal" do + user = User.create!(email: "snapshot-active-#{SecureRandom.hex(4)}@example.com", name: "Snapshot Active") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_snapshot_active_#{SecureRandom.hex(4)}" + ) + + assert_difference -> { wallet.reload.credits }, 320 do + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_snapshot_active_#{SecureRandom.hex(4)}", + processor_plan: "retired_plan", + status: "active", + quantity: 1, + metadata: { + purchase_type: "credit_subscription", + processor_plan: "retired_plan", + subscription_name: "retired", + credits_per_period: "300", + signup_bonus_credits: "20", + trial_credits: "10", + fulfillment_period: "1.month", + rollover_enabled: "false" + } + ) + + fulfillment = UsageCredits::Fulfillment.find_by!(source: subscription) + assert_equal 300, fulfillment.metadata["credits_per_period"] + assert_equal "retired_plan", fulfillment.metadata["plan"] + end + end + + test "trial activation uses persisted terms after configuration removal" do + user = User.create!(email: "snapshot-trial-#{SecureRandom.hex(4)}@example.com", name: "Snapshot Trial") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_snapshot_trial_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_snapshot_trial_#{SecureRandom.hex(4)}", + processor_plan: "pro_plan_monthly", + status: "trialing", + quantity: 1, + trial_ends_at: 7.days.from_now + ) + + assert_equal 50, wallet.reload.credits + UsageCredits.reset! + + assert_difference -> { wallet.reload.credits }, 600 do + subscription.update!(status: "active") + end + + fulfillment = UsageCredits::Fulfillment.find_by!(source: subscription) + assert_equal "active", fulfillment.metadata["initial_award_state"] + assert_equal 500, fulfillment.metadata["credits_per_period"] + end + test "active subscription sets credit expiration for non-rollover plans" do # Create a fresh user to avoid fixture wallet conflicts user = User.create!(email: "sub_expire@example.com", name: "Sub Expire User") @@ -326,6 +588,49 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase assert_not_nil wallet.transactions.find_by(category: "subscription_trial") end + test "trial activation awards signup and first paid-period credits exactly once" do + user = User.create!(email: "trial_activation@example.com", name: "Trial Activation User") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_trial_activation" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_trial_activation", + processor_plan: "pro_plan_monthly", + status: "trialing", + trial_ends_at: 7.days.from_now, + quantity: 1 + ) + fulfillment = UsageCredits::Fulfillment.find_by!(source: subscription) + + assert_equal 50, wallet.reload.credits + assert_equal "trial", fulfillment.metadata["initial_award_state"] + + assert_difference -> { wallet.reload.credits }, 600 do + assert_no_difference -> { UsageCredits::Fulfillment.count } do + subscription.update!( + status: "active", + current_period_start: Time.current, + current_period_end: 1.month.from_now + ) + end + end + + fulfillment.reload + assert_equal "active", fulfillment.metadata["initial_award_state"] + assert_not fulfillment.metadata.key?("trial") + assert_equal 1, wallet.transactions.where(category: "subscription_signup_bonus").count + assert_equal 1, wallet.transactions.where(category: "subscription_credits").count + + assert_no_difference -> { wallet.reload.credits } do + subscription.send(:handle_initial_award_and_fulfillment_setup) + end + end + # ======================================== # SUBSCRIPTION STATUS TRANSITIONS # ======================================== @@ -443,6 +748,145 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase assert fulfillment.stopped? end + test "cancellation applies snapshotted credit expiration after plan removal" do + UsageCredits.configure do |config| + config.subscription_plan :cancel_expiring do + processor_plan(:fake_processor, "cancel_expiring_plan") + gives 100.credits.every(:month) + unused_credits :rollover + expire_after 2.days + end + end + + user = User.create!(email: "cancel-expiring-#{SecureRandom.hex(4)}@example.com", name: "Cancel Expiring") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_cancel_expiring_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_cancel_expiring_#{SecureRandom.hex(4)}", + processor_plan: "cancel_expiring_plan", + status: "active", + quantity: 1 + ) + credit_transaction = wallet.transactions.find_by!(category: "subscription_credits") + fulfillment = UsageCredits::Fulfillment.find_by!(source: subscription) + assert_nil credit_transaction.expires_at + + # Commercial terms must come from the fulfillment snapshot, not mutable + # process configuration, when a delayed cancellation webhook arrives. + UsageCredits.reset! + cancellation_at = 1.day.from_now + subscription.update!(status: "canceled", ends_at: cancellation_at) + + expected_expiration = cancellation_at + 2.days + assert_in_delta expected_expiration.to_i, credit_transaction.reload.expires_at.to_i, 1 + assert_in_delta expected_expiration.to_i, + fulfillment.reload.metadata["cancellation_credit_expiration_at"].to_time.to_i, + 1 + assert_equal 100, wallet.reload.credits + + travel_to expected_expiration + 1.second do + assert_equal 0, wallet.reload.credits + end + end + + test "cancellation can expire subscription credits immediately without touching unrelated credits" do + low_balance_events = [] + depleted_events = [] + + UsageCredits.configure do |config| + config.subscription_plan :cancel_immediately do + processor_plan(:fake_processor, "cancel_immediately_plan") + gives 100.credits.every(:month) + unused_credits :rollover + expire_after nil + end + + config.low_balance_threshold = 50 + config.on_low_balance_reached { |context| low_balance_events << context } + config.on_balance_depleted { |context| depleted_events << context } + end + + user = User.create!(email: "cancel-now-#{SecureRandom.hex(4)}@example.com", name: "Cancel Now") + wallet = user.credit_wallet + wallet.give_credits(25, reason: "manual") + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_cancel_now_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_cancel_now_#{SecureRandom.hex(4)}", + processor_plan: "cancel_immediately_plan", + status: "active", + quantity: 1 + ) + subscription_credit = wallet.transactions.find_by!(category: "subscription_credits") + manual_credit = wallet.transactions.find_by!(category: "manual_adjustment") + assert_equal 125, wallet.reload.credits + + canceled_at = Time.current + subscription.update!(status: "canceled", ends_at: canceled_at) + + assert subscription_credit.reload.expires_at <= Time.current + assert_nil manual_credit.reload.expires_at + assert_equal 25, wallet.reload.credits + assert_equal 1, low_balance_events.size + assert_equal 125, low_balance_events.sole.previous_balance + assert_equal 25, low_balance_events.sole.new_balance + assert_empty depleted_events + end + + test "immediate cancellation expiration dispatches low balance and depleted callbacks" do + low_balance_events = [] + depleted_events = [] + + UsageCredits.configure do |config| + config.subscription_plan :cancel_to_zero do + processor_plan(:fake_processor, "cancel_to_zero_plan") + gives 100.credits.every(:month) + unused_credits :rollover + expire_after nil + end + + config.low_balance_threshold = 50 + config.on_low_balance_reached { |context| low_balance_events << context } + config.on_balance_depleted { |context| depleted_events << context } + end + + user = User.create!(email: "cancel-zero-#{SecureRandom.hex(4)}@example.com", name: "Cancel Zero") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_cancel_zero_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_cancel_zero_#{SecureRandom.hex(4)}", + processor_plan: "cancel_to_zero_plan", + status: "active", + quantity: 1 + ) + assert_equal 100, wallet.reload.credits + + subscription.update!(status: "canceled", ends_at: Time.current) + + assert_equal 0, wallet.reload.credits + assert_equal 1, low_balance_events.size + assert_equal 1, depleted_events.size + assert_equal [100, 0], [low_balance_events.sole.previous_balance, low_balance_events.sole.new_balance] + assert_equal [100, 0], [depleted_events.sole.previous_balance, depleted_events.sole.new_balance] + end + # ======================================== # DOUBLE FULFILLMENT PREVENTION # ======================================== @@ -475,6 +919,63 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase end end + test "stale active callback cannot mint after a newer cancellation" do + user = User.create!(email: "stale-sub-#{SecureRandom.hex(4)}@example.com", name: "Stale Subscription") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_stale_sub_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_stale_#{SecureRandom.hex(4)}", + processor_plan: "pro_plan_monthly", + status: "incomplete", + quantity: 1 + ) + stale_callback_record = Pay::Subscription.find(subscription.id) + stale_callback_record.status = "active" + + Pay::Subscription.where(id: subscription.id).update_all( + status: "canceled", + updated_at: 1.second.from_now + ) + + assert_no_difference -> { wallet.reload.credits } do + stale_callback_record.send(:handle_initial_award_and_fulfillment_setup) + end + assert_nil UsageCredits::Fulfillment.find_by(source: subscription) + end + + test "database timestamp precision does not suppress a current processor callback" do + user = User.create!(email: "timestamp-precision-#{SecureRandom.hex(4)}@example.com", name: "Timestamp Precision") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :stripe, + processor_id: "cus_timestamp_precision_#{SecureRandom.hex(4)}" + ) + period_start = Time.current.change(nsec: 123_456_789) + + subscription = nil + assert_difference -> { wallet.reload.credits }, 600 do + subscription = Pay::Stripe::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_timestamp_precision_#{SecureRandom.hex(4)}", + processor_plan: "stripe_pause_pro", + status: "active", + quantity: 1, + current_period_start: period_start, + current_period_end: period_start + 1.month + ) + end + + assert UsageCredits::Fulfillment.exists?(source: subscription) + end + test "fulfillment record prevents duplicate credit awards" do subscription = pay_subscriptions(:active_subscription) wallet = subscription.customer.owner.credit_wallet @@ -916,7 +1417,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase ) fulfillment = UsageCredits::Fulfillment.find_by(source: subscription) - initial_next_fulfillment = fulfillment.next_fulfillment_at + fulfillment.next_fulfillment_at # Upgrade to premium (same period, different credits) travel_to 5.seconds.from_now do @@ -1044,7 +1545,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase fulfillment = UsageCredits::Fulfillment.find_by(source: subscription) assert_not_nil fulfillment - original_stops_at = fulfillment.stops_at + fulfillment.stops_at # Downgrade to a non-credit plan (not defined in our test setup) subscription.update!(processor_plan: "basic_plan_no_credits") @@ -1507,7 +2008,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase # When subscription is created, old_plan_id is nil # plan_changed? should return false, not trigger upgrade logic - subscription = Pay::Subscription.create!( + Pay::Subscription.create!( customer: customer, name: "default", processor_id: "sub_regression_guard", @@ -1536,7 +2037,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "REGRESSION: downgrade from credit plan to non-credit plan triggers plan change" do user = User.create!(email: "regression_noncredit@example.com", name: "Regression NonCredit") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -1556,7 +2057,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase fulfillment = UsageCredits::Fulfillment.find_by(source: subscription) assert_not_nil fulfillment - original_stops_at = fulfillment.stops_at + fulfillment.stops_at # THE BUG: Without the fix, downgrading to non-credit plan wouldn't trigger # handle_plan_change because provides_credits? would return false @@ -1634,7 +2135,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase quantity: 1 ) - initial_credits = wallet.reload.credits # 600 (500 + 100 bonus) + wallet.reload.credits # 600 (500 + 100 bonus) fulfillment = UsageCredits::Fulfillment.find_by(source: subscription) # Change to weekly plan (same credits, different period) @@ -1651,7 +2152,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "cancellation with pending downgrade works correctly" do user = User.create!(email: "cancel_pending@example.com", name: "Cancel Pending") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -1731,7 +2232,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase # switching them back unexpectedly. Upgrade should clear pending downgrades. user = User.create!(email: "upgrade_clears_pending@example.com", name: "Upgrade Clears Pending") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -1790,7 +2291,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase # The plan change would only take effect when the subscription becomes active. user = User.create!(email: "trial_downgrade@example.com", name: "Trial Downgrade") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -1883,7 +2384,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "fulfillment metadata stays consistent through multiple changes" do user = User.create!(email: "metadata_consistent@example.com", name: "Metadata Consistent") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -1921,7 +2422,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "pending plan change metadata is cleared after application" do user = User.create!(email: "pending_clear@example.com", name: "Pending Clear") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -2005,7 +2506,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase processor_id: "cus_tx_initial" ) - subscription = Pay::Subscription.create!( + Pay::Subscription.create!( customer: customer, name: "default", processor_id: "sub_tx_initial", @@ -2033,7 +2534,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "renewal during scheduled downgrade applies change correctly" do user = User.create!(email: "concurrent_renew@example.com", name: "Concurrent Renew") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -2142,7 +2643,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "downgrade handles missing current_period_end gracefully" do user = User.create!(email: "no_period_end@example.com", name: "No Period End") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -2223,7 +2724,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase quantity: 1 ) - initial_credits = wallet.reload.credits + wallet.reload.credits # Destroy wallet wallet.destroy! @@ -2416,7 +2917,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase end user = User.create!(email: "lateral_after_downgrade@example.com", name: "Lateral After Downgrade") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -2475,7 +2976,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase current_period_end: 30.days.from_now ) - initial_credits = wallet.reload.credits # 2200 (2000 + 200 bonus) + wallet.reload.credits # 2200 (2000 + 200 bonus) fulfillment = UsageCredits::Fulfillment.find_by(source: subscription) # Schedule downgrade to pro (500 credits) @@ -2507,7 +3008,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase # When a downgrade is scheduled but not yet applied, the fulfillment job # should still award credits from the CURRENT plan (not the pending plan) user = User.create!(email: "fulfillment_timing@example.com", name: "Fulfillment Timing") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -2546,7 +3047,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase test "multiple plan changes preserve subscription_id in metadata" do # Ensure subscription_id is never lost during plan change chaos user = User.create!(email: "preserve_sub_id@example.com", name: "Preserve Sub ID") - wallet = user.credit_wallet + user.credit_wallet customer = Pay::Customer.create!( owner: user, @@ -2608,7 +3109,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase # Create subscription with current_period_start in the past (simulating reactivation) # In reality, this happens when a subscription is paused and then resumed - subscription = Pay::Subscription.create!( + Pay::Subscription.create!( customer: customer, name: "default", processor_id: "sub_past_period_start", @@ -2663,7 +3164,7 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase current_period_end: 30.days.from_now ) - initial_credits = wallet.reload.credits + wallet.reload.credits # Schedule a downgrade to the temp plan subscription.update!(processor_plan: "temp_plan_monthly") @@ -2733,11 +3234,11 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase 3.times do |i| # Schedule downgrade subscription.update!(processor_plan: "pro_plan_monthly") - assert_equal 2600, wallet.reload.credits, "Downgrade #{i+1} should not change credits" + assert_equal 2600, wallet.reload.credits, "Downgrade #{i + 1} should not change credits" # Try to "upgrade" back to premium (should be BLOCKED - same plan in metadata) subscription.update!(processor_plan: "premium_plan_monthly") - assert_equal 2600, wallet.reload.credits, "Return to premium #{i+1} should NOT grant credits" + assert_equal 2600, wallet.reload.credits, "Return to premium #{i + 1} should NOT grant credits" end # Verify only 1 upgrade transaction ever occurred @@ -2994,4 +3495,35 @@ class PaySubscriptionExtensionTest < ActiveSupport::TestCase assert_equal 0, upgrade_count, "ANTI-GAMING CRITICAL: No upgrade when comparing against current (not pending)" end + + private + + def stripe_subscription_context(label) + token = SecureRandom.hex(4) + user = User.create!( + email: "#{label}-#{token}@example.com", + name: "Stripe Pause Test" + ) + customer = Pay::Customer.create!( + owner: user, + processor: :stripe, + processor_id: "cus_#{label.tr("-", "_")}_#{token}" + ) + + [user.credit_wallet, customer] + end + + def create_stripe_subscription(customer, processor_plan:, **attributes) + token = SecureRandom.hex(4) + Pay::Stripe::Subscription.create!({ + customer: customer, + name: "default", + processor_id: "sub_stripe_pause_#{token}", + processor_plan: processor_plan, + status: "active", + quantity: 1, + current_period_start: Time.current, + current_period_end: 1.month.from_now + }.merge(attributes)) + end end diff --git a/test/models/usage_credits/allocation_test.rb b/test/models/usage_credits/allocation_test.rb index 9374bfa..377df4a 100644 --- a/test/models/usage_credits/allocation_test.rb +++ b/test/models/usage_credits/allocation_test.rb @@ -8,8 +8,12 @@ class UsageCredits::AllocationTest < ActiveSupport::TestCase # ======================================== test "creates allocation linking spend to source" do - spend_tx = usage_credits_transactions(:rich_spent_credit) source_tx = usage_credits_transactions(:rich_initial_credit) + spend_tx = UsageCredits::Transaction.create!( + wallet: source_tx.wallet, + amount: -50, + category: :operation_charge + ) allocation = UsageCredits::Allocation.create!( spend_transaction: spend_tx, @@ -150,7 +154,7 @@ class UsageCredits::AllocationTest < ActiveSupport::TestCase # NOTE: The validation runs differently on create vs subsequent valid? calls # After creation, the allocation is included in the source's allocated_amount, # making remaining_amount drop, which causes the validation to fail on subsequent checks - #test "allocation with exact remaining amount is valid" do + # test "allocation with exact remaining amount is valid" do # source_tx = UsageCredits::Transaction.create!( # wallet: usage_credits_wallets(:rich_wallet), # amount: 100, @@ -171,7 +175,7 @@ class UsageCredits::AllocationTest < ActiveSupport::TestCase # # assert allocation.valid? # assert_equal 0, source_tx.reload.remaining_amount - #end + # end test "zero amount allocation is invalid" do allocation = UsageCredits::Allocation.new( diff --git a/test/models/usage_credits/configuration_test.rb b/test/models/usage_credits/configuration_test.rb index 5eed0c6..0027fc9 100644 --- a/test/models/usage_credits/configuration_test.rb +++ b/test/models/usage_credits/configuration_test.rb @@ -197,6 +197,14 @@ class UsageCredits::ConfigurationTest < ActiveSupport::TestCase assert_equal 1.second, @config.fulfillment_grace_period end + test "fulfillment_grace_period rejects string zero instead of coercing arbitrary strings" do + error = assert_raises(ArgumentError) do + @config.fulfillment_grace_period = "0" + end + + assert_includes error.message, "must be an ActiveSupport::Duration" + end + # ======================================== # CURRENCY CONFIGURATION # ======================================== @@ -260,6 +268,12 @@ class UsageCredits::ConfigurationTest < ActiveSupport::TestCase assert_equal 50, @config.low_balance_threshold end + test "low_balance_threshold rejects fractional and non-numeric values" do + [1.5, "not-a-number", Float::INFINITY].each do |value| + assert_raises(ArgumentError) { @config.low_balance_threshold = value } + end + end + test "low_balance_threshold setter raises for negative value" do error = assert_raises(ArgumentError) do @config.low_balance_threshold = -10 diff --git a/test/models/usage_credits/credit_pack_test.rb b/test/models/usage_credits/credit_pack_test.rb index 0c56482..161a971 100644 --- a/test/models/usage_credits/credit_pack_test.rb +++ b/test/models/usage_credits/credit_pack_test.rb @@ -456,8 +456,8 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Verify metadata contains pack info metadata = args[:payment_intent_data][:metadata] assert_equal "credit_pack", metadata[:purchase_type] - assert_equal :pro, metadata[:pack_name] - assert_equal 5000, metadata[:credits] + assert_equal "pro", metadata[:pack_name] + assert_equal "5000", metadata[:credits] true end @@ -485,10 +485,10 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Verify all pack configuration is included assert_equal "credit_pack", metadata[:purchase_type] - assert_equal :enterprise, metadata[:pack_name] - assert_equal 10_000, metadata[:credits] - assert_equal 2_000, metadata[:bonus_credits] - assert_equal 19900, metadata[:price_cents] + assert_equal "enterprise", metadata[:pack_name] + assert_equal "10000", metadata[:credits] + assert_equal "2000", metadata[:bonus_credits] + assert_equal "19900", metadata[:price_cents] assert_equal "USD", metadata[:price_currency] # Also verify session-level metadata @@ -567,8 +567,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase user.stub(:payment_processor, mock_payment_processor) do pack.create_checkout_session(user, success_url: "https://example.com/success", - cancel_url: "https://example.com/cancel" - ) + cancel_url: "https://example.com/cancel") end mock_payment_processor.verify @@ -665,8 +664,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase cancel_url: "https://example.com/cancel", allow_promotion_codes: true, locale: "fr", - billing_address_collection: "required" - ) + billing_address_collection: "required") end mock_payment_processor.verify @@ -695,10 +693,10 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Base metadata must still be present (critical for fulfillment) assert_equal "credit_pack", metadata[:purchase_type] - assert_equal :starter, metadata[:pack_name] - assert_equal 1000, metadata[:credits] - assert_equal 0, metadata[:bonus_credits] - assert_equal 4900, metadata[:price_cents] + assert_equal "starter", metadata[:pack_name] + assert_equal "1000", metadata[:credits] + assert_equal "0", metadata[:bonus_credits] + assert_equal "4900", metadata[:price_cents] assert_equal "USD", metadata[:price_currency] true @@ -706,8 +704,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase user.stub(:payment_processor, mock_payment_processor) do pack.create_checkout_session(user, - metadata: { custom_key: "custom_value", organization_id: "org_123" } - ) + metadata: {custom_key: "custom_value", organization_id: "org_123"}) end mock_payment_processor.verify @@ -728,8 +725,8 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Attempting to override critical fields should fail - base_metadata wins assert_equal "credit_pack", metadata[:purchase_type], "purchase_type should not be overridable" - assert_equal :starter, metadata[:pack_name], "pack_name should not be overridable" - assert_equal 1000, metadata[:credits], "credits should not be overridable" + assert_equal "starter", metadata[:pack_name], "pack_name should not be overridable" + assert_equal "1000", metadata[:credits], "credits should not be overridable" true end @@ -738,11 +735,10 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Try to override critical metadata fields (malicious or accidental) pack.create_checkout_session(user, metadata: { - purchase_type: "something_else", - pack_name: "hacked", - credits: 999999 - } - ) + "purchase_type" => "something_else", + "pack_name" => "hacked", + "credits" => 999999 + }) end mock_payment_processor.verify @@ -767,17 +763,16 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Base metadata must still be present assert_equal "credit_pack", pi_metadata[:purchase_type] - assert_equal :pro, pi_metadata[:pack_name] - assert_equal 5000, pi_metadata[:credits] - assert_equal 500, pi_metadata[:bonus_credits] + assert_equal "pro", pi_metadata[:pack_name] + assert_equal "5000", pi_metadata[:credits] + assert_equal "500", pi_metadata[:bonus_credits] true end user.stub(:payment_processor, mock_payment_processor) do pack.create_checkout_session(user, - payment_intent_data: { metadata: { internal_ref: "ref_123" } } - ) + payment_intent_data: {metadata: {internal_ref: "ref_123"}}) end mock_payment_processor.verify @@ -811,8 +806,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase payment_intent_data: { receipt_email: "customer@example.com", description: "Thank you!" - } - ) + }) end mock_payment_processor.verify @@ -867,8 +861,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase user.stub(:payment_processor, mock_payment_processor) do # Try to override line_items (should be ignored) pack.create_checkout_session(user, - line_items: [{ price: "price_malicious", quantity: 1 }] - ) + line_items: [{price: "price_malicious", quantity: 1}]) end mock_payment_processor.verify @@ -996,7 +989,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # Create a hash that the caller might want to reuse caller_payment_intent_data = { receipt_email: "customer@example.com", - metadata: { internal_ref: "ref_123", tracking_id: "track_456" } + metadata: {internal_ref: "ref_123", tracking_id: "track_456"} } # Store the original state for comparison @@ -1025,7 +1018,7 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase # A reusable hash that a caller might use for multiple checkout sessions reusable_options = { receipt_email: "customer@example.com", - metadata: { campaign: "summer_sale" } + metadata: {campaign: "summer_sale"} } mock_payment_processor = Minitest::Mock.new @@ -1044,9 +1037,17 @@ class UsageCredits::CreditPackTest < ActiveSupport::TestCase end # The hash should still have its metadata after both calls - assert_equal({ campaign: "summer_sale" }, reusable_options[:metadata], + assert_equal({campaign: "summer_sale"}, reusable_options[:metadata], "Metadata should still be present after multiple checkout calls") mock_payment_processor.verify end + + test "credit and price setters reject fractional or non-finite ledger values" do + pack = UsageCredits::CreditPack.new(:strict_values) + + assert_raises(ArgumentError) { pack.gives(10.5) } + assert_raises(ArgumentError) { pack.bonus(Float::INFINITY) } + assert_raises(ArgumentError) { pack.costs(499.5) } + end end diff --git a/test/models/usage_credits/credit_subscription_plan_test.rb b/test/models/usage_credits/credit_subscription_plan_test.rb index bab1d66..7259f22 100644 --- a/test/models/usage_credits/credit_subscription_plan_test.rb +++ b/test/models/usage_credits/credit_subscription_plan_test.rb @@ -179,6 +179,14 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase assert_equal 0, plan.credit_expiration_period end + test "expire_after rejects negative, fractional, and non-numeric periods" do + plan = UsageCredits::CreditSubscriptionPlan.new(:test) + + assert_raises(ArgumentError) { plan.expire_after(-1.second) } + assert_raises(ArgumentError) { plan.expire_after(0.5.seconds) } + assert_raises(ArgumentError) { plan.expire_after("1.day") } + end + # ======================================== # DSL - METADATA # ======================================== @@ -341,7 +349,7 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase assert_equal true, metadata[:rollover_enabled] assert_equal true, metadata[:expire_credits_on_cancel] assert_equal 30.days.to_i, metadata[:credit_expiration_period] - assert_equal({ tier: "premium" }, metadata[:metadata]) + assert_equal({tier: "premium"}, metadata[:metadata]) end # ======================================== @@ -557,14 +565,15 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase assert_equal 1, args[:line_items].first[:quantity] assert args[:subscription_data].present?, "subscription_data should be present" assert args[:subscription_data][:metadata].present?, "subscription_data metadata should be present" + assert_equal "price_123", args[:subscription_data][:metadata][:processor_plan] # Verify metadata contains all required fields metadata = args[:subscription_data][:metadata] assert_equal "credit_subscription", metadata[:purchase_type] - assert_equal :premium, metadata[:subscription_name] - assert_equal 1000, metadata[:credits_per_period] - assert_equal 200, metadata[:signup_bonus_credits] - assert_equal 50, metadata[:trial_credits] + assert_equal "premium", metadata[:subscription_name] + assert_equal "1000", metadata[:credits_per_period] + assert_equal "200", metadata[:signup_bonus_credits] + assert_equal "50", metadata[:trial_credits] true end @@ -654,14 +663,14 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase # Verify all configuration is included assert_equal "credit_subscription", metadata[:purchase_type] - assert_equal :enterprise, metadata[:subscription_name] - assert_equal 10_000, metadata[:credits_per_period] - assert_equal 1_000, metadata[:signup_bonus_credits] - assert_equal 500, metadata[:trial_credits] - assert_equal true, metadata[:rollover_enabled] - assert_equal true, metadata[:expire_credits_on_cancel] - assert_equal 30.days.to_i, metadata[:credit_expiration_period] - assert_equal({ tier: "enterprise", max_users: 100 }, metadata[:metadata]) + assert_equal "enterprise", metadata[:subscription_name] + assert_equal "10000", metadata[:credits_per_period] + assert_equal "1000", metadata[:signup_bonus_credits] + assert_equal "500", metadata[:trial_credits] + assert_equal "true", metadata[:rollover_enabled] + assert_equal "true", metadata[:expire_credits_on_cancel] + assert_equal 30.days.to_i.to_s, metadata[:credit_expiration_period] + assert_equal({"tier" => "enterprise", "max_users" => 100}, ActiveSupport::JSON.decode(metadata[:metadata])) true end @@ -686,7 +695,7 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase plan.gives(1000).every(:month) plan.stripe_price month: "price_monthly", year: "price_yearly" - assert_equal({ month: "price_monthly", year: "price_yearly" }, plan.stripe_price) + assert_equal({month: "price_monthly", year: "price_yearly"}, plan.stripe_price) end test "stripe_price getter returns specific period from multi-period plan" do @@ -724,14 +733,14 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase plan = UsageCredits::CreditSubscriptionPlan.new(:pro) plan.stripe_price month: "price_m", year: "price_y" - assert_equal({ month: "price_m", year: "price_y" }, plan.stripe_prices) + assert_equal({month: "price_m", year: "price_y"}, plan.stripe_prices) end test "stripe_prices wraps single price in hash with :default key" do plan = UsageCredits::CreditSubscriptionPlan.new(:basic) plan.stripe_price "price_single" - assert_equal({ default: "price_single" }, plan.stripe_prices) + assert_equal({default: "price_single"}, plan.stripe_prices) end test "stripe_prices returns empty hash when no prices set" do @@ -748,7 +757,7 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase # Keys should be normalized to symbols for consistent lookup assert_equal "price_m", plan.stripe_price(:month) assert_equal "price_y", plan.stripe_price(:year) - assert_equal({ month: "price_m", year: "price_y" }, plan.stripe_price) + assert_equal({month: "price_m", year: "price_y"}, plan.stripe_price) end test "stripe_price rejects empty hash" do @@ -763,14 +772,14 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase test "processor_plan accepts hash for multi-period storage" do plan = UsageCredits::CreditSubscriptionPlan.new(:pro) - plan.processor_plan(:stripe, { month: "price_m", year: "price_y" }) + plan.processor_plan(:stripe, {month: "price_m", year: "price_y"}) - assert_equal({ month: "price_m", year: "price_y" }, plan.plan_id_for(:stripe)) + assert_equal({month: "price_m", year: "price_y"}, plan.plan_id_for(:stripe)) end test "plan_id_for with period returns specific period price" do plan = UsageCredits::CreditSubscriptionPlan.new(:pro) - plan.processor_plan(:stripe, { month: "price_m", year: "price_y" }) + plan.processor_plan(:stripe, {month: "price_m", year: "price_y"}) assert_equal "price_m", plan.plan_id_for(:stripe, period: :month) assert_equal "price_y", plan.plan_id_for(:stripe, period: :year) @@ -967,6 +976,14 @@ class UsageCredits::CreditSubscriptionPlanTest < ActiveSupport::TestCase multi = UsageCredits.find_subscription_plan(:multi_price) assert_equal "price_single", single.stripe_price - assert_equal({ month: "price_m", year: "price_y" }, multi.stripe_price) + assert_equal({month: "price_m", year: "price_y"}, multi.stripe_price) + end + + test "credit setters reject fractional or non-finite ledger values" do + plan = UsageCredits::CreditSubscriptionPlan.new(:strict_values) + + assert_raises(ArgumentError) { plan.gives(10.5) } + assert_raises(ArgumentError) { plan.signup_bonus(Float::NAN) } + assert_raises(ArgumentError) { plan.trial_includes(Float::INFINITY) } end end diff --git a/test/models/usage_credits/fulfillment_test.rb b/test/models/usage_credits/fulfillment_test.rb index dc95274..828b281 100644 --- a/test/models/usage_credits/fulfillment_test.rb +++ b/test/models/usage_credits/fulfillment_test.rb @@ -42,7 +42,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase fulfillment_type: "credit_pack", credits_last_fulfillment: 1000, last_fulfilled_at: Time.current, - metadata: { test: true } + metadata: {test: true} ) assert fulfillment.persisted? @@ -61,7 +61,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: Time.current, next_fulfillment_at: 1.month.from_now, - metadata: { plan: "pro_plan_monthly" } + metadata: {plan: "pro_plan_monthly"} ) assert fulfillment.persisted? @@ -105,6 +105,18 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase assert fulfillment.errors[:fulfillment_type].present? end + test "rejects unsupported fulfillment types and negative credit snapshots" do + fulfillment = UsageCredits::Fulfillment.new( + wallet: usage_credits_wallets(:rich_wallet), + fulfillment_type: "unknown", + credits_last_fulfillment: -1 + ) + + assert_not fulfillment.valid? + assert_includes fulfillment.errors[:fulfillment_type], "is not included in the list" + assert_includes fulfillment.errors[:credits_last_fulfillment], "must be greater than or equal to 0" + end + test "validates fulfillment_period format" do fulfillment = UsageCredits::Fulfillment.new( wallet: usage_credits_wallets(:rich_wallet), @@ -119,6 +131,23 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase assert fulfillment.errors[:fulfillment_period].present? end + test "persisted fulfillment cadence survives a higher configuration minimum" do + UsageCredits.configuration.min_fulfillment_period = 1.month + next_fulfillment_at = 1.week.from_now + fulfillment = UsageCredits::Fulfillment.new( + wallet: usage_credits_wallets(:subscribed_wallet), + fulfillment_type: "subscription", + credits_last_fulfillment: 100, + fulfillment_period: "1.week", + last_fulfilled_at: Time.current, + next_fulfillment_at: next_fulfillment_at, + metadata: {plan: "legacy_weekly"} + ) + + assert fulfillment.valid?, fulfillment.errors.full_messages.to_sentence + assert_equal (next_fulfillment_at + 1.week).to_i, fulfillment.calculate_next_fulfillment.to_i + end + test "validates unique source" do existing = usage_credits_fulfillments(:active_subscription_fulfillment) @@ -134,6 +163,24 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase assert duplicate.errors[:source_id].present? end + test "database uniquely enforces one fulfillment per polymorphic source" do + existing = usage_credits_fulfillments(:active_subscription_fulfillment) + now = Time.current + + assert_raises(ActiveRecord::RecordNotUnique) do + UsageCredits::Fulfillment.insert_all!([{ + wallet_id: existing.wallet_id, + source_type: existing.source_type, + source_id: existing.source_id, + fulfillment_type: "subscription", + credits_last_fulfillment: 1, + metadata: {}, + created_at: now, + updated_at: now + }]) + end + end + # ======================================== # RECURRING? METHOD # ======================================== @@ -350,7 +397,8 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase count = UsageCredits::FulfillmentService.process_pending_fulfillments - assert count >= 2 + assert count >= 1 + assert f2.reload.next_fulfillment_at.past?, "trial fulfillment should remain due until Pay activates it" end test "FulfillmentService skips non-due fulfillments" do @@ -401,7 +449,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "rollover_plan_monthly" } + metadata: {plan: "rollover_plan_monthly"} ) # Make it due now (bypass validation with update_columns) @@ -436,7 +484,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "nonexistent_plan" } + metadata: {plan: "nonexistent_plan"} ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -459,7 +507,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, fulfillment_period: "1.month", - metadata: { credits: 100 } # Has credits, will process + metadata: {credits: 100} # Has credits, will process ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -491,7 +539,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, - metadata: { credits: 100 } + metadata: {credits: 100} ) f2 = UsageCredits::Fulfillment.create!( @@ -501,7 +549,7 @@ class UsageCredits::FulfillmentTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 2.days.from_now, - metadata: { credits: 200 } + metadata: {credits: 200} ) # Make them due now (bypass validation) diff --git a/test/models/usage_credits/operation_test.rb b/test/models/usage_credits/operation_test.rb index 7cc703c..f7837d0 100644 --- a/test/models/usage_credits/operation_test.rb +++ b/test/models/usage_credits/operation_test.rb @@ -90,6 +90,24 @@ class UsageCredits::OperationTest < ActiveSupport::TestCase assert_equal 100, operation.calculate_cost(units: 100) end + test "calculates variable costs for kilobytes and gigabytes" do + per_kilobyte = UsageCredits::Operation.new(:per_kb) do + costs 1.credit_per(:kb) + end + per_gigabyte = UsageCredits::Operation.new(:per_gb) do + costs 3.credits_per(:gb) + end + + assert_equal 2, per_kilobyte.calculate_cost(kb: 2) + assert_equal 6, per_gigabyte.calculate_cost(gb: 2) + assert_equal 3, per_gigabyte.calculate_cost(size: 1.gigabyte) + end + + test "rejects unsupported variable cost units at definition time" do + error = assert_raises(ArgumentError) { 1.credit_per(:minute) } + assert_includes error.message, "Unknown unit" + end + test "variable cost with zero units returns zero" do operation = UsageCredits::Operation.new(:send_emails) do costs 5.credits_per(:units) @@ -298,7 +316,7 @@ class UsageCredits::OperationTest < ActiveSupport::TestCase assert_equal :audited_op, audit[:operation] assert_equal 25, audit[:cost] - assert_equal({ file_id: 123 }, audit[:params]) + assert_equal({file_id: 123}, audit[:params]) assert_equal "processing", audit[:metadata]["category"] assert_not_nil audit[:executed_at] assert_equal UsageCredits::VERSION, audit[:gem_version] @@ -334,6 +352,51 @@ class UsageCredits::OperationTest < ActiveSupport::TestCase assert_equal 2, operation.calculate_cost(mb: 2.9) end + test "rounds the multiplied cost instead of rounding units first" do + UsageCredits.configure do |config| + config.rounding_strategy = :floor + end + + operation = UsageCredits::Operation.new(:floor_rate_test) do + costs 2.credits_per(:mb) + end + + # floor(2 credits * 2.9 MB) = floor(5.8), not 2 * floor(2.9). + assert_equal 5, operation.calculate_cost(mb: 2.9) + end + + test "applies rounding once across a compound cost" do + UsageCredits.configure do |config| + config.rounding_strategy = :ceil + end + + operation = UsageCredits::Operation.new(:compound_rounding_test) do + costs 1.credit_per(:mb) + 1.credit_per(:units) + end + + # ceil(0.2 + 0.2) = 1; rounding each component would overcharge 2. + assert_equal 1, operation.calculate_cost(mb: 0.2, units: 0.2) + end + + test "cost calculators preserve raw fractions until the operation rounding boundary" do + per_megabyte = 1.credit_per(:mb) + compound = per_megabyte + 1.credit_per(:units) + + assert_in_delta 0.2, per_megabyte.calculate(mb: 0.2) + assert_in_delta 0.4, compound.calculate(mb: 0.2, units: 0.2) + end + + test "fixed and dynamic costs share canonical amount validation errors" do + fixed_error = assert_raises(ArgumentError) { UsageCredits::Cost::Fixed.new(-1) } + dynamic_operation = UsageCredits::Operation.new(:invalid_dynamic_cost) do + costs ->(_params) { -1 } + end + dynamic_error = assert_raises(ArgumentError) { dynamic_operation.calculate_cost } + + assert_equal "Credit amount cannot be negative (got: -1)", fixed_error.message + assert_equal fixed_error.message, dynamic_error.message + end + test "round rounding strategy uses standard rounding" do UsageCredits.configure do |config| config.rounding_strategy = :round @@ -387,4 +450,43 @@ class UsageCredits::OperationTest < ActiveSupport::TestCase operation.calculate_cost({}) end end + + test "rejects negative, non-finite, and non-numeric quantities" do + operation = UsageCredits::Operation.new(:quantity_validation) do + costs 2.credits_per(:mb) + end + + [-1, Float::INFINITY, Float::NAN, "not-a-number"].each do |quantity| + error = assert_raises(ArgumentError) { operation.calculate_cost(mb: quantity) } + assert_includes error.message, "finite, non-negative" + end + end + + test "rejects non-finite dynamic costs and rates with a stable argument error" do + [Float::INFINITY, Float::NAN].each do |value| + assert_raises(ArgumentError) { value.credits } + assert_raises(ArgumentError) { value.credits_per(:mb) } + + operation = UsageCredits::Operation.new(:non_finite_dynamic) do + costs ->(_params) { value } + end + assert_raises(ArgumentError) { operation.calculate_cost } + end + end + + test "audit hash accepts an already calculated cost without reevaluating it" do + evaluations = 0 + operation = UsageCredits::Operation.new(:single_audit_cost) do + costs ->(_params) { + evaluations += 1 + 12 + } + end + + cost = operation.calculate_cost + audit = operation.to_audit_hash({}, cost: cost) + + assert_equal 1, evaluations + assert_equal 12, audit[:cost] + end end diff --git a/test/models/usage_credits/transaction_test.rb b/test/models/usage_credits/transaction_test.rb index a8d3541..7633c86 100644 --- a/test/models/usage_credits/transaction_test.rb +++ b/test/models/usage_credits/transaction_test.rb @@ -18,7 +18,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet: wallet, amount: 100, category: "signup_bonus", - metadata: { source: "test" } + metadata: {source: "test"} ) assert transaction.persisted? @@ -45,22 +45,22 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase # ======================================== # NOTE: Rails enforces belongs_to at DB level with foreign keys, not at validation level - #test "requires wallet" do + # test "requires wallet" do # transaction = UsageCredits::Transaction.new(amount: 100, category: "signup_bonus") # assert_not transaction.valid? # assert_includes transaction.errors[:wallet], "must exist" - #end + # end # NOTE: Amount validation triggers other validations that expect amount to be present # Commenting out since the model's internal validations have dependencies - #test "requires amount" do + # test "requires amount" do # transaction = UsageCredits::Transaction.new( # wallet: usage_credits_wallets(:rich_wallet), # category: "signup_bonus" # ) # assert_not transaction.valid? # assert_includes transaction.errors[:amount], "can't be blank" - #end + # end test "requires category" do transaction = UsageCredits::Transaction.new( @@ -307,6 +307,16 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase end end + test "credit direction scopes remain unambiguous when joining transfers" do + assert_nothing_raised do + UsageCredits::Transaction + .left_joins(:transfer) + .credits_added + .credits_deducted + .load + end + end + test "not_expired scope excludes expired" do not_expired = UsageCredits::Transaction.not_expired @@ -428,9 +438,9 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet = usage_credits_wallets(:rich_wallet) complex_metadata = { operation: "process_video", - params: { size_mb: 100, format: "mp4" }, + params: {size_mb: 100, format: "mp4"}, executed_at: Time.current.iso8601, - nested: { key: "value" } + nested: {key: "value"} } transaction = UsageCredits::Transaction.create!( @@ -477,7 +487,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase test "handles very long metadata JSON" do wallet = usage_credits_wallets(:rich_wallet) - long_metadata = { data: "x" * 10000 } + long_metadata = {data: "x" * 10000} transaction = UsageCredits::Transaction.create!( wallet: wallet, @@ -513,7 +523,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase end # NOTE: This test doesn't have clear expectations - commenting out - #test "transaction with zero amount is allowed by model but might be validated elsewhere" do + # test "transaction with zero amount is allowed by model but might be validated elsewhere" do # wallet = usage_credits_wallets(:rich_wallet) # # # The model itself might allow 0, but business logic should prevent it @@ -525,7 +535,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase # # # Test that it's either invalid or we document that 0-amount is not allowed # # Depending on your validation rules - #end + # end test "transaction timestamps are set correctly" do wallet = usage_credits_wallets(:rich_wallet) @@ -605,7 +615,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet: wallet, amount: -10, category: "operation_charge", - metadata: { operation: "process_video", cost: 10 } + metadata: {operation: "process_video", cost: 10} ) description = transaction.description @@ -719,7 +729,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet: wallet, amount: 100, category: "signup_bonus", - metadata: { balance_after: 500 } + metadata: {balance_after: 500} ) assert_equal 500, transaction.balance_after @@ -745,7 +755,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet: wallet, amount: 100, category: "signup_bonus", - metadata: { balance_before: 400, balance_after: 500 } + metadata: {balance_before: 400, balance_after: 500} ) assert_equal 400, transaction.balance_before @@ -791,7 +801,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet.give_credits(100, reason: "initial") # Spend 30 credits - spend_tx = wallet.deduct_credits(30, category: "operation_charge", metadata: { test: true }) + spend_tx = wallet.deduct_credits(30, category: "operation_charge", metadata: {test: true}) assert_equal 70, spend_tx.balance_after assert_equal 100, spend_tx.balance_before @@ -859,7 +869,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet: wallet, amount: 100, category: "signup_bonus", - metadata: { balance_after: 500 } + metadata: {balance_after: 500} ) assert_equal "500 tokens", transaction.formatted_balance_after @@ -906,12 +916,10 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) wallet.give_credits(10, reason: "initial") - # Deduct more than available (goes "negative" but credits floors at 0) + # Deduct more than available - usage_credits preserves the legacy public + # contract of flooring displayed balances to zero. spend_tx = wallet.deduct_credits(25, category: "operation_charge", metadata: {}) - # Note: The credits method floors at 0, so balance_after shows 0 even when - # allow_negative_balance is enabled. This is the existing system behavior. - # The negative deduction is tracked but the balance is capped at 0. assert_equal 0, spend_tx.balance_after assert_equal 10, spend_tx.balance_before @@ -1082,7 +1090,7 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) wallet.give_credits(100, reason: "initial") - custom_metadata = { custom_key: "custom_value", tracking_id: "abc123" } + custom_metadata = {custom_key: "custom_value", tracking_id: "abc123"} spend_tx = wallet.deduct_credits(30, category: "operation_charge", metadata: custom_metadata) # Both custom metadata AND balance_after should be present diff --git a/test/models/usage_credits/wallet_test.rb b/test/models/usage_credits/wallet_test.rb index 0bcbb19..0259274 100644 --- a/test/models/usage_credits/wallet_test.rb +++ b/test/models/usage_credits/wallet_test.rb @@ -44,7 +44,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase wallet = usage_credits_wallets(:rich_wallet) initial_credits = wallet.credits - wallet.deduct_credits(50, category: "operation_charge", metadata: { test: true }) + wallet.deduct_credits(50, category: "operation_charge", metadata: {test: true}) assert_equal initial_credits - 50, wallet.credits end @@ -179,6 +179,47 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase assert transactions.exists?(id: usage_credits_transactions(:expiry_expires_later).id) end + test "expire_fulfillment_credits shortens only matching later expirations and is idempotent" do + wallet = usage_credits_wallets(:subscribed_wallet) + fulfillment = usage_credits_fulfillments(:active_subscription_fulfillment) + subscription_credit = usage_credits_transactions(:subscribed_month1_credit) + unrelated_credit = usage_credits_transactions(:subscribed_signup_bonus) + shortened_expiration = 10.days.from_now + + assert_equal 1, wallet.expire_fulfillment_credits!( + fulfillment: fulfillment, + expires_at: shortened_expiration + ) + assert_in_delta shortened_expiration.to_i, subscription_credit.reload.expires_at.to_i, 1 + assert_nil unrelated_credit.reload.expires_at + + assert_equal 0, wallet.expire_fulfillment_credits!( + fulfillment: fulfillment, + expires_at: 20.days.from_now + ) + assert_in_delta shortened_expiration.to_i, subscription_credit.reload.expires_at.to_i, 1 + end + + test "expire_fulfillment_credits rejects an invalid expiration" do + wallet = usage_credits_wallets(:subscribed_wallet) + fulfillment = usage_credits_fulfillments(:active_subscription_fulfillment) + + error = assert_raises(ArgumentError) do + wallet.expire_fulfillment_credits!(fulfillment: fulfillment, expires_at: Object.new) + end + assert_equal "Expiration date must respond to to_datetime", error.message + + invalid_date = Object.new + def invalid_date.to_datetime + raise ArgumentError, "not a date" + end + + error = assert_raises(ArgumentError) do + wallet.expire_fulfillment_credits!(fulfillment: fulfillment, expires_at: invalid_date) + end + assert_equal "Expiration date must be a valid date or time", error.message + end + test "includes never-expiring credits" do wallet = usage_credits_wallets(:expiry_wallet) @@ -206,7 +247,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase end test "respects grace period for expiration" do - wallet = usage_credits_wallets(:empty_wallet) + wallet = UsageCredits::Wallet.create!(owner: users(:walletless_user), asset_code: "grace_test") # Add credit that expires very soon (within grace period) expires_at = 1.minute.from_now @@ -281,6 +322,135 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase assert_equal 100, total_allocated end + test "credit wallet supports direct wallet transfers without transfer callback wiring" do + sender = User.create!(email: "sender-#{SecureRandom.hex(4)}@example.com", name: "Sender") + recipient = User.create!(email: "recipient-#{SecureRandom.hex(4)}@example.com", name: "Recipient") + + sender.credit_wallet.give_credits(100, reason: "bonus") + + assert_difference -> { UsageCredits::Transfer.count }, 1 do + transfer = sender.credit_wallet.transfer_to( + recipient.credit_wallet, + 30, + category: :gift, + metadata: {source: "test"} + ) + + assert_equal sender.credit_wallet, transfer.from_wallet + assert_equal recipient.credit_wallet, transfer.to_wallet + assert_equal 30, transfer.amount + assert_instance_of UsageCredits::Transaction, transfer.outbound_transaction + assert_instance_of UsageCredits::Transaction, transfer.inbound_transactions.sole + assert_equal "transfer_out", transfer.outbound_transaction.category + assert_equal "transfer_in", transfer.inbound_transactions.sole.category + assert_equal "preserve", transfer.expiration_policy + end + + assert_equal 70, sender.credit_wallet.reload.credits + assert_equal 30, recipient.credit_wallet.reload.credits + end + + test "transfer_credits_to is a full backwards-compatible alias for transfer_to" do + sender = User.create!(email: "sender-alias-#{SecureRandom.hex(4)}@example.com", name: "Sender Alias") + recipient = User.create!(email: "recipient-alias-#{SecureRandom.hex(4)}@example.com", name: "Recipient Alias") + sender.credit_wallet.give_credits(100, reason: "promo", expires_at: 10.days.from_now) + + transfer = sender.credit_wallet.transfer_credits_to( + recipient.credit_wallet, + 30, + category: :gift, + metadata: {source: "alias-test"}, + expiration_policy: :none + ) + + assert_instance_of UsageCredits::Transfer, transfer + assert_equal "gift", transfer.category + assert_equal "alias-test", transfer.metadata["source"] + assert_equal "none", transfer.expiration_policy + assert_nil transfer.inbound_transactions.sole.expires_at + assert_equal 70, sender.credit_wallet.reload.credits + assert_equal 30, recipient.credit_wallet.reload.credits + end + + test "both transfer entry points translate invalid transfers without writing ledger rows" do + sender = User.create!(email: "sender-invalid-#{SecureRandom.hex(4)}@example.com", name: "Sender Invalid") + recipient = User.create!(email: "recipient-invalid-#{SecureRandom.hex(4)}@example.com", name: "Recipient Invalid") + sender.credit_wallet.give_credits(100, reason: "bonus") + incompatible_wallet = UsageCredits::Wallet.create!(owner: recipient, asset_code: "other") + + %i[transfer_to transfer_credits_to].each do |entry_point| + error = nil + + assert_no_difference -> { UsageCredits::Transfer.count } do + assert_no_difference -> { UsageCredits::Transaction.count } do + error = assert_raises(UsageCredits::InvalidTransfer) do + sender.credit_wallet.public_send(entry_point, incompatible_wallet, 30) + end + end + end + + assert_equal "Wallet assets must match", error.message + end + + assert_equal 100, sender.credit_wallet.reload.credits + assert_equal 0, incompatible_wallet.reload.credits + end + + test "both transfer entry points translate insufficient balance without writing ledger rows" do + sender = User.create!(email: "sender-insufficient-#{SecureRandom.hex(4)}@example.com", name: "Sender Insufficient") + recipient = User.create!(email: "recipient-insufficient-#{SecureRandom.hex(4)}@example.com", name: "Recipient Insufficient") + sender.credit_wallet.give_credits(5, reason: "bonus") + + %i[transfer_to transfer_credits_to].each do |entry_point| + error = nil + + assert_no_difference -> { UsageCredits::Transfer.count } do + assert_no_difference -> { UsageCredits::Transaction.count } do + error = assert_raises(UsageCredits::InsufficientCredits) do + sender.credit_wallet.public_send(entry_point, recipient.credit_wallet, 30) + end + end + end + + assert_equal "Insufficient balance (5 < 30)", error.message + end + + assert_equal 5, sender.credit_wallet.reload.credits + assert_equal 0, recipient.credit_wallet.reload.credits + end + + test "credit wallet transfers preserve expiration buckets by default" do + sender = User.create!(email: "sender-exp-#{SecureRandom.hex(4)}@example.com", name: "Sender Exp") + recipient = User.create!(email: "recipient-exp-#{SecureRandom.hex(4)}@example.com", name: "Recipient Exp") + earliest_credit = sender.credit_wallet.give_credits(100, reason: "promo", expires_at: 5.days.from_now) + later_credit = sender.credit_wallet.give_credits(80, reason: "promo", expires_at: 20.days.from_now) + + transfer = sender.credit_wallet.transfer_to(recipient.credit_wallet, 130, category: :gift) + inbound_legs = transfer.inbound_transactions.order(:expires_at, :id).to_a + + assert_equal "preserve", transfer.expiration_policy + assert_equal 2, inbound_legs.size + assert_nil transfer.inbound_transaction + assert_equal [100, 30], inbound_legs.map(&:amount) + assert_equal [earliest_credit.expires_at.to_i, later_credit.expires_at.to_i], inbound_legs.map { |tx| tx.expires_at.to_i } + end + + test "credit wallet transfer can override expiration policy to none" do + sender = User.create!(email: "sender-none-#{SecureRandom.hex(4)}@example.com", name: "Sender None") + recipient = User.create!(email: "recipient-none-#{SecureRandom.hex(4)}@example.com", name: "Recipient None") + sender.credit_wallet.give_credits(100, reason: "promo", expires_at: 10.days.from_now) + + transfer = sender.credit_wallet.transfer_to( + recipient.credit_wallet, + 30, + category: :gift, + expiration_policy: :none + ) + + assert_equal "none", transfer.expiration_policy + assert_nil transfer.inbound_transactions.sole.expires_at + end + test "partial allocation from multiple sources" do wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) @@ -319,7 +489,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase test "deduct_credits creates transaction with metadata" do wallet = usage_credits_wallets(:rich_wallet) - metadata = { operation: "test", param: "value" } + metadata = {operation: "test", param: "value"} tx = wallet.deduct_credits(10, category: "operation_charge", metadata: metadata) @@ -414,6 +584,40 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase end end + test "spend_credits_on evaluates dynamic cost exactly once" do + evaluations = 0 + UsageCredits.configure do |config| + config.operation :single_evaluation do + costs ->(_params) { + evaluations += 1 + 25 + } + end + end + + wallet = usage_credits_wallets(:rich_wallet) + transaction = wallet.spend_credits_on(:single_evaluation) + + assert_equal 1, evaluations + assert_equal 25, transaction.metadata["cost"] + end + + test "spend_credits_on executes free operations without a zero-value transaction" do + UsageCredits.configure do |config| + config.operation(:free_operation) { costs 0.credits } + end + + wallet = usage_credits_wallets(:rich_wallet) + executed = false + + assert_no_difference -> { wallet.transactions.count } do + result = wallet.spend_credits_on(:free_operation) { executed = true } + assert_nil result + end + + assert executed + end + test "spend_credits_on creates transaction with operation metadata" do wallet = usage_credits_wallets(:rich_wallet) @@ -434,6 +638,17 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase end end + test "spend_credits_on never executes its block when the locked balance is insufficient" do + wallet = usage_credits_wallets(:poor_wallet) + block_executed = false + + assert_raises(UsageCredits::InsufficientCredits) do + wallet.spend_credits_on(:test_operation) { block_executed = true } + end + + refute block_executed + end + test "spend_credits_on raises for unknown operation" do wallet = usage_credits_wallets(:rich_wallet) @@ -567,7 +782,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase # Perform multiple sequential operations 10.times do |i| - wallet.deduct_credits(50, category: "operation_charge", metadata: { iteration: i }) + wallet.deduct_credits(50, category: "operation_charge", metadata: {iteration: i}) end assert_equal 500, wallet.reload.credits @@ -579,11 +794,11 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase # NOTE: Rails doesn't enforce belongs_to presence for polymorphic associations by default # The database has NOT NULL constraints, so this is enforced at the DB level - #test "requires owner" do + # test "requires owner" do # wallet = UsageCredits::Wallet.new # assert_not wallet.valid? # assert_includes wallet.errors[:owner], "must exist" - #end + # end test "balance defaults to 0" do wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) @@ -623,10 +838,29 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase wallet.deduct_credits(10, category: "operation_charge", metadata: {}) end - # The credits method calculates from remaining positive transactions - # With negative balance enabled, it should show 0 or the actual negative - # depending on implementation - assert wallet.reload.balance <= 0 + # usage_credits historically floors negative balances to zero for public + # balance access, even when negative balances are allowed. + assert_equal 0, wallet.reload.credits + assert_equal 0, wallet.balance + ensure + UsageCredits.configuration.allow_negative_balance = original_setting + end + end + + test "new credits remain fully usable after an unbacked negative debit" do + original_setting = UsageCredits.configuration.allow_negative_balance + + begin + UsageCredits.configuration.allow_negative_balance = true + + wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) + wallet.give_credits(10, reason: "initial") + wallet.deduct_credits(25, category: "operation_charge", metadata: {}) + + refill = wallet.give_credits(20, reason: "refill") + + assert_equal 20, wallet.reload.credits + assert_equal 20, refill.balance_after ensure UsageCredits.configuration.allow_negative_balance = original_setting end @@ -679,7 +913,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase # NOTE: This test has an ambiguous SQL query that needs table name qualification # The current implementation uses a simpler credits calculation method - #test "recalculates balance accurately after many transactions" do + # test "recalculates balance accurately after many transactions" do # wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) # # # Add 50 credits in various amounts @@ -690,7 +924,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase # # # Balance should be consistent - using the model's credits method # assert_equal wallet.credits, wallet.balance - #end + # end test "recalculates balance accurately after many transactions" do wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) @@ -699,7 +933,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase 25.times { |i| wallet.give_credits((i + 1) * 10, reason: "credit_#{i}") } # Spend some credits - 20.times { |i| wallet.deduct_credits(50, category: "operation_charge", metadata: { iteration: i }) } + 20.times { |i| wallet.deduct_credits(50, category: "operation_charge", metadata: {iteration: i}) } # Balance should be consistent with the credits calculation assert_equal wallet.credits, wallet.balance @@ -733,7 +967,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase test "handles mixed currency metadata" do wallet = UsageCredits::Wallet.create!( owner: users(:new_user), - metadata: { currency: "USD", region: "US" } + metadata: {currency: "USD", region: "US"} ) assert_equal "USD", wallet.metadata["currency"] diff --git a/test/services/fulfillment_service_test.rb b/test/services/fulfillment_service_test.rb index 87a2ced..750efcb 100644 --- a/test/services/fulfillment_service_test.rb +++ b/test/services/fulfillment_service_test.rb @@ -38,6 +38,18 @@ class FulfillmentServiceTest < ActiveSupport::TestCase unused_credits :rollover end + config.subscription_plan :stripe_pause_service_pro do + processor_plan(:stripe, "stripe_pause_service_pro") + gives 500.credits.every(:month) + unused_credits :expire + end + + config.subscription_plan :stripe_pause_service_premium do + processor_plan(:stripe, "stripe_pause_service_premium") + gives 2000.credits.every(:month) + unused_credits :expire + end + config.credit_pack :test_pack do gives 1000.credits costs 49.dollars @@ -222,7 +234,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "rollover_plan_monthly" } + metadata: {plan: "rollover_plan_monthly"} ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -234,6 +246,231 @@ class FulfillmentServiceTest < ActiveSupport::TestCase assert_nil latest_tx.expires_at end + test "does not award recurring credits while the Pay subscription is trialing" do + fulfillment = usage_credits_fulfillments(:trial_fulfillment) + wallet = fulfillment.wallet + initial_credits = wallet.credits + fulfillment.update_columns(next_fulfillment_at: 1.second.ago) + + result = FulfillmentService.new(fulfillment).process + + assert_nil result + assert_equal initial_credits, wallet.reload.credits + assert fulfillment.reload.next_fulfillment_at.past?, "skipped fulfillment should remain due for retry after activation" + end + + test "does not award recurring credits during an effective Stripe pause and resumes afterward" do + wallet, subscription, fulfillment = stripe_subscription_fulfillment("effective-pause") + initial_credits = wallet.reload.credits + + subscription.update_columns( + pause_behavior: "void", + pause_starts_at: 1.minute.ago, + updated_at: Time.current + ) + fulfillment.update_columns( + last_fulfilled_at: 1.month.ago, + next_fulfillment_at: 1.second.ago + ) + + result = FulfillmentService.new(fulfillment.reload).process + + assert_nil result + assert_equal "active", subscription.reload.status + assert_not subscription.active? + assert_equal initial_credits, wallet.reload.credits + assert fulfillment.reload.next_fulfillment_at.past? + + subscription.update_columns( + pause_behavior: nil, + pause_starts_at: nil, + updated_at: Time.current + ) + + assert subscription.reload.eligible_for_usage_credit_fulfillment? + assert fulfillment.reload.due_for_fulfillment? + assert_difference -> { wallet.reload.credits }, 500 do + FulfillmentService.new(fulfillment.reload).process + end + end + + test "reconciles a deferred paused plan change before a recurring award" do + wallet, subscription, fulfillment = stripe_subscription_fulfillment("deferred-reconcile") + initial_credits = wallet.reload.credits + + subscription.update_columns( + pause_behavior: "void", + pause_starts_at: 1.minute.ago, + updated_at: Time.current + ) + subscription.update!(processor_plan: "stripe_pause_service_premium") + assert_equal "stripe_pause_service_premium", fulfillment.reload.metadata["deferred_plan_change"] + + # Simulate a committed processor resume whose after-commit callback was + # interrupted before it could install the deferred commercial terms. + subscription.update_columns( + pause_behavior: nil, + pause_starts_at: nil, + updated_at: Time.current + ) + fulfillment.update_columns( + last_fulfilled_at: 1.month.ago, + next_fulfillment_at: 1.second.ago + ) + + assert_difference -> { wallet.reload.credits }, 2000 do + FulfillmentService.new(fulfillment.reload).process + end + + fulfillment.reload + assert_equal "stripe_pause_service_premium", fulfillment.metadata["plan"] + assert_nil fulfillment.metadata["deferred_plan_change"] + assert_equal initial_credits + 2000, wallet.reload.credits + end + + test "fails closed when deferred plan terms are corrupt" do + wallet, subscription, fulfillment = stripe_subscription_fulfillment("corrupt-deferred") + initial_credits = wallet.reload.credits + + subscription.update_columns( + processor_plan: "stripe_pause_service_premium", + updated_at: Time.current + ) + fulfillment.update_columns( + last_fulfilled_at: 1.month.ago, + next_fulfillment_at: 1.second.ago, + metadata: fulfillment.metadata.merge( + "deferred_plan_change" => "stripe_pause_service_premium", + "deferred_plan_snapshot" => {"plan" => "wrong_plan"} + ) + ) + + error = assert_raises(UsageCredits::InvalidOperation) do + FulfillmentService.new(fulfillment.reload).process + end + + assert_includes error.message, "do not match" + assert_equal initial_credits, wallet.reload.credits + end + + test "fails closed while an active processor plan transition is unreconciled" do + wallet, subscription, fulfillment = stripe_subscription_fulfillment("unreconciled-plan") + initial_credits = wallet.reload.credits + + # update_columns models the interval after Pay committed the new plan but + # before UsageCredits' after-commit reconciliation completed. + subscription.update_columns( + processor_plan: "stripe_pause_service_premium", + updated_at: Time.current + ) + fulfillment.update_columns( + last_fulfilled_at: 1.month.ago, + next_fulfillment_at: 1.second.ago + ) + + error = assert_raises(UsageCredits::InvalidOperation) do + FulfillmentService.new(fulfillment.reload).process + end + + assert_includes error.message, "has not been reconciled" + assert_equal initial_credits, wallet.reload.credits + end + + test "does not award credits when a recorded Pay subscription is missing" do + wallet = usage_credits_wallets(:subscribed_wallet) + initial_credits = wallet.credits + fulfillment = usage_credits_fulfillments(:active_subscription_fulfillment) + initial_transactions = wallet.transactions.where(fulfillment: fulfillment).count + fulfillment.update_columns(source_id: 999_999, next_fulfillment_at: 1.second.ago) + + error = assert_raises(UsageCredits::Error) do + FulfillmentService.new(fulfillment.reload).process + end + + assert_includes error.message, "no longer exists" + assert_equal initial_credits, wallet.reload.credits + assert_equal initial_transactions, wallet.transactions.where(fulfillment: fulfillment).count + end + + test "recognizes a dangling processor-specific Pay source type" do + wallet = usage_credits_wallets(:subscribed_wallet) + initial_credits = wallet.credits + fulfillment = usage_credits_fulfillments(:active_subscription_fulfillment) + fulfillment.update_columns( + source_type: "Pay::Stripe::Subscription", + source_id: 999_999, + next_fulfillment_at: 1.second.ago + ) + + error = assert_raises(UsageCredits::Error) do + FulfillmentService.new(fulfillment.reload).process + end + + assert_includes error.message, "no longer exists" + assert_equal initial_credits, wallet.reload.credits + end + + test "trial boundary activates processors whose status stays active" do + UsageCredits.configure do |config| + config.subscription_plan :active_trial_test do + processor_plan(:fake_processor, "active_trial_plan") + gives 500.credits.every(:month) + signup_bonus 100.credits + trial_includes 50.credits + unused_credits :expire + end + end + + user = User.create!(email: "active-trial-#{SecureRandom.hex(4)}@example.com", name: "Active Trial") + wallet = user.credit_wallet + customer = Pay::Customer.create!( + owner: user, + processor: :fake_processor, + processor_id: "cus_active_trial_#{SecureRandom.hex(4)}" + ) + subscription = Pay::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_active_trial_#{SecureRandom.hex(4)}", + processor_plan: "active_trial_plan", + status: "active", + quantity: 1, + trial_ends_at: 1.day.from_now + ) + fulfillment = Fulfillment.find_by!(source: subscription) + + assert_equal 50, wallet.reload.credits + assert_equal "trial", fulfillment.metadata["initial_award_state"] + + # Some processors do not emit a status transition when an active trial + # ends. Simulate the clock boundary without an Active Record callback. + subscription.update_columns(trial_ends_at: 1.second.ago, updated_at: Time.current) + fulfillment.update_columns(next_fulfillment_at: 1.second.ago) + + assert_difference -> { wallet.reload.credits }, 600 do + FulfillmentService.new(fulfillment.reload).process + end + + assert_equal "active", fulfillment.reload.metadata["initial_award_state"] + assert fulfillment.next_fulfillment_at.future? + end + + test "dispatches subscription credits awarded after a recurring fulfillment" do + fulfillment = usage_credits_fulfillments(:active_subscription_fulfillment) + fulfillment.update!(next_fulfillment_at: 1.second.ago) + events = [] + UsageCredits.configure do |config| + config.on_subscription_credits_awarded { |context| events << context } + end + + transaction = FulfillmentService.new(fulfillment).process + + assert_equal 1, events.size + assert_equal transaction, events.first.transaction + assert_equal transaction.amount, events.first.amount + assert_equal fulfillment.id, events.first.metadata[:fulfillment].id + end + # ======================================== # CREDIT PACK FULFILLMENT PROCESSING # ======================================== @@ -249,7 +486,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase credits_last_fulfillment: 1000, last_fulfilled_at: nil, next_fulfillment_at: 1.second.ago, - metadata: { pack: "test_pack" } + metadata: {pack: "test_pack"} ) service = FulfillmentService.new(fulfillment) @@ -267,7 +504,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase credits_last_fulfillment: 1000, last_fulfilled_at: nil, next_fulfillment_at: 1.second.ago, - metadata: { pack: "test_pack" } + metadata: {pack: "test_pack"} ) service = FulfillmentService.new(fulfillment) @@ -293,7 +530,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase last_fulfilled_at: nil, next_fulfillment_at: 1.day.from_now, fulfillment_period: "1.month", - metadata: { credits: 250 } + metadata: {credits: 250} ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -315,7 +552,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase last_fulfilled_at: nil, next_fulfillment_at: 1.day.from_now, fulfillment_period: "1.month", - metadata: { credits: 100 } + metadata: {credits: 100} ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -373,7 +610,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "nonexistent_plan_id" } + metadata: {plan: "nonexistent_plan_id"} ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -397,7 +634,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase credits_last_fulfillment: 1000, last_fulfilled_at: nil, next_fulfillment_at: nil, - metadata: { pack: "nonexistent_pack" } + metadata: {pack: "nonexistent_pack"} ) # Manually set to make it appear due (for testing) @@ -426,7 +663,8 @@ class FulfillmentServiceTest < ActiveSupport::TestCase count = FulfillmentService.process_pending_fulfillments - assert count >= 2 + assert count >= 1 + assert f2.reload.next_fulfillment_at.past?, "trial fulfillment should be skipped and not counted" end test "process_pending_fulfillments continues on error" do @@ -441,7 +679,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase last_fulfilled_at: nil, next_fulfillment_at: 1.day.from_now, fulfillment_period: "1.month", - metadata: { credits: 100 } + metadata: {credits: 100} ) valid.update_columns(next_fulfillment_at: 1.second.ago) @@ -453,7 +691,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase fulfillment_period: "1.month", last_fulfilled_at: 1.month.ago, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "nonexistent" } + metadata: {plan: "nonexistent"} ) invalid.update_columns(next_fulfillment_at: 1.second.ago) @@ -601,7 +839,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase fulfillment_period: "5.seconds", last_fulfilled_at: 6.seconds.ago, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "rapid_plan_id" } + metadata: {plan: "rapid_plan_id"} ) fulfillment.update_columns(next_fulfillment_at: 1.second.ago) @@ -642,7 +880,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase end end - wallet = usage_credits_wallets(:empty_wallet) + wallet = UsageCredits::Wallet.create!(owner: users(:walletless_user), asset_code: "accumulation_test") # Create fulfillment fulfillment = Fulfillment.create!( @@ -652,7 +890,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase fulfillment_period: "2.seconds", last_fulfilled_at: nil, next_fulfillment_at: 1.day.from_now, - metadata: { plan: "rapid_accumulation_plan" } + metadata: {plan: "rapid_accumulation_plan"} ) # Simulate multiple fulfillment cycles @@ -681,5 +919,46 @@ class FulfillmentServiceTest < ActiveSupport::TestCase "Balance is #{wallet.credits}, but should not exceed #{max_expected_balance + 100}. " \ "Credits are accumulating because grace period is not capped to fulfillment period." end + + test "manual fulfillment rejects fractional string credits instead of truncating" do + fulfillment = Fulfillment.new( + wallet: usage_credits_wallets(:rich_wallet), + fulfillment_type: "manual", + credits_last_fulfillment: 0, + metadata: {credits: "10.5"} + ) + + service = FulfillmentService.new(fulfillment) + error = assert_raises(UsageCredits::Error) { service.send(:calculate_credits) } + + assert_includes error.message, "positive whole number" + end + + private + + def stripe_subscription_fulfillment(label) + token = SecureRandom.hex(4) + user = User.create!( + email: "stripe-pause-service-#{label}-#{token}@example.com", + name: "Stripe Pause Service" + ) + customer = Pay::Customer.create!( + owner: user, + processor: :stripe, + processor_id: "cus_stripe_pause_service_#{token}" + ) + subscription = Pay::Stripe::Subscription.create!( + customer: customer, + name: "default", + processor_id: "sub_stripe_pause_service_#{token}", + processor_plan: "stripe_pause_service_pro", + status: "active", + quantity: 1, + current_period_start: Time.current, + current_period_end: 1.month.from_now + ) + + [user.credit_wallet, subscription, Fulfillment.find_by!(source: subscription)] + end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index edaafb0..0bf1aed 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,7 +1,8 @@ # test/test_helper.rb # SimpleCov must be loaded before any application code -require 'simplecov' +require "simplecov" +SimpleCov.start # Configure Rails Environment ENV["RAILS_ENV"] = "test" @@ -27,10 +28,6 @@ ActiveSupport::TestCase.file_fixture_path = File.expand_path("../fixtures/files", __FILE__) ActiveSupport::TestCase.fixtures :all -# Ensure Pay extensions are loaded in test environment -Pay::Subscription.include UsageCredits::PaySubscriptionExtension unless Pay::Subscription.include?(UsageCredits::PaySubscriptionExtension) -Pay::Charge.include UsageCredits::PayChargeExtension unless Pay::Charge.include?(UsageCredits::PayChargeExtension) - class ActiveSupport::TestCase include ActionMailer::TestHelper include ActiveJob::TestHelper diff --git a/test/usage_credits/backward_compat_test.rb b/test/usage_credits/backward_compat_test.rb index 7a49de7..d0c460b 100644 --- a/test/usage_credits/backward_compat_test.rb +++ b/test/usage_credits/backward_compat_test.rb @@ -89,10 +89,10 @@ class BackwardCompatibilityTest < ActiveSupport::TestCase second_output = capture_io { UsageCredits.handle_event(:low_balance_reached, wallet: @user.credit_wallet) } # First call should have deprecation warning - assert_match /DEPRECATION/, first_output[1] + assert_match(/DEPRECATION/, first_output[1]) # Second call should NOT have warning (already warned) - refute_match /DEPRECATION/, second_output[1] + refute_match(/DEPRECATION/, second_output[1]) end test "reset! clears deprecation warnings" do @@ -102,7 +102,7 @@ class BackwardCompatibilityTest < ActiveSupport::TestCase # First call - should warn first_output = capture_io { UsageCredits.handle_event(:low_balance_reached, wallet: @user.credit_wallet) } - assert_match /DEPRECATION/, first_output[1] + assert_match(/DEPRECATION/, first_output[1]) # Reset UsageCredits.reset! @@ -114,6 +114,6 @@ class BackwardCompatibilityTest < ActiveSupport::TestCase # Should warn again after reset after_reset_output = capture_io { UsageCredits.handle_event(:low_balance_reached, wallet: @user.credit_wallet) } - assert_match /DEPRECATION/, after_reset_output[1] + assert_match(/DEPRECATION/, after_reset_output[1]) end end diff --git a/test/usage_credits/callbacks_test.rb b/test/usage_credits/callbacks_test.rb index b0122ec..61f0c34 100644 --- a/test/usage_credits/callbacks_test.rb +++ b/test/usage_credits/callbacks_test.rb @@ -51,6 +51,12 @@ class UsageCredits::CallbacksTest < ActiveSupport::TestCase end end + test "dispatch ignores unsupported callback events" do + assert_nothing_raised do + UsageCredits::Callbacks.dispatch(:transfer_completed, wallet: @user.credit_wallet, amount: 100) + end + end + test "CallbackContext provides owner convenience method" do wallet = @user.credit_wallet ctx = UsageCredits::CallbackContext.new(event: :test, wallet: wallet) @@ -87,7 +93,7 @@ class UsageCredits::CallbacksTest < ActiveSupport::TestCase assert_nil UsageCredits.configuration.on_credits_added_callback end - test "dispatch handles all 7 callback events" do + test "dispatch handles all 7 supported callback events" do events_received = [] UsageCredits.configure do |config| diff --git a/test/usage_credits/embedded_base_class_test.rb b/test/usage_credits/embedded_base_class_test.rb new file mode 100644 index 0000000..c9a1115 --- /dev/null +++ b/test/usage_credits/embedded_base_class_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "test_helper" + +module UsageCredits + # Regression guard for the fresh-install crash: apps that install ONLY + # usage_credits (no wallets_* tables) crashed on their first wallet + # creation, because ActiveRecord builds a subclass's attribute methods on + # its parent's ("superclass.define_attribute_methods unless base_class?") + # and the models used to subclass the CONCRETE Wallets::* classes — forcing + # a schema load of tables that don't exist in embedded-only installs. + # + # The dummy app migrates both schemas, so the crash can't reproduce here + # directly; these invariants are the exact conditions that prevent it. + class EmbeddedBaseClassTest < ActiveSupport::TestCase + EMBEDDED_MODELS = [ + UsageCredits::Wallet, + UsageCredits::Transaction, + UsageCredits::Allocation, + UsageCredits::Transfer + ].freeze + + test "every embedded model is its own base_class under an abstract parent" do + EMBEDDED_MODELS.each do |model| + assert_equal model, model.base_class, + "#{model} must be its own base_class — otherwise ActiveRecord " \ + "loads the base wallets_* schema, which fresh embedded-only " \ + "installs don't have" + assert_predicate model.superclass, :abstract_class?, + "#{model}'s parent (#{model.superclass}) must be abstract" + end + end + + test "embedded models keep their own tables and never point at wallets_*" do + assert_equal "usage_credits_wallets", UsageCredits::Wallet.table_name + assert_equal "usage_credits_transactions", UsageCredits::Transaction.table_name + assert_equal "usage_credits_allocations", UsageCredits::Allocation.table_name + assert_equal "usage_credits_transfers", UsageCredits::Transfer.table_name + end + + test "low_balance_threshold accepts the DSL form its own template shows" do + config = UsageCredits::Configuration.new + config.low_balance_threshold = 100.credits + assert_equal 100, config.low_balance_threshold + + config.low_balance_threshold = 250 + assert_equal 250, config.low_balance_threshold + + config.low_balance_threshold = nil + assert_nil config.low_balance_threshold + end + end +end diff --git a/test/usage_credits/engine_test.rb b/test/usage_credits/engine_test.rb new file mode 100644 index 0000000..62c6b73 --- /dev/null +++ b/test/usage_credits/engine_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" + +module UsageCredits + class EngineTest < ActiveSupport::TestCase + test "gem code is not added to the host app autoloaders" do + gem_lib = UsageCredits::Engine.root.join("lib").to_s + + Rails.autoloaders.each do |autoloader| + autoloader.dirs.each do |dir| + refute dir.to_s.start_with?(gem_lib), "#{dir} must not be autoloaded from usage_credits" + end + end + end + + test "all gem constants are eagerly available without autoloading" do + assert defined?(UsageCredits::Wallet) + assert defined?(UsageCredits::Transaction) + assert defined?(UsageCredits::Allocation) + assert defined?(UsageCredits::Transfer) + assert defined?(UsageCredits::Fulfillment) + assert defined?(UsageCredits::HasWallet) + end + + test "active record models gain the has_credits macro" do + assert_respond_to ActiveRecord::Base, :has_credits + assert_respond_to User, :has_credits + end + + test "Pay models receive usage credits extensions through the engine hook" do + assert_includes Pay::Charge.included_modules, UsageCredits::PayChargeExtension + assert_includes Pay::Subscription.included_modules, UsageCredits::PaySubscriptionExtension + end + + test "usage credits railtie require path remains available for compatibility" do + assert_nothing_raised { require "usage_credits/railtie" } + assert_same UsageCredits::Engine, UsageCredits::Railtie + end + + test "usage credits railtie can be required directly" do + script = 'require "bundler/setup"; require "usage_credits/railtie"; abort unless UsageCredits::Railtie == UsageCredits::Engine' + _stdout, stderr, status = Open3.capture3( + RbConfig.ruby, + "-I", UsageCredits::Engine.root.join("lib").to_s, + "-e", script + ) + + assert status.success?, stderr + end + end +end diff --git a/test/usage_credits/migration_templates_test.rb b/test/usage_credits/migration_templates_test.rb new file mode 100644 index 0000000..276f963 --- /dev/null +++ b/test/usage_credits/migration_templates_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "test_helper" + +class UsageCredits::MigrationTemplatesTest < ActiveSupport::TestCase + test "fresh install template matches the wallets-core transfer schema" do + template = File.read(template_path("create_usage_credits_tables.rb.erb")) + + assert_includes template, 't.string :asset_code, null: false, default: "credits"' + assert_includes template, 't.string :expiration_policy, null: false, default: "preserve"' + assert_includes template, "t.references :transfer" + assert_includes template, 'index: { unique: true, name: "index_usage_credits_fulfillments_on_source" }' + assert_includes template, "add_foreign_key :usage_credits_transactions" + assert_includes template, '"amount <> 0"' + assert_includes template, '"from_wallet_id <> to_wallet_id"' + refute_includes template, "outbound_transaction" + refute_includes template, "inbound_transaction" + end + + test "upgrade template uses an explicit up migration without adding new legacy fulfillment foreign keys" do + template = File.read(template_path("upgrade_usage_credits_to_wallets_core.rb.erb")) + + assert_includes template, "def up" + assert_includes template, "class UpgradeUsageCreditsToWalletsCore" + assert_includes template, "add_column :usage_credits_wallets, :asset_code" + assert_includes template, "ensure_bigint_column!(:usage_credits_wallets, :balance" + assert_includes template, "create_table :usage_credits_transfers" + assert_includes template, 't.string :expiration_policy, null: false, default: "preserve"' + assert_includes template, "ensure_no_duplicate_fulfillment_sources!" + assert_includes template, "ensure_transfer_reference!" + refute_includes template, "outbound_transaction" + refute_includes template, "inbound_transaction" + refute_includes template, "add_foreign_key :usage_credits_fulfillments" + end + + private + + def template_path(filename) + File.expand_path("../../lib/generators/usage_credits/templates/#{filename}", __dir__) + end +end diff --git a/test/usage_credits/subscription_terms_test.rb b/test/usage_credits/subscription_terms_test.rb new file mode 100644 index 0000000..03e587a --- /dev/null +++ b/test/usage_credits/subscription_terms_test.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "test_helper" + +class UsageCredits::SubscriptionTermsTest < ActiveSupport::TestCase + TestPlan = Struct.new( + :name, + :credits_per_period, + :signup_bonus_credits, + :trial_credits, + :fulfillment_period_display, + :rollover_enabled, + :expire_credits_on_cancel, + :credit_expiration_period, + keyword_init: true + ) + + setup do + UsageCredits.reset! + end + + test "from_plan snapshots every commercial term and retains the callback plan" do + configured_plan = build_plan + + terms = UsageCredits::SubscriptionTerms.from_plan( + configured_plan, + processor_plan_id: "price_pro_monthly" + ) + + assert_equal :pro, terms.name + assert_equal "price_pro_monthly", terms.processor_plan_id + assert_equal 100, terms.credits_per_period + assert_equal 25, terms.signup_bonus_credits + assert_equal 10, terms.trial_credits + assert_equal "1 month", terms.fulfillment_period_display + assert_equal 1.month, terms.parsed_fulfillment_period + assert terms.rollover_enabled + assert terms.expire_credits_on_cancel + assert_equal 2.days.to_i, terms.credit_expiration_period_seconds + assert_same configured_plan, terms.configured_plan + assert_same configured_plan, terms.callback_plan + assert terms.frozen? + end + + test "from_plan returns nil when no configured plan exists" do + assert_nil UsageCredits::SubscriptionTerms.from_plan(nil, processor_plan_id: "missing") + end + + test "from_metadata accepts indifferent keys and strict serialized values" do + terms = UsageCredits::SubscriptionTerms.from_metadata( + { + "plan_name" => "snapshot_pro", + "credits_per_period" => "100", + "signup_bonus_credits" => "25", + "trial_credits" => "10", + "fulfillment_period" => "2 weeks", + "rollover_enabled" => "1", + "expire_credits_on_cancel" => "true", + "credit_expiration_period" => 86_400.to_s + }, + processor_plan_id: :price_snapshot + ) + + assert_equal :snapshot_pro, terms.name + assert_equal "price_snapshot", terms.processor_plan_id + assert_equal 100, terms.credits_per_period + assert_equal 25, terms.signup_bonus_credits + assert_equal 10, terms.trial_credits + assert_equal 2.weeks, terms.parsed_fulfillment_period + assert terms.rollover_enabled + assert terms.expire_credits_on_cancel + assert_equal 1.day.to_i, terms.credit_expiration_period_seconds + assert_same terms, terms.callback_plan + end + + test "from_metadata requires the three canonical snapshot fields" do + %i[credits_per_period fulfillment_period rollover_enabled].each do |key| + assert_nil UsageCredits::SubscriptionTerms.from_metadata( + valid_metadata.except(key), + processor_plan_id: "price_missing_#{key}" + ) + end + end + + test "serialized booleans accept only explicit true and false representations" do + UsageCredits::SubscriptionTerms::TRUE_VALUES.each do |value| + terms = terms_from_metadata(rollover_enabled: value, expire_credits_on_cancel: value) + assert terms.rollover_enabled, "expected #{value.inspect} to parse as true" + assert terms.expire_credits_on_cancel, "expected #{value.inspect} to parse as true" + end + + UsageCredits::SubscriptionTerms::FALSE_VALUES.each do |value| + terms = terms_from_metadata(rollover_enabled: value, expire_credits_on_cancel: value) + assert_not terms.rollover_enabled, "expected #{value.inspect} to parse as false" + assert_not terms.expire_credits_on_cancel, "expected #{value.inspect} to parse as false" + end + end + + test "serialized booleans reject ambiguous values" do + [nil, "", "TRUE", "yes", 2].each do |value| + error = assert_raises(ArgumentError) do + terms_from_metadata(rollover_enabled: value) + end + assert_includes error.message, "rollover_enabled must be true or false" + end + + ["TRUE", "yes", 2].each do |value| + error = assert_raises(ArgumentError) do + terms_from_metadata(expire_credits_on_cancel: value) + end + assert_includes error.message, "expire_credits_on_cancel must be true or false" + end + end + + test "missing optional metadata falls back to the configured plan" do + configured_plan = build_plan(name: :configured_fallback, credit_expiration_period: 3.days) + metadata = valid_metadata.except( + :plan_name, + :subscription_name, + :expire_credits_on_cancel, + :credit_expiration_period + ) + + terms = UsageCredits::SubscriptionTerms.from_metadata( + metadata, + processor_plan_id: "price_fallback", + configured_plan: configured_plan + ) + + assert_equal :configured_fallback, terms.name + assert terms.expire_credits_on_cancel + assert_equal 3.days.to_i, terms.credit_expiration_period_seconds + assert_same configured_plan, terms.callback_plan + end + + test "explicit serialized false and zero override a truthy configured fallback" do + configured_plan = build_plan + terms = UsageCredits::SubscriptionTerms.from_metadata( + valid_metadata.merge( + expire_credits_on_cancel: "0", + credit_expiration_period: "0" + ), + processor_plan_id: "price_override", + configured_plan: configured_plan + ) + + assert_not terms.expire_credits_on_cancel + assert_equal 0, terms.credit_expiration_period_seconds + end + + test "credits per period must be a positive whole number" do + [nil, 0, -1, "0", "-1", 1.5, "1.5", Float::NAN, Float::INFINITY, "many"].each do |value| + assert_raises(ArgumentError, "expected #{value.inspect} to be rejected") do + terms_from_metadata(credits_per_period: value) + end + end + end + + test "signup and trial credits must be non-negative whole numbers" do + %i[signup_bonus_credits trial_credits].each do |field| + [-1, "-1", 1.5, "1.5", Float::NAN, Float::INFINITY, "many"].each do |value| + assert_raises(ArgumentError, "expected #{field}=#{value.inspect} to be rejected") do + terms_from_metadata(field => value) + end + end + end + end + + test "credit expiration period allows blank immediate expiry and rejects invalid values" do + [nil, "", 0, "0"].each do |value| + assert_equal 0, terms_from_metadata(credit_expiration_period: value).credit_expiration_period_seconds + end + + [-1, "-1", 1.5, "1.5", Float::NAN, Float::INFINITY, "later"].each do |value| + assert_raises(ArgumentError, "expected #{value.inspect} to be rejected") do + terms_from_metadata(credit_expiration_period: value) + end + end + end + + test "persisted cadence ignores later operator minimums but never the hard one-second floor" do + UsageCredits.configuration.min_fulfillment_period = 1.day + + terms = terms_from_metadata(fulfillment_period: "1 second") + assert_equal 1.second, terms.parsed_fulfillment_period + + ["0 seconds", "0.second", "invalid", "1 fortnight"].each do |period| + assert_raises(ArgumentError, "expected #{period.inspect} to be rejected") do + terms_from_metadata(fulfillment_period: period) + end + end + end + + test "snapshots are immutable after construction" do + terms = terms_from_metadata + + assert_raises(FrozenError) do + terms.instance_variable_set(:@credits_per_period, 1_000_000) + end + assert_equal 100, terms.credits_per_period + end + + private + + def build_plan(**overrides) + attributes = { + name: :pro, + credits_per_period: 100, + signup_bonus_credits: 25, + trial_credits: 10, + fulfillment_period_display: "1 month", + rollover_enabled: true, + expire_credits_on_cancel: true, + credit_expiration_period: 2.days + }.merge(overrides) + + TestPlan.new(**attributes) + end + + def valid_metadata + { + plan_name: "snapshot_pro", + credits_per_period: "100", + signup_bonus_credits: "25", + trial_credits: "10", + fulfillment_period: "1 month", + rollover_enabled: "true", + expire_credits_on_cancel: "false", + credit_expiration_period: "0" + } + end + + def terms_from_metadata(overrides = {}) + UsageCredits::SubscriptionTerms.from_metadata( + valid_metadata.merge(overrides), + processor_plan_id: "price_snapshot" + ) + end +end diff --git a/test/usage_credits/upgrade_migration_test.rb b/test/usage_credits/upgrade_migration_test.rb new file mode 100644 index 0000000..746c2a4 --- /dev/null +++ b/test/usage_credits/upgrade_migration_test.rb @@ -0,0 +1,590 @@ +# frozen_string_literal: true + +require "test_helper" +require "erb" +require "fileutils" +require "tmpdir" + +class UsageCredits::UpgradeMigrationTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + class TemporaryRecord < ActiveRecord::Base + self.abstract_class = true + end + + class TemporaryConnectionRecord < TemporaryRecord + self.abstract_class = true + end + + def setup + super + + @tmpdir = Dir.mktmpdir("usage-credits-upgrade") + @database_path = File.join(@tmpdir, "upgrade.sqlite3") + + @migration_base = TemporaryConnectionRecord + @migration_base.establish_connection(adapter: "sqlite3", database: @database_path) + @connection = @migration_base.connection + end + + def teardown + super + end + + def after_teardown + super + @migration_base.connection_pool.disconnect! if defined?(@migration_base) && @migration_base&.connection_pool + FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir) + end + + test "upgrade migration preserves pre-1.0 data while adding the wallets core schema" do + create_pre_1_0_schema! + seed_pre_1_0_data! + + run_upgrade_migration! + + wallet_row = @connection.select_one("SELECT * FROM usage_credits_wallets WHERE id = 1") + assert_equal "User", wallet_row["owner_type"] + assert_equal 42, wallet_row["owner_id"] + assert_equal 150, wallet_row["balance"] + assert_equal "credits", wallet_row["asset_code"] + + transaction_rows = @connection.exec_query("SELECT id, wallet_id, amount, category, transfer_id FROM usage_credits_transactions ORDER BY id").to_a + assert_equal [ + {"id" => 1, "wallet_id" => 1, "amount" => 200, "category" => "signup_bonus", "transfer_id" => nil}, + {"id" => 2, "wallet_id" => 1, "amount" => -50, "category" => "operation_charge", "transfer_id" => nil} + ], transaction_rows + + # Pre-1.0 stored balance snapshots inside metadata; they must survive untouched. + credit_metadata = ActiveSupport::JSON.decode(@connection.select_value("SELECT metadata FROM usage_credits_transactions WHERE id = 1")) + assert_equal "welcome", credit_metadata["reason"] + assert_equal 200, credit_metadata["balance_after"] + + allocation_row = @connection.select_one("SELECT * FROM usage_credits_allocations WHERE id = 1") + assert_equal 50, allocation_row["amount"] + assert_equal 2, allocation_row["transaction_id"] + assert_equal 1, allocation_row["source_transaction_id"] + + fulfillment_row = @connection.select_one("SELECT * FROM usage_credits_fulfillments WHERE id = 1") + assert_equal 1, fulfillment_row["wallet_id"] + assert_equal 200, fulfillment_row["credits_last_fulfillment"] + assert_equal "Pay::Charge", fulfillment_row["source_type"] + assert_equal 7, fulfillment_row["source_id"] + assert_equal "credit_pack", fulfillment_row["fulfillment_type"] + + assert_includes @connection.tables, "usage_credits_transfers" + assert_equal 0, @connection.select_value("SELECT COUNT(*) FROM usage_credits_transfers") + + wallet_index = @connection.indexes(:usage_credits_wallets).find { |index| index.name == "index_usage_credits_wallets_on_owner_and_asset" } + assert wallet_index, "expected owner/asset index to be created" + assert wallet_index.unique + assert_equal %w[owner_type owner_id asset_code], wallet_index.columns + + transfers_index = @connection.indexes(:usage_credits_transfers).find { |index| index.name == "index_usage_credits_transfers_on_wallets_and_asset" } + assert transfers_index, "expected transfers wallet/asset index to be created" + + source_index = @connection.indexes(:usage_credits_fulfillments).find { |index| index.columns == %w[source_type source_id] } + assert source_index, "expected fulfillment source index to be created" + assert source_index.unique, "fulfillment source idempotency must be enforced by the database" + + transfer_reference_index = @connection.indexes(:usage_credits_transactions).find { |index| index.columns == ["transfer_id"] } + assert transfer_reference_index, "expected interrupted reference index to be independently ensured" + + transaction_transfer_fk = @connection.foreign_keys(:usage_credits_transactions).find do |foreign_key| + foreign_key.to_table == "usage_credits_transfers" && foreign_key.options[:column].to_s == "transfer_id" + end + assert transaction_transfer_fk, "expected transfer foreign key to be created" + + assert @connection.check_constraint_exists?( + :usage_credits_transactions, + name: "check_usage_credits_transactions_amount_nonzero" + ) + assert @connection.check_constraint_exists?( + :usage_credits_allocations, + name: "check_usage_credits_allocations_amount_positive" + ) + assert @connection.check_constraint_exists?( + :usage_credits_transfers, + name: "check_usage_credits_transfers_distinct_wallets" + ) + + assert @connection.foreign_key_exists?( + :usage_credits_transactions, + :usage_credits_wallets, + column: :wallet_id + ) + assert @connection.foreign_key_exists?( + :usage_credits_fulfillments, + :usage_credits_wallets, + column: :wallet_id + ) + assert @connection.foreign_key_exists?( + :usage_credits_transactions, + :usage_credits_fulfillments, + column: :fulfillment_id + ) + + # Pre-1.0 index names are intentionally preserved (no renames on production tables). + legacy_allocation_index = @connection.indexes(:usage_credits_allocations).find { |index| index.name == "index_allocations_on_tx_and_source_tx" } + assert legacy_allocation_index, "expected pre-1.0 allocation index name to be preserved" + + wallet_balance_column = @connection.columns(:usage_credits_wallets).find { |column| column.name == "balance" } + transaction_amount_column = @connection.columns(:usage_credits_transactions).find { |column| column.name == "amount" } + allocation_amount_column = @connection.columns(:usage_credits_allocations).find { |column| column.name == "amount" } + fulfillment_amount_column = @connection.columns(:usage_credits_fulfillments).find { |column| column.name == "credits_last_fulfillment" } + transfer_amount_column = @connection.columns(:usage_credits_transfers).find { |column| column.name == "amount" } + transfer_policy_column = @connection.columns(:usage_credits_transfers).find { |column| column.name == "expiration_policy" } + + assert_equal "bigint", wallet_balance_column.sql_type + assert_equal "bigint", transaction_amount_column.sql_type + assert_equal "bigint", allocation_amount_column.sql_type + assert_equal "bigint", fulfillment_amount_column.sql_type + assert_equal "bigint", transfer_amount_column.sql_type + assert_equal "preserve", transfer_policy_column.default + + transfer_reference = @connection.columns(:usage_credits_transactions).find { |column| column.name == "transfer_id" } + + assert transfer_reference + refute @connection.columns(:usage_credits_transfers).any? { |column| column.name == "outbound_transaction_id" } + refute @connection.columns(:usage_credits_transfers).any? { |column| column.name == "inbound_transaction_id" } + end + + test "upgrade migration aborts before touching the schema when duplicate owner wallets exist" do + create_pre_1_0_schema! + seed_pre_1_0_data! + + # The pre-1.0 schema never enforced one-wallet-per-owner, so a race could + # have created duplicates. Simulate that exact production scenario. + insert_row :usage_credits_wallets, + id: 2, + owner_type: "User", + owner_id: 42, + balance: 25, + created_at: Time.current, + updated_at: Time.current + + error = assert_raises(StandardError) { run_upgrade_migration! } + assert_match(/more than one usage_credits wallet/, error.message) + assert_match(/User#42 \(2 wallets\)/, error.message) + assert_match(/No schema changes have been applied yet/, error.message) + + # The database must be completely untouched so the user can fix data and re-run. + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + refute_includes @connection.tables, "usage_credits_transfers" + refute @connection.columns(:usage_credits_transactions).any? { |column| column.name == "transfer_id" } + end + + test "upgrade migration is safe to re-run after a completed or interrupted attempt" do + create_pre_1_0_schema! + seed_pre_1_0_data! + + run_upgrade_migration! + run_upgrade_migration! + + assert_equal 1, @connection.indexes(:usage_credits_wallets).count { |index| index.name == "index_usage_credits_wallets_on_owner_and_asset" } + assert_equal 150, @connection.select_value("SELECT balance FROM usage_credits_wallets WHERE id = 1") + end + + test "upgrade aborts before schema changes when payment sources have duplicate fulfillments" do + create_pre_1_0_schema! + seed_pre_1_0_data! + duplicate = @connection.select_one("SELECT * FROM usage_credits_fulfillments WHERE id = 1").symbolize_keys + duplicate[:id] = 2 + insert_row :usage_credits_fulfillments, duplicate + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/duplicate fulfillments/, error.message) + assert_match(/Pay::Charge#7 \(2 fulfillments\)/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + refute_includes @connection.tables, "usage_credits_transfers" + end + + test "upgrade aborts before schema changes when ledger references are orphaned" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.disable_referential_integrity do + @connection.execute("UPDATE usage_credits_transactions SET wallet_id = 999 WHERE id = 1") + end + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/orphaned ledger references/, error.message) + assert_match(/usage_credits_transactions\.wallet_id: 1/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + refute_includes @connection.tables, "usage_credits_transfers" + end + + test "upgrade aborts before schema changes when a Pay source is orphaned" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.execute(<<~SQL.squish) + UPDATE usage_credits_fulfillments + SET source_type = 'Pay::Subscription', source_id = 999 + WHERE id = 1 + SQL + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/fulfillment payment sources are missing/, error.message) + assert_match(/Pay::Subscription: 1/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + refute_includes @connection.tables, "usage_credits_transfers" + end + + test "upgrade aborts before schema changes when allocation direction is invalid" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.execute("UPDATE usage_credits_transactions SET amount = 50 WHERE id = 2") + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/violates wallets accounting invariants/, error.message) + assert_match(/invalid debit\/credit direction/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + refute_includes @connection.tables, "usage_credits_transfers" + end + + test "upgrade aborts before schema changes when allocations exceed a ledger leg" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.execute("UPDATE usage_credits_allocations SET amount = 250 WHERE id = 1") + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/over-allocated credit sources: 1/, error.message) + assert_match(/over-allocated debit transactions: 1/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade aborts before schema changes for zero transactions or negative fulfillment snapshots" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.execute("UPDATE usage_credits_transactions SET amount = 0 WHERE id = 1") + @connection.execute("UPDATE usage_credits_fulfillments SET credits_last_fulfillment = -1 WHERE id = 1") + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/zero-amount transactions: 1/, error.message) + assert_match(/negative fulfillment credit snapshots: 1/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade aborts before schema changes when a reserved index name has different columns" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.add_index :usage_credits_wallets, + :owner_id, + name: "index_usage_credits_wallets_on_owner_and_asset" + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/index_usage_credits_wallets_on_owner_and_asset/, error.message) + assert_match(/reserved/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade aborts before schema changes for half-populated fulfillment sources" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.execute("UPDATE usage_credits_fulfillments SET source_id = NULL WHERE id = 1") + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/incomplete polymorphic/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade repairs an interrupted transfer reference column" do + create_pre_1_0_schema! + seed_pre_1_0_data! + + create_interrupted_transfers_table! + @connection.add_column :usage_credits_transactions, :transfer_id, :integer + + run_upgrade_migration! + + assert @connection.index_exists?(:usage_credits_transactions, :transfer_id) + assert @connection.foreign_key_exists?( + :usage_credits_transactions, + :usage_credits_transfers, + column: :transfer_id + ) + assert @connection.check_constraint_exists?( + :usage_credits_transactions, + name: "check_usage_credits_transactions_amount_nonzero" + ) + assert @connection.check_constraint_exists?( + :usage_credits_fulfillments, + name: "check_usage_credits_fulfillments_credits_nonnegative" + ) + assert @connection.foreign_key_exists?( + :usage_credits_transfers, + :usage_credits_wallets, + column: :from_wallet_id + ) + end + + test "upgrade rejects an orphan in an interrupted transfers table before new changes" do + create_pre_1_0_schema! + seed_pre_1_0_data! + create_interrupted_transfers_table! + insert_row :usage_credits_transfers, + id: 1, + from_wallet_id: 1, + to_wallet_id: 999, + asset_code: "credits", + amount: 10, + category: "transfer", + expiration_policy: "preserve", + metadata: json_payload({}), + created_at: Time.current, + updated_at: Time.current + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/usage_credits_transfers\.to_wallet_id: 1/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade rejects an incomplete interrupted transfers table before new changes" do + create_pre_1_0_schema! + seed_pre_1_0_data! + @connection.create_table :usage_credits_transfers do |t| + t.references :from_wallet, null: false + end + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/usage_credits_transfers is incomplete/, error.message) + assert_match(/to_wallet_id/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade rejects an incomplete pre-1.0 schema without touching existing tables" do + create_pre_1_0_schema! + @connection.drop_table :usage_credits_allocations + + error = assert_raises(StandardError) { run_upgrade_migration! } + + assert_match(/incomplete usage_credits schema/, error.message) + assert_match(/usage_credits_allocations/, error.message) + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + end + + test "upgrade migration tells fresh apps to use the install generator instead" do + error = assert_raises(StandardError) { run_upgrade_migration! } + assert_match(/No usage_credits tables found/, error.message) + assert_match(/usage_credits:install/, error.message) + end + + test "fresh install migration executes up and down with all integrity constraints" do + migration = load_install_migration_class.new + migration.verbose = false + migration.exec_migration(@connection, :up) + + expected_tables = %w[ + usage_credits_wallets + usage_credits_transfers + usage_credits_transactions + usage_credits_fulfillments + usage_credits_allocations + ] + assert_empty expected_tables - @connection.tables + + source_index = @connection.indexes(:usage_credits_fulfillments).find { |index| index.columns == %w[source_type source_id] } + assert source_index&.unique + assert @connection.foreign_key_exists?( + :usage_credits_transactions, + :usage_credits_fulfillments, + column: :fulfillment_id + ) + assert @connection.foreign_key_exists?( + :usage_credits_transactions, + :usage_credits_transfers, + column: :transfer_id + ) + + migration.exec_migration(@connection, :down) + assert_empty expected_tables & @connection.tables + end + + private + + # Mirrors the actual 0.5.0 install template (lib/generators/usage_credits/templates/ + # create_usage_credits_tables.rb.erb on the 0.5.0 tag) so we test the upgrade + # against the schema real production apps are coming from. + def create_pre_1_0_schema! + # usage_credits 0.5 fulfillment sources point at Pay's tables. Including + # them here makes the migration fixture representative and lets preflight + # tests prove that polymorphic payment references are not silently orphaned. + @connection.create_table :pay_charges + @connection.create_table :pay_subscriptions + + @connection.create_table :usage_credits_wallets do |t| + t.references :owner, polymorphic: true, null: false + t.integer :balance, null: false, default: 0 + t.json :metadata, null: false, default: {} + + t.timestamps + end + + @connection.create_table :usage_credits_transactions do |t| + t.references :wallet, null: false + t.integer :amount, null: false + t.string :category, null: false + t.datetime :expires_at + t.references :fulfillment + t.json :metadata, null: false, default: {} + + t.timestamps + end + + @connection.create_table :usage_credits_fulfillments do |t| + t.references :wallet, null: false + t.references :source, polymorphic: true + t.integer :credits_last_fulfillment, null: false + t.string :fulfillment_type, null: false + t.datetime :last_fulfilled_at + t.datetime :next_fulfillment_at + t.string :fulfillment_period + t.datetime :stops_at + t.json :metadata, null: false, default: {} + + t.timestamps + end + + @connection.create_table :usage_credits_allocations do |t| + t.references :transaction, null: false, + foreign_key: {to_table: :usage_credits_transactions}, + index: {name: "index_allocations_on_transaction_id"} + t.references :source_transaction, null: false, + foreign_key: {to_table: :usage_credits_transactions}, + index: {name: "index_allocations_on_source_transaction_id"} + t.integer :amount, null: false + + t.timestamps + end + + @connection.add_index :usage_credits_transactions, :category + @connection.add_index :usage_credits_transactions, :expires_at + @connection.add_index :usage_credits_transactions, [:expires_at, :id], name: "index_transactions_on_expires_at_and_id" + @connection.add_index :usage_credits_transactions, [:wallet_id, :amount], name: "index_transactions_on_wallet_id_and_amount" + @connection.add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx" + @connection.add_index :usage_credits_fulfillments, :next_fulfillment_at + @connection.add_index :usage_credits_fulfillments, :fulfillment_type + end + + def seed_pre_1_0_data! + now = Time.current + + insert_row :pay_charges, id: 7 + + insert_row :usage_credits_wallets, + id: 1, + owner_type: "User", + owner_id: 42, + balance: 150, + created_at: now, + updated_at: now + + # 0.5.0 stored balance snapshots in metadata, not in dedicated columns. + insert_row :usage_credits_transactions, + id: 1, + wallet_id: 1, + fulfillment_id: 1, + amount: 200, + category: "signup_bonus", + metadata: json_payload(reason: "welcome", balance_before: 0, balance_after: 200), + created_at: now, + updated_at: now + + insert_row :usage_credits_transactions, + id: 2, + wallet_id: 1, + fulfillment_id: nil, + amount: -50, + category: "operation_charge", + metadata: json_payload(operation: "generate_report", balance_before: 200, balance_after: 150), + created_at: now, + updated_at: now + + insert_row :usage_credits_allocations, + id: 1, + transaction_id: 2, + source_transaction_id: 1, + amount: 50, + created_at: now, + updated_at: now + + insert_row :usage_credits_fulfillments, + id: 1, + wallet_id: 1, + source_type: "Pay::Charge", + source_id: 7, + credits_last_fulfillment: 200, + fulfillment_type: "credit_pack", + last_fulfilled_at: now, + metadata: json_payload(purchase: "starter_pack"), + created_at: now, + updated_at: now + end + + def create_interrupted_transfers_table! + @connection.create_table :usage_credits_transfers do |t| + t.references :from_wallet, null: false + t.references :to_wallet, null: false + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.json :metadata, null: false, default: {} + t.timestamps + end + end + + def run_upgrade_migration! + migration_class = load_upgrade_migration_class + migration = migration_class.new + migration.verbose = false + migration.exec_migration(@connection, :up) + end + + def load_upgrade_migration_class + source = ERB.new(File.read(template_path("upgrade_usage_credits_to_wallets_core.rb.erb"))).result_with_hash( + migration_version: "[#{ActiveRecord::VERSION::STRING.to_f}]" + ) + + mod = Module.new + mod.module_eval(source, template_path("upgrade_usage_credits_to_wallets_core.rb.erb"), 1) + mod.const_get(:UpgradeUsageCreditsToWalletsCore) + end + + def load_install_migration_class + source = ERB.new(File.read(template_path("create_usage_credits_tables.rb.erb"))).result_with_hash( + migration_version: "[#{ActiveRecord::VERSION::STRING.to_f}]" + ) + + mod = Module.new + mod.module_eval(source, template_path("create_usage_credits_tables.rb.erb"), 1) + mod.const_get(:CreateUsageCreditsTables) + end + + def insert_row(table_name, attributes) + columns = attributes.keys.map(&:to_s) + values = attributes.values.map { |value| @connection.quote(value) } + + @connection.execute(<<~SQL.squish) + INSERT INTO #{table_name} (#{columns.join(", ")}) + VALUES (#{values.join(", ")}) + SQL + end + + def template_path(filename) + File.expand_path("../../lib/generators/usage_credits/templates/#{filename}", __dir__) + end + + def json_payload(attributes) + ActiveSupport::JSON.encode(attributes) + end +end diff --git a/usage_credits.gemspec b/usage_credits.gemspec index 68fdaad..fa85744 100644 --- a/usage_credits.gemspec +++ b/usage_credits.gemspec @@ -12,27 +12,25 @@ Gem::Specification.new do |spec| spec.description = "Add a usage-based credit system to your Rails app, easily. Let users buy and spend credits on usage-based actions. Refill Stripe subscriptions with credits, sell one-time booster credit packs, implement PAYG (pay-as-you-go) billing, award free credits as bonuses, manage prepaid credits / tokens. Your users will have wallet balances that they can spend on features, API calls, or other usage-based actions. Perfect for SaaS, AI apps, games, and API products with metered pricing / billing." spec.homepage = "https://github.com/rameerez/usage_credits" spec.license = "MIT" - spec.required_ruby_version = ">= 3.1.0" + spec.required_ruby_version = ">= 3.2.0" spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main" spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" spec.metadata["rubygems_mfa_required"] = "true" - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - gemspec = File.basename(__FILE__) spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| - ls.readlines("\x0", chomp: true).reject do |f| - (f == gemspec) || - f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) + ls.readlines("\x0", chomp: true).select do |file| + file.start_with?("lib/", "sig/") || + %w[CHANGELOG.md LICENSE.txt README.md].include?(file) end end spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "pay", ">= 8.3", "< 12.0" - spec.add_dependency "rails", ">= 6.1" + spec.add_dependency "pay", ">= 11.6.2", "< 12.0" + spec.add_dependency "rails", ">= 7.2.3.1", "< 9.0" + spec.add_dependency "wallets", "~> 0.3.0" end