From 1020b1a96f1e16c03f2246abb28bd54460496892 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 17 Oct 2026 09:05:40 +0900 Subject: [PATCH] Add an admin-set full feature access override for shopkeepers Port feature_access_state from myturntagapi so the paid clients can unlock every paid feature without a purchase: testing on production, staff/partner/comp accounts and App Review demo accounts. - shopkeepers.feature_access_state enum (normal_feature_access 1 default, all_feature_access 2), indexed, editable only in madmin - Sign-in returns feature_access_state and account_owner_feature_access_state; accounts return owner_feature_access_state - Tests: madmin can set it; sign-up and PATCH /shopkeeper_auth ignore it; serializers return the owner's state - OpenAPI, README, AGENTS.md and CHANGELOG document the override and RevenueCat promotional entitlements as the no-code alternative Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016JZoj1UtkFVFNcwoDfdb9f --- AGENTS.md | 6 +++++ CHANGELOG.md | 1 + README.md | 13 ++++++++++ app/madmin/resources/shopkeeper_resource.rb | 1 + app/models/shopkeeper.rb | 5 ++++ app/serializers/account_serializer.rb | 4 ++++ .../shopkeeper_sign_in_serializer.rb | 6 +++++ ...add_feature_access_state_to_shopkeepers.rb | 8 +++++++ db/schema.rb | 4 +++- docs/openapi.yaml | 12 ++++++++++ .../registrations_controller_test.rb | 24 +++++++++++++++++++ test/fixtures/shopkeepers.yml | 2 ++ test/integration/madmin_test.rb | 20 ++++++++++++++++ test/serializers/account_serializer_test.rb | 20 ++++++++++++++++ .../shopkeeper_sign_in_serializer_test.rb | 12 ++++++++++ 15 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20261017010000_add_feature_access_state_to_shopkeepers.rb diff --git a/AGENTS.md b/AGENTS.md index 5582134..9c37248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,12 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - **Config & secrets**: `config/push.yml` reads everything from credentials (`bin/rails credentials:edit --environment `) under `action_push_native:apns:{key_id, team_id, topic, encryption_key}` and `action_push_native:fcm:{project_id, encryption_key}`. APNs `encryption_key` is the full `.p8` PEM; FCM `encryption_key` is the entire service-account JSON. `topic` stays in credentials (not source) because the agent's rename pipeline would otherwise desync the bundle id. - **APNs sandbox vs production**: `connect_to_development_server: <%= Rails.env.development? %>`. The token's environment is fixed at iOS build time — Xcode debug builds get **sandbox** tokens, TestFlight/App Store get **production**. They must match the server: test Xcode debug builds against a **local development** server; use TestFlight to test against the deployed server. A mismatch returns APNs `400 BadDeviceToken`, which the gem treats as `TokenError` and **destroys the device row** (the default `rescue_from`). FCM has no such split. +### Billing (paid clients) +- The paid iOS/Android clients charge through RevenueCat and make the entitlement check themselves. This API stores no plan or subscription state and receives no RevenueCat webhooks. +- The RevenueCat app user ID is the current account's owner (`shopkeepers.id`), so members inherit the owner's entitlement. Account responses therefore carry `owner_id` and `owner_feature_access_state`; sign-in also returns `feature_access_state` and `account_owner_feature_access_state`. The apps read these exact names and values; don't rename or remove them. +- `shopkeepers.feature_access_state` (`normal_feature_access` default, `all_feature_access`) is an admin-set override that unlocks every paid feature in the accounts the shopkeeper owns. Change it only in `/madmin`; never permit it in sign-up or `PATCH /shopkeeper_auth` (`RegistrationsControllerTest` checks both). README "Full feature access override" has when to use it. +- Server-side limits stay the fixed `limit_count` values in `config/settings.yml`. A modified client or a direct API call can skip the paid check; that costs revenue, never data. + ### Error Monitoring - Optional Sentry (`sentry-ruby` + `sentry-rails`), configured in `config/initializers/sentry.rb` (`NativeapptemplateSentry`). Off unless `SENTRY_DSN` is set; events are sent only from production. README "Error monitoring (Sentry)" has what is sent and how to turn it on. Never commit a DSN: this repo is public. - Only real bugs: 4xx answers (`rescue_from` handlers, `RecordInvalid`, Pundit denials) are not sent. Add a new expected exception to `NativeapptemplateSentry::EXPECTED_EXCEPTIONS` rather than letting it reach Sentry. diff --git a/CHANGELOG.md b/CHANGELOG.md index bd69e9f..6c26f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- API change: add an admin-set full feature access override for the paid clients. `shopkeepers.feature_access_state` (`normal_feature_access` by default, or `all_feature_access`, indexed) is editable only in `/madmin`; sign-up and `PATCH /shopkeeper_auth` ignore it. Sign-in returns `feature_access_state` and `account_owner_feature_access_state`, and account responses return `owner_feature_access_state`, so the apps can unlock every paid feature in an account whose owner has the override (testing on production, staff/partner/comp accounts, App Review demo accounts). The README's "Full feature access override" covers it and RevenueCat promotional entitlements as the no-code alternative. Ported from myturntagapi - Fix saving a shopkeeper in madmin failing with 500 (`PG::UniqueViolation` on `index_shopkeepers_on_reset_password_token` or `index_shopkeepers_on_confirmation_token`). The edit form sends empty token fields as `""`, so a second shopkeeper with no reset or confirmation token collided with the first. madmin's shopkeeper form no longer shows `reset_password_token` and `confirmation_token` (read only on the show page), and a migration clears the `""` already stored. An earlier version of this fix normalized blank tokens to NULL in `Shopkeeper`; that also rewrote finder values, so `where(reset_password_token: "")` matched every shopkeeper without a token, and it was removed. Ported from myturntagapi, where it hit production (#158) - API change: `Shop#time_zone` and `Shopkeeper#time_zone` must be a Rails time zone name (`ActiveSupport::TimeZone.all.map(&:name)`, e.g. `Tokyo`, `London`); anything else, IANA ids such as `Asia/Tokyo` included, gets 422 "Time zone is not included in the list" from `PATCH /shops/:id`, `POST /shops`, sign-up and `PATCH /shopkeeper_auth` instead of being stored. `test/config/time_zones_test.rb` pins the list against `test/fixtures/files/rails_time_zones.tsv`, which the iOS and Android apps embed for their time zone pickers (#155) - Add optional Sentry error monitoring (`sentry-ruby` / `sentry-rails` 7.1). Off unless `SENTRY_DSN` is set (a Kamal secret read from `NATIVEAPPTEMPLATEAPI_SENTRY_DSN`, optional); sends only from production, only unhandled errors (no 4xx, no tracing, profiling or breadcrumbs), with the shopkeeper's ID as the only user data, the account ID as a tag, devise_token_auth headers dropped, email addresses masked and `filter_parameters` applied. The README's "Error monitoring (Sentry)" covers turning it on, what is sent and the recommended Sentry privacy settings. New tests: `test/config/sentry_test.rb`, `test/integration/sentry_context_test.rb` (#154) diff --git a/README.md b/README.md index 1639357..8365d0f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Want a customized backend generated for you? [nativeapptemplate-agent](https://g - Force Privacy Policy Version Update - Force Terms of Use Version Update - Push Notifications (APNs for iOS, FCM for Android) — paid clients only +- Admin-Set Full Feature Access Override for subscriptions — paid clients only - Optional Error Monitoring with Sentry (no personal data sent) - And more! @@ -130,6 +131,18 @@ bin/kamal setup Later deploys are `bin/kamal deploy`; `bin/kamal console`, `bin/kamal logs` and `bin/kamal dbc` are defined as aliases. +## Full feature access override + +The paid clients sell a subscription through RevenueCat and check the entitlement in the app; this API keeps no subscription state. To unlock every paid feature for a shopkeeper without a purchase, open the shopkeeper in `/madmin` and set **Feature access state** to `all_feature_access`. Every account that shopkeeper owns is unlocked, for its members too. The apps pick it up on the next sign-in or organization switch. Use it for: + +- testing paid features on production without buying, +- staff, partner and complimentary accounts, +- the demo account you give App Review. + +The apps cannot set it: sign-up and `PATCH /shopkeeper_auth` ignore `feature_access_state`. The API returns it as `feature_access_state` and `account_owner_feature_access_state` on sign-in and `owner_feature_access_state` on accounts. + +The no-code alternative is a promotional entitlement, granted on the customer page of the RevenueCat dashboard. It works only with billing on, and it goes on the RevenueCat customer whose app user ID is the account owner's shopkeeper ID, not the member's. + ## Error monitoring (Sentry) The API can report unexpected errors to [Sentry](https://sentry.io) through `sentry-ruby` and `sentry-rails` (`config/initializers/sentry.rb`). It is optional and off by default. diff --git a/app/madmin/resources/shopkeeper_resource.rb b/app/madmin/resources/shopkeeper_resource.rb index 4885a50..e9c267a 100644 --- a/app/madmin/resources/shopkeeper_resource.rb +++ b/app/madmin/resources/shopkeeper_resource.rb @@ -29,6 +29,7 @@ class ShopkeeperResource < Madmin::Resource attribute :created_at, form: false attribute :updated_at, form: false attribute :current_platform + attribute :feature_access_state attribute :confirmed_privacy_version attribute :confirmed_terms_version attribute :token, index: false diff --git a/app/models/shopkeeper.rb b/app/models/shopkeeper.rb index 9e8be5b..f2e0ba2 100644 --- a/app/models/shopkeeper.rb +++ b/app/models/shopkeeper.rb @@ -17,6 +17,11 @@ class Shopkeeper < ApplicationRecord has_many :application_push_devices, as: :owner, class_name: "ApplicationPushDevice", dependent: :destroy has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification" + # Admin-set override: all_feature_access unlocks every paid feature in the + # accounts this shopkeeper owns, without a purchase. Changed only in /madmin; + # never permit it in sign-up or account update (RegistrationsController) + enum :feature_access_state, {normal_feature_access: 1, all_feature_access: 2} + # No `normalizes` on the token columns: it also rewrites finder values, and # turning "" into nil makes where(reset_password_token: "") match every # shopkeeper without a token. madmin keeps the tokens out of its edit form diff --git a/app/serializers/account_serializer.rb b/app/serializers/account_serializer.rb index 6b7c2d3..72806d2 100644 --- a/app/serializers/account_serializer.rb +++ b/app/serializers/account_serializer.rb @@ -13,6 +13,10 @@ class AccountSerializer account.owner.name end + attribute :owner_feature_access_state do |account| + account.owner.feature_access_state + end + attribute :accounts_shopkeepers_count do |account| account.accounts_shopkeepers.size end diff --git a/app/serializers/shopkeeper_sign_in_serializer.rb b/app/serializers/shopkeeper_sign_in_serializer.rb index 44e7398..eaf8b71 100644 --- a/app/serializers/shopkeeper_sign_in_serializer.rb +++ b/app/serializers/shopkeeper_sign_in_serializer.rb @@ -5,6 +5,7 @@ class ShopkeeperSignInSerializer :name, :uid, :time_zone, + :feature_access_state, :locale, :token, :client, @@ -19,6 +20,11 @@ class ShopkeeperSignInSerializer shopkeeper.personal_account.owner_id end + # The apps' paid-feature checks follow the account owner (see AGENTS.md "Billing") + attribute :account_owner_feature_access_state do |shopkeeper| + shopkeeper.personal_account.owner.feature_access_state + end + attribute :account_name do |shopkeeper| shopkeeper.personal_account.name end diff --git a/db/migrate/20261017010000_add_feature_access_state_to_shopkeepers.rb b/db/migrate/20261017010000_add_feature_access_state_to_shopkeepers.rb new file mode 100644 index 0000000..d90c2e4 --- /dev/null +++ b/db/migrate/20261017010000_add_feature_access_state_to_shopkeepers.rb @@ -0,0 +1,8 @@ +# Admin-set override that unlocks every paid feature for a shopkeeper's +# accounts, changed only in /madmin (see Shopkeeper#feature_access_state) +class AddFeatureAccessStateToShopkeepers < ActiveRecord::Migration[8.1] + def change + add_column :shopkeepers, :feature_access_state, :integer, default: 1, null: false + add_index :shopkeepers, :feature_access_state + end +end diff --git a/db/schema.rb b/db/schema.rb index 92f7511..4d59fc0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_17_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_17_010000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -209,6 +209,7 @@ t.inet "current_sign_in_ip" t.string "email" t.string "encrypted_password", default: "", null: false + t.integer "feature_access_state", default: 1, null: false t.string "image" t.datetime "last_sign_in_at", precision: nil t.inet "last_sign_in_ip" @@ -227,6 +228,7 @@ t.datetime "updated_at", null: false t.index ["confirmation_token"], name: "index_shopkeepers_on_confirmation_token", unique: true t.index ["email"], name: "index_shopkeepers_on_email", unique: true + t.index ["feature_access_state"], name: "index_shopkeepers_on_feature_access_state" t.index ["reset_password_token"], name: "index_shopkeepers_on_reset_password_token", unique: true t.index ["uid", "provider"], name: "index_shopkeepers_on_uid_and_provider", unique: true end diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 648165f..2096a20 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -146,6 +146,10 @@ components: type: boolean owner_name: type: string + owner_feature_access_state: + type: string + enum: [normal_feature_access, all_feature_access] + description: The owner's admin-set override. `all_feature_access` unlocks every paid feature in this account without a purchase. accounts_shopkeepers_count: type: integer accounts_invitations_count: @@ -440,6 +444,10 @@ components: format: email time_zone: type: string + feature_access_state: + type: string + enum: [normal_feature_access, all_feature_access] + description: Admin-set override, changed only in `/madmin`. Sign-up and `PATCH /shopkeeper_auth` ignore it. locale: type: string token: @@ -457,6 +465,10 @@ components: account_owner_id: type: string format: uuid + account_owner_feature_access_state: + type: string + enum: [normal_feature_access, all_feature_access] + description: The `feature_access_state` of the owner of the personal account (`account_owner_id`), the account the apps open after sign-in. account_name: type: string diff --git a/test/controllers/shopkeeper_auth/registrations_controller_test.rb b/test/controllers/shopkeeper_auth/registrations_controller_test.rb index a06c72c..a5c9873 100644 --- a/test/controllers/shopkeeper_auth/registrations_controller_test.rb +++ b/test/controllers/shopkeeper_auth/registrations_controller_test.rb @@ -33,6 +33,30 @@ class ShopkeeperAuth::RegistrationsControllerTest < ActionDispatch::IntegrationT assert_equal "Osaka", shopkeeper.reload.time_zone end + # feature_access_state unlocks paid features, so only /madmin may change it + test "sign up ignores feature_access_state" do + post shopkeeper_registration_url, params: {email: "sneaky@example.com", name: "Sneaky", password: "password", + time_zone: "Tokyo", current_platform: "ios", feature_access_state: "all_feature_access"} + assert_response :success + + # The column default (migration) is normal_feature_access + assert_predicate Shopkeeper.find_by!(email: "sneaky@example.com"), :normal_feature_access? + assert_equal "normal_feature_access", response.parsed_body.dig("data", "attributes", "feature_access_state") + end + + test "update ignores feature_access_state" do + shopkeeper.create_default_account + assert_predicate shopkeeper, :normal_feature_access? + + patch shopkeeper_registration_url, params: {name: "Renamed", feature_access_state: "all_feature_access"}, + headers: shopkeeper.create_new_auth_token + + assert_response :success + shopkeeper.reload + assert_equal "Renamed", shopkeeper.name, "the permitted field in the same request is applied" + assert_predicate shopkeeper, :normal_feature_access? + end + test "rejects an unknown time zone on update with 422" do shopkeeper.create_default_account patch shopkeeper_registration_url, params: {time_zone: "Bogus"}, headers: shopkeeper.create_new_auth_token diff --git a/test/fixtures/shopkeepers.yml b/test/fixtures/shopkeepers.yml index e7e8b40..cc19d79 100644 --- a/test/fixtures/shopkeepers.yml +++ b/test/fixtures/shopkeepers.yml @@ -7,6 +7,7 @@ one: confirmed_terms_version: 1.0.0 time_zone: Tokyo current_platform: ios + feature_access_state: normal_feature_access confirmed_at: <%= Time.current %> two: @@ -18,4 +19,5 @@ two: confirmed_terms_version: 1.0.0 time_zone: Tokyo current_platform: ios + feature_access_state: all_feature_access confirmed_at: <%= Time.current %> diff --git a/test/integration/madmin_test.rb b/test/integration/madmin_test.rb index b70d74f..2c1ba7a 100644 --- a/test/integration/madmin_test.rb +++ b/test/integration/madmin_test.rb @@ -85,6 +85,26 @@ class MadminTest < ActionDispatch::IntegrationTest assert_equal "Renamed by admin", @shopkeeper.reload.name end + # The only way to grant the full feature access override; the apps cannot set it + test "sets feature_access_state from the shopkeeper edit form" do + assert_predicate @shopkeeper, :normal_feature_access? + sign_in_admin + get ShopkeeperResource.edit_path(@shopkeeper) + assert_response :success + + form = Nokogiri::HTML(response.body).at_css("form[action$='/shopkeepers/#{@shopkeeper.id}']") + fields = form_fields(form) + assert fields.any? { |name, _| name == "shopkeeper[feature_access_state]" }, "the edit form has no feature_access_state field" + fields = fields.map do |name, value| + (name == "shopkeeper[feature_access_state]") ? [name, "all_feature_access"] : [name, value] + end + + patch madmin_shopkeeper_path(@shopkeeper), params: URI.encode_www_form(fields), + headers: {"CONTENT_TYPE" => "application/x-www-form-urlencoded"} + assert_redirected_to madmin_shopkeeper_path(@shopkeeper) + assert_predicate @shopkeeper.reload, :all_feature_access? + end + test "blobs and variant records hide write actions" do sign_in_admin diff --git a/test/serializers/account_serializer_test.rb b/test/serializers/account_serializer_test.rb index e4b1f1d..d7ea20f 100644 --- a/test/serializers/account_serializer_test.rb +++ b/test/serializers/account_serializer_test.rb @@ -25,6 +25,26 @@ def setup assert_equal @account.owner.name, attributes[:owner_name] end + # The apps unlock paid features in an account when its owner has the override + test "should serialize owner_feature_access_state from the owner" do + attributes = AccountSerializer.new(@account).serializable_hash[:data][:attributes] + # Fixture one is normal_feature_access + assert_equal "normal_feature_access", attributes[:owner_feature_access_state] + + @shopkeeper.all_feature_access! + attributes = AccountSerializer.new(@account.reload).serializable_hash[:data][:attributes] + assert_equal "all_feature_access", attributes[:owner_feature_access_state] + end + + test "should serialize owner_feature_access_state of the owner, not the viewing member" do + owner = shopkeepers(:two) # fixture: all_feature_access + team = Account.create!(name: "Team", owner: owner, personal: false) + AccountsShopkeeper.create!(account: team, shopkeeper: @shopkeeper, member: true) + + attributes = AccountSerializer.new(team, params: {current_shopkeeper: @shopkeeper}).serializable_hash[:data][:attributes] + assert_equal "all_feature_access", attributes[:owner_feature_access_state] + end + test "should serialize accounts_shopkeepers_count" do AccountsShopkeeper.create!( account: @account, diff --git a/test/serializers/shopkeeper_sign_in_serializer_test.rb b/test/serializers/shopkeeper_sign_in_serializer_test.rb index e5a5279..1684013 100644 --- a/test/serializers/shopkeeper_sign_in_serializer_test.rb +++ b/test/serializers/shopkeeper_sign_in_serializer_test.rb @@ -56,6 +56,18 @@ def setup assert_equal @shopkeeper.personal_account.owner_id, attributes[:account_owner_id] end + test "should serialize feature_access_state and account_owner_feature_access_state" do + attributes = ShopkeeperSignInSerializer.new(@shopkeeper).serializable_hash[:data][:attributes] + # Fixture one is normal_feature_access + assert_equal "normal_feature_access", attributes[:feature_access_state] + assert_equal "normal_feature_access", attributes[:account_owner_feature_access_state] + + @shopkeeper.all_feature_access! + attributes = ShopkeeperSignInSerializer.new(@shopkeeper).serializable_hash[:data][:attributes] + assert_equal "all_feature_access", attributes[:feature_access_state] + assert_equal "all_feature_access", attributes[:account_owner_feature_access_state] + end + test "should serialize account_name" do serializer = ShopkeeperSignInSerializer.new(@shopkeeper) serialized = serializer.serializable_hash