├── .env.example ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── reviewdog.yml ├── .gitignore ├── .ruby-version ├── .standard.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── Procfile ├── Procfile.dev ├── README.md ├── Rakefile ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── config │ │ └── manifest.js │ ├── images │ │ ├── .keep │ │ ├── empty_state │ │ │ ├── AnalyticsMinor.svg │ │ │ └── ImportMinor.svg │ │ └── logo.png │ └── stylesheets │ │ ├── application.css │ │ ├── loading.css │ │ ├── polaris_overrides.css │ │ └── utilities.css ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── delete_apps_controller.rb │ ├── home_controller.rb │ ├── imports │ │ ├── destroy_all_controller.rb │ │ ├── globes_controller.rb │ │ └── retry_controller.rb │ ├── imports_controller.rb │ ├── metrics_controller.rb │ ├── partner_api_credentials_controller.rb │ ├── public │ │ └── smiirl_controller.rb │ ├── rename_apps_controller.rb │ ├── smiirl_integrations_controller.rb │ ├── summarys │ │ ├── monthly_controller.rb │ │ └── shop_controller.rb │ ├── summarys_controller.rb │ └── users_controller.rb ├── helpers │ ├── application_helper.rb │ ├── imports_helper.rb │ ├── layouts_helper.rb │ ├── metrics_helper.rb │ └── partner_api_credentials_helper.rb ├── javascript │ ├── application.js │ ├── controllers │ │ ├── application.js │ │ ├── datefield_controller.js │ │ ├── filters_controller.js │ │ ├── form_controller.js │ │ ├── globe_controller.js │ │ ├── index.js │ │ ├── loading_controller.js │ │ └── submittable_controller.js │ └── libraries │ │ ├── dirty-form.js │ │ └── globe │ │ ├── countries-map-data.json │ │ └── country-data.js ├── jobs │ ├── application_job.rb │ ├── calculate_metrics_job.rb │ └── import_payments_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── app_deleter.rb │ ├── app_renamer.rb │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ ├── graphql │ │ └── transactions_query.rb │ ├── import.rb │ ├── import │ │ ├── adaptor │ │ │ ├── csv_file.rb │ │ │ └── shopify_payments_api.rb │ │ ├── metrics_processor.rb │ │ └── payments_processor.rb │ ├── metric.rb │ ├── metric │ │ ├── calculator.rb │ │ ├── forecast_charter.rb │ │ ├── tile_presenter.rb │ │ ├── tiles_config.rb │ │ ├── tiles_filter.rb │ │ └── tiles_presenter.rb │ ├── partner_api_credential.rb │ ├── payment.rb │ ├── smiirl_integration.rb │ ├── summary.rb │ ├── summary │ │ ├── monthly.rb │ │ └── shop.rb │ └── user.rb └── views │ ├── delete_apps │ └── new.html.erb │ ├── devise │ ├── mailer │ │ └── reset_password_instructions.html.erb │ ├── passwords │ │ ├── edit.html.erb │ │ └── new.html.erb │ ├── registrations │ │ ├── edit.html.erb │ │ └── new.html.erb │ └── sessions │ │ └── new.html.erb │ ├── home │ └── index.html.erb │ ├── imports │ ├── _api_credentials_banner.html.erb │ ├── _form.html.erb │ ├── _import.html.erb │ ├── _table.html.erb │ ├── globes │ │ └── show.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb │ ├── layouts │ ├── _flash_messages.html.erb │ ├── _flash_messages.turbo_stream.erb │ ├── application.html.erb │ ├── frame │ │ ├── _navigation.html.erb │ │ └── _top_bar.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ ├── metrics │ ├── _filter.html.erb │ ├── _forecasts_form.html.erb │ ├── _revenue_per.html.erb │ ├── _tile.html.erb │ ├── _tile_with_chart.html.erb │ ├── affiliate_revenue │ │ └── _revenue_per.html.erb │ ├── onetime_revenue │ │ └── _revenue_per.html.erb │ ├── recurring_revenue │ │ └── _revenue_per.html.erb │ └── show.html.erb │ ├── modals │ └── _destroy.html.erb │ ├── partner_api_credentials │ ├── _form.html.erb │ ├── edit.html.erb │ └── new.html.erb │ ├── rename_apps │ └── new.html.erb │ ├── shared │ ├── _empty_state.html.erb │ ├── _page_actions.html.erb │ └── _status.html.erb │ ├── smiirl_integrations │ └── edit.html.erb │ ├── summarys │ ├── _app_filter.html.erb │ ├── _monthly.html.erb │ ├── _shop.html.erb │ └── index.html.erb │ └── users │ └── _count_usage_charges_as_recurring_fields.html.erb ├── bin ├── bundle ├── dev ├── importmap ├── rails ├── rake └── setup ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── credentials │ ├── production.yml.enc │ ├── test.key │ └── test.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── assets.rb │ ├── chartkick.rb │ ├── chartkick_helper_override.rb │ ├── content_security_policy.rb │ ├── devise.rb │ ├── filter_parameter_logging.rb │ ├── generate_test_fixture.rb │ ├── http_logger.rb │ ├── inflections.rb │ ├── permissions_policy.rb │ ├── rack_timeout.rb │ └── sidekiq.rb ├── locales │ └── en.yml ├── master.key ├── partner-api-schema.json ├── puma.rb ├── routes.rb ├── sidekiq.yml └── storage.yml ├── db ├── migrate │ ├── 20230828145011_create_active_storage_tables.active_storage.rb │ ├── 20230901095646_create_imports.rb │ ├── 20230907074633_rename_payment_history.rb │ ├── 20230907075820_add_import_associations.rb │ ├── 20230907115433_remove_unused_import_columns.rb │ ├── 20230911110717_create_partner_api_credentials.rb │ ├── 20230914091554_add_show_forecasts_to_users.rb │ ├── 20230919173404_add_is_yearly_revenue.rb │ └── 20250930000000_create_smiirl_integrations.rb ├── schema.rb └── seeds.rb ├── lib ├── assets │ └── .keep ├── http_client.rb ├── shopify_partner_api.rb └── tasks │ ├── .keep │ ├── create_initial_imports.rake │ ├── create_partner_api_credentials.rake │ └── import_all_from_partner_api.rake ├── log └── .keep ├── package.json ├── public ├── 404.html ├── 422.html ├── 500.html ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon-precompiled.png ├── apple-touch-icon.png ├── favicon-32x32.png ├── favicon.ico └── robots.txt ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ ├── .keep │ ├── delete_apps_controller_test.rb │ ├── imports │ │ ├── destroy_all_controller_test.rb │ │ └── retry_controller_test.rb │ ├── imports_controller_test.rb │ ├── partner_api_credentials_controller_test.rb │ ├── payments_controller_test.rb │ ├── public_smiirl_controller_test.rb │ ├── rename_apps_controller_test.rb │ └── smiirl_integrations_controller_test.rb ├── fixtures │ ├── active_storage │ │ ├── attachments.yml │ │ └── blobs.yml │ ├── files │ │ ├── .keep │ │ └── payouts-recurring.csv │ ├── imports.yml │ ├── metrics.yml │ ├── partner_api_credentials.yml │ ├── payments.yml │ └── users.yml ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── import_test.rb │ ├── partner_api_credential_test.rb │ └── smiirl_integration_test.rb ├── support │ └── capybara.rb ├── system │ ├── .keep │ ├── delete_apps_test.rb │ ├── home_test.rb │ ├── imports │ │ └── destroy_all_test.rb │ ├── imports_test.rb │ ├── metrics_test.rb │ ├── partner_api_credentials_test.rb │ ├── rename_apps_test.rb │ ├── summarys_test.rb │ └── users_test.rb └── test_helper.rb ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep ├── vendor ├── .keep └── javascript │ └── .keep └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Please keep .env.example identical to .env so that future developers know what goes into the file 2 | REDIS_URL=redis://localhost:6379/1 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | inputs: 6 | app_name: 7 | required: true 8 | type: string 9 | default: partner-metrics 10 | use_node: 11 | type: boolean 12 | required: false 13 | default: false 14 | secrets: 15 | master_key: 16 | required: true 17 | bundle_token: 18 | required: true 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | services: 24 | postgres: 25 | image: postgres:11 26 | env: 27 | POSTGRES_USER: postgres 28 | POSTGRES_DB: ${{ inputs.app_name }}-test 29 | POSTGRES_PASSWORD: "" 30 | POSTGRES_HOST_AUTH_METHOD: trust 31 | ports: ["5432:5432"] 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | redis: 38 | image: redis 39 | ports: ['6379:6379'] 40 | options: --entrypoint redis-server 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Set up Node 44 | if: ${{ inputs.use_node == true }} 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version-file: '.node-version' 48 | - name: Set up Ruby 49 | uses: ruby/setup-ruby@v1 50 | env: 51 | BUNDLE_GITHUB__COM: x-access-token:${{ secrets.bundle_token }} 52 | with: 53 | bundler-cache: true 54 | - name: Copy .env sample 55 | run: | 56 | cp .env.example .env 57 | - name: Setup test database 58 | env: 59 | RAILS_ENV: test 60 | RAILS_MASTER_KEY: ${{ secrets.master_key }} 61 | run: | 62 | bin/rails db:create 63 | bin/rails db:schema:load 64 | bin/rails db:migrate 65 | - name: Build assets 66 | env: 67 | RAILS_ENV: test 68 | RAILS_MASTER_KEY: ${{ secrets.master_key }} 69 | run: | 70 | bin/rails assets:precompile 71 | - name: Run tests 72 | env: 73 | REDIS_URL: redis://localhost:6379/0 74 | RAILS_MASTER_KEY: ${{ secrets.master_key }} 75 | run: | 76 | bin/rails test 77 | - name: Upload artifacts 78 | if: failure() 79 | uses: actions/upload-artifact@v4 80 | with: 81 | name: screenshots 82 | path: /home/runner/work/${{ inputs.app_name }}/${{ inputs.app_name }}/tmp/screenshots/ 83 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: Reviewdog 2 | 3 | on: [pull_request] 4 | 5 | env: 6 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 7 | 8 | jobs: 9 | standard: 10 | uses: forsbergplustwo/github-actions/.github/workflows/standard.yml@main 11 | secrets: 12 | bundle_token: ${{ secrets.BUNDLE_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore pidfiles, but keep the directory. 17 | /tmp/pids/* 18 | !/tmp/pids/ 19 | !/tmp/pids/.keep 20 | 21 | # Ignore uploaded files in development. 22 | /storage/* 23 | !/storage/.keep 24 | /tmp/storage/* 25 | !/tmp/storage/ 26 | !/tmp/storage/.keep 27 | 28 | /public/assets 29 | 30 | /app/assets/builds/* 31 | !/app/assets/builds/.keep 32 | 33 | /node_modules 34 | 35 | # Ignore production credentials key 36 | # Note: Dev and Test keys are included for easier setup. 37 | /config/credentials/production.key 38 | 39 | .env 40 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.3 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - standard-rails 3 | - standard-minitest 4 | - standard-thread_safety 5 | 6 | ignore: 7 | - 'bin/*' 8 | - 'db/**/*' 9 | - 'config/environments/*' 10 | - 'config/puma.rb' 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at {{ email }}. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby "3.2.3" 5 | 6 | # Backend 7 | gem "rails", "~> 7.0.8" 8 | gem "pg", "~> 1.1" 9 | gem "puma", "~> 6.0" 10 | gem "redis", "~> 5.0" 11 | gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] 12 | gem "bootsnap", require: false 13 | gem "sidekiq" 14 | gem "rack-timeout", require: "rack/timeout/base" 15 | gem "active_storage_validations" 16 | gem "sendgrid-actionmailer" 17 | gem "rubyzip" 18 | 19 | # Frontend 20 | gem "sprockets-rails" 21 | gem "jsbundling-rails" 22 | gem "turbo-rails" 23 | gem "stimulus-rails" 24 | gem "devise" 25 | gem "polaris_view_components" 26 | 27 | # Charting + Metrics Display 28 | gem "chartkick" 29 | gem "groupdate" 30 | gem "convenient_grouper" 31 | gem "prophet-rb" 32 | 33 | # Importing 34 | gem "csvreader" 35 | gem "activerecord-import" 36 | gem "graphql-client" 37 | gem "aws-sdk-s3", require: false 38 | 39 | group :development, :test do 40 | gem "debug", platforms: %i[mri mingw x64_mingw] 41 | end 42 | 43 | group :development do 44 | gem "web-console" 45 | gem "pry-rails" 46 | gem "foreman" 47 | gem "standard" 48 | gem "standard-rails" 49 | gem "standard-minitest" 50 | gem "standard-thread_safety" 51 | gem "hotwire-livereload" 52 | gem "letter_opener" 53 | gem "http_logger" 54 | end 55 | 56 | group :test do 57 | gem "capybara" 58 | gem "selenium-webdriver" 59 | gem "webdrivers" 60 | gem "mocha" 61 | end 62 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | worker: bundle exec sidekiq -C config/sidekiq.yml 3 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | worker: bundle exec sidekiq -C config/sidekiq.yml 3 | js: yarn build --watch 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Partner Metrics 2 | 3 | Partner Metrics is an open-source project providing you with metrics of your app, theme and affiliate revenue from the Shopify Partner program. Currently it calculates metrics based on monthly and yearly subscriptions, one-time charges and usage charges from Shopify. 4 | 5 | This project is not officially related to Shopify in any way. 6 | 7 | ## Usage 8 | 9 | Partner Metrics was created by [@forsbergplustwo](@forsbergplustwo), and will remain free to use at: 10 | 11 | ##### https://partnermetrics.io 12 | 13 | ## Development 14 | 15 | ### Upgrading 16 | 17 | The app in this repo was recently upgraded to Rails 7. If you had the earlier version running locally, you can upgrade by performing the following actions on your existing local app: 18 | 19 | ``` 20 | bin/rails db:migrate 21 | bin/rails db:encryption:init 22 | bin/rails create_initial_imports 23 | bin/rails migrate_partner_api_credentials 24 | ``` 25 | 26 | Note: We recommend deleting your existing metrics data and re-importing to take advantage of improvements to churn calculations + yearly subscriptions support. 27 | 28 | ### First time setup 29 | 30 | 1. Setup dependencies, environment & database: `bin/setup` 31 | 2. Start web server and sidekiq workers with: `bin/dev` 32 | 33 | Visit `localhost:4000` 34 | 35 | To run tests: 36 | 37 | ```bash 38 | bin/rails test 39 | 40 | # including system tests 41 | bin/rails test:all 42 | ``` 43 | 44 | To import data from Partner API manually (once you have added your credentials in the app UI): 45 | ```bash 46 | bin/rails import_all_from_partner_api 47 | ``` 48 | 49 | ### Deploying to Production 50 | 51 | 1. Delete `config/credentials/production.yml.enc` 52 | 2. Run `bin/rails credentials:edit -e production` and update as necessary 53 | 3. If deploying to Heroku, make sure to set `heroku config:set RAILS_MASTER_KEY=[key]` where `[key]` is the value of your `config/credentials/production.key` file. 54 | 4. Setup a cron job to run `bin/rails import_all_from_partner_api` on a daily basis. 55 | 56 | ## Contributing 57 | We'd love for you to contribute join us in making it better! In general, please follow the "fork-and-pull" Git workflow. 58 | 59 | 1. Check out the Issues page, feel free to pick an existing issue or add a new one with clear title and description. 60 | 2. Fork and clone the repo on GitHub 61 | 3. Create a new branch for your fix or code improvement 62 | 4. Run `standardrb --fix` to safely-autofix any linter or formatter corrections 63 | 5. Commit changes to your own branch 64 | 6. Push your work back up to your fork 65 | 7. Submit a Pull request so that @forsbergplustwo can review your changes. Please link your PR to the existing issue if you are solving one. 66 | 67 | ## Testing 68 | We have a handful of MiniTests and Fixtures in the codebase, and welcome more. Please write MiniTests for new code you create. 69 | 70 | ## Code of Conduct 71 | Everyone interacting in Partner Metrics repository is expected to follow the [Code of Conduct](https://github.com/forsbergplustwo/partner-metrics-saas/blob/main/CODE_OF_CONDUCT.md). 72 | 73 | ## License 74 | 75 | Partner Metrics is released under the [GPLv3 License](https://github.com/forsbergplustwo/partner-metrics-saas/blob/main/LICENSE.md). 76 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/app/assets/builds/.keep -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../builds 4 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/empty_state/AnalyticsMinor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/empty_state/ImportMinor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/app/assets/images/logo.png -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/loading.css: -------------------------------------------------------------------------------- 1 | .loading-overlay { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | width: 100%; 9 | height: 100%; 10 | z-index: 1; 11 | opacity: 0.9; 12 | } 13 | -------------------------------------------------------------------------------- /app/assets/stylesheets/polaris_overrides.css: -------------------------------------------------------------------------------- 1 | .empty-state img { 2 | max-width: 100px; 3 | border-radius: 50%; 4 | background-color: var(--p-color-bg-app); 5 | margin-bottom: var(--p-space-4); 6 | } 7 | 8 | .Polaris-Button--plain .Polaris-Icon--colorSubdued svg { 9 | fill: var(--p-color-icon-subdued) !important; 10 | 11 | &:hover { 12 | fill: var(--p-color-icon) !important; 13 | } 14 | } 15 | 16 | .Polaris-Tooltip { 17 | z-index: var(--p-z-index-1); 18 | } 19 | 20 | @media screen and (max-width: 1024px) { 21 | .Polaris-HorizontalGrid { 22 | grid-template-columns: repeat(2, minmax(0, 1fr)); 23 | } 24 | } 25 | 26 | @media screen and (max-width: 500px) { 27 | .Polaris-HorizontalGrid { 28 | grid-template-columns: repeat(1, minmax(0, 1fr)); 29 | } 30 | 31 | .filter-margin { 32 | margin-left: var(--p-space-4); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/assets/stylesheets/utilities.css: -------------------------------------------------------------------------------- 1 | .hidden { 2 | display: none; 3 | } 4 | 5 | .relative { 6 | position: relative; 7 | } 8 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | 4 | def after_sign_in_path_for(resource) 5 | metrics_path 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/delete_apps_controller.rb: -------------------------------------------------------------------------------- 1 | class DeleteAppsController < ApplicationController 2 | before_action :authenticate_user! 3 | before_action :get_app_titles 4 | 5 | def new 6 | end 7 | 8 | def create 9 | app = delete_app_params[:app_title] 10 | app_deleter = AppDeleter.new( 11 | user: current_user, 12 | app_title: app 13 | ) 14 | if @app_titles.include?(app) && app_deleter.delete 15 | redirect_to delete_apps_path, notice: "App deleted successfully" 16 | else 17 | flash[:alert] = "App delete failed" 18 | render :new, status: :unprocessable_entity 19 | end 20 | end 21 | 22 | private 23 | 24 | def get_app_titles 25 | @app_titles = current_user.metrics.distinct.pluck(:app_title) 26 | end 27 | 28 | def delete_app_params 29 | params.require(:delete_apps).permit(:app_title) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | def index 3 | if current_user.present? 4 | redirect_to metrics_path 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/imports/destroy_all_controller.rb: -------------------------------------------------------------------------------- 1 | class Imports::DestroyAllController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def destroy 5 | current_user.imports.destroy_all 6 | redirect_to imports_url, notice: "All imports deleted", status: :see_other 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/imports/globes_controller.rb: -------------------------------------------------------------------------------- 1 | class Imports::GlobesController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def show 5 | @import = current_user.imports.find(params[:import_id]) 6 | @globe_data = globe_data 7 | 8 | respond_to do |format| 9 | format.json { render json: @globe_data } 10 | format.html { render :show } 11 | end 12 | end 13 | 14 | private 15 | 16 | def globe_data 17 | @import.payments 18 | .where.not(shop_country: nil) 19 | .pluck(:shop_country, :charge_type) 20 | .map { |country, charge_type| {countryCode: country, reverse: charge_type == "refund"} } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/imports/retry_controller.rb: -------------------------------------------------------------------------------- 1 | class Imports::RetryController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def create 5 | @import = current_user.imports.find(params[:import_id]) 6 | if @import.retriable? && @import.retry 7 | redirect_to @import, notice: "Import being retried." 8 | else 9 | redirect_to @import, alert: "Import failed to retry." 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/imports_controller.rb: -------------------------------------------------------------------------------- 1 | class ImportsController < ApplicationController 2 | before_action :authenticate_user! 3 | before_action :set_import, only: %i[show update destroy] 4 | 5 | def index 6 | @imports = current_user.imports.all.order(created_at: :desc) 7 | end 8 | 9 | def show 10 | end 11 | 12 | def new 13 | redirect_if_import_in_progress 14 | @import = current_user.imports.new(source: Import.sources[:csv_file]) 15 | end 16 | 17 | def create 18 | @import = current_user.imports.new( 19 | source: Import.sources[:csv_file], 20 | **import_params.except(:user_attributes) 21 | ) 22 | if @import.save 23 | save_user_attributes 24 | redirect_to @import, notice: "Import successfully created." 25 | else 26 | flash.now[:alert] = "Import failed to create." 27 | render :new, status: :unprocessable_entity 28 | end 29 | end 30 | 31 | def destroy 32 | @import.destroy 33 | redirect_to imports_url, notice: "Import successfully destroyed.", status: :see_other 34 | end 35 | 36 | private 37 | 38 | def set_import 39 | @import = current_user.imports.find(params[:id]) 40 | end 41 | 42 | def import_params 43 | params.require(:import).permit( 44 | :import_type, 45 | :payouts_file, 46 | user_attributes: [:id, :count_usage_charges_as_recurring] 47 | ) 48 | end 49 | 50 | def save_user_attributes 51 | current_user.update( 52 | count_usage_charges_as_recurring: import_params.dig(:user_attributes, :count_usage_charges_as_recurring) 53 | ) 54 | end 55 | 56 | def redirect_if_import_in_progress 57 | if current_user.imports.in_progress.any? 58 | redirect_to imports_path, alert: "An import is already in progress." 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /app/controllers/metrics_controller.rb: -------------------------------------------------------------------------------- 1 | class MetricsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def show 5 | @filter = Metric::TilesFilter.new(user: current_user, params: query_params) 6 | 7 | @app_titles = @filter.app_titles 8 | @tiles = @filter.tiles 9 | @selected_tile = @filter.selected_tile 10 | end 11 | 12 | private 13 | 14 | def query_params 15 | params.permit( 16 | :app, 17 | :chart, 18 | :date, 19 | :period, 20 | :charge_type 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/controllers/partner_api_credentials_controller.rb: -------------------------------------------------------------------------------- 1 | class PartnerApiCredentialsController < ApplicationController 2 | before_action :authenticate_user! 3 | before_action :set_partner_api_credential, only: %i[edit update destroy] 4 | 5 | def new 6 | if current_user.partner_api_credential.present? 7 | redirect_to_edit and return 8 | else 9 | @partner_api_credential = current_user.build_partner_api_credential 10 | end 11 | end 12 | 13 | def edit 14 | add_status_messsage_error if @partner_api_credential.invalid_status? 15 | end 16 | 17 | def create 18 | @partner_api_credential = current_user.build_partner_api_credential( 19 | partner_api_credential_params.except(:user_attributes) 20 | ) 21 | if @partner_api_credential.save 22 | save_user_attributes 23 | redirect_to edit_partner_api_credential_path(@partner_api_credential), notice: "Partner api credential was successfully created." 24 | else 25 | render :new, status: :unprocessable_entity, notice: "Partner api credential was not created." 26 | end 27 | end 28 | 29 | def update 30 | if @partner_api_credential.update(partner_api_credential_params) 31 | redirect_to edit_partner_api_credential_path(@partner_api_credential), notice: "Partner api credential was successfully updated.", status: :see_other 32 | else 33 | render :edit, status: :unprocessable_entity 34 | end 35 | end 36 | 37 | def destroy 38 | @partner_api_credential.destroy 39 | redirect_to new_partner_api_credential_path, notice: "Partner api credential was successfully destroyed.", status: :see_other 40 | end 41 | 42 | private 43 | 44 | def redirect_to_edit 45 | redirect_to edit_partner_api_credential_path(current_user.partner_api_credential) 46 | end 47 | 48 | def set_partner_api_credential 49 | @partner_api_credential = current_user.partner_api_credential 50 | end 51 | 52 | def add_status_messsage_error 53 | @partner_api_credential.errors.add(:base, @partner_api_credential.status_message) 54 | end 55 | 56 | def save_user_attributes 57 | current_user.update( 58 | count_usage_charges_as_recurring: partner_api_credential_params.dig(:user_attributes, :count_usage_charges_as_recurring) 59 | ) 60 | end 61 | 62 | def partner_api_credential_params 63 | params.require(:partner_api_credential).permit( 64 | :access_token, 65 | :organization_id, 66 | user_attributes: [:id, :count_usage_charges_as_recurring] 67 | ) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /app/controllers/public/smiirl_controller.rb: -------------------------------------------------------------------------------- 1 | class Public::SmiirlController < ActionController::API 2 | before_action :set_integration 3 | 4 | def show 5 | unless @integration.enabled? 6 | head :not_found and return 7 | end 8 | 9 | count_value = compute_count(@integration) 10 | render json: {count: count_value} 11 | end 12 | 13 | private 14 | 15 | def set_integration 16 | @integration = SmiirlIntegration.find_by!(token: params[:token]) 17 | rescue ActiveRecord::RecordNotFound 18 | head :not_found 19 | end 20 | 21 | def compute_count(integration) 22 | user = integration.user 23 | return 0 if user.blank? 24 | 25 | case integration.metric_type 26 | when "paying_users_30d" 27 | user.paying_users_30d.to_i 28 | else # "total_revenue_30d" 29 | user.total_revenue_30d.to_i 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/controllers/rename_apps_controller.rb: -------------------------------------------------------------------------------- 1 | class RenameAppsController < ApplicationController 2 | before_action :authenticate_user! 3 | before_action :get_app_titles 4 | 5 | def new 6 | end 7 | 8 | def create 9 | app_renamer = AppRenamer.new( 10 | user: current_user, 11 | from: rename_app_params[:from], 12 | to: rename_app_params[:to] 13 | ) 14 | if app_renamer.rename 15 | redirect_to rename_apps_path, notice: "App renamed successfully" 16 | else 17 | flash[:alert] = "App rename failed" 18 | render :new, status: :unprocessable_entity 19 | end 20 | end 21 | 22 | private 23 | 24 | def get_app_titles 25 | @app_titles = current_user.metrics.distinct.pluck(:app_title) 26 | end 27 | 28 | def rename_app_params 29 | params.require(:rename_app).permit(:from, :to) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/controllers/smiirl_integrations_controller.rb: -------------------------------------------------------------------------------- 1 | class SmiirlIntegrationsController < ApplicationController 2 | before_action :authenticate_user! 3 | before_action :set_smiirl_integration 4 | 5 | def edit 6 | end 7 | 8 | def update 9 | if @smiirl_integration.update(smiirl_integration_params) 10 | redirect_to edit_smiirl_integration_path, notice: "Smiirl integration updated." 11 | else 12 | render :edit, status: :unprocessable_entity 13 | end 14 | end 15 | 16 | def rotate_token 17 | @smiirl_integration.rotate_token! 18 | redirect_to edit_smiirl_integration_path, notice: "Smiirl integration token rotated." 19 | end 20 | 21 | private 22 | 23 | def set_smiirl_integration 24 | @smiirl_integration = current_user.smiirl_integration || current_user.build_smiirl_integration 25 | end 26 | 27 | def smiirl_integration_params 28 | params.require(:smiirl_integration).permit(:enabled, :metric_type) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/controllers/summarys/monthly_controller.rb: -------------------------------------------------------------------------------- 1 | class Summarys::MonthlyController < SummarysController 2 | def index 3 | @selected_app = summary_params[:selected_app] 4 | @summaries = Summary::Monthly.new(user: current_user, selected_app: @selected_app).summarize 5 | 6 | render "summarys/index" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/summarys/shop_controller.rb: -------------------------------------------------------------------------------- 1 | class Summarys::ShopController < SummarysController 2 | def index 3 | @selected_app = summary_params[:selected_app] 4 | @summaries = Summary::Shop.new(user: current_user, selected_app: @selected_app).summarize 5 | 6 | render "summarys/index" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/summarys_controller.rb: -------------------------------------------------------------------------------- 1 | class SummarysController < ApplicationController 2 | before_action :authenticate_user! 3 | before_action :get_app_titles 4 | 5 | private 6 | 7 | def get_app_titles 8 | @app_titles = current_user.metrics.distinct.pluck(:app_title) 9 | end 10 | 11 | def summary_params 12 | params.permit(:selected_app) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def update 5 | if current_user.update(user_params) 6 | redirect_to request.referrer, status: :see_other 7 | else 8 | redirect_to request.referrer, alert: "Error saving user!", status: :see_other 9 | end 10 | end 11 | 12 | private 13 | 14 | def user_params 15 | params.require(:user).permit(:show_forecasts) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def resource_name_for(klass, pluralize = false) 3 | klass.model_name.human.pluralize(pluralize ? 2 : 1).downcase 4 | end 5 | 6 | def status_badge(status) 7 | badge_status = case status 8 | when "complete", "valid" then :success 9 | when "draft", "importing", "calculating" then :info 10 | when "cancelled", "pending_validation" then :warning 11 | when "failed", "invalid" then :attention 12 | else 13 | :default 14 | end 15 | badge_progress = case status 16 | when "scheduled", "invalid" then :incomplete 17 | when "calculating", "importing", "failed", "pending_validation" then :partially_complete 18 | when "complete", "cancelled", "valid" then :complete 19 | else 20 | :default 21 | end 22 | 23 | polaris_badge(status: badge_status, progress: badge_progress) do 24 | t("statuses.#{status}") 25 | end 26 | end 27 | 28 | def icon_source_url(name) 29 | Polaris::ViewComponents::Engine.root.join("app", "assets", "icons", "polaris", "#{name}.svg") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/helpers/imports_helper.rb: -------------------------------------------------------------------------------- 1 | module ImportsHelper 2 | def secondary_import_actions(import) 3 | actions = [] 4 | if import.retriable? 5 | actions << { 6 | content: t("actions.retry", resource: resource_name_for(Import)), 7 | url: import_retry_path(import), 8 | data: { 9 | turbo_method: "post" 10 | } 11 | } 12 | end 13 | actions << { 14 | content: t("actions.delete", resource: resource_name_for(Import)), 15 | destructive: true, 16 | data: { 17 | controller: "polaris", 18 | target: "#destroy-modal", 19 | action: "polaris#openModal" 20 | } 21 | } 22 | end 23 | 24 | def metrics_date_range_text(import) 25 | if import.metrics.empty? 26 | t("imports.no_metrics") 27 | else 28 | "#{import.metrics.minimum(:metric_date)} - #{import.metrics.maximum(:metric_date)}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/helpers/layouts_helper.rb: -------------------------------------------------------------------------------- 1 | module LayoutsHelper 2 | def nav_item_selected?(url) 3 | url_part = url.include?("?") ? url.split("?").first : url 4 | path_part = request.path.include?("?") ? request.path.split("?").first : request.path 5 | url_part == path_part 6 | end 7 | 8 | def avatar_url(user, size) 9 | gravatar_id = Digest::MD5.hexdigest(user.email.downcase) 10 | "https://gravatar.com/avatar/#{gravatar_id}.png?s=#{size}" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/helpers/partner_api_credentials_helper.rb: -------------------------------------------------------------------------------- 1 | module PartnerApiCredentialsHelper 2 | def partner_api_credential_path_for(current_user) 3 | if current_user.partner_api_credential&.persisted? 4 | edit_partner_api_credential_path(current_user.partner_api_credential) 5 | else 6 | new_partner_api_credential_path 7 | end 8 | end 9 | 10 | def partner_api_credential_badge_for(current_user) 11 | current_user.partner_api_credential&.status_message.present? ? "!" : nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "./controllers" 4 | import "chartkick" 5 | import * as ActiveStorage from "@rails/activestorage" 6 | ActiveStorage.start() 7 | 8 | // Polaris 9 | import { registerPolarisControllers } from "polaris-view-components" 10 | registerPolarisControllers(Stimulus) 11 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/javascript/controllers/datefield_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | 3 | export default class extends Controller { 4 | static targets = ['input'] 5 | 6 | show() { 7 | this.inputTarget.showPicker() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/javascript/controllers/filters_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import debounce from 'debounce' 3 | 4 | export default class extends Controller { 5 | initialize() { 6 | this.submit = debounce(this.submit.bind(this), 300) 7 | } 8 | 9 | submit() { 10 | this.element.requestSubmit() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/javascript/controllers/form_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import DirtyForm from '../libraries/dirty-form' 3 | 4 | export default class extends Controller { 5 | static targets = ['submitButton'] 6 | static values = { 7 | showSaveBar: { type: Boolean, default: false }, 8 | submitButtonTarget: String 9 | } 10 | 11 | connect() { 12 | this.setupDirtyForm() 13 | 14 | this.element.addEventListener('turbo:submit-start', this.submitStart) 15 | this.element.addEventListener('turbo:submit-end', this.submitEnd) 16 | } 17 | 18 | disconnect() { 19 | this.dirtyForm = null 20 | } 21 | 22 | setupDirtyForm() { 23 | this.isDirty = false 24 | this.dirtyForm = new DirtyForm(this.element, { 25 | onDirty: this.formDirty, 26 | message: "You have unsaved changes." 27 | }) 28 | this.disableSubmitWithoutSpinner() 29 | } 30 | 31 | // Actions 32 | 33 | markAsDirty() { 34 | this.dirtyForm.markAsDirty() 35 | } 36 | 37 | // Handlers 38 | 39 | submitStart = () => { 40 | this.submitButtonController.disable() 41 | } 42 | 43 | submitEnd = () => { 44 | this.submitButtonController.enable() 45 | this.dirtyForm.disconnect() 46 | this.setupDirtyForm() 47 | } 48 | 49 | formDirty = () => { 50 | if (!this.isDirty) { 51 | this.isDirty = true 52 | this.submitButtonController.enable() 53 | if (this.showSaveBarValue) 54 | this.frameController.showSaveBar() 55 | } 56 | } 57 | 58 | // Private 59 | 60 | get submitButton() { 61 | if (this.hasSubmitButtonTarget) { 62 | return this.submitButtonTarget 63 | } else { 64 | return document.querySelector(this.submitButtonTargetValue) 65 | } 66 | } 67 | 68 | get frameController() { 69 | const target = document.querySelector('[data-controller~="polaris-frame"]') 70 | return this.application.getControllerForElementAndIdentifier(target, 'polaris-frame') 71 | } 72 | 73 | get submitButtonController() { 74 | return this.application.getControllerForElementAndIdentifier(this.submitButton, 'polaris-button') 75 | } 76 | 77 | disableSubmitWithoutSpinner() { 78 | this.submitButton.disabled = true 79 | this.submitButton.classList.add('Polaris-Button--disabled') 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by ./bin/rails stimulus:manifest:update 2 | // Run that command whenever you add a new controller or create them with 3 | // ./bin/rails generate stimulus controllerName 4 | 5 | import { application } from "./application" 6 | 7 | import DatefieldController from "./datefield_controller" 8 | application.register("datefield", DatefieldController) 9 | 10 | import FiltersController from "./filters_controller" 11 | application.register("filters", FiltersController) 12 | 13 | import FormController from "./form_controller" 14 | application.register("form", FormController) 15 | 16 | import GlobeController from "./globe_controller" 17 | application.register("globe", GlobeController) 18 | 19 | import LoadingController from "./loading_controller" 20 | application.register("loading", LoadingController) 21 | 22 | import SubmittableController from "./submittable_controller" 23 | application.register("submittable", SubmittableController) 24 | -------------------------------------------------------------------------------- /app/javascript/controllers/loading_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | static targets = ["overlay"]; 5 | 6 | connect() { 7 | this.eventHandler = this.show.bind(this); 8 | document.addEventListener("turbo:before-fetch-request", this.eventHandler); 9 | } 10 | 11 | disconnect() { 12 | document.removeEventListener( 13 | "turbo:before-fetch-request", 14 | this.eventHandler 15 | ); 16 | } 17 | 18 | show() { 19 | this.overlayTargets.forEach((overlay) => 20 | overlay.classList.toggle("hidden") 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/javascript/controllers/submittable_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | 3 | export default class extends Controller { 4 | static targets = ['form'] 5 | 6 | submit() { 7 | this.formTarget.requestSubmit() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/jobs/calculate_metrics_job.rb: -------------------------------------------------------------------------------- 1 | class CalculateMetricsJob < ApplicationJob 2 | queue_as :default 3 | sidekiq_options retry: 0 4 | 5 | def perform(import:) 6 | import.calculate 7 | rescue => e 8 | import&.fail 9 | raise e 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/import_payments_job.rb: -------------------------------------------------------------------------------- 1 | class ImportPaymentsJob < ApplicationJob 2 | queue_as :default 3 | sidekiq_options retry: 0 4 | 5 | def perform(import:) 6 | import.import 7 | rescue => e 8 | import&.fail 9 | raise e 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: -> { default_from } 3 | layout "mailer" 4 | 5 | private 6 | 7 | def default_from 8 | Rails.application.credentials[:action_mailer][:email_from_address] 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/app_deleter.rb: -------------------------------------------------------------------------------- 1 | class AppDeleter 2 | def initialize(user:, app_title:) 3 | @user = user 4 | @app_title = app_title 5 | end 6 | 7 | def delete 8 | delete_metrics 9 | delete_payments 10 | true 11 | end 12 | 13 | private 14 | 15 | def delete_metrics 16 | @user.metrics.where(app_title: @app_title).delete_all 17 | end 18 | 19 | def delete_payments 20 | @user.payments.where(app_title: @app_title).delete_all 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/models/app_renamer.rb: -------------------------------------------------------------------------------- 1 | class AppRenamer 2 | def initialize(user:, from:, to:) 3 | @user = user 4 | @from = from 5 | @to = to 6 | end 7 | 8 | def rename 9 | return false unless valid? 10 | rename_metrics 11 | rename_payments 12 | true 13 | end 14 | 15 | private 16 | 17 | def rename_metrics 18 | @user.metrics.where(app_title: @from).update_all(app_title: @to) 19 | end 20 | 21 | def rename_payments 22 | @user.payments.where(app_title: @from).update_all(app_title: @to) 23 | end 24 | 25 | def valid? 26 | @from.present? && @to.present? && @from != @to 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/graphql/transactions_query.rb: -------------------------------------------------------------------------------- 1 | require "shopify_partner_api" 2 | 3 | Graphql::TransactionsQuery = ShopifyPartnerAPI.client.parse <<~GRAPHQL 4 | query($createdAtMin: DateTime, $cursor: String, $first: Int) { 5 | transactions(createdAtMin: $createdAtMin, after: $cursor, first: $first) { 6 | edges { 7 | cursor 8 | node { 9 | id, 10 | createdAt, 11 | # Apps 12 | ... on AppSubscriptionSale { 13 | billingInterval, 14 | netAmount { 15 | amount 16 | }, 17 | app { 18 | name 19 | }, 20 | shop { 21 | myshopifyDomain 22 | } 23 | }, 24 | ... on AppOneTimeSale { 25 | netAmount { 26 | amount 27 | }, 28 | app { 29 | name 30 | }, 31 | shop { 32 | myshopifyDomain 33 | } 34 | }, 35 | ... on AppSaleAdjustment { 36 | netAmount { 37 | amount 38 | }, 39 | app { 40 | name 41 | }, 42 | shop { 43 | myshopifyDomain 44 | } 45 | }, 46 | ... on AppSaleCredit { 47 | netAmount { 48 | amount 49 | }, 50 | app { 51 | name 52 | }, 53 | shop { 54 | myshopifyDomain 55 | } 56 | }, 57 | ... on AppUsageSale { 58 | netAmount { 59 | amount 60 | }, 61 | app { 62 | name 63 | }, 64 | shop { 65 | myshopifyDomain 66 | } 67 | }, 68 | # skipped LegacyTransaction, not sure what it is 69 | ... on ReferralAdjustment { 70 | amount { 71 | amount 72 | }, 73 | shop { 74 | myshopifyDomain 75 | } 76 | }, 77 | ... on ReferralTransaction { 78 | amount { 79 | amount 80 | }, 81 | shopNonNullable: shop { 82 | myshopifyDomain 83 | } 84 | }, 85 | ... on ServiceSale { 86 | netAmount { 87 | amount 88 | }, 89 | shop { 90 | myshopifyDomain 91 | } 92 | }, 93 | ... on ServiceSaleAdjustment { 94 | netAmount { 95 | amount 96 | }, 97 | shop { 98 | myshopifyDomain 99 | } 100 | }, 101 | # skipped TaxTransaction 102 | ... on ThemeSale { 103 | netAmount { 104 | amount 105 | }, 106 | theme { 107 | name # may not match CSV import behaviour 108 | }, 109 | shop { 110 | myshopifyDomain 111 | } 112 | }, 113 | ... on ThemeSaleAdjustment { 114 | netAmount { 115 | amount 116 | }, 117 | theme { 118 | name # may not match CSV import behaviour 119 | }, 120 | shop { 121 | myshopifyDomain 122 | } 123 | }, 124 | } 125 | }, 126 | pageInfo { 127 | hasNextPage 128 | } 129 | } 130 | } 131 | GRAPHQL 132 | -------------------------------------------------------------------------------- /app/models/import/metrics_processor.rb: -------------------------------------------------------------------------------- 1 | class Import::MetricsProcessor 2 | def initialize(import:) 3 | @import = import 4 | @user = import.user 5 | @import_from = import.import_metrics_after_date 6 | @import_to = import.import_metrics_before_date 7 | end 8 | 9 | def calculate! 10 | return if @import_from.blank? || @import_to.blank? 11 | calculate_new_metrics 12 | rescue => error 13 | @import&.fail 14 | raise error 15 | end 16 | 17 | private 18 | 19 | def calculate_new_metrics 20 | @import_from.upto(@import_to) do |date| 21 | metrics = [] 22 | Metric::CHARGE_TYPES.each do |charge_type| 23 | is_yearly_revenue_intervals_for(charge_type).each do |is_yearly_revenue| 24 | app_titles = app_titles_for(date: date, charge_type: charge_type, is_yearly_revenue: is_yearly_revenue) 25 | next if app_titles.empty? 26 | 27 | app_titles.each do |app_title| 28 | calculator = Metric::Calculator.new( 29 | user: @user, 30 | date: date, 31 | charge_type: charge_type, 32 | app_title: app_title, 33 | is_yearly_revenue: is_yearly_revenue 34 | ) 35 | metrics << new_metric_from(calculator: calculator) if calculator.has_metrics? 36 | end 37 | end 38 | end 39 | Metric.import!(metrics, validate: false, no_returning: true) if metrics.present? 40 | @import.touch 41 | end 42 | end 43 | 44 | def is_yearly_revenue_intervals_for(charge_type) 45 | if Metric::CHARGE_TYPE_CAN_HAVE_YEARLY_INTERVAL[charge_type] 46 | [true, false] 47 | else 48 | [false] 49 | end 50 | end 51 | 52 | def app_titles_for(date:, charge_type:, is_yearly_revenue:) 53 | @user.payments.where( 54 | payment_date: date, 55 | charge_type: charge_type, 56 | is_yearly_revenue: is_yearly_revenue 57 | ).pluck(:app_title).uniq 58 | end 59 | 60 | def new_metric_from(calculator:) 61 | { 62 | user_id: @user.id, 63 | import_id: @import.id, 64 | metric_date: calculator.date, 65 | charge_type: calculator.charge_type, 66 | app_title: calculator.app_title, 67 | revenue: calculator.revenue, 68 | is_yearly_revenue: calculator.is_yearly_revenue, 69 | number_of_charges: calculator.number_of_charges, 70 | number_of_shops: calculator.number_of_shops, 71 | average_revenue_per_shop: calculator.average_revenue_per_shop, 72 | average_revenue_per_charge: calculator.average_revenue_per_charge, 73 | revenue_churn: calculator.revenue_churn, 74 | shop_churn: calculator.shop_churn, 75 | lifetime_value: calculator.lifetime_value, 76 | repeat_customers: calculator.repeat_customers, 77 | repeat_vs_new_customers: calculator.repeat_vs_new_customers 78 | } 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/models/import/payments_processor.rb: -------------------------------------------------------------------------------- 1 | class Import::PaymentsProcessor 2 | def initialize(import:) 3 | @import = import 4 | @user = import.user 5 | @import_payments_after_date = import.import_payments_after_date 6 | @source_adaptor = import.source_adaptor.new(import: import, import_payments_after_date: @import_payments_after_date) 7 | end 8 | 9 | def import! 10 | @user.clear_old_payments!(after: @import_payments_after_date) 11 | import_new_payments 12 | rescue => error 13 | @import&.fail 14 | raise error 15 | end 16 | 17 | private 18 | 19 | def import_new_payments 20 | fetched_payments.each_slice(@source_adaptor.batch_size) do |batch| 21 | payments = [] 22 | batch.each do |transaction| 23 | next if transaction[:payment_date] <= @import_payments_after_date 24 | next if transaction[:charge_type].nil? 25 | next if transaction[:shop].nil? 26 | next if transaction[:revenue].zero? 27 | 28 | payments << new_payment(transaction) 29 | end 30 | 31 | Payment.import!(payments.compact, validate: false, no_returning: true) if payments.present? 32 | @import.touch 33 | end 34 | end 35 | 36 | def fetched_payments 37 | @source_adaptor.fetch_payments 38 | end 39 | 40 | def new_payment(payment) 41 | payment[:charge_type] = adjust_usage_charge_type(payment) if payment[:charge_type] == "usage_revenue" 42 | # Note to self: Do not refactor to payment.new objects 43 | # It grows memory like crazy when processing large files 44 | { 45 | user_id: @user.id, 46 | import_id: @import.id, 47 | payment_date: payment[:payment_date], 48 | charge_type: payment[:charge_type], 49 | revenue: payment[:revenue], 50 | is_yearly_revenue: payment[:is_yearly_revenue], 51 | app_title: payment[:app_title], 52 | shop: payment[:shop], 53 | shop_country: payment[:shop_country] 54 | } 55 | end 56 | 57 | def adjust_usage_charge_type(charge_type) 58 | (@user.count_usage_charges_as_recurring == true) ? "recurring_revenue" : "onetime_revenue" 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /app/models/metric.rb: -------------------------------------------------------------------------------- 1 | class Metric < ApplicationRecord 2 | belongs_to :user 3 | belongs_to :import 4 | 5 | PERIODS = [1, 7, 28, 29, 30, 31, 90, 180, 365].freeze 6 | PERIODS_AGO = [1, 2, 3, 6, 12].freeze 7 | 8 | CHARGE_TYPES = ["recurring_revenue", "onetime_revenue", "affiliate_revenue", "refund"].freeze 9 | 10 | # Used to calculate metrics in groups of not applicable, yearly & monthly 11 | # nil = not applicable, true = yearly, false = monthly 12 | CHARGE_TYPE_CAN_HAVE_YEARLY_INTERVAL = { 13 | "recurring_revenue" => true, 14 | "onetime_revenue" => false, 15 | "affiliate_revenue" => false, 16 | "refund" => false 17 | }.freeze 18 | 19 | DISPLAYABLE_TYPES = [ 20 | :recurring_revenue, 21 | :onetime_revenue, 22 | :affiliate_revenue 23 | ].freeze 24 | 25 | class << self 26 | def by_optional_app_title(app_title) 27 | app_title.blank? ? all : where(app_title: app_title) 28 | end 29 | 30 | def by_optional_charge_type(charge_type) 31 | charge_type.blank? ? all : where(charge_type: charge_type) 32 | end 33 | 34 | def by_optional_is_yearly_revenue(is_yearly_revenue) 35 | is_yearly_revenue.nil? ? all : where(is_yearly_revenue: is_yearly_revenue) 36 | end 37 | 38 | def by_date_and_period(date:, period:) 39 | previous_period = date - period.days + 1 40 | where(metric_date: previous_period.beginning_of_day..date.end_of_day) 41 | end 42 | 43 | def calculate_value(calculation, column) 44 | (calculation == :sum) ? sum(column) : average(column) 45 | end 46 | 47 | def chart_data(date, period, calculation, column) 48 | # Get the first date of metrics, so we know how far back to go 49 | first_date = minimum("metric_date") 50 | return [] unless first_date 51 | 52 | # Build the date ranges, and group the metrics by date 53 | group_options = group_options(date, first_date, period) 54 | metrics = group(group_options, {restrict: true}) 55 | 56 | # Calculate the metrics for each date range 57 | metrics = (calculation == :sum) ? metrics.sum(column) : metrics.average(column) 58 | 59 | # Fill in dates with no metrics with 0 60 | group_options[:metric_date].keys.each { |k| metrics[k.to_s] ||= 0 } 61 | 62 | # Sort the dates and return the metrics 63 | metrics.sort_by { |h| h[0].to_datetime } 64 | end 65 | 66 | # Build a hash of dates containing date ranges, 67 | # for each period between the first date and the date selected 68 | def group_options(date, first_date, period) 69 | counter_date = date 70 | group_options = {metric_date: {}} 71 | until counter_date < first_date 72 | group_options[:metric_date][counter_date] = (counter_date.beginning_of_day - period.days + 1.day)..counter_date.end_of_day 73 | counter_date -= period.days 74 | end 75 | group_options 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /app/models/metric/forecast_charter.rb: -------------------------------------------------------------------------------- 1 | require "prophet-rb" 2 | require "rover" 3 | 4 | class Metric::ForecastCharter 5 | FORECAST_PERIODS = 6 6 | FREQUENCIES = { 7 | monthly: "MS" 8 | }.freeze 9 | 10 | def initialize(chart_data:) 11 | @chart_data = chart_data 12 | end 13 | 14 | def chart_data 15 | return [] if insufficient_data? 16 | 17 | dataframe = dataframe_from_chart_data 18 | prophet = prophet_for(dataframe) 19 | future_dataframe = future_dataframe_from(prophet) 20 | 21 | generate_forecast_data(prophet, future_dataframe, @chart_data.keys.last) 22 | end 23 | 24 | private 25 | 26 | def insufficient_data? 27 | @chart_data.size < 10 28 | end 29 | 30 | def dataframe_from_chart_data 31 | Rover::DataFrame.new({"ds" => @chart_data.keys, "y" => @chart_data.values}) 32 | end 33 | 34 | def prophet_for(dataframe) 35 | Prophet.new.fit(dataframe) 36 | end 37 | 38 | def future_dataframe_from(prophet) 39 | prophet.make_future_dataframe(periods: FORECAST_PERIODS, freq: FREQUENCIES[:monthly]) 40 | end 41 | 42 | def generate_forecast_data(prophet, future_dataframe, last_date) 43 | forecast = prophet.predict(future_dataframe) 44 | data = [] 45 | forecast["ds"].each_with_index do |date, index| 46 | data << [date, forecast["yhat"][index]] if date > last_date 47 | end 48 | data 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/models/metric/tile_presenter.rb: -------------------------------------------------------------------------------- 1 | class Metric::TilePresenter 2 | def initialize(filter:, tile_config:) 3 | @filter = filter 4 | 5 | @handle = tile_config[:handle] 6 | @charge_type = tile_config[:charge_type] 7 | @calculation = tile_config[:calculation] 8 | @column = tile_config[:column] 9 | @display = tile_config[:display] 10 | @positive_change_is_good = tile_config[:positive_change_is_good] 11 | @is_yearly_revenue = tile_config[:is_yearly_revenue] 12 | @width = tile_config[:width].presence || :third 13 | end 14 | attr_reader :handle, :display, :calculation, :positive_change_is_good, :is_yearly_revenue, :width 15 | 16 | def current_value 17 | metrics = @filter.current_period_metrics 18 | .by_optional_charge_type(@charge_type) 19 | .by_optional_is_yearly_revenue(@is_yearly_revenue) 20 | .calculate_value(@calculation, @column) 21 | metrics.presence || 0 22 | end 23 | 24 | def previous_value 25 | metrics = @filter.previous_period_metrics 26 | .by_optional_charge_type(@charge_type) 27 | .by_optional_is_yearly_revenue(@is_yearly_revenue) 28 | .calculate_value(@calculation, @column) 29 | metrics.presence || 0 30 | end 31 | 32 | def change 33 | return 0 if current_value.blank? || previous_value.blank? 34 | (current_value.to_f / previous_value * 100) - 100 35 | end 36 | 37 | def average_value 38 | return 0 if current_value.blank? 39 | current_value / @filter.period 40 | end 41 | 42 | def period_ago_value(period_ago) 43 | period_ago_date = @filter.date - (period_ago * @filter.period).days 44 | @filter.user_metrics_by_app 45 | .by_date_and_period(date: period_ago_date, period: @filter.period) 46 | .by_optional_charge_type(@charge_type) 47 | .by_optional_is_yearly_revenue(@is_yearly_revenue) 48 | .calculate_value(@calculation, @column) 49 | end 50 | 51 | def period_ago_change(period_ago) 52 | return 0 if current_value.blank? || period_ago_value(period_ago).blank? 53 | (current_value.to_f / period_ago_value(period_ago) * 100) - 100 54 | end 55 | 56 | def chart_data 57 | chart_data = basic_chart_data 58 | 59 | if @filter.show_forecasts? && @filter.period == 30 60 | forecast_data = forecast_chart_data(chart_data) 61 | return chart_data if forecast_data[:data].empty? 62 | chart_data << forecast_data 63 | end 64 | chart_data 65 | end 66 | 67 | private 68 | 69 | def basic_chart_data 70 | metrics_chart = metrics_chart_data 71 | [{name: @display, data: metrics_chart}] 72 | end 73 | 74 | def forecast_chart_data(chart_data) 75 | forecast_data = Metric::ForecastCharter.new(chart_data: metrics_chart_data).chart_data 76 | {name: "Forecast", data: forecast_data} 77 | end 78 | 79 | def metrics_chart_data 80 | @metrics_chart_data ||= @filter.user_metrics_by_app 81 | .by_optional_charge_type(@charge_type) 82 | .by_optional_is_yearly_revenue(@is_yearly_revenue) 83 | .chart_data(@filter.date, @filter.period, @calculation, @column) 84 | .to_h 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /app/models/metric/tiles_filter.rb: -------------------------------------------------------------------------------- 1 | class Metric::TilesFilter 2 | def initialize(user:, params:) 3 | @user = user 4 | @date = params[:date]&.to_date || user.newest_metric_date_or_today 5 | @charge_type = params[:charge_type]&.to_s || nil 6 | @chart = params[:chart]&.to_s || nil 7 | @period = params[:period]&.to_i || 30 8 | @app = params[:app]&.to_s || nil 9 | end 10 | 11 | attr_reader :user, :date, :charge_type, :chart, :period, :app 12 | 13 | delegate :show_forecasts?, :oldest_metric_date, :newest_metric_date_or_today, to: :user 14 | 15 | def app_titles 16 | @user.app_titles(@charge_type) 17 | end 18 | 19 | def tiles 20 | tiles_presenter.tiles 21 | end 22 | 23 | def selected_tile 24 | tiles_presenter.selected_tile 25 | end 26 | 27 | def has_metrics? 28 | current_period_metrics.any? 29 | end 30 | 31 | def user_metrics_by_app 32 | @user.metrics.by_optional_app_title(@app) 33 | end 34 | 35 | def current_period_metrics 36 | user_metrics_by_app.by_date_and_period(date: @date, period: @period) 37 | end 38 | 39 | def previous_period_metrics 40 | user_metrics_by_app.by_date_and_period(date: previous_date, period: @period) 41 | end 42 | 43 | def to_param 44 | { 45 | date: @date, 46 | charge_type: @charge_type, 47 | chart: @chart, 48 | period: @period, 49 | app: @app 50 | } 51 | end 52 | 53 | private 54 | 55 | def previous_date 56 | @date - @period.days 57 | end 58 | 59 | def tiles_presenter 60 | @tiles_presenter ||= Metric::TilesPresenter.new(filter: self) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/models/metric/tiles_presenter.rb: -------------------------------------------------------------------------------- 1 | class Metric::TilesPresenter 2 | extend Metric::TilesConfig 3 | 4 | def initialize(filter:) 5 | @filter = filter 6 | end 7 | 8 | def tiles 9 | @tiles ||= build_tiles 10 | end 11 | 12 | def selected_tile 13 | chart = @filter.chart.presence || default_chart 14 | tiles.find { |t| t.handle.to_s == chart } 15 | end 16 | 17 | private 18 | 19 | def build_tiles 20 | tiles_for_charge_type.collect do |tile| 21 | Metric::TilePresenter.new(filter: @filter, tile_config: tile) 22 | end 23 | end 24 | 25 | def tiles_for_charge_type 26 | case @filter.charge_type&.to_sym 27 | when :recurring_revenue 28 | Metric::TilesConfig::RECURRING_TILES 29 | when :onetime_revenue 30 | Metric::TilesConfig::ONETIME_TILES 31 | when :affiliate_revenue 32 | Metric::TilesConfig::AFFILIATE_TILES 33 | else 34 | Metric::TilesConfig::OVERVIEW_TILES 35 | end 36 | end 37 | 38 | def default_chart 39 | tiles.first.handle.to_s 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/models/partner_api_credential.rb: -------------------------------------------------------------------------------- 1 | require "shopify_partner_api" 2 | require "graphql/client" 3 | require "graphql/client/http" 4 | 5 | class PartnerApiCredential < ApplicationRecord 6 | include ShopifyPartnerAPI 7 | 8 | encrypts :access_token 9 | encrypts :organization_id 10 | 11 | belongs_to :user 12 | 13 | enum status: { 14 | draft: "draft", 15 | valid: "valid", 16 | invalid: "invalid" 17 | }, _default: :draft, _suffix: true 18 | 19 | validates :access_token, presence: true 20 | validates :organization_id, presence: true 21 | validates :status, presence: true, inclusion: {in: statuses.keys} 22 | validate :credentials_have_access, on: [:create, :update] 23 | 24 | accepts_nested_attributes_for :user, update_only: true 25 | 26 | def context 27 | { 28 | access_token: access_token, 29 | organization_id: organization_id 30 | } 31 | end 32 | 33 | def invalidate_with_message!(message) 34 | update!(status: :invalid, status_message: message) 35 | end 36 | 37 | private 38 | 39 | def credentials_have_access 40 | errors.add(:access_token, "is required") if access_token.blank? 41 | errors.add(:organization_id, "is required") if organization_id.blank? 42 | 43 | return if !will_save_change_to_access_token? && !will_save_change_to_organization_id? 44 | 45 | if errors.empty? 46 | response = test_api_credentials 47 | if response.success? 48 | puts "Validation success." 49 | self.status = :valid 50 | self.status_message = "" 51 | else 52 | puts "Validation failed: #{response.error_message}" 53 | errors.add(:base, "Invalid credentials: #{response.error_message}") 54 | self.status = :invalid 55 | self.status_message = response.error_message 56 | end 57 | end 58 | end 59 | 60 | def test_api_credentials 61 | response = ShopifyPartnerAPI.client.query( 62 | Graphql::TransactionsQuery, 63 | variables: {first: 50}, 64 | context: context 65 | ) 66 | if response.errors.any? 67 | OpenStruct.new(success?: false, error_message: errors_from_response(response)) 68 | else 69 | OpenStruct.new(success?: true) 70 | end 71 | end 72 | 73 | def errors_from_response(response) 74 | response.errors.messages.map { |k, v| v }&.to_sentence 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /app/models/payment.rb: -------------------------------------------------------------------------------- 1 | class Payment < ApplicationRecord 2 | UNKNOWN_APP_TITLE = "Unknown".freeze 3 | 4 | belongs_to :user 5 | belongs_to :import 6 | 7 | class << self 8 | def by_optional_app_title(app_title) 9 | app_title.blank? ? all : where(app_title: app_title) 10 | end 11 | 12 | def by_date_and_period(date:, period:) 13 | previous_period = date - period.days + 1 14 | where(payment_date: previous_period.beginning_of_day..date.end_of_day) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/smiirl_integration.rb: -------------------------------------------------------------------------------- 1 | class SmiirlIntegration < ApplicationRecord 2 | METRIC_TYPES = [ 3 | "paying_users_30d", 4 | "total_revenue_30d" 5 | ].freeze 6 | 7 | belongs_to :user 8 | 9 | validates :token, presence: true, length: {minimum: 32}, uniqueness: true 10 | validates :metric_type, inclusion: {in: METRIC_TYPES} 11 | 12 | before_validation :ensure_token 13 | 14 | def rotate_token! 15 | update!(token: self.class.generate_unique_token) 16 | end 17 | 18 | def self.generate_unique_token 19 | loop do 20 | token = SecureRandom.hex(16) # 32 chars 21 | break token unless exists?(token: token) 22 | end 23 | end 24 | 25 | private 26 | 27 | def ensure_token 28 | self.token ||= self.class.generate_unique_token 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/models/summary.rb: -------------------------------------------------------------------------------- 1 | class Summary 2 | def initialize(user:, selected_app: nil) 3 | @user = user 4 | @selected_app = selected_app 5 | end 6 | 7 | private 8 | 9 | def payments 10 | @payments ||= @user.payments.by_optional_app_title(@selected_app) 11 | end 12 | 13 | def metrics 14 | @metrics ||= @user.metrics.by_optional_app_title(@selected_app) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/summary/monthly.rb: -------------------------------------------------------------------------------- 1 | class Summary::Monthly < Summary 2 | MONTHS_TO_SUMMARIZE = 36 3 | GROUP_OPTIONS = {reverse: true, last: MONTHS_TO_SUMMARIZE}.freeze 4 | 5 | def summarize 6 | summary = {} 7 | 8 | payments_count.each do |month, count| 9 | summary[month] ||= {} 10 | summary[month][:payments] = count 11 | end 12 | 13 | payments_revenue.each do |month, revenue| 14 | summary[month] ||= {} 15 | summary[month][:revenue] = revenue 16 | summary[month][:revenue_per_payment] = revenue / summary[month][:payments].to_f 17 | end 18 | 19 | metrics_revenue_churn.each do |month, churn| 20 | summary[month] ||= {} 21 | summary[month][:revenue_churn] = churn 22 | end 23 | 24 | metrics_user_churn.each do |month, churn| 25 | summary[month] ||= {} 26 | summary[month][:user_churn] = churn 27 | end 28 | 29 | summary 30 | end 31 | 32 | private 33 | 34 | def payments_count 35 | payments.group_by_month(:payment_date, **GROUP_OPTIONS).count 36 | end 37 | 38 | def payments_revenue 39 | payments.group_by_month(:payment_date, **GROUP_OPTIONS).sum(:revenue) 40 | end 41 | 42 | def metrics_revenue_churn 43 | metrics.group_by_month(:metric_date, **GROUP_OPTIONS).average(:revenue_churn) 44 | end 45 | 46 | def metrics_user_churn 47 | metrics.group_by_month(:metric_date, **GROUP_OPTIONS).average(:shop_churn) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/models/summary/shop.rb: -------------------------------------------------------------------------------- 1 | class Summary::Shop < Summary 2 | TOP_SHOPS_LIMIT = 100 3 | SQL_QUERY = "shop, COUNT(payment_date) AS payment_count, SUM(revenue) AS total_revenue, MAX(payment_date) AS last_payment_date".freeze 4 | 5 | def summarize 6 | summary = {} 7 | 8 | summarized_shop_data.each do |shop, data| 9 | summary[shop] = { 10 | payments: data["payment_count"], 11 | revenue: data["total_revenue"], 12 | last_payment: data["last_payment_date"] 13 | } 14 | end 15 | 16 | summary 17 | end 18 | 19 | private 20 | 21 | def summarized_shop_data 22 | result = payments 23 | .select(SQL_QUERY) 24 | .group(:shop) 25 | .order("total_revenue DESC") 26 | .limit(TOP_SHOPS_LIMIT) 27 | 28 | result.each_with_object({}) do |record, hash| 29 | hash[record.shop] = record.attributes.except("shop") 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/views/delete_apps/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("delete_apps.delete"), 4 | subtitle: t("delete_apps.subtitle"), 5 | back_url: imports_path 6 | ) do |page| %> 7 | 8 | <%= polaris_card do %> 9 | <%= turbo_frame_tag :delete_apps, target: "_top" do %> 10 | <%= form_for(:delete_apps, url: delete_apps_path, builder: Polaris::FormBuilder) do |form| %> 11 | <%= polaris_form_layout do |form_layout| %> 12 | 13 | <% form_layout.with_item do %> 14 | <%= form.polaris_select(:app_title, 15 | label: t("delete_apps.app_title"), 16 | options: @app_titles.map { |app_title| [app_title, app_title] }, 17 | selected: nil 18 | ) %> 19 | <% end %> 20 | 21 | <% form_layout.with_item do %> 22 | <%= polaris_button( 23 | submit: true, 24 | destructive: true, 25 | data: {form_target: "submitButton"}, 26 | ) { t('delete_apps.delete') } %> 27 | <% end %> 28 | 29 | <% end %> 30 | <% end %> 31 | <% end %> 32 | <% end %> 33 | 34 | <% end %> 35 | -------------------------------------------------------------------------------- /app/views/devise/mailer/reset_password_instructions.html.erb: -------------------------------------------------------------------------------- 1 |
<%= t('.greeting', recipient: @resource.email, default: "Hello #{@resource.email}!") %>
2 | 3 |<%= t('.instruction', default: 'Someone has requested a link to change your password, and you can do this through the link below.') %>
4 | 5 |<%= link_to t('.action', default: 'Change my password'), edit_password_url(@resource, reset_password_token: @token, locale: I18n.locale) %>
6 | 7 |<%= t('.instruction_2', default: "If you didn't request this, please ignore this email.") %>
8 |<%= t('.instruction_3', default: "Your password won't change until you access the link above and create a new one.") %>
9 | -------------------------------------------------------------------------------- /app/views/devise/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("devise.new_password"), 4 | secondary_actions: [ 5 | { content: t("devise.sign_in"), url: new_user_session_path }, 6 | ] 7 | ) do |page| %> 8 | 9 | <%= polaris_card do %> 10 | <%= turbo_frame_tag resource, target: "_top" do %> 11 | <%= form_with(model: resource, as: resource_name, url: password_path(resource), builder: Polaris::FormBuilder, method: :put, data: {turbo: false}) do |form| %> 12 | 13 | <%= form.hidden_field :reset_password_token %> 14 | 15 | <%= polaris_form_layout do |form_layout| %> 16 | 17 | <% if resource.errors.any? %> 18 | <% form_layout.with_item do %> 19 | <%= form.errors_summary %> 20 | <% end %> 21 | <% end %> 22 | 23 | <% form_layout.with_item do %> 24 | <%= form.polaris_text_field :password, label: t("devise.password"), type: :password, required: true %> 25 | <% end %> 26 | 27 | <% form_layout.with_item do %> 28 | <%= form.polaris_text_field :password_confirmation, label: t("devise.password_confirmation"), type: :password, required: true %> 29 | <% end %> 30 | 31 | <% form_layout.with_item do %> 32 | <%= polaris_button( 33 | submit: true, 34 | primary: true, 35 | data: {form_target: "submitButton"}, 36 | ) { t('devise.change_password') } %> 37 | <% end %> 38 | 39 | <% end %> 40 | <% end %> 41 | <% end %> 42 | <% end %> 43 | 44 | <% end %> 45 | -------------------------------------------------------------------------------- /app/views/devise/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("devise.forgot_password"), 4 | back_url: request.referer 5 | ) do |page| %> 6 | 7 | <%= polaris_card do %> 8 | <%= turbo_frame_tag resource, target: "_top" do %> 9 | <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), builder: Polaris::FormBuilder, data: {turbo: false}, format: :html) do |form| %> 10 | <%= polaris_form_layout do |form_layout| %> 11 | 12 | <% if resource.errors.any? %> 13 | <% form_layout.with_item do %> 14 | <%= form.errors_summary %> 15 | <% end %> 16 | <% end %> 17 | 18 | <% form_layout.with_item do %> 19 | <%= form.polaris_text_field :email, label: t("devise.email"), type: :email, required: true %> 20 | <% end %> 21 | 22 | <% form_layout.with_item do %> 23 | <%= polaris_button( 24 | submit: true, 25 | primary: true, 26 | data: {form_target: "submitButton"}, 27 | ) { t('devise.send_me_reset_password_instructions') } %> 28 | <% end %> 29 | 30 | <% end %> 31 | <% end %> 32 | <% end %> 33 | <% end %> 34 | <% end %> 35 | -------------------------------------------------------------------------------- /app/views/devise/registrations/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("devise.edit"), 4 | back_url: request.referer 5 | ) do |page| %> 6 | 7 | <%= polaris_card do %> 8 | <%= turbo_frame_tag resource, target: "_top" do %> 9 | <%= form_with(model: resource, as: resource_name, url: user_registration_path(resource_name), builder: Polaris::FormBuilder, data: {turbo: false}, format: :html) do |form| %> 10 | <%= polaris_form_layout do |form_layout| %> 11 | 12 | <% if resource.errors.any? %> 13 | <% form_layout.with_item do %> 14 | <%= form.errors_summary %> 15 | <% end %> 16 | <% end %> 17 | 18 | <% form_layout.with_item do %> 19 | <%= form.polaris_text_field :email, label: t("devise.email"), type: :email, required: true %> 20 | <% end %> 21 | 22 | <% form_layout.with_item do %> 23 | <%= form.polaris_text_field :password, label: t("devise.leave_blank_if_you_don_t_want_to_change_it"), type: :password %> 24 | <% end %> 25 | 26 | <% form_layout.with_item do %> 27 | <%= form.polaris_text_field :password_confirmation, label: t("devise.password_confirmation"), type: :password %> 28 | <% end %> 29 | 30 | <% form_layout.with_item do %> 31 | <%= form.polaris_text_field :current_password, label: t("devise.we_need_your_current_password_to_confirm_your_changes"), type: :password, required: true %> 32 | <% end %> 33 | 34 | <% form_layout.with_item do %> 35 | <%= polaris_button( 36 | submit: true, 37 | primary: true, 38 | data: {form_target: "submitButton"}, 39 | ) { t('actions.save') } %> 40 | <% end %> 41 | 42 | <% end %> 43 | <% end %> 44 | <% end %> 45 | <% end %> 46 | 47 | <%= polaris_page_actions do |actions| %> 48 | <% actions.with_secondary_action( 49 | destructive: true, 50 | outline: true, 51 | data: { 52 | controller: "polaris", 53 | target: "#destroy-modal", 54 | action: "polaris#openModal" 55 | } 56 | ) { t("devise.delete_account") } %> 57 | <% end %> 58 | 59 | <% end %> 60 | 61 | <%= render "modals/destroy", 62 | id: "destroy-modal", 63 | url: user_registration_path, 64 | title: t("actions.delete", resource: "account?"), 65 | message: t("devise.confirm_destroy"), 66 | primary_action_text: t("actions.delete", resource: "account") 67 | %> 68 | -------------------------------------------------------------------------------- /app/views/devise/registrations/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("devise.sign_up"), 4 | secondary_actions: [ 5 | { content: t("devise.sign_in"), url: new_user_session_path }, 6 | ] 7 | ) do |page| %> 8 | 9 | <%= polaris_card do %> 10 | <%= turbo_frame_tag resource, target: "_top" do %> 11 | <%= form_with(model: resource, as: resource_name, url: registration_path(resource_name), builder: Polaris::FormBuilder, data: {turbo: false}, format: :html) do |form| %> 12 | <%= polaris_form_layout do |form_layout| %> 13 | 14 | <% if resource.errors.any? %> 15 | <% form_layout.with_item do %> 16 | <%= form.errors_summary %> 17 | <% end %> 18 | <% end %> 19 | 20 | <% form_layout.with_item do %> 21 | <%= form.polaris_text_field :email, label: t("devise.email"), type: :email, required: true %> 22 | <% end %> 23 | 24 | <% form_layout.with_item do %> 25 | <%= form.polaris_text_field :password, label: t("devise.password"), type: :password, required: true %> 26 | <% end %> 27 | 28 | <% form_layout.with_item do %> 29 | <%= form.polaris_text_field :password_confirmation, label: t("devise.password_confirmation"), type: :password, required: true %> 30 | <% end %> 31 | 32 | <% form_layout.with_item do %> 33 | <%= polaris_button( 34 | submit: true, 35 | primary: true, 36 | data: {form_target: "submitButton"}, 37 | ) { t('devise.sign_up') } %> 38 | <% end %> 39 | 40 | <% end %> 41 | <% end %> 42 | <% end %> 43 | <% end %> 44 | 45 | <% end %> 46 | -------------------------------------------------------------------------------- /app/views/devise/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("devise.sign_in"), 4 | secondary_actions: [ 5 | { content: t("devise.sign_up"), url: new_user_registration_path }, 6 | ] 7 | ) do |page| %> 8 | 9 | <%= polaris_card do %> 10 | <%= turbo_frame_tag resource, target: "_top" do %> 11 | <%= form_with(model: resource, as: resource_name, url: session_path(resource_name), builder: Polaris::FormBuilder, data: {turbo: false}, format: :html) do |form| %> 12 | <%= polaris_form_layout do |form_layout| %> 13 | 14 | <% if resource.errors.any? %> 15 | <% form_layout.with_item do %> 16 | <%= form.errors_summary %> 17 | <% end %> 18 | <% end %> 19 | 20 | <% form_layout.with_item do %> 21 | <%= form.polaris_text_field :email, label: t("devise.email"), type: :email %> 22 | <% end %> 23 | 24 | <% form_layout.with_item do %> 25 | <%= form.polaris_text_field :password, label: t("devise.password"), type: :password %> 26 | <% end %> 27 | 28 | <% if devise_mapping.rememberable? %> 29 | <% form_layout.with_item do %> 30 | <%= form.polaris_check_box :remember_me, label: t("devise.remember_me") %> 31 | <% end %> 32 | <% end %> 33 | 34 | <% form_layout.with_item do %> 35 | <%= polaris_button_group do |group| %> 36 | 37 | <% group.with_button( 38 | submit: true, 39 | primary: true, 40 | data: {form_target: "submitButton"}, 41 | ) { t('devise.sign_in') } %> 42 | 43 | <% group.with_button( 44 | url: new_user_password_path, 45 | plain: true, 46 | ) { t('devise.forgot_password') } %> 47 | 48 | <% end %> 49 | <% end %> 50 | 51 | <% end %> 52 | <% end %> 53 | <% end %> 54 | <% end %> 55 | 56 | <% end %> 57 | -------------------------------------------------------------------------------- /app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page do %> 2 | <%= polaris_card do %> 3 | <%= polaris_empty_state( 4 | heading: "A free metrics dashboard for Shopify Partners", 5 | classes: "empty-state", 6 | image: image_path("empty_state/AnalyticsMinor.svg"), 7 | ) do |state| %> 8 | 9 | <% state.with_primary_action(url: new_user_registration_path) { "Sign up" } %> 10 | <% state.with_secondary_action(url: new_user_session_path) { "Log in" } %> 11 | 12 | <%= polaris_text(variant: :bodyLg, as: :p) do %> 13 | Get every revenue metric you care about — Total Revenue, Churn, LTV and more in one central hub. Dive deeper by app or revenue type, with graphs and forecasts included. 14 | <% end %> 15 | 16 | <% state.with_footer do %> 17 | <%= polaris_text(variant: :bodySm, as: :p) do %> 18 | PartnerMetrics.io is not affiliated with or endorsed by Shopify in any way. Shopify is a trademark of Shopify Inc. 19 | <% end %> 20 | <% end %> 21 | 22 | <% end %> 23 | <% end %> 24 | <% end %> 25 | -------------------------------------------------------------------------------- /app/views/imports/_api_credentials_banner.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_banner( 2 | title: t(".title"), 3 | status: :info 4 | ) do |banner| %> 5 |6 | <%= t(".message") %> 7 |
8 | 9 | <% banner.with_action( 10 | url: new_partner_api_credential_path 11 | ) { t("actions.add", resource: resource_name_for(PartnerApiCredential, true)) } %> 12 | <% end %> 13 | -------------------------------------------------------------------------------- /app/views/imports/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_frame_tag import, target: "_top" do %> 2 | <%= form_with( 3 | model: import, 4 | builder: Polaris::FormBuilder, 5 | data: { 6 | controller: "form" 7 | } 8 | ) do |form| %> 9 | 10 | <%= polaris_layout do |layout| %> 11 | <%# Required to submit form via Return/Enter %> 12 | <%= form.submit hidden: true %> 13 | 14 | <%= form.hidden_field :source %> 15 | 16 | <% if current_user.errors.any? %> 17 | <% layout.with_section do %> 18 | <%= form.errors_summary %> 19 | <% end %> 20 | <% end %> 21 | 22 | <% layout.with_section do %> 23 | <%= polaris_card do %> 24 | <%= polaris_form_layout do |form_layout| %> 25 | <%= form_layout.with_item do %> 26 | <%= polaris_dropzone( 27 | form: form, 28 | direct_upload: true, 29 | attribute: :payouts_file, 30 | error: form.error_for(:payouts_upload), 31 | label: Import.human_attribute_name(:payouts_file), 32 | label_hidden: false, 33 | accept: Import::ACCEPTED_FILE_TYPES.join(","), 34 | multiple: false, 35 | data: { 36 | action: "ondrop@window->form#markAsDirty" 37 | } 38 | ) %> 39 | <% end %> 40 | 41 | <%= form_layout.with_item do %> 42 | <%= polaris_text(variant: :bodySm, color: :subdued) { t(".payouts_file_help_text") } %> 43 | <% end %> 44 | 45 | <%= form_layout.with_item do %> 46 | <%= render "users/count_usage_charges_as_recurring_fields", form: form %> 47 | <% end %> 48 | 49 | 50 | <%= form_layout.with_item do %> 51 | <%= polaris_button( 52 | submit: true, 53 | primary: true, 54 | data: {form_target: "submitButton"}, 55 | ) { t('actions.save') } %> 56 | <% end %> 57 | 58 | <% end %> 59 | <% end %> 60 | <% end %> 61 | 62 | <% end %> 63 | 64 | <% end %> 65 | <% end %> 66 | -------------------------------------------------------------------------------- /app/views/imports/_import.html.erb: -------------------------------------------------------------------------------- 1 | <%= tag.div id: dom_id(import, :details) do %> 2 | <%= polaris_card do %> 3 | <%= polaris_vertical_stack(gap: "6") do |stack| %> 4 | 5 | <%= polaris_stack(alignment: :center) do |nested_stack| %> 6 | <% nested_stack.with_item do %> 7 | <%= polaris_icon(name: "DynamicSourceMajor", color: :base) %> 8 | <% end %> 9 | <% nested_stack.with_item do %> 10 | <%= polaris_text_strong { t("imports.source") } %><%= message %>
5 | <% end %> 6 | 7 | <% modal.with_primary_action( 8 | destructive: true, 9 | url: url, 10 | data: {turbo_method: :delete} 11 | ) { primary_action_text } %> 12 | 13 | <% modal.with_secondary_action(data: {action: "polaris-modal#close"}) { t("actions.cancel") } %> 14 | 15 | <% end %> 16 | -------------------------------------------------------------------------------- /app/views/partner_api_credentials/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_frame_tag partner_api_credential, target: "_top" do %> 2 | <%= form_with( 3 | model: partner_api_credential, 4 | builder: Polaris::FormBuilder, 5 | data: { 6 | controller: "form" 7 | } 8 | ) do |form| %> 9 | 10 | <%= polaris_layout do |layout| %> 11 | <%# Required to submit form via Return/Enter %> 12 | <%= form.submit hidden: true %> 13 | 14 | <% if partner_api_credential.errors.any? %> 15 | <% layout.with_section do %> 16 | <%= form.errors_summary %> 17 | <% end %> 18 | <% end %> 19 | 20 | <% layout.with_section do %> 21 | <%= polaris_card do %> 22 | <%= polaris_form_layout do |form_layout| %> 23 | 24 | <% form_layout.with_item do %> 25 | <%= form.polaris_text_field :organization_id, 26 | label: t(".organization_id"), 27 | required: true, 28 | type: :number, 29 | max: 9999999 30 | %> 31 | <% end %> 32 | 33 | <% form_layout.with_item do %> 34 | <%= form.polaris_text_field :access_token, value: partner_api_credential.access_token, label: t(".access_token"), type: :password, required: true %> 35 | <% end %> 36 | 37 | <%= form_layout.with_item do %> 38 | <%= render "users/count_usage_charges_as_recurring_fields", form: form %> 39 | <% end %> 40 | 41 | <%= form_layout.with_item do %> 42 | <%= polaris_button( 43 | submit: true, 44 | primary: true, 45 | data: {form_target: "submitButton"}, 46 | ) { t('actions.save') } %> 47 | <% end %> 48 | 49 | <% end %> 50 | <% end %> 51 | <% end %> 52 | 53 | <% end %> 54 | 55 | <% end %> 56 | <% end %> 57 | -------------------------------------------------------------------------------- /app/views/partner_api_credentials/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_stream_from [current_user, :partner_api_credential] %> 2 | 3 | <%= polaris_page( 4 | narrow_width: true, 5 | title: t(".title"), 6 | subtitle: t(".subtitle"), 7 | back_url: imports_path, 8 | secondary_actions: [ 9 | { 10 | content: t("actions.delete", resource: nil), 11 | destructive: true, 12 | data: { 13 | controller: "polaris", 14 | target: "#destroy-modal", 15 | action: "polaris#openModal" 16 | } 17 | } 18 | ], 19 | ) do |page| %> 20 | 21 | <% page.with_title_metadata do %> 22 | <%= render "shared/status", resource: @partner_api_credential %> 23 | <% end %> 24 | 25 | <%= render "form", partner_api_credential: @partner_api_credential %> 26 | 27 | <% end %> 28 | 29 | <%= render "modals/destroy", 30 | id: "destroy-modal", 31 | url: partner_api_credential_path, 32 | title: t("actions.delete", resource: resource_name_for(PartnerApiCredential, true)) + "?", 33 | message: t(".confirm_destroy"), 34 | primary_action_text: t("actions.delete", resource: nil) 35 | %> 36 | -------------------------------------------------------------------------------- /app/views/partner_api_credentials/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_stream_from [current_user, :partner_api_credential] %> 2 | 3 | <%= polaris_page( 4 | narrow_width: true, 5 | title: t(".title"), 6 | subtitle: t(".subtitle", instructions_link: link_to("wiki article for instructions", "https://github.com/forsbergplustwo/partner-metrics/wiki/How-to-create-your-Shopify-Partner-API-credentials", target: "_blank")).html_safe, 7 | back_url: imports_path 8 | ) do |page| %> 9 | 10 | <% page.with_title_metadata do %> 11 | <%= render "shared/status", resource: @partner_api_credential %> 12 | <% end %> 13 | 14 | <%= render "form", partner_api_credential: @partner_api_credential %> 15 | 16 | <% end %> 17 | -------------------------------------------------------------------------------- /app/views/rename_apps/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: t("rename_apps.rename"), 4 | subtitle: t("rename_apps.subtitle"), 5 | back_url: imports_path 6 | ) do |page| %> 7 | 8 | <%= polaris_card do %> 9 | <%= turbo_frame_tag :rename_apps, target: "_top" do %> 10 | <%= form_for(:rename_app, url: rename_apps_path, builder: Polaris::FormBuilder) do |form| %> 11 | <%= polaris_form_layout do |form_layout| %> 12 | 13 | <% form_layout.with_item do %> 14 | <%= form.polaris_select(:from, 15 | label: t("rename_apps.from"), 16 | options: @app_titles.map { |app_title| [app_title, app_title] }, 17 | selected: nil 18 | ) %> 19 | <% end %> 20 | 21 | <% form_layout.with_item do %> 22 | <%= form.polaris_select(:to, 23 | label: t("rename_apps.to"), 24 | options: @app_titles.map { |app_title| [app_title, app_title] }, 25 | selected: nil 26 | ) %> 27 | <% end %> 28 | 29 | <% form_layout.with_item do %> 30 | <%= polaris_button( 31 | submit: true, 32 | primary: true, 33 | data: {form_target: "submitButton"}, 34 | ) { t('rename_apps.rename') } %> 35 | <% end %> 36 | 37 | <% end %> 38 | <% end %> 39 | <% end %> 40 | <% end %> 41 | 42 | <% end %> 43 | -------------------------------------------------------------------------------- /app/views/shared/_empty_state.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_card do %> 2 | <%= polaris_empty_state( 3 | heading: t(".title", resource: resource_name_for(resource, true)), 4 | classes: "empty-state", 5 | image: image_path("empty_state/ImportMinor.svg"), 6 | ) do |state| %> 7 | 8 | <% state.with_primary_action(url: new_import_path, data: {turbo_frame: "_top"}) { "New import" } %> 9 |<%= t(".description")%>
10 | 11 | <% state.with_footer do %> 12 |13 | <%= t(".footer_html", url: polaris_link(url: "#", monochrome: true) { "Shopify Partner API" } )%> 14 |
15 | <% end %> 16 | <% end %> 17 | <% end %> 18 | -------------------------------------------------------------------------------- /app/views/shared/_page_actions.html.erb: -------------------------------------------------------------------------------- 1 | <% page.with_action_group( 2 | title: t("actions.more_actions"), 3 | actions: [ 4 | { content: t("rename_apps.rename"), url: rename_apps_path }, 5 | { content: t("delete_apps.delete"), url: delete_apps_path } 6 | ] 7 | ) %> 8 | -------------------------------------------------------------------------------- /app/views/shared/_status.html.erb: -------------------------------------------------------------------------------- 1 | <%= tag.span id: dom_id(resource, :status) do %> 2 | <%= status_badge(resource.status) { t("statuses.#{status}") } %> 3 | <% end %> 4 | -------------------------------------------------------------------------------- /app/views/smiirl_integrations/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | narrow_width: true, 3 | title: "Smiirl integration", 4 | subtitle: "Expose a public counter endpoint for your account", 5 | back_url: imports_path, 6 | secondary_actions: @smiirl_integration.persisted? ? [ 7 | { 8 | content: "Rotate token", 9 | destructive: true, 10 | url: rotate_token_smiirl_integration_path, 11 | method: :post 12 | } 13 | ] : [], 14 | ) do |page| %> 15 | 16 | <%= form_with( 17 | model: @smiirl_integration, 18 | url: smiirl_integration_path, 19 | method: :put, 20 | builder: Polaris::FormBuilder, 21 | data: { controller: "form" } 22 | ) do |form| %> 23 | 24 | <%= polaris_layout do |layout| %> 25 | <%# Required to submit form via Return/Enter %> 26 | <%= form.submit hidden: true %> 27 | 28 | <% if @smiirl_integration.errors.any? %> 29 | <% layout.with_section do %> 30 | <%= form.errors_summary %> 31 | <% end %> 32 | <% end %> 33 | 34 | <% layout.with_section do %> 35 | <%= polaris_card do %> 36 | <%= polaris_form_layout do |form_layout| %> 37 | 38 | <% form_layout.with_item do %> 39 | <%= form.polaris_check_box :enabled, label: "Enable public endpoint" %> 40 | <% end %> 41 | 42 | <% form_layout.with_item do %> 43 | <%= form.polaris_select :metric_type, 44 | label: "Metric to expose", 45 | options: [["Paying users (30 days)", "paying_users_30d"], ["Total revenue (30 days)", "total_revenue_30d"]] 46 | %> 47 | <% end %> 48 | 49 | <% if @smiirl_integration.persisted? %> 50 | <% form_layout.with_item do %> 51 | <% endpoint_url = public_smiirl_url(token: @smiirl_integration.token, format: :json) %> 52 | <%= polaris_text_field(label: "Endpoint URL", 53 | value: endpoint_url, 54 | disabled: true, 55 | help_text: "Copy this URL into your Smiirl dashboard".html_safe, 56 | connected_right: polaris_button(url: endpoint_url, external: true) { "Open" } 57 | ) %> 58 | <% end %> 59 | <% end %> 60 | 61 | <% form_layout.with_item do %> 62 | <%= polaris_button( 63 | submit: true, 64 | primary: true, 65 | data: {form_target: "submitButton"}, 66 | ) { t('actions.save') } %> 67 | <% end %> 68 | 69 | <% end %> 70 | <% end %> 71 | <% end %> 72 | 73 | <% end %> 74 | 75 | <% end %> 76 | <% end %> 77 | -------------------------------------------------------------------------------- /app/views/summarys/_app_filter.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_tag(url_for(action: action_name), method: :get, data: {controller: "filters", action: "change->filters#submit"}) do %> 2 | 3 | <%= polaris_filters(classes: "filter-margin") do |filters| %> 4 | 5 | <% unless app_titles.blank? %> 6 | <% filters.with_item(label: selected_app.presence || "All apps", sectioned: false, style: "white-space: nowrap;") do %> 7 | <%= polaris_option_list(name: :selected_app) do |list| %> 8 | <% list.with_radio_button( 9 | label: "All apps", 10 | value: "", 11 | checked: selected_app == nil, 12 | data: {action: "filters#submit"} 13 | ) %> 14 | <% app_titles.each do |key| %> 15 | <% list.with_radio_button( 16 | label: key, 17 | value: key, 18 | checked: selected_app == key, 19 | data: {action: "filters#submit"} 20 | ) %> 21 | <% end %> 22 | <% end %> 23 | <% end %> 24 | <% end %> 25 | 26 | <% end %> 27 | 28 | <% end %> 29 | -------------------------------------------------------------------------------- /app/views/summarys/_monthly.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_index_table(rows) do |table| %> 2 | 3 | <% table.with_column(t('.date', date: nil)) do |row| %> 4 | <%= row[0].strftime('%b %Y') %> 5 | <% end %> 6 | 7 | <% table.with_column(t('.payments')) do |row| %> 8 | <%= row[1][:payments] %> 9 | <% end %> 10 | 11 | <% table.with_column(t('.revenue')) do |row| %> 12 | <%= number_to_currency(row[1][:revenue], precision: 0) %> 13 | <% end %> 14 | 15 | <% table.with_column(t('.revenue_per_payment')) do |row| %> 16 | <%= number_to_currency(row[1][:revenue_per_payment], precision: 0) %> 17 | <% end %> 18 | 19 | <% table.with_column(t('.revenue_churn')) do |row| %> 20 | <%= number_to_percentage(row[1][:revenue_churn], precision: 1) %> 21 | <% end %> 22 | 23 | <% table.with_column(t('.user_churn')) do |row| %> 24 | <%= number_to_percentage(row[1][:user_churn], precision: 1) %> 25 | <% end %> 26 | 27 | <% end %> 28 | -------------------------------------------------------------------------------- /app/views/summarys/_shop.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_index_table(rows) do |table| %> 2 | 3 | <% table.with_column(t('.shop', date: nil)) do |row| %> 4 | <%= row[0] %> 5 | <% end %> 6 | 7 | <% table.with_column(t('.payments')) do |row| %> 8 | <%= row[1][:payments] %> 9 | <% end %> 10 | 11 | <% table.with_column(t('.revenue')) do |row| %> 12 | <%= number_to_currency(row[1][:revenue], precision: 0) %> 13 | <% end %> 14 | 15 | <% table.with_column(t('.last_payment')) do |row| %> 16 | <%= row[1][:last_payment] %> 17 | <% end %> 18 | 19 | <% end %> 20 | -------------------------------------------------------------------------------- /app/views/summarys/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= polaris_page( 2 | title: t("summarys.#{controller_name}.title"), 3 | subtitle: t("summarys.#{controller_name}.subtitle") 4 | ) do |page| %> 5 | 6 | <%= render "shared/page_actions", page: page %> 7 | 8 | <% if @summaries.any? %> 9 | 10 | <%= polaris_vertical_stack(gap: "6") do %> 11 | 12 | <%= render "app_filter", selected_app: @selected_app, app_titles: @app_titles %> 13 | 14 | <%= polaris_card do %> 15 | <%= render controller_name, rows: @summaries %> 16 | <% end %> 17 | 18 | <% end %> 19 | 20 | <% else %> 21 | 22 | <%= render "shared/empty_state", resource: Payment %> 23 | 24 | <% end %> 25 | 26 | <% end %> 27 | -------------------------------------------------------------------------------- /app/views/users/_count_usage_charges_as_recurring_fields.html.erb: -------------------------------------------------------------------------------- 1 | <%= form.fields_for :user do |user_fields| %> 2 | <%= user_fields.polaris_check_box :count_usage_charges_as_recurring, 3 | label: t('user.count_usage_charges_as_recurring'), 4 | help_text: t('user.count_usage_charges_as_recurring_help_text') 5 | %> 6 | <% end %> 7 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! gem list foreman -i --silent; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | exec foreman start -p 4000 -f Procfile.dev "$@" 9 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing bundle dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | puts "== Installing yarn dependencies ==" 21 | system!("yarn install") 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?("config/database.yml") 25 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 26 | # end 27 | 28 | puts "\n== Copying sample files ==" 29 | unless File.exist?(".env") 30 | FileUtils.cp ".env.example", ".env" 31 | end 32 | 33 | puts "\n== Preparing database ==" 34 | system! "bin/rails db:prepare" 35 | 36 | puts "\n== Removing old logs and tempfiles ==" 37 | system! "bin/rails log:clear tmp:clear" 38 | 39 | puts "\n== Restarting application server ==" 40 | system! "bin/rails restart" 41 | end 42 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | require "action_cable/engine" 15 | require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module PartnerMetrics 22 | class Application < Rails::Application 23 | # Initialize configuration defaults for originally generated Rails version. 24 | config.load_defaults 7.0 25 | 26 | # Configuration for the application, engines, and railties goes here. 27 | # 28 | # These settings can be overridden in specific environments using the files 29 | # in config/environments, which are processed later. 30 | # 31 | # config.time_zone = "Central Time (US & Canada)" 32 | # config.eager_load_paths << Rails.root.join("extras") 33 | 34 | config.active_job.queue_adapter = :sidekiq 35 | 36 | config.active_record.encryption.primary_key = Rails.application.credentials[:active_record_encryption][:primary_key] 37 | config.active_record.encryption.deterministic_key = Rails.application.credentials[:active_record_encryption][:deterministic_key] 38 | config.active_record.encryption.key_derivation_salt = Rails.application.credentials[:active_record_encryption][:key_derivation_salt] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: redis://localhost:6379/1 4 | 5 | test: 6 | adapter: test 7 | 8 | production: 9 | adapter: redis 10 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 11 | channel_prefix: partner_metrics_production 12 | ssl_params: 13 | verify_mode: <%= OpenSSL::SSL::VERIFY_NONE %> 14 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | EmmwQm97zj+OjBVcbePIAgLPgkCk1MZIUorFyscpy9KPly/rMZ1dl/QY5b4WyecbP8lqIWh0UlZefmiQu0fj1tf52hAV+doYAATJ4YCyj7rOvTKQhL8SAlyImiLpsHANXKUSI6q/XH5FHQswIhFXlMdycScnJjUozs0SHlv9urMdfacsQkS1RHE4/cQHekaCiYNx+OhAdk8uoYTTFbCe6hX9OgoYERXwN/ziQMHFv3auLDJA/zBsPUc91jtaxUUQYRWLGcCGxYat9gg07PaGyTfcAy9kfUy5YCLkULie31SX2hHQcgyfK9ECGDCDcwjuf36/1GOvVZBW2rd5+W1uVo2mnh4nBVaH+vY/ti+Yx1I0AVsGTJ20ou8lcSQULvfzMEwmmxRNQnxGt7AdC897S8/D59xF7tnqMezq5k9rgpAaatbP1b9zcZXlogTsrpHlS04bdRF6U/wE7o0nsMzptn4GghK+1AznuXL5XwN5cyOFykyKkX+PnRv4VX/r85tsJTdR00RMNhLx/F4WRR5RICAgejlJjE74BQyIKcRMTSTW7njqbEsbdY9zC+Z/6Te8F7/Kz7o7CUrLyxQsmHrqsXgaO3rNkDHQZ1vHzbLc4pa+jsoVslVfFvPfz2Ld/WdqMRdZ7KXwdfSFSV/EYpgkW+iz+/xE8wswNary2K/VvtFgpEKfyBw76SOqZ2i7Ug==--4dRzbrG3hzms4RbG--OgDjBzPv+80oGdlLiWJKkQ== -------------------------------------------------------------------------------- /config/credentials/production.yml.enc: -------------------------------------------------------------------------------- 1 | byL4vcSW+u89CFO6mgz+dhZ4CieIn/malhA6F6FfUdTqXedTd1KO7H7Qqn5QX2nCnAkJj9AYp+eYbCO4yevtONGWZzR8Dxf3kQxNDXm4ZwLk9ROXzNU6i1EpvBQqeA088e9QX3huLFZ/IeU/WWBYFRxosJcj2d+CHmeI8qe2tbolHxwOo7XFncxwBQD0M3T88aD6Eg2H6t2KN1GoIUyWA3l8C13mjaaMvNI/5V5Bepwoy1KHSuuMrHbQVzaR4iojmmrpvQXF/DeXAMXIKtqWlwQBPOLTv6OX1Ru6auyefFPr4hvbGZyA+5TyYtx0NH0Q+ZgrJQn5eUW9eZMVYAlHWhYA9KLfp5xB8TYO2FLLLfsZxrwBR4Vn+vZrt92w4KAXXQhw5KmrX1cKy9JRboOqFtO90X8WJqta4W74hEV6Jvf/EzvK1jW64Wh6cwNEYkrVfoIbmY5mecVSJutIxGGDDepdPCkDIG+QzcxvQfycvzN9ERZIgBwr+GsRzR8FA4TuToN+hz6kJYc1wua425t1DGkOhaIPN/TBEKH2jyCPuIL1boW2DU+1Ia/Sv8PwVQJrnD8Gup4yiMs8YmOfOX0ut50OS+2hcqnSfy2K3HkN7au8ZjRvMimbRKvsB6E2BnKZwx2rBhxAZ97+nhjYzOPSDEUTJ4dqzJ1LDpQz/mLCIaCZP0xC2Du2KSWqXlKWHS4qnRXcBRjtQXdpJ/cnj29zTApEPo8knsg5nu/VbM2n41MOjUWtpourWEktGdtKDvyTU/5O5nwycmd6skzaE5ADoHAnru6+yIGDYRvYEeA3trkWcsI2oMVM41WE58njjiwHeY56NS706qjrif3XWazDTIAEDjqhuyDitlUWZMH/mMUgKiOF4VqYVdkDH8qmkef2+QzUw79D1hF3XUgK/h7TsB1D+t9s1oBteiGFyTzinpz0LC4Csl3LB8OTnvDF+8Y0C3f5Of9+SQschxhDrSE1GGScw/vNIxwgIcswKXpDPqtnxhRi+Fs=--K4EPe0nX+gECQbud--zJ55TP82XKVbpDBQ4qidfg== -------------------------------------------------------------------------------- /config/credentials/test.key: -------------------------------------------------------------------------------- 1 | f92bc421f102601d6f6cf768611138e7 -------------------------------------------------------------------------------- /config/credentials/test.yml.enc: -------------------------------------------------------------------------------- 1 | aNL1Vs9sc2ZzV7P1vAaownk976GABtBwDckPotSk45zdsoRofjG34JgNwLFh11FNAuES89qdDLtEBaTJLsQJShYB4NPntRGHWbz6PzmjsNSkxAGPY1TuSckVPxKOZdo7SF/F9n9itlS1Ela2mpVyJuoSaiSnB0HdA3T4AiCavLVkprQH3/Egt/hfUk2ZZDKHvIh6f/tpQbOTo2IRcNzDPbe3ToL+jl4fDlPMKRPLH7ujXsakiq6cnT9XIKhpGb+v+HdZejJpqLit53MW0gai0OBJmio6P5zMvk0Jv+gwnb/wj6eiVM2FcPaT5cdFUs08cs/WAP5NuKlOD1r5L+q1ShunwwQSRa03JlDVlz9A0alQSkEog2Px8qndundubqPFZf9jK/9A4OnbNUxLY6Iv+09frsXg4qX4rnUVgzuhTM0JvR1XU+agOqvWwHtCOl1Zl73gAZNEHcRCi/h4wsfBj3NaZ9lRSaftQWpLh3GrbGtoLTaB5yU2aBkMw6SyHJmpFNo7pX2bEmhzzcsu6FhodGd+3cwzWHnXBKRvSNFfqvIyCEYw4FneztuJcw6EXmYw+7YZGz/oMgPAo505K2ZuvZHAy6tqCJp16aiP0+HVXuHyZ6/SUrMRSHlFy+hgQHIO1A+tjynJiWdYSzuQZNFYmcVjR4jCY0AEJrGggkwjw6PYccCECAgSdd7g0u6fNQ==--PTUk6JrdnQOGCVXI--FLVn5lEiR45m2q6dCVdU7A== -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 9.3 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On macOS with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On macOS with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem "pg" 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | username: postgres 21 | host: localhost 22 | # For details on connection pooling, see Rails configuration guide 23 | # https://guides.rubyonrails.org/configuring.html#database-pooling 24 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 25 | 26 | development: 27 | <<: *default 28 | database: partner-metrics-dev 29 | 30 | # The specified database role being used to connect to postgres. 31 | # To create additional roles in postgres see `$ createuser --help`. 32 | # When left blank, postgres will use the default role. This is 33 | # the same name as the operating system user running Rails. 34 | #username: partner_metrics 35 | 36 | # The password associated with the postgres role (username). 37 | #password: 38 | 39 | # Connect on a TCP socket. Omitted by default since the client uses a 40 | # domain socket that doesn't need configuration. Windows does not have 41 | # domain sockets, so uncomment these lines. 42 | #host: localhost 43 | 44 | # The TCP port the server listens on. Defaults to 5432. 45 | # If your server runs on a different port number, change accordingly. 46 | #port: 5432 47 | 48 | # Schema search path. The server defaults to $user,public 49 | #schema_search_path: myapp,sharedapp,public 50 | 51 | # Minimum log levels, in increasing order: 52 | # debug5, debug4, debug3, debug2, debug1, 53 | # log, notice, warning, error, fatal, and panic 54 | # Defaults to warning. 55 | #min_messages: notice 56 | 57 | # Warning: The database defined as "test" will be erased and 58 | # re-generated from your development database when you run "rake". 59 | # Do not set this db to the same as development or production. 60 | test: 61 | <<: *default 62 | database: partner-metrics-test 63 | 64 | # As with config/credentials.yml, you never want to store sensitive information, 65 | # like your database password, in your source code. If your source code is 66 | # ever seen by anyone, they now have access to your database. 67 | # 68 | # Instead, provide the password or a full connection URL as an environment 69 | # variable when you boot the app. For example: 70 | # 71 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 72 | # 73 | # If the connection URL is provided in the special DATABASE_URL environment 74 | # variable, Rails will automatically merge its configuration values on top of 75 | # the values provided in this file. Alternatively, you can specify a connection 76 | # URL environment variable explicitly: 77 | # 78 | # production: 79 | # url: <%= ENV["MY_APP_DATABASE_URL"] %> 80 | # 81 | # Read https://guides.rubyonrails.org/configuring.html#configuring-a-database 82 | # for a full overview on how database connection configuration can be specified. 83 | # 84 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | 41 | # ACTION MAILER 42 | config.action_mailer.raise_delivery_errors = true 43 | config.action_mailer.perform_caching = false 44 | config.action_mailer.perform_deliveries = true 45 | config.action_mailer.delivery_method = :letter_opener 46 | config.action_mailer.default_url_options = { host: "localhost:4000" } 47 | config.action_mailer.asset_host = "http://localhost:4000" 48 | config.action_mailer.smtp_settings = { 49 | from: Rails.application.credentials[:action_mailer][:from_email_address], 50 | } 51 | 52 | # Print deprecation notices to the Rails logger. 53 | config.active_support.deprecation = :log 54 | 55 | # Raise exceptions for disallowed deprecations. 56 | config.active_support.disallowed_deprecation = :raise 57 | 58 | # Tell Active Support which deprecation messages to disallow. 59 | config.active_support.disallowed_deprecation_warnings = [] 60 | 61 | # Raise an error on page load if there are pending migrations. 62 | config.active_record.migration_error = :page_load 63 | 64 | # Highlight code that triggered database queries in logs. 65 | config.active_record.verbose_query_logs = true 66 | 67 | # Suppress logger output for asset requests. 68 | config.assets.quiet = true 69 | 70 | # Raises error for missing translations. 71 | config.i18n.raise_on_missing_translations = true 72 | 73 | # Annotate rendered view with file names. 74 | # config.action_view.annotate_rendered_view_with_filenames = true 75 | 76 | # Uncomment if you wish to allow Action Cable access from any origin. 77 | # config.action_cable.disable_request_forgery_protection = true 78 | 79 | logger = ActiveSupport::Logger.new(STDOUT) 80 | logger.formatter = config.log_formatter 81 | config.logger = ActiveSupport::TaggedLogging.new(logger) 82 | end 83 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | 61 | config.active_record.encryption.encrypt_fixtures = true 62 | end 63 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | Rails.application.config.assets.precompile += %w[.svg] 13 | -------------------------------------------------------------------------------- /config/initializers/chartkick.rb: -------------------------------------------------------------------------------- 1 | Chartkick.options = { 2 | height: "230px", 3 | colors: ["#5912D5", "#007f5f", "#9C6ADE", "#F49342", "#454F5B", "#ED6347"], 4 | library: { 5 | 6 | vAxis: {textStyle: {color: "#616a75", fontSize: "14", paddingRight: "100", marginRight: "100"}, format: "short", gridlines: {color: "#F1F2F4"}}, 7 | hAxis: {textStyle: {color: "#616a75", fontSize: "14", paddingRight: "100", marginRight: "100"}}, 8 | chartArea: {width: "100%", left: "10%"} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/generate_test_fixture.rb: -------------------------------------------------------------------------------- 1 | # Allows for creating fixtures from your console, based on DB records in development. 2 | # Source: https://stackoverflow.com/a/70618670 3 | # Usage: Metric.find(1).dump_fixture or User.find(1).dump_fixture(include_attachments: true) 4 | 5 | class ActiveRecord::Base 6 | # Append this record to the fixture.yml for this record class 7 | def dump_fixture(name: nil, include_attachments: false) 8 | # puts "Dumping fixture for #{self.class.name} id=#{id} #{"with name #{name}" if name}" 9 | 10 | attributes_to_exclude = [:updated_at, :created_at, *Rails.application.config.filter_parameters].map(&:to_s) 11 | attributes_to_exclude << "id" if !name.nil? 12 | 13 | # puts " Attributes excluded: #{attributes_to_exclude.inspect}" 14 | 15 | attributes_to_dump = attributes 16 | .except(*attributes_to_exclude) 17 | .reject { |k, v| v.blank? } 18 | 19 | name = "#{self.class.table_name.singularize}_#{id}" if name.nil? 20 | 21 | dump_raw_fixture({name => attributes_to_dump}.to_yaml.sub(/---\s?/, "\n")) 22 | 23 | if include_attachments != false 24 | self.class.reflect_on_all_attachments 25 | .each { |association| 26 | a_name = association.name 27 | Array(send(a_name.to_sym)).each_with_index { |attachment, index| 28 | attachment_name = "#{name}_#{a_name.to_s.underscore}_#{index}" 29 | blob_name = "#{attachment_name}_blob" 30 | 31 | attachment.dump_raw_fixture({name => { 32 | "name" => a_name, 33 | "record" => "#{name} (#{self.class.name})", 34 | "blob" => blob_name 35 | }}.to_yaml.sub(/---\s?/, "\n")) 36 | 37 | blob = attachment.blob 38 | blob.dump_raw_fixture("#{blob_name}: <%= ActiveStorage::Blob.fixture(filename: '#{blob.filename}') %>\n") 39 | blob_path = "#{Rails.root.join("test/fixtures/files/#{blob.filename}")}" 40 | File.open(blob_path, "wb+") do |file| 41 | blob.download { |chunk| file.write(chunk) } 42 | end 43 | } 44 | } 45 | end 46 | end 47 | 48 | def dump_raw_fixture(text) 49 | fixture_file = "#{Rails.root.join("test/fixtures/#{self.class.name.underscore.pluralize}.yml")}" 50 | File.open(fixture_file, "a+") do |f| 51 | f.puts(text) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /config/initializers/http_logger.rb: -------------------------------------------------------------------------------- 1 | if Rails.env.development? 2 | require "http_logger" 3 | HttpLogger.logger = Rails.logger if defined?(Rails) 4 | HttpLogger.colorize = true 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /config/initializers/rack_timeout.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.middleware.insert_before Rack::Runtime, Rack::Timeout, service_timeout: 27 2 | -------------------------------------------------------------------------------- /config/initializers/sidekiq.rb: -------------------------------------------------------------------------------- 1 | Sidekiq.configure_server do |config| 2 | config.redis = { 3 | url: ENV["REDIS_URL"], 4 | ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_NONE} 5 | } 6 | end 7 | 8 | Sidekiq.configure_client do |config| 9 | config.redis = { 10 | url: ENV["REDIS_URL"], 11 | ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_NONE} 12 | } 13 | end 14 | -------------------------------------------------------------------------------- /config/master.key: -------------------------------------------------------------------------------- 1 | 9ee90a482567871fa149f66742d6ccc4 -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | devise_for :users 3 | 4 | resource :user, only: [:update] 5 | 6 | get "/metrics/(:charge_type)", to: "metrics#show", as: :metrics 7 | 8 | resources :imports, only: [:index, :show, :new, :create, :destroy] do 9 | resource :globe, only: [:show], controller: "imports/globes" 10 | resource :retry, only: [:create], controller: "imports/retry" 11 | collection do 12 | delete :destroy_all, to: "imports/destroy_all#destroy" 13 | end 14 | end 15 | 16 | resources :partner_api_credentials, only: [:new, :create, :edit, :update, :destroy] 17 | 18 | resources :summarys, only: [] do 19 | collection do 20 | get :monthly, to: "summarys/monthly#index" 21 | get :shop, to: "summarys/shop#index" 22 | end 23 | end 24 | 25 | resources :rename_apps, only: [] do 26 | collection do 27 | get :new 28 | post :create 29 | end 30 | end 31 | 32 | resources :delete_apps, only: [] do 33 | collection do 34 | get :new 35 | post :create 36 | end 37 | end 38 | 39 | resource :smiirl_integration, only: [:edit, :update] do 40 | post :rotate_token, on: :collection 41 | end 42 | 43 | namespace :public do 44 | get "/smiirl/:token", to: "smiirl#show", as: :smiirl 45 | end 46 | 47 | root to: "home#index" 48 | end 49 | -------------------------------------------------------------------------------- /config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :queues: 3 | - critical 4 | - default 5 | - low 6 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the required secrets 10 | # Cloudflare tutorial: https://kirillplatonov.com/posts/activestorage-cloudflare-r2/ 11 | cloudflare: 12 | service: S3 13 | endpoint: https://<%= Rails.application.credentials[:cloudflare][:account_id] %>.r2.cloudflarestorage.com 14 | access_key_id: <%= Rails.application.credentials[:cloudflare][:access_key_id] %> 15 | secret_access_key: <%= Rails.application.credentials[:cloudflare][:secret_access_key] %> 16 | region: auto 17 | bucket: <%= Rails.application.credentials[:cloudflare][:bucket] %> 18 | 19 | # amazon: 20 | # service: S3 21 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 22 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 23 | # region: <%= Rails.application.credentials.dig(:aws, :region) %> 24 | # bucket: <%= Rails.application.credentials.dig(:aws, :s3_bucket) %> 25 | 26 | # Remember not to checkin your GCS keyfile to a repository 27 | # google: 28 | # service: GCS 29 | # project: your_project 30 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 31 | # bucket: your_own_bucket-<%= Rails.env %> 32 | 33 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 34 | # microsoft: 35 | # service: AzureStorage 36 | # storage_account_name: your_account_name 37 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 38 | # container: your_container_name-<%= Rails.env %> 39 | 40 | # mirror: 41 | # service: Mirror 42 | # primary: local 43 | # mirrors: [ amazon, google, microsoft ] 44 | -------------------------------------------------------------------------------- /db/migrate/20230828145011_create_active_storage_tables.active_storage.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_storage (originally 20170806125915) 2 | class CreateActiveStorageTables < ActiveRecord::Migration[5.2] 3 | def change 4 | # Use Active Record's configured type for primary and foreign keys 5 | primary_key_type, foreign_key_type = primary_and_foreign_key_types 6 | 7 | create_table :active_storage_blobs, id: primary_key_type do |t| 8 | t.string :key, null: false 9 | t.string :filename, null: false 10 | t.string :content_type 11 | t.text :metadata 12 | t.string :service_name, null: false 13 | t.bigint :byte_size, null: false 14 | t.string :checksum 15 | 16 | if connection.supports_datetime_with_precision? 17 | t.datetime :created_at, precision: 6, null: false 18 | else 19 | t.datetime :created_at, null: false 20 | end 21 | 22 | t.index [ :key ], unique: true 23 | end 24 | 25 | create_table :active_storage_attachments, id: primary_key_type do |t| 26 | t.string :name, null: false 27 | t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type 28 | t.references :blob, null: false, type: foreign_key_type 29 | 30 | if connection.supports_datetime_with_precision? 31 | t.datetime :created_at, precision: 6, null: false 32 | else 33 | t.datetime :created_at, null: false 34 | end 35 | 36 | t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true 37 | t.foreign_key :active_storage_blobs, column: :blob_id 38 | end 39 | 40 | create_table :active_storage_variant_records, id: primary_key_type do |t| 41 | t.belongs_to :blob, null: false, index: false, type: foreign_key_type 42 | t.string :variation_digest, null: false 43 | 44 | t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true 45 | t.foreign_key :active_storage_blobs, column: :blob_id 46 | end 47 | end 48 | 49 | private 50 | def primary_and_foreign_key_types 51 | config = Rails.configuration.generators 52 | setting = config.options[config.orm][:primary_key_type] 53 | primary_key_type = setting || :primary_key 54 | foreign_key_type = setting || :bigint 55 | [primary_key_type, foreign_key_type] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /db/migrate/20230901095646_create_imports.rb: -------------------------------------------------------------------------------- 1 | class CreateImports < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :imports do |t| 4 | t.string :source, null: false 5 | t.string :status, null: false 6 | t.integer :progress, null: false, default: 0 7 | t.references :user, null: false, foreign_key: true 8 | t.string :timestamps 9 | t.datetime :started_at 10 | t.datetime :ended_at 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20230907074633_rename_payment_history.rb: -------------------------------------------------------------------------------- 1 | class RenamePaymentHistory < ActiveRecord::Migration[7.0] 2 | def change 3 | rename_table :payment_histories, :payments 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20230907075820_add_import_associations.rb: -------------------------------------------------------------------------------- 1 | class AddImportAssociations < ActiveRecord::Migration[7.0] 2 | def change 3 | add_reference :payments, :import, index: true 4 | add_reference :metrics, :import, index: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20230907115433_remove_unused_import_columns.rb: -------------------------------------------------------------------------------- 1 | class RemoveUnusedImportColumns < ActiveRecord::Migration[7.0] 2 | def change 3 | remove_column :imports, :started_at, :datetime 4 | remove_column :imports, :ended_at, :datetime 5 | remove_column :imports, :progress, :integer 6 | remove_column :imports, :timestamps, :string 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20230911110717_create_partner_api_credentials.rb: -------------------------------------------------------------------------------- 1 | class CreatePartnerApiCredentials < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :partner_api_credentials do |t| 4 | t.references :user, null: false, foreign_key: true, index: {unique: true} 5 | t.string :access_token, null: false, length: 510 6 | t.string :organization_id, null: false, length: 510 7 | t.string :status, null: false 8 | t.text :status_message 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20230914091554_add_show_forecasts_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddShowForecastsToUsers < ActiveRecord::Migration[7.0] 2 | def change 3 | add_column :users, :show_forecasts, :boolean, default: false, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20230919173404_add_is_yearly_revenue.rb: -------------------------------------------------------------------------------- 1 | class AddIsYearlyRevenue < ActiveRecord::Migration[7.0] 2 | def change 3 | add_column :payments, :is_yearly_revenue, :boolean, default: false 4 | add_column :metrics, :is_yearly_revenue, :boolean, default: false 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20250930000000_create_smiirl_integrations.rb: -------------------------------------------------------------------------------- 1 | class CreateSmiirlIntegrations < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :smiirl_integrations do |t| 4 | t.references :user, null: false, foreign_key: true, index: { unique: true } 5 | t.boolean :enabled, null: false, default: false 6 | t.string :metric_type, null: false, default: "total_revenue_30d" 7 | t.string :token, null: false, limit: 64 8 | 9 | t.timestamps 10 | end 11 | 12 | add_index :smiirl_integrations, :token, unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) 7 | # Character.create(name: "Luke", movie: movies.first) 8 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/lib/assets/.keep -------------------------------------------------------------------------------- /lib/http_client.rb: -------------------------------------------------------------------------------- 1 | module ShopifyPartnerAPI 2 | class HTTPClient < GraphQL::Client::HTTP 3 | SHOPIFY_PARTNER_API_VERSION = "2025-07" 4 | 5 | def initialize 6 | super("https://partners.shopify.com/") 7 | end 8 | 9 | def headers(context) 10 | { 11 | "X-Shopify-Access-Token": context.fetch(:access_token) 12 | } 13 | end 14 | 15 | def execute(document:, operation_name: nil, variables: {}, context: {}) 16 | @uri = URI.parse("https://partners.shopify.com/#{context.fetch(:organization_id)}/api/#{SHOPIFY_PARTNER_API_VERSION}/graphql.json") 17 | 18 | super 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/shopify_partner_api.rb: -------------------------------------------------------------------------------- 1 | require "graphql/client" 2 | require "graphql/client/http" 3 | require "http_client" 4 | 5 | module ShopifyPartnerAPI 6 | class << self 7 | delegate :parse, :query, to: :client 8 | 9 | def client 10 | Thread.current[:_shopify_client_cache] ||= initialize_client 11 | end 12 | 13 | def initialize_client 14 | http = ShopifyPartnerAPI::HTTPClient.new 15 | 16 | # So the schema is not requested every time the client is initialized we store it on disk. 17 | # If the schema (or Shopify Partner API version) changes, run: 18 | # GraphQL::Client.dump_schema( 19 | # http, 20 | # "config/partner-api-schema.json", 21 | # context: {organization_id: "xxxx", access_token: "xxxx"} 22 | # ) 23 | # 24 | # to update the schema in the file. 25 | schema = GraphQL::Client.load_schema("config/partner-api-schema.json") 26 | GraphQL::Client.new(schema: schema, execute: http) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/create_initial_imports.rake: -------------------------------------------------------------------------------- 1 | desc "Creates the inital import for all users, so all historical payments and metrics have an import" 2 | task create_initial_imports: :environment do 3 | User.find_each do |user| 4 | import = user.imports.create!( 5 | source: Import.sources[:shopify_payments_api], 6 | status: Import.statuses[:completed] 7 | ) 8 | user.metrics.update_all(import_id: import.id) if user.metrics.any? 9 | user.payments.update_all(import_id: import.id) if user.payments.any? 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tasks/create_partner_api_credentials.rake: -------------------------------------------------------------------------------- 1 | desc "Creates the inital import for all users, so all historical payments and metrics have an import" 2 | task create_partner_api_credentials: :environment do 3 | User.find_each do |user| 4 | if user.partner_api_access_token.present? && user.partner_api_organization_id.present? && !user.partner_api_errors&.include?("Unauthorized") 5 | if user.partner_api_credential.blank? 6 | user.create_partner_api_credential( 7 | organization_id: user.partner_api_organization_id, 8 | access_token: user.partner_api_access_token 9 | ) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tasks/import_all_from_partner_api.rake: -------------------------------------------------------------------------------- 1 | desc "Import transactions for existing users, with partner api credentials" 2 | task import_all_from_partner_api: :environment do 3 | User.find_each do |user| 4 | if user.partner_api_credential&.valid_status? 5 | user.imports.create(source: Import.sources[:shopify_payments_api]) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forsbergplustwo/partner-metrics/f8319be31b10956ac431ededfaf3b8ab54414bcd/log/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "private": "true", 4 | "dependencies": { 5 | "@hotwired/stimulus": "^3.2.1", 6 | "@hotwired/turbo-rails": "^7.3.0", 7 | "@rails/activestorage": "7.0.8", 8 | "@rails/request.js": "^0.0.8", 9 | "chartkick": "^5.0.1", 10 | "debounce": "^1.2.1", 11 | "esbuild": "^0.19.2", 12 | "globe.gl": "^2.29.2", 13 | "polaris-view-components": "^1.1.0" 14 | }, 15 | "scripts": { 16 | "build": "esbuild app/javascript/*.* --bundle --minify --outdir=app/assets/builds --public-path=/assets" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |You may have mistyped the address or the page may have moved.
63 |If you are the application owner check the logs for more information.
65 |Maybe you tried to change something you didn't have access to.
63 |If you are the application owner check the logs for more information.
65 |If you are the application owner check the logs for more information.
64 |