Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <env>`) 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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/madmin/resources/shopkeeper_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/models/shopkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/serializers/account_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/serializers/shopkeeper_sign_in_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class ShopkeeperSignInSerializer
:name,
:uid,
:time_zone,
:feature_access_state,
:locale,
:token,
:client,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
24 changes: 24 additions & 0 deletions test/controllers/shopkeeper_auth/registrations_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/shopkeepers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 %>
20 changes: 20 additions & 0 deletions test/integration/madmin_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions test/serializers/account_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions test/serializers/shopkeeper_sign_in_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading