|
| 1 | +# Feature passes: samples, evaluations, and customer promises |
| 2 | + |
| 3 | +A feature pass grants one plan owner access to a feature independently of billing. |
| 4 | +Use it for a sales evaluation, support compensation, a partner promise, or beta |
| 5 | +access. A pass can be permanent, expire at a deadline, carry named capacity limits, |
| 6 | +and/or have a cumulative usage allowance. These are existing FeatureGrant rows, |
| 7 | +not another subscription system. Available in 0.7.0. |
| 8 | + |
| 9 | +## Issue a sample without replacing a promise |
| 10 | + |
| 11 | +```ruby |
| 12 | +pass = organization.issue_feature_pass!( |
| 13 | + :distribution, |
| 14 | + source: "sales_evaluation", |
| 15 | + note: "Evaluate managed updates; follow up after the first release", |
| 16 | + expires_at: 3.months.from_now, |
| 17 | + limits: { storage_bytes: 1.gigabyte, max_artifact_bytes: 200.megabytes }, |
| 18 | + usage_limit: 2.gigabytes |
| 19 | +) |
| 20 | +``` |
| 21 | + |
| 22 | +This permits access until the deadline, at most 1 GB stored concurrently, and at |
| 23 | +most 2 GB cumulatively reserved through the write API below. A single artifact |
| 24 | +may be at most 200 MB. The host must supply its live capacity measurements. |
| 25 | + |
| 26 | +`issue_feature_pass!` raises `PricingPlans::FeatureGrantConflict` if there is |
| 27 | +already an active grant for this owner and feature. It never silently shortens |
| 28 | +a permanent grant or replaces a support promise. `source:` is required. |
| 29 | + |
| 30 | +Omit `expires_at` for permanent access. Omit `usage_limit` for no cumulative cap. |
| 31 | +An omitted named limit is unlimited for an entitled feature; a feature with no |
| 32 | +entitlement is still denied. Use `0` to prohibit consumption or a capacity, and |
| 33 | +`:unlimited` explicitly where appropriate. Counts must be nonnegative integers |
| 34 | +within signed bigint range; strings, fractional amounts, negatives, and unknown |
| 35 | +options are rejected by the public pass APIs. |
| 36 | + |
| 37 | +The existing `grant_feature!` remains an upsert. It now accepts `limits:` and |
| 38 | +`usage_limit:` too. When omitted during an upsert, those two settings and consumed |
| 39 | +usage are preserved. Existing semantics for source, note and expiration remain |
| 40 | +unchanged (omitting expiration on that legacy API makes the grant permanent). |
| 41 | +Use the create-only API for an operator's "issue" button. |
| 42 | + |
| 43 | +## Read access and show an offer |
| 44 | + |
| 45 | +```ruby |
| 46 | +organization.plan_allows?(:distribution) # same boolean API as before |
| 47 | +access = organization.feature_access(:distribution) |
| 48 | +access.allowed? # entitlement exists; does not mean every operation fits |
| 49 | +access.source # :plan | :grandfather | :grant | nil |
| 50 | +access.grant # selected grant row, or nil |
| 51 | +access.expires_at # grant deadline, or nil |
| 52 | +access.limit(:storage_bytes) # integer or :unlimited |
| 53 | +access.usage_count # cumulative grant consumption |
| 54 | +access.remaining_allowance # integer or :unlimited |
| 55 | +access.available?(amount: upload.bytesize, |
| 56 | + usage: { storage_bytes: stored_bytes + upload.bytesize, |
| 57 | + max_artifact_bytes: upload.bytesize }) |
| 58 | +``` |
| 59 | + |
| 60 | +`FeatureAccess` is a snapshot for UI and preflight. Do not cache it across requests |
| 61 | +or use an old snapshot to authorize a write. `available?` returns false on denial; |
| 62 | +`check!` raises `FeatureDenied` or its `FeatureLimitExceeded` subclass, which exposes |
| 63 | +`feature_key`, `plan_owner`, `limit_key`, `allowed`, and `requested`. |
| 64 | + |
| 65 | +Boolean access is intentionally separate from consumption. A fully used upload |
| 66 | +allowance still permits zero-cost operations, such as finalizing or publishing |
| 67 | +an already reserved artifact. A new upload fails when it would exceed the |
| 68 | +allowance. At `expires_at` exactly, the grant is inactive, including for zero-cost |
| 69 | +writes. There is no expiry worker: the next entitlement check sees the deadline. |
| 70 | + |
| 71 | +## Enforce and reserve in the same transaction |
| 72 | + |
| 73 | +```ruby |
| 74 | +organization.with_feature_access!( |
| 75 | + :distribution, |
| 76 | + amount: upload.bytesize, |
| 77 | + usage: -> { |
| 78 | + { |
| 79 | + storage_bytes: organization.artifacts.sum(:byte_size) + upload.bytesize, |
| 80 | + max_artifact_bytes: upload.bytesize |
| 81 | + } |
| 82 | + } |
| 83 | +) do |access| |
| 84 | + organization.artifacts.create!(byte_size: upload.bytesize, filename: upload.filename) |
| 85 | +end |
| 86 | +``` |
| 87 | + |
| 88 | +The gem locks a fresh copy of the owner row, resolves access again, reads the |
| 89 | +usage callable, checks the named limits and cumulative allowance, reserves the |
| 90 | +amount, then yields. It bypasses query caches so a preflight read cannot stay |
| 91 | +stale after waiting for a competing writer. It does not reload the caller's |
| 92 | +unsaved attributes. The block's return value is returned. |
| 93 | +Grant consumption is an internal operation; |
| 94 | +use `with_feature_access!`, not a direct counter update, to spend an allowance. |
| 95 | + |
| 96 | +All competing writes must use this API. Capacity values must be measured inside |
| 97 | +the callable, on the owner's database connection. `amount:` and each value in |
| 98 | +`usage:` are separate: the former is cumulative consumption, the latter is the |
| 99 | +prospective capacity after this operation (or its size for a per-operation cap). |
| 100 | +Only the dimensions the host supplies are checked; the gem cannot discover your |
| 101 | +storage system or infer which operation uses a named limit. Declaring `limits:` |
| 102 | +alone does not install callbacks on arbitrary models. Include each applicable |
| 103 | +capacity dimension at its write boundary. Existing association limits continue |
| 104 | +to use their own documented model integration. |
| 105 | + |
| 106 | +The grant update and same-database business write roll back together on an |
| 107 | +exception. A savepoint isolates failure even if an outer transaction rescues it. |
| 108 | +A block that returns false still commits; use bang writes and raise on failure. |
| 109 | +For controller actions that rescue internally, raise `ActiveRecord::Rollback` |
| 110 | +inside the block when the rendered response is unsuccessful. External storage |
| 111 | +and HTTP requests are not database transactions: reserve before handing out an |
| 112 | +upload URL, and define abandoned-upload policy explicitly. Keep lock duration |
| 113 | +short; stream file bytes outside it. |
| 114 | + |
| 115 | +This API does not deduplicate arbitrary operations. Reuse an existing operation |
| 116 | +record/idempotency key before reserving again. Uniqueness violations that escape |
| 117 | +the block roll back consumption. Deleting artifacts does not refund cumulative |
| 118 | +usage. Use live stored capacity when deletions should free room. The allowance |
| 119 | +belongs to this grant lifecycle, with no periodic reset, transferable balance, |
| 120 | +refund API, or purchase ledger; use `usage_credits` for a credit economy. |
| 121 | + |
| 122 | +Row-level concurrency guarantees require a database such as PostgreSQL or MySQL. |
| 123 | +SQLite does not provide equivalent row locks. Owners and grants must use the |
| 124 | +same database/connection for atomicity. Direct SQL, `update_columns`, and writes |
| 125 | +that bypass this API are outside the contract. |
| 126 | + |
| 127 | +## Paid access wins |
| 128 | + |
| 129 | +Precedence remains plan, then qualifying grandfather, then active grant. A paid |
| 130 | +plan's access never spends or inherits a pass's allowance, and a pass never |
| 131 | +restricts an owner whose plan already carries the feature. Named limits belong |
| 132 | +to the pass alone: plan and grandfather access carry none, so `limit(:key)` |
| 133 | +answers `:unlimited` for them and your app keeps owning its plan quotas exactly |
| 134 | +as it did before passes existed. Limits do not stack and are not merged between |
| 135 | +sources. If the owner later downgrades, an unexpired, unrevoked pass resumes |
| 136 | +with its previous consumed usage. Buying a plan does not delete the pass. A plan |
| 137 | +that merely has a higher price does not win unless it actually allows the feature. |
| 138 | + |
| 139 | +## Revise or revoke explicitly |
| 140 | + |
| 141 | +```ruby |
| 142 | +pass.revise!(expires_at: 6.months.from_now, usage_limit: 4.gigabytes, |
| 143 | + note: "Customer requested time for the next release") |
| 144 | +organization.revoke_feature!(:distribution, note: "Evaluation ended early") |
| 145 | +organization.feature_grants # retained active, expired, and revoked lifecycles |
| 146 | +``` |
| 147 | + |
| 148 | +`revise!` accepts only `expires_at`, `limits`, `usage_limit`, and `note`, preserves |
| 149 | +consumption, and refuses expired/revoked rows. Lowering the allowance below used |
| 150 | +usage blocks further consumption; it does not rewrite history. Passing nil to |
| 151 | +`usage_limit` removes that cap. Passing `{}` to `limits` clears named caps. |
| 152 | + |
| 153 | +Revocation is serialized with consumption. Revoking a grant cannot remove plan |
| 154 | +or grandfather access. Issue a new pass after expiry/revocation for a new |
| 155 | +allowance and history row. The table retains lifecycle history, not an immutable |
| 156 | +log of every edit: revisions and legacy upserts update their row. Apps should |
| 157 | +record the operator and reason, and append change notes or use their audit system. |
| 158 | + |
| 159 | +## Installation and backwards compatibility |
| 160 | + |
| 161 | +| Starting point | Command | |
| 162 | +| --- | --- | |
| 163 | +| New app | `rails generate pricing_plans:install` | |
| 164 | +| App without feature grants (before 0.6.0) | `rails generate pricing_plans:grants` | |
| 165 | +| App with the 0.6.x grants table | `rails generate pricing_plans:passes` | |
| 166 | + |
| 167 | +Then run `rails db:migrate`. Run only the applicable generator, not all three. |
| 168 | +The additive upgrade adds JSON `limits` (default `{}`), nullable bigint |
| 169 | +`usage_limit`, bigint `usage_count` (default `0`), and a nonnegative usage check. |
| 170 | +It does not alter owners, existing expirations, revocations, or billing rows. |
| 171 | +Existing grants remain unbounded unless the app deliberately supplies a policy. |
| 172 | + |
| 173 | +Old-schema boolean grants continue to work after upgrading gem code. New |
| 174 | +capacity/consumption writes raise an actionable migration error before doing work |
| 175 | +if the pass columns are missing. Deploy the schema before enabling pass UI and |
| 176 | +metered write paths. No destructive backfill or scheduled expiry job is needed. |
| 177 | + |
| 178 | +## A complete SaaS offering |
| 179 | + |
| 180 | +The gem owns entitlement resolution, capacity comparison, deadlines, consumption, |
| 181 | +locking, and lifecycle APIs. The app owns authenticated operator controls, |
| 182 | +allowed feature/metric choices, units, emails, customer wording, pricing links, |
| 183 | +and storage measurements. Never present an editable metric the app does not |
| 184 | +actually enforce. An organization-owned SaaS should grant to the organization, |
| 185 | +not a member's User record; a user-owned SaaS can include PlanOwner on User. |
| 186 | + |
| 187 | +For distribution, a useful default is to block new uploads/publishing at expiry |
| 188 | +while continuing to serve existing feeds and downloads. Enforce this by putting |
| 189 | +gates on write endpoints only. Do not delete customer data as an expiry side |
| 190 | +effect. Explain the deadline and paid continuation option before the evaluation. |
| 191 | +A whole-plan Stripe trial or expiring plan override is a separate product: |
| 192 | +feature passes do not create trials, change subscriptions, or expire assignments. |
0 commit comments