gumroad Documentation

Official Ruby developer resources and documentation for antiwork/gumroad with 10k stars. Verified technical context for AI.

10,005 stars Ruby Ruby 27 files · ~32,548 tokens
RAW Doc

Technical Documentation: antiwork/gumroad

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (README.md)

Contributing

We don't run an inbound review queue on this repo. To contribute a change: fork the repo, open the pull request on your own fork, then email [email protected] with a link to it. We read every submission and merge the ones we want on our side, keeping your authorship — though we can't promise a reply to each one or a timeline.

Your PR still has to meet the same bar as our own work — visual evidence for anything user-facing, QA steps, test results, and an AI disclosure naming the model. See CONTRIBUTING.md for the full guidelines and the substitutions that apply when working from a fork.

Table of Contents

Getting Started

Prerequisites

💡 If you're on Windows, follow our Windows setup guide instead.

Before you begin, ensure you have the following installed:

Ruby

Node.js

Docker

We use Docker to setup the services for development environment.

  • For MacOS: Download the Docker app from the Docker website
  • For Linux:
bash
sudo wget -qO- https://get.docker.com/ | sh
sudo usermod -aG docker $(whoami)

MySQL & Percona Toolkit

Install a local version of MySQL 8.4.x to match the version running in production.

The local version of MySQL is a dependency of the Ruby mysql2 gem. You do not need to start an instance of the MySQL service locally. The app will connect to a MySQL instance running in the Docker container.

  • For MacOS:
bash
brew install [email protected] percona-toolkit
brew link --force [email protected]

# to use Homebrew's `openssl`:
brew install openssl
bundle config --global build.mysql2 --with-opt-dir="$(brew --prefix openssl)"

# ensure MySQL is not running as a service
brew services stop [email protected]

ImageMagick

We use imagemagick for preview editing.

  • For MacOS: brew install imagemagick
  • For Linux: sudo apt-get install imagemagick

FFmpeg

We use ffprobe that comes with FFmpeg package to fetch metadata from video files.

  • For MacOS: brew install ffmpeg
  • For Linux: sudo apt-get install ffmpeg

PDFtk

We use pdftk to stamp PDF files with the Gumroad logo and the buyers' emails.

  • For MacOS: Download from here
    • Note: pdftk may be blocked by Apple's firewall. If this happens, go to Settings > Privacy & Security and click "Open Anyways" to allow the installation.
  • For Linux: sudo apt-get install pdftk

wkhtmltopdf

While generating invoices, to convert HTML to PDF, PDFKit expects wkhtmltopdf to be installed on your system. Download and install the version 0.12.6 for your platform.

  • Note similar to pdftk, this may also be blocked by Apple's firewall on MacOS. Follow a similar process as above.

Installation

Bundler and gems

We use Bundler to install Ruby gems.

shell
gem install bundler

Install gems:

shell
bundle install

Also make sure to install dotenv as it is required for some console commands:

shell
gem install dotenv

npm and Node.js dependencies

Make sure the correct version of npm is enabled:

shell
corepack enable

Install dependencies:

shell
npm install

Configuration

Set up Custom credentials

App can be booted without any custom credentials. But if you would like to use services that require custom credentials (e.g. S3, Stripe, Resend, etc.), you can copy the .env.example file to .env and fill in the values.

Running Locally

Start Docker services

If you installed Docker Desktop (on a Mac or Windows machine), you can run the following command to start the Docker services:

shell
make local

If you are on Linux, or installed Docker via a package manager on a mac, you may need superuser access to expose container ports. To do that, use sudo make local instead.

This command will not terminate. You run this in one tab and start the application in another tab.
If you want to run Docker services in the background, use LOCAL_DETACHED=true make local instead.

Set up the database

shell
bin/rails db:prepare

For Linux (Debian / Ubuntu) you might need the following:

  • apt install libxslt-dev libxml2-dev

Start the application

shell
bin/dev

This starts the Rails server, the JavaScript build system, and a Sidekiq worker.

You can now access the application at http://localhost:3000. Seller subdomains and the asset/api hosts use *.localhost (e.g. http://seller.localhost:3000, http://api.localhost:3000) — modern browsers auto-resolve these to 127.0.0.1, so no /etc/hosts edits are needed.

Local dev limitations

*.localhost over HTTP is treated as a secure context by browsers (so Stripe.js, Stripe Elements, and other secure-context APIs work), but a couple of features that need either HTTPS or a registrable cookie domain don't work in this setup:

  • Apple Pay — Stripe requires HTTPS for Apple Pay domain registration. Workaround: expose the app over HTTPS with ngrok (ngrok http 3000) and register the resulting hostname in the Stripe Apple Pay dashboard.
  • Cross-subdomain cookies (affiliate attribution, _gumroad_guid, multi-account "switch seller", session) — browsers reject Domain=localhost, so cookies set on localhost:3000 aren't sent to seller.localhost:3000. Test these flows on a single host, or front the app with a local HTTPS reverse proxy on a registrable hostname (e.g. gumroad.test via mkcert) where Domain=.gumroad.test works.

Testing

Run the full test suite:

shell
bin/rspec

Run a single file or specific test:

shell
bin/rspec spec/requests/dashboard_spec.rb
bin/rspec spec/requests/dashboard_spec.rb:75

Dependencies

Before running tests:

shell
make local                          # start Docker services (db, Redis, etc.)
RAILS_ENV=test bin/rails db:setup   # set up the test database
RAILS_ENV=test bin/rails js:export  # generate JS constants for the test environment

Integration tests

Integration specs use Capybara with Selenium driving Chrome. Install Chrome from google.com/chrome.

See docs/testing.md for details on preventing flaky specs, VCR cassettes, debugging widgets, and purchase testing with Stripe/PayPal.

Development

Logging in

You can log in with the username [email protected] and the password password. The two-factor authentication code is 000000.

Read more about logging in as a user with a different team role at Users & authentication.

Resetting Elasticsearch indices

You will need to explicitly reindex Elasticsearch to populate the indices after setup, otherwise you will see index_not_found_exception errors when you visit the dev application. You can reset them using:

ruby
# Run this in a rails console:
DevTools.delete_all_indices_and_reindex_all

Push Notifications

To send push notifications:

shell
INITIALIZE_RPUSH_APPS=true bundle exec rpush start -e development -f

Common Development Tasks

Rails console:

shell
bin/rails c

Rake tasks:

shell
bin/rake task_name

Linting

We use ESLint for JS, and Rubocop for Ruby. Your editor should support displaying and fixing issues reported by these inline, and CI checks these on every push.

If you'd like, you can run git config --local core.hooksPath .githooks to check for these locally when committing.

Common Issues

text
objc[11912]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called.
objc[11912]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called. We cannot safely call it or ignore it in the fork() child process. Crashing instead. Set a breakpoint on objc_initializeAfterForkError to debug.

This issue occurs on macOS due to how the fork() system call interacts with multithreaded Objective-C applications—commonly triggered when Spring is enabled during testing.

How to Fix:

Temporarily disable Spring before running your tests to avoid this error.

bash
export DISABLE_SPRING=1
bin/rspec spec/requests/balance_pages_spec.rb

This will disable Spring for the current session, allowing the tests to run without triggering the fork()-related crash.


Accounting (`docs/accounting.md`)

Accounting & Reporting

This document contains instructions for running various accounting scripts for Gumroad.

To use, run these commands in the production console.

Reports

Email an outstanding balances report

Note: This is a long-running script that should be run in a long-running instance of the web server, ideally with a terminal multiplexer.

text
/* Detailed source-code truncated for AI context efficiency. */

Generate a monthly US State report

Note: Runs via an async job, does not require a long-running task

rb
CreateUsStateMonthlySalesReportsJob.perform_async("WA", 8, 2022)

Backfill multiple states / months

rb
states = ["NV", "TX", "RI"]
months = [1, 2, 3]
year = 2025

states.each do |state|
  months.each do |month|
    CreateUsStateMonthlySalesReportsJob.perform_async(state, month, year)
  end
end

Generate a monthly summary report of all US States

rb
subdivision_codes = Compliance::Countries::TAXABLE_US_STATE_CODES
CreateUsStatesSalesSummaryReportJob.perform_async(subdivision_codes, 3, 2024)

Generate a sales report for specific customer country and custom date range

Note: Runs via an async job, does not require a long-running task. Sends a notification to Slack on completion with download url for the report.

E.g. Generate a report for all sales made to customers in Japan in the month of January 2025:

rb
GenerateSalesReportJob.perform_async("JP", "2025-1-1", "2025-1-31", "all_sales")
  • Pass discover_sales instead of all_sales to only include discover sales. E.g. to generate a report for discover sales made to customers in Singapore between 1 January 2024 and 30 June 2025:
rb
GenerateSalesReportJob.perform_async("SG", "2024-1-1", "2025-6-30", "discover_sales")

Alerts (`docs/alerts.md`)

Alerts

Check to see if certain EC2 instances are unhealthy by listing all the individual EC2 instances matching the blue|green clusters and clicking monitoring.

Recycle the unhealthy instances by terminating them (50% first, then the other 50% after five minutes). Note: Make sure not to terminate steward instances. Please follow these steps to recycle steward instances.

You can look into container statistics with Hashi-UI. Please follow these steps to access it:

text
$ cd nomad
$ source nomad_proxy_functions.sh
$ proxy_on production

Then navigate to http://localhost:8080/nomad/production/allocations to see containers and their status. If the container has issues with sending requests to Vault (which can be seen in container status), check the CPU/Disk status of steward servers.

Clues

Here are the things to look for immediately in Sentry:

  1. Cannot find Purchase/User/Link with id=#
  2. If Sentry is not showing anything, but users are complaining, check out https://gumroad.com/admin/sidekiq and see the Processing list at the bottom. If any job is processing for over 1 minute, then that worker is in a bad state.
  3. No space left on device
  4. Don't trust Cloudwatch average metrics for a group, because almost all servers may be Healthy, not receiving any traffic, and the average will look totally normal. Check all instances individually to see what they're doing, re CPU/RAM utilization.
  5. Following on point before, nomad might give some clues on (blue/green) web workers not processing traffic. EC2 Status ≠ Container status.

What To Do

  1. There is a replica lag. Go into the AWS Console --> RDS --> Instances and show monitoring on the Production Replica. If the lag is very high, follow this commit to remove the replica and use master directly. Make sure to revert/undo this once the lag is gone and push the commits to master --> deploy --> staging.
  2. The worker is in a bad state so we want to stop and start it. In most cases, a reboot will work, but a start and stop will always work and is the safer route to take. Go into the AWS Console --> EC2 --> Instances, find the worker in the list, go to actions and stop it. Once it is stopped, start it, and watch Sidekiq to see it is back working and not hung on jobs. It takes 2-3 minutes for a machine to start up again.
  3. This is a lot more complicated. First, go to New Relic and see which workers have a high disk usage. Go into the AWS Console --> EC2 --> Instances and stop & start those workers. This is usually a good way to go. Once the machines have stopped and started, check New Relic again and see that the disk usage has gone down. If not, ssh into the worker and run sudo rm -rf /tmp/*. You may get errors saying "Operation not permitted", nothing to do about that. Then follow these steps:
text
grd
cd ..
ls -l

Take note of the directory current links to. Then:

text
cd releases
ls -l

You want to remove all the past deploys using rm -rf [name] where the name is NOT the one current links to. If the size of the directory current links to is huge, then you have no choice but to make some change to the code, deploy to production, and remove that directory which is now old as current will link to the new deploy.

  1. If containers are not receiving any traffic, try restart on EC2 for the green/blue servers. Containers might not have allocated properly on deploy.

Server Disks Full

You will get an alert saying the Fullest disk > 90%. First check New Relic to see which servers they are. 'ssh' into the server and run:

text
sudo rm -rf /tmp/*

After that, run:

text
sudo find / -type f -size +20M -exec ls -lh {} \\;

This will tell you all files larger than 20MB. Likely it will be a log directory that is exploding. If not, make sure to examine what you are removing before you actually remove it. If it is safe to remove, remove the files and/or directories. Check New Relic to ensure the disk usage went down. If not, try starting the stopping the instance. HOWEVER, follow the directions here to ensure the site does not go down.

Checking connectivity

In our production environment (and generally anywhere) you can use the following commands to check connectivity, SSL, etc.

Checking connectivity to a port, or that a port is open

text
nc -z -w 5 [host] [port]

where:

  • -z tells it to try connecting then tell us :+1: or :-1:
  • -w 5 only wait 5 seconds

Checking or getting SSL certs for a hostname

text
openssl s_client -showcerts -connect [host]:[port]

Apple Pay (`docs/apple_pay.md`)

Running Apple Pay locally

Apple Pay cannot be tested against http://localhost:3000 — Stripe requires HTTPS for Apple Pay domain registration, so the Apple Pay button will not render in local development. To test it, expose the app over HTTPS with ngrok (ngrok http 3000) and register the resulting hostname in the Stripe dashboard.

To see the apple pay button on custom domains, add the domain name to Stripe Dashboard (or via Rails console: Stripe::ApplePayDomain.create(domain_name: domain)) and visit product checkout page from a browser that supports Apple Pay.


Compliance (`docs/compliance.md`)

Compliance & Risk

This document contains instructions for running various compliance scripts for Gumroad.

To use, run these commands in the production console.

User compliance

Suspend users for ToS violation

rb
user_ids = []

users = User.find(user_ids)
users.each do |user|
  begin
    user.flag_for_tos_violation!(
      author_id: GUMROAD_ADMIN_ID,
      content: "Suspended for TOS violation on #{Time.current.to_fs(:formatted_date_full_month)}",
      bulk: true
    )
    user.suspend_for_tos_violation!(
      author_id: GUMROAD_ADMIN_ID,
      content: "Suspended for TOS violation on #{Time.current.to_fs(:formatted_date_full_month)}",
      bulk: true
    )
  rescue => e
    puts "Error processing user #{user.id}: #{e.message}"
  end
end

Suspend users for fraud

rb
user_ids = []

users = User.find(user_ids)
users.each do |user|
  begin
    user.flag_for_fraud!(
      author_id: GUMROAD_ADMIN_ID,
      content: "Suspended for fraud on #{Time.current.to_fs(:formatted_date_full_month)}"
    )
    user.suspend_for_fraud!(
      author_id: GUMROAD_ADMIN_ID,
      content: "Suspended for fraud on #{Time.current.to_fs(:formatted_date_full_month)}"
    )
  rescue => e
    puts "Error processing user #{user.id}: #{e.message}"
  end
end

Suspend for fraud and refund user purchases

rb
user_ids = []

users = User.find(user_ids)
users.each do |user|
  begin
    user.flag_for_fraud!(
      author_id: GUMROAD_ADMIN_ID,
      content: "Suspended for fraud on #{Time.current.to_fs(:formatted_date_full_month)}"
    )
    user.suspend_for_fraud!(
      author_id: GUMROAD_ADMIN_ID,
      content: "Suspended for fraud on #{Time.current.to_fs(:formatted_date_full_month)}"
    )
    user.sales.where(purchase_success_balance_id: user.unpaid_balances.pluck(:id)).not_fully_refunded.not_chargedback_or_chargedback_reversed.each do |purchase|
      begin
        next if user.unpaid_balance_cents <= 0
        purchase.refund!(refunding_user_id: GUMROAD_ADMIN_ID)
      rescue => e
        puts "Error refunding purchase #{purchase.id} for user #{user.id}: #{e.message}"
      end
    end
  rescue => e
    puts "Error processing user #{user.id}: #{e.message}"
  end
end

Mark users compliant (unsuspend users)

rb
user_ids = []

users = User.find(user_ids)
users.each do |user|
  begin
    user.mark_compliant!(author_name: "Iffy")
  rescue => e
    puts "Error processing user #{user.id}: #{e.message}"
  end
end

Purchases

Refund purchases

rb
purchase_ids = []

purchase_ids.each do |purchase_id|
  begin
    purchase = Purchase.not_fully_refunded.not_chargedback_or_chargedback_reversed.find_by(id: purchase_id)
    if purchase
      purchase.refund!(refunding_user_id: GUMROAD_ADMIN_ID)
    end
  rescue => e
    puts "Error processing purchase #{purchase_id}: #{e.message}"
  end
end

Payouts

Issue payouts

text
/* Detailed source-code truncated for AI context efficiency. */

Debugging (`docs/debugging.md`)

Debugging

Read debugging tips and tricks in Notion if you face problems when setting up the development environment locally.

Visual Studio Code / Cursor Debugging

  1. Install the Ruby extension.
  2. Install the vscode-rdbg extension.
  3. Start the supporting services by running make local in a terminal.
  4. Run the non-rails services by running foreman start -f Procfile.debug in a terminal.
  5. Debug the Rails server by running the "Run Rails server" launch configuration in VS Code from the "Run -> Start Debugging" menu item.

Now you should be able to set breakpoints in the code and debug the application.


Deploying (`docs/deploying.md`)


Deploying

Deployments are automated. When Buildkite builds the main branch, it will automatically deploy it to production. The rest of this section explains how deployments can be performed outside of this automated process.

Prerequisites

Before deploying, make sure you have the nomad/.env environment variables file with these credentials.

Also, install awscli and configure it with your IAM credentials.

Deployments to staging happen as part of the Buildkite workflow. Whenever a new commit is merged into the main branch, and the tests pass, it gets merged into the staging branch and deployed. You can deploy manually if the Buildkite workflow is broken for some reason, like some test failure, and you need to deploy to staging.

Make sure you read through nomad/README.md to get a general understanding of what you're doing, and prerequisite software you'll have to install.

Automatic way

If you're doing a simple deployment of verified commits, just run bin/deploy and follow the instructions. It doesn't matter which branch you're on when you run this, and you'll be prompted to confirm which commits will be deployed.

Manual way

Rebase the staging branch into production, and deploy production:

bash
cd nomad/production && dotenv -f ../.env ./deploy_unattended.sh

If everything goes accordingly, you should check the Nomad-UI jobs list to make sure that your specific docker image for production production-<git short sha> was deployed.

Deployment lock

Deployment is automatically locked when it is in progress.

Manually lock the deployment:

bash
cd nomad/production && ./lock_deployment.sh

Manually unlock the deployment:

bash
cd nomad/production && ./unlock_deployment.sh

Hotfixes

This section is written to facilitate deployments that add commits to the production application without going through the normal branching process(feature branch -> main -> staging -> production-release tag).

Step 1: Hotfix branch creation

The strategy of creating the hotfix branch will change depending on how the latest code was deployed to production(normally or as a hotfix).

This is how the hotfix branch should be created if the latest deployed code is in the production-release tag:

bash
# Delete existing tag
git tag -d production-release

# Fetch the tag from origin
git fetch origin tag production-release

# Create a new branch from the tag
git checkout -b comp-assets-hotfix-branch-name production-release

This is how the hotfix branch should be created if the latest deployed code is not in the production-release tag (meaning the latest deployment was a hotfix itself):
Find the tag that was last deployed to production from the Slack channel #releases.

Example of a message in the channel:

Ershad Kunnakkadan has finished deploying production-d6b4605

Find the git tag for that commit from the tags page on GitHub. Let's say the tag name is production-d6b4605/2018-05-02-13-16-03.
Execute the following command to create a new branch from the tag:

bash
git fetch --tags && git checkout -b comp-assets-hotfix-branch-name production-d6b4605/2018-05-02-13-16-03

Make sure that the branch name starts with comp-assets-. The branch name is important because unless it matches with this pattern docker images won't be built for the branch.

Step 2: Make changes in the branch as required

After the branch is created by following one of the approaches described above add new commits to this branch as required.

Step 3: Actual deployment

Wait for Buildkite to finish running the docker_asset_compile job.

Run git rev-parse --short=12 HEAD while in the new branch. Let's say this displays 491255bb0a4d.

Post a message to Slack #releases channel that you're doing a hotfix deployment, then deploy to production:

bash
export DEPLOY_TAG="production-xxxxxxx"
cd nomad/production && dotenv -f ../.env ./deploy_unattended.sh

When deployment goes bad

To kill the running containers (like the container that runs database migration), visit http://localhost:8080 between the deployment and kill the container in Allocations page (or use the nomad command). If localhost:8080 throws an error, please connect to the server by running cd nomad && source nomad_proxy_functions.sh && proxy_on production.

To rollback a deployment, get the previous revision from #releases channel in Slack and set the environment variable export DEPLOY_TAG=production-<revision> and proceed with normal deployment.

Logs

All logs generated in the production docker containers (which includes db migration, web servers, Sidekiq) are being pushed to https://logs.gumroad.com. It's a Kibana instance connected to Elasticsearch. To view deployment/application logs, please search with the 7-letter SHA of the revision - https://logs.gumroad.com/app/kibana#/discover

Hotfixing workers only

Ershad Kunnakkadan: The easiest way would be to do something like this and proceed with manual deployment with ./deploy_unattended.sh

diff
diff --git a/nomad/common.sh b/nomad/common.sh
index bb60a7a598..d128504bca 100644
--- a/nomad/common.sh
+++ b/nomad/common.sh
@@ -138,26 +138,26 @@ function create_release_tag() {
 }

 function gr_deploy() {
-  check_for_deployment_lock
+  # check_for_deployment_lock

-  run_job database_migration
+  # run_job database_migration

-  logger "Waiting for db:migrate to complete"
+  # logger "Waiting for db:migrate to complete"

-  wait_for_db_migrate
+  # wait_for_db_migrate

-  run_job rpush
+  # run_job rpush
   run_job sidekiq_worker

-  if (production_deployment); then
-    scale_up_web_server_clusters
-    deploy_to_web_servers
-  else
-    deploy_to_web_servers
-  fi
+  # if (production_deployment); then
+  #   scale_up_web_server_clusters
+  #   deploy_to_web_servers
+  # else
+  #   deploy_to_web_servers
+  # fi

-  run_job post_deployment
+  # run_job post_deployment

   create_release_tag
 }

Or, (for Sidekiq only) we can do this:

bash
$ export DEPLOY_TAG=<>
$ cd nomad
$ source nomad_proxy_functions.sh
$ cd production
$ alias  nomad=nomad_insecure_wrapper
$ dotenv -f ../.env erb sidekiq_worker.nomad.erb > sidekiq_worker.nomad
$ nomad run sidekiq_worker.nomad

Deploying to a preview app

Every branch build deploys a preview app automatically — no label and no open pull request required. This works for any branch except main and comp-assets-*. Do not add a preview label; the label workflow is gone (see .buildkite/scripts/preview_gate.sh). Each subsequent push to the branch redeploys automatically, and running 50+ preview apps in parallel is a supported state.

The preview app URL is https://<app>.apps.staging.gumroad.org, where <app> is the branch name lowercased with non-alphanumerics replaced by -, any deploy- prefix stripped, and truncated to 32 characters (get_app_name in the deployment repo's nomad/staging/deploy_branch/deploy_branch_common.sh).

When the branch has an open pull request, the URL is also posted there as a GitHub deployment: look for the "View deployment" button (and the Deployments section), which shows the deploy in progress and links to the running app once it is ready. The deployment environment is named preview/<app>; deployments are created against the commit SHA, so query the Deployments API by environment rather than by ref.

Deployments are removed automatically when the associated branch is deleted in the repository.

Rails console on a preview app

Preview apps have Rails console access — see Connect to Rails console in gumroad-private. The console.sh script there resolves the preview instance from the BRANCH= environment variable (it falls back to gumroad-private's own checked-out branch, so always set it explicitly) and opens rails c in the running container. It also supports COMMAND=... for one-shot commands — the value is executed directly as the container command, so it must be a runnable program like bundle exec rake "...", not a bare Ruby snippet — and a standalone -w flag for a writable database connection (the default is a read-only replica).

Seeding QA state with `preview_qa` rake tasks

Use the permanent tasks in lib/tasks/preview_qa.rake to seed edge-case state on a preview app instead of adding temporary, param-gated seed hooks to your PR ("TEMP: revert before merge" commits). The tasks only run on preview apps, development, and the test suite — they are unavailable in production and on shared staging (staging.gumroad.com), where mutating records would interfere with other people's testing.

Run them through the preview app's Rails console (see the section above for how console.sh resolves the branch and the -w writable flag — the seeding tasks write, so they need -w).

The simplest path is to open an interactive writable console and shell out to rake from the prompt (rake tasks aren't preloaded in a console session, so bundle exec rake in a subprocess is the entrypoint):

shell
cd nomad/staging/deploy_branch && BRANCH=<branch name> ./console.sh -w
# then, at the console prompt:
system 'bundle exec rake "preview_qa:backdate_purchase[<purchase external_id>]"'

Alternatively, run a task as a one-shot. COMMAND is executed directly in the container as a shell command (not evaluated as Ruby), so it must be a full bundle exec rake invocation:

shell
cd nomad/staging/deploy_branch && \
  BRANCH=<branch name> \
  COMMAND='bundle exec rake "preview_qa:backdate_purchase[<purchase external_id>]"' \
  ./console.sh -w

The available tasks (all need the writable connection except inspect_subscription, which is read-only):

shell
# Backdate a subscription purchase past its billing period so a renewal charge is due
bundle exec rake "preview_qa:backdate_purchase[<purchase external_id>]"

# Remove the e-mandate linkage from the card charged for a subscription (Indian card QA)
bundle exec rake "preview_qa:clear_mandate[<subscription external_id>]"

# Seed a job into the Sidekiq dead set (morgue)
bundle exec rake "preview_qa:seed_dead_job[RecurringChargeWorker,123]"

# Run a worker inline, e.g. to force a renewal charge attempt
bundle exec rake "preview_qa:run_worker[RecurringChargeWorker,123]"

# Inspect a subscription after a QA run: renewal timing, mandate linkage, recent charges (read-only)
bundle exec rake "preview_qa:inspect_subscription[<subscription external_id>]"

Record ids can be passed as either database ids or external ids. If a QA scenario you need isn't covered, add a task to the namespace (with specs) rather than shipping a temporary hook in your feature PR.


Local Dev Parallel Lanes (`docs/local-dev-parallel-lanes.md`)

Parallel local development lanes

The default development configuration uses one set of ports, databases, Redis DBs, and Elasticsearch indices. Use a lane to run up to four isolated development environments on the same machine:

shell
bin/dev-lane 1

Lane 0 preserves the existing bin/dev values. Lanes 1–3 derive isolated values from the lane number.

Lane Rails port Vite port Database Redis DBs ES suffix DynamoDB prefix AnyCable WS / RPC
0 3000 3036 gumroad_development 0–3 none none 8080 / 50051
1 3001 3038 gumroad_development_lane1 4–5 _lane1 lane1_ 8081 / 50052
2 3002 3040 gumroad_development_lane2 6–7 _lane2 lane2_ 8082 / 50053
3 3003 3042 gumroad_development_lane3 8–9 _lane3 lane3_ 8083 / 50054

Vite ports step by two so no lane collides with 3037, the test environment's Vite port. Extra lanes get two Redis databases (app+rpush, sidekiq+rack-attack — the namespaced stores share) because databases 10 and above belong to the test suite: 10–13 are .env.test's fallback block and config/test_redis_isolation.rb leases from 14 up, both flushed before every example. That test-suite boundary is also why lanes stop at 3.

Absolute URLs follow the lane: config/domain.rb derives the development domain constants (DOMAIN, ROOT_DOMAIN, ASSET_DOMAIN, SHORT_DOMAIN, API_DOMAIN, DISCOVER_DOMAIN) from DEV_LANE_PORT, so mailer links, js-routes *_url helpers, and cross-subdomain redirects generated inside a lane point at that lane's Rails port instead of lane 0's localhost:3000. Don't reach for CUSTOM_DOMAIN here: it also rewrites VALID_DISCOVER_REQUEST_HOST to a port-carrying value that can never equal the port-less request.host, which silently unroutes /, /discover taxonomy pages, and the other DiscoverDomainConstraint routes.

First-time setup

Run setup commands inside a subshell so the lane environment cannot leak into the rest of your session (bin/dev-lane also exports DISABLE_SPRING=1, so a warm Spring server preloaded with another lane's database can never serve these commands):

shell
(
  set -a
  eval "$(bin/dev-lane 1 --print-env)"
  set +a
  bin/rails db:create db:migrate db:seed
  bin/rails runner "DevTools.delete_all_indices_and_reindex_all"
  bin/rails runner "begin; EmailEngagementDynamoStore.create_table!; rescue Aws::DynamoDB::Errors::ResourceInUseException; end"
)

Never run the test suite from a shell holding lane environment: the exported *_REDIS_HOST / DYNAMODB_TABLE_PREFIX values outrank .env.test (dotenv does not override existing variables), so specs would flush the lane's Redis databases and write into its DynamoDB tables.

The test suite already isolates per run through TEST_DATABASE_NAME and Redis database leasing (config/test_redis_isolation.rb); development lanes extend the same idea to the dev server stack.


Logs (`docs/logs.md`)

Accessing logs

production

text
cd nomad/production && ./logs.sh

staging

text
cd nomad/staging && ./logs.sh

Marketing Api (`docs/marketing-api.md`)

Marketing API v2

All endpoints require the edit_emails OAuth scope. The broad legacy account scope is not accepted. The seller must pass the same Marketing::Eligibility gate the product page uses — auto_marketing enabled and not in the holdout; otherwise every endpoint returns HTTP 404. Products and actions are scoped to the token's seller. No scheduler or marketing state lives in the client.

Endpoints

  • GET /v2/products/:product_id/marketing/recommendations: returns {success: true, channels: [...]} using Marketing::Recommendations. Accepts a product external ID or permalink. Only published products; prepares one open action and tagged link per live channel, without posting.
  • POST /v2/products/:product_id/marketing_actions, with channel=x: explicitly prepares/reuses the same recommendation and returns {success: true, marketing_action: {...}, handle: "..."}. Reuses an open action, but a new recommendation after a terminal action is a new intent, not a retry. Retry an existing action by its ID, never by requesting another recommendation.
  • GET /v2/marketing_actions/:id: returns the action and account handle without creating anything.
  • POST /v2/marketing_actions/:id/approve: approves the existing row; optional copy is validated by the shared model. Requires idempotency_key and confirmation_token from the reviewed action. This does not post.
  • POST /v2/marketing_actions/:id/execute: executes the existing approved action through the channel executor. Requires the same keys. Execution is immediate; there is no future-time scheduling API in this version.
  • POST /v2/marketing_actions/:id/cancel: cancels an unclaimed action; requires its idempotency_key. Repeated cancellation returns the same row. Claimed or posted actions cannot be cancelled.

The action includes id, idempotency_key, confirmation_token, channel, status, copy, post_text, link_url, external_url, error_code, approved_at, and posted_at. Keys are opaque; store and return them unchanged. The stable idempotency key derives from the existing persisted key without exposing internal IDs. The confirmation token binds the reviewed text, link and account; copy/account changes after preview are refused before approval or the outbound claim. An edited approval returns a new confirmation token.

Execute also returns intent_url and connect_path for the existing reconnect/manual-share fallback. HTTP 200 indicates the action was processed, not necessarily posted: inspect marketing_action.status and error_code. A permission refusal stays approved for reconnect; an ambiguous outbound result is never resent. Repeated approve/execute requests for a posted action return the same row without a second post.

Validation failures return HTTP 422 with {success: false, message: "..."}. Missing authentication returns 401, insufficient scope 403, and missing/foreign resources 404.

CLI post-publish flow

  1. GET /v2/products/:id/marketing/recommendations after publishing. Select a live channel and display its handle, action.post_text, and action.link_url. Do not approve automatically.
  2. After the seller explicitly confirms, POST /v2/marketing_actions/:action_id/approve with idempotency_key and confirmation_token from that preview (and optional seller-edited copy).
  3. POST /v2/marketing_actions/:action_id/execute with the keys from the approve response. Resolve retries with the same action ID; GET /v2/marketing_actions/:action_id shows the outcome.
  4. Offer cancellation before execution via POST /v2/marketing_actions/:action_id/cancel, with idempotency_key.

The companion CLI exposes marketing recommend, approve, schedule, cancel, and status. schedule invokes the immediate execute endpoint; it does not accept a future date. Merge and deploy this Rails API before merging/releasing the CLI.

Launch email drafts

The live email channel prepares a draft for the seller's saved audience. Email execute returns edit_url (or null when no draft can be prepared), with intent_url and connect_path set to null. It never sends or schedules email; the seller reviews and publishes or schedules in Emails. Cancelled actions and unpublished products return 422 without preparing a draft. Deleting the launch draft continues to decline its recreation.

The recommendation's draft.state is draft, scheduled, or published when there is no regular blast. A published draft with a requested regular blast uses that blast's existing sending, waiting, incomplete, or sent status. published does not mean emailed; sent means processing is complete, not confirmed inbox delivery. Open Emails for delivery details.


Mcp Review Walkthrough (`docs/mcp-review-walkthrough.md`)

MCP review recording script

Use this script to record a real Gumroad connector walkthrough in ChatGPT. Run it
again after changing authentication, tools, or their descriptions. Record actual
tool results; the expectations below are not evidence that a case passed.

Before recording

  • Use a dedicated, non-admin review account containing only synthetic products.
    Keep its password in the company password manager, never in this file, a
    prompt, terminal output, or the recording.
  • Verify its identity and current product, sales, and payout state before the
    take. Do not record a creator's real sales, buyer emails, or payout details.
  • Open a separate Chrome window, hide the conversation sidebar, close unrelated
    tabs, and frame only the walkthrough window. Disable microphone capture unless
    intentionally narrating. Keep login/password entry outside the recording.
  • At ChatGPT Plugins, use Add → Create custom MCP
    server
    , URL https://gumroad.com/chatgpt/v1/mcp, and OAuth. Use the isolated
    account when authorizing. Inspect the consent screen before accepting it.
    In advanced OAuth settings, choose DCR and keep only view_profile,
    view_sales, edit_products, and view_payouts; deselect account and
    view_public.
  • Verify the discovered inventory: get_account, list_products, get_product,
    create_draft_product, publish_product, unpublish_product, list_sales,
    and list_payouts. Start a new conversation with this connector selected.
  • Choose a unique run label, e.g. Review Focus Timer 2026-10-06-01. Reuse that
    exact name and the returned product ID throughout this take. Do not act on an
    unrelated existing product.

Recording script

Capture with the OS screen recorder through Codex computer use. On macOS open
Screenshot with Command-Shift-5, choose Record Selected Portion, frame the
Chrome content, choose a local output directory, and start recording. Pause or
stop before password entry, security challenges, or unrelated account screens.
For each scene, keep the prompt, relevant tool invocation, and completed response
legible before advancing. Follow the live UI instead of reusing element numbers
from an earlier recording.

Browser-only results video

When the OS recorder captures the wrong desktop/window, make a clearly labeled
walkthrough of the completed live results with scripts/mcp-review-video.mjs.
This captures actual browser pixels through Codex computer use. It is a sequence
of screenshots with chosen reading durations, not continuous execution footage.
Do not present it as evidence that the OAuth interaction or a tool approval was
recorded live. Keep the full recording workflow above for that requirement.

In the computer-use REPL, select and inspect the intended browser/app target,
then use absolute paths for the helper and output directory:

js
var helper = await import("/path/to/gumroad/scripts/mcp-review-video.mjs");
var scenes = [];
scenes.push(await helper.captureScene(chrome, "/path/to/private-output", "account", 12));
// Navigate through the actual results with computer use; inspect each scene.
scenes.push(await helper.captureScene(chrome, "/path/to/private-output", "draft", 15));
await helper.finishVideoManifest("/path/to/private-output", scenes);

Capture the tool inventory, connected account, sales/payout results, draft
confirmation and creation, publish result, final state, and unsupported-action
response. Use a new scene name each time; existing images are never overwritten.
Check every image before encoding. Encode with FFmpeg, omitting microphone audio
and labeling the result visibly:

sh
ffmpeg -f concat -safe 0 -i /path/to/private-output/scenes.ffconcat \
  -vf "scale=1920:-2,drawbox=x=0:y=0:w=iw:h=86:color=black:t=fill,drawtext=text='Gumroad MCP - screenshot walkthrough of live results':fontsize=25:fontcolor=white:x=40:y=25" \
  -r 5 -c:v libx264 -crf 18 -pix_fmt yuv420p -an -movflags +faststart \
  /path/to/private-output/walkthrough.mp4

The manifest records capture times and display durations. Keep it with the
private source images; commit only the helper and this script.

Scene prompts

Scene Prompt / on-screen action Expected behavior and narration
Connect Show the connector name and discovered tools after OAuth returns. “Gumroad connects with OAuth. This demonstration uses an isolated review account.” Do not claim public directory approval.
Account “Show the connected Gumroad account and its currency.” get_account identifies the intended account. Stop if it is the wrong creator.
Products “What products do I have on Gumroad? Show their names, prices, and published states.” list_products returns only this account's products.
Product detail “Show the details of [ID returned above].” get_product uses a real returned identifier. If the account has no products, use the ID returned by the Draft scene instead.
Sales “Show my 10 most recent successful sales.” list_sales; an empty result is valid. “This synthetic account has no sales.” Do not call a limited result a complete date-range report.
Draft “Create an unpublished draft named [run label] for USD $12 with the description ‘Synthetic connector review fixture. Not offered for sale.’ Confirm the name and price before creating it.” Confirm the requested values in the live conversation. If the account is not USD, clarify before proceeding. create_draft_product uses price_cents: 1200 for USD.
Verify draft “Fetch the new draft by its returned ID and show its published state.” get_product confirms published: false. Optionally show the same product in Gumroad's editor.
Publish boundary “Try publishing only the new review draft. Ask me to confirm before doing so.” Show the confirmation and actual tool result. With no payout method, publishing may be rejected. State the returned error; do not add financial details or claim a successful publish. Only record a success path with an account already approved and configured for that purpose.
Restore draft “Unpublish only the review product we just created, then fetch it again.” unpublish_product, then get_product confirms it is unpublished. If it never published, describe this as a no-op, not proof of a live-to-draft transition.
Payouts “Show my recent payouts.” list_payouts; an empty result is valid. Do not invent a current balance or next payout date from historical payout records.
Unsupported actions In separate turns: “Delete my Gumroad account.” / “Show another creator's sales.” / “Email all my buyers a discount code.” No account deletion, cross-account access, or outbound email tool is available. Record what the assistant actually does; no fabricated refusals.
End Show the final unpublished state and summarize observed limitations. “This recording demonstrates the flows shown. Empty data and a publishing restriction are reported as observed.”

Review and reuse

  1. Stop the OS recording and save outside tracked source, for example
    tmp/mcp-review/YYYY-MM-DD/walkthrough.mov.
  2. Watch the whole recording. Check text legibility, tool outcomes, account
    identity, sensitive data, and any accidental views of other apps. Remove
    credential segments entirely. Do not edit errors into apparent successes.
  3. Record the date, endpoint, client, account's synthetic-data status, tool
    results, product IDs, and any failures in the private submission tracker.
    Keep passwords and OAuth tokens out of the tracker.
  4. Attach the reviewed video using GitHub's media upload or another approved
    reviewer-accessible host. Do not commit video binaries. Verify playback and
    access using the reviewer's expected access level; a private-repo URL alone
    is not evidence that an external reviewer can view it.
  5. Put the verified URL in the submission's walkthrough field (or
    extensions.com.openai.review.demo_recording_url). Keep reviewer credentials
    in the provider's dedicated credentials field. A recording is not a substitute
    for working demo access or a completed domain verification.

See OpenAI's connection and test guide
and submission guide
for the current portal requirements.


Memory Profiling (`docs/memory_profiling.md`)

Steps for memory profiling

  1. Download and run the docker instance

    bash
    docker pull gumroad/web:<env>-<revision>
    make local # In a seperate window
    docker run -it -e BUILD_JS=1 -e RAILS_LOG_LEVEL=error --shm-size=256m -p 3000:3000 --network web_default gumroad/web:<env>-<revision> /bin/bash
  2. Run ngrok in local machine

    bash
    ngrok http 3000
  3. Run puma in docker instance

    bash
    screen -S puma bash
    cd /app
    RAILS_ENV=<env> CUSTOM_DOMAIN=<ngrok domain> PUMA_WORKER_PROCESSES=<workers count> ./bin/memusg.sh ./docker/web/server.sh
  4. Run Sidekiq in docker instance

    bash
    screen -S puma bash
    cd /app
    RAILS_ENV=<env> ./bin/memusg.sh ./docker/web/sidekiq_worker.sh
  5. Create products and make purchases in Gumroad through ngrok URL

  6. Connect to screen and read memory usage

    bash
    screen -x puma
    Press Ctrl+c and note the peak memory usage.
    bash
    screen -x sidekiq
    Press Ctrl+c and note the peak memory usage.

Operations in application

Repeat the following steps for 5 different products

text
/* Detailed source-code truncated for AI context efficiency. */

Migrations (`docs/migrations.md`)

(Gumroad-specific, go crazy with the other repos)

Frozen tables

Do not write migrations that alter the schema of users or purchases. These tables are too large for online schema changes. Even with PT-OSC, column additions/removals/renames block deployments.

If you need new data associated with users or purchases, create a separate table (e.g., user_settings, purchase_metadata) and join to it.

This includes:

  • Adding columns
  • Removing columns
  • Renaming columns
  • Adding or removing indexes
  • Any ALTER TABLE operation

General rules

  1. Submit migrations in separate PRs, except for:
    • Creating new tables
    • Adding columns/indexes to normal tables
    • Backward-compatible changes
  2. Use change_table(table, bulk: true) for multiple changes to a table.
  3. Avoid new foreign key constraints due to performance and logistical issues.
  4. Make one change per migration to prevent partial commits on failure.
  5. Consider alternatives to adding columns/indexes to large tables.
  6. Make new boolean columns NOT NULL when possible.
  7. Don't directly rename columns or tables. Instead:
    • Add new column
    • Update code to use new column
    • Deploy
    • Copy data
    • Remove old column references
    • Remove old column
    • Deploy
  8. Before removing a column:
    • Remove all usages in code
    • Add to ignored_columns
    • Deploy
    • Remove column and ignored_columns entry

Deploying

  1. Be present during deployment of your migration.
  2. Monitor:
    • Sentry for recent production errors
    • Cloudwatch Web dashboard (errors rate, successful purchases)
  3. Use ./logs.sh in nomad/production to view migration logs.
  4. For large table migrations, ensure experienced support is available.
  5. Be aware of PT-OSC (pt-online-schema-change) process:
    • Creates duplicate table
    • Adds triggers for data sync
    • Copies data in batches
    • Renames tables
  6. If migration gets stuck:
    • Check for queries "waiting for table metadata lock"
    • Kill long-running queries if necessary
  7. To cancel a migration:
    • Remove added triggers
    • Empty and remove the temporary table
  8. Use DISABLE_ALTERITY=1 to bypass PT-OSC if needed.

Long-running tasks

  • For long-running tasks, we can use web_server_generic to run the script in production. Please ask in #engineering Slack channel to make sure nobody else is using that instance before redeploying it. As of now, only one web_server_generic instance can run at a time.
bash
export DEPLOY_TAG=production-<revision>
cd nomad/production && ./start_generic_web.sh

This will output an URL. Open the URL and note the client IP address from the hostname:

SSH into the server and open the Rails console as follows:

bash
INSTANCE_IP=10.1.x.x COMMAND="screen -adR" ./console.sh
bundle exec rails c

This will stay running even if your connection is interrupted or if somebody deploys to production in between. If you get disconnected, run the same INSTANCE_IP=10.1.x.x COMMAND="screen -adR" ./console.sh command again to reconnect to the terminal.

Once the task is finished running, you can stop the web_server_generic instance as follows:

bash
cd nomad
source nomad_proxy_functions.sh
proxy_off production; proxy_on production
nomad_insecure_wrapper stop -purge web_server_generic
proxy_off production

How to add, change, remove a column or index

Maintaining production databases


Minitest Migration (`docs/minitest-migration.md`)

Migrating specs to Minitest + fixtures

The test suite is moving from RSpec + FactoryBot to Minitest + Rails YAML
fixtures. Profiling (#5801)
showed 58–77% of wall time in models/controllers/mailers/jobs specs is spent
inside factory.create; fixtures load once per process and make each test's
setup cost near zero.

This document is the playbook for moving a spec file over. It's written so a
migration PR can be produced and reviewed mechanically — same file mapping,
same fixture conventions, same verification steps every time.

Layout

Piece Location
Tests test/**/*_test.rb, same relative path as the old spec/**/*_spec.rb
Fixtures test/fixtures/.yml (flat, top-level only — Rails won't auto-load subdirectories)
Shared helpers/assertions test/support/*.rb (auto-required by test_helper.rb)
Boot test/test_helper.rb

CI runs the whole test/ tree as the test_minitest job in
.github/workflows/tests.yml (bare runner, no Docker image — services boot as
native GitHub Actions services, including Elasticsearch and MinIO). The job is
wired into the Buildkite deployment gate alongside test_fast/test_slow.

The job is a 6-shard matrix. Each shard loads the whole tree and runs the tests
whose name hashes to its index (MINITEST_SHARD=/<total>, see
test/support/test_shard.rb), so a large test file spreads across all shards.
Raise ci_node_total and extend ci_node_index to add shards. Locally, run the
tree in one go with bin/rails test.

Rules

  1. No FactoryBot in test/. Every create(:thing) becomes a fixture row
    or explicit Model.create! in setup when the object is genuinely
    per-test.
  2. One *_spec.rb → one *_test.rb, and the migration PR deletes the
    spec file it replaces. A spec and its test must never both run.
  3. Descriptive fixture names (named_seller,
    accountant_for_named_seller), never one/two.
  4. Migrate in profiling-rank order: models → mailers →
    policies/presenters/lib/modules → controllers → jobs/sidekiq. Requests and
    services specs are wait-bound, not factory-bound — leave them for the
    system-test track.
  5. Keep each PR to one directory slice (or ~20 files max) so review and revert
    stay cheap.

Fixture gotchas (each of these has burned a real PR)

  • Fixtures bypass validations, callbacks, and attribute ... default:.
    Any column with both a default and a validator must be spelled out on the
    row — for User that means at least user_risk_state and
    recommendation_type, or the first save! in a test blows up on a
    validation the test never touched.
  • Renamed foreign keys need FixtureSet.identify. Rails' short form
    (user: named_seller) only works when the association name matches the
    model. For seller_id pointing at users, write
    seller_id: <%= ActiveRecord::FixtureSet.identify(:named_seller) %>.
    The (User) polymorphic syntax silently writes nothing for non-polymorphic
    columns.
  • before(:create) factory hooks become explicit rows. Example: every
    seller needs an owner-role self-team_membership (the
    owner_membership_must_exist validation) — see
    test/fixtures/team_memberships.yml.
  • RSpec's stub_const doesn't exist. Use with_const(:NAME, value) { }
    from test/support/constant_stubbing_helpers.rb.
  • Minitest::Mock#expect with kwargs needs the empty positional array:
    mock.expect(:exists?, true, [], index: "link-test").
  • Mailer tests that build *_urls in assertions need
    include Rails.application.routes.url_helpers plus a default_url_options
    returning { host: DOMAIN, protocol: PROTOCOL }.

Recipe for one file

  1. Read the spec. List every create(...)/let(...) object and decide:
    shared shape → fixture row (reuse an existing one when possible);
    test-specific mutation → build/mutate inside the test.
  2. Add fixture rows. Grep the model for attribute ... default: +
    validates ... inclusion: pairs and spell those columns out.
  3. Write test//<name>_test.rb. describe/it → test "...";
    expect(x).to eq(y) → assert_equal y, x; RSpec
    permissions :a, :b do ... end for policies →
    assert_policy_permits/refute_policy_permits from
    test/support/policy_assertions.rb.
  4. Run it: RAILS_ENV=test bin/rails test test//<name>_test.rb.
    (Run the whole test/ tree too — fixture changes can leak into sibling
    tests.)
  5. git rm the spec file. Run rubocop on the new files.
  6. In the PR body: which files moved, spec-vs-test example counts (they should
    match or the body explains why), and local rails test output.

Verification gate for autonomous merges

A migration PR is mergeable when all of:

  • Diff touches only test/**, test/fixtures/**, and deletes the
    corresponding spec/** files (no app code).
  • Example count in the new tests ≥ example count in the deleted specs, or the
    delta is itemized in the PR body (e.g. a spec that tested RSpec-loader-only
    infrastructure is a documented skip, not a silent drop).
  • test_minitest and the full existing suite are green.

What NOT to migrate (document as skips instead)

  • Specs testing classes defined under spec/support/ (RSpec-loader
    infrastructure) — out of scope unless the class is relocated.
  • Capybara feature/system specs — those move to the Playwright system-test
    suite (test/system/, separate track), not to plain Minitest.

VCR-heavy external-API specs are now in scope. The cassettes are
framework-agnostic and test/support/vcr.rb points at the same directory, so a
port replays the existing recordings unchanged — see
test/business/sales_tax/sales_tax_calculator_test.rb for a whole context
driven that way. The cassette name is the path RSpec's metadata derived from the
describe/context/it chain; insert it unconditionally so a stale name fails with
VCR's own error rather than a WebMock connection refusal.


Paypal (`docs/paypal.md`)

Manage webhooks for Gumroad partner account

Generate access token

text
curl <https://api.paypal.com/v1/oauth2/token> \\
  -H "Accept: application/json" \\
  -H "Accept-Language: en_US" \\
  -u "<client_id>:<client_secret>" \\
  -d "grant_type=client_credentials"

Fetch Webhooks

text
curl -v -X GET <https://api.paypal.com/v1/notifications/webhooks> \\
-H "Content-Type:application/json" \\
-H "Authorization: Bearer <access_token>"

Register Webhook

text
curl -v -X POST <https://api.paypal.com/v1/notifications/webhooks> \\
-H "Content-Type:application/json" \\
-H "Authorization: Bearer <access_token>" \\
-d '{
  "url": "https://<URL>/paypal-webhook",
  "event_types": [
  {
    "name": "CHECKOUT.ORDER.PROCESSED"
  }
  ]
}'

Update Webhook

List of all the required Paypal webhooks is maintained here.

text
curl -v -X PATCH <https://api.paypal.com/v1/notifications/webhooks/><webhook-id> \\
-H "Content-Type:application/json" \\
-H "Authorization: Bearer <access_token>" \\
-d '[
  {
  "op": "replace",
  "path": "/event_types",
  "value": [
    {
      "name": "CHECKOUT.ORDER.PROCESSED"
    },
    {
      "name":"CUSTOMER.DISPUTE.CREATED"
    },
    {
      "name":"CUSTOMER.DISPUTE.RESOLVED"
    },
    {
      "name":"CUSTOMER.DISPUTE.UPDATED"
    },
    {
      "name": "MERCHANT.PARTNER-CONSENT.REVOKED"
    },
    {
      "name": "PAYMENT.CAPTURE.COMPLETED"
    },
    {
      "name": "PAYMENT.CAPTURE.DENIED"
    },
    {
      "name":"PAYMENT.CAPTURE.PENDING"
    },
    {
      "name": "PAYMENT.CAPTURE.REFUNDED"
    },
    {
      "name":"PAYMENT.CAPTURE.REVERSED"
    },
    {
      "name":"PAYMENT.ORDER.CREATED"
    },
    {
      "name": "PAYMENT.REFERENCED-PAYOUT-ITEM.COMPLETED"
    }
  ]
  }
]'

Delete Webhook

text
curl -v -X DELETE <https://api.paypal.com/v1/notifications/webhooks/><webhook-id> \\
-H "Content-Type:application/json" \\
-H "Authorization: Bearer <access_token>"

IPN simulator Guide

https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNSimulator/

(IPN) settings (Live)

https://www.paypal.com/cgi-bin/customerprofileweb?cmd=_profile-ipn-notify

IPN Simulator

https://developer.paypal.com/developer/ipnSimulator/

Site to view sample webhook data

https://webhook.site

IPN history

https://www.paypal.com/us/cgi-bin/webscr?cmd=_display-ipns-history

Orders API sample application setup Instructions

  1. Clone repo - https://github.com/sharang-d/orders
  2. Create file paypal.rb in config/initializers/
  3. Add POST endpoint in the application to handle webhook notifications
  4. Change webhook URL in Paypal Developer account.
    1. Login [email protected] account
    2. Go to dashboard
    3. Click on My Apps & Credentials
    4. Under REST API apps section Click on Gumroad Application.
    5. Under Sandbox Webhooks section Add webhook URL of your application.

paypal.rb

text
PAYPAL_ENDPOINT = "<https://api-3t.sandbox.paypal.com/nvp>"
PAYPAL_REST_ENDPOINT = "<https://api.sandbox.paypal.com>"
PAYPAL_ENV = "sandbox"
PAYPAL_CLIENT_ID = "AQiKjZAqXGcN_oU8wh-RKelv6Nf3IrWVY9J9rrhz1pF7aqiyZjutSdG75I6ahd3zJe1ThpklFp5jNman"
PAYPAL_CLIENT_SECRET = "EPnNdtAW6jsUleHWQmjsFNef37f1GGWLwtJx3uO2PRdbENAFxTgW1WA1V83MVYvSrRrkH0tXNevE0CJ_"
PAYPAL_LIB_MODE = "sandbox"
PAYPAL_URL = "<https://www.sandbox.paypal.com>"
PAYPAL_MERCHANT_ID = "5SCHBGJUCTP2U"
PAYPAL_USER_EMAIL = "[email protected]"
PAYPAL_USER = "paypal-facilitator_api1.gumroad.com"
PAYPAL_PASS = "1383112423"
PAYPAL_SIGNATURE = "AFcWxV21C7fd0v3bYYYRCpSSRl31A9TRhGDrqj7x7lF9P6NGOruW.7ak"

PayPal::SDK.configure(
  :mode => PAYPAL_ENV,
  :client_id => PAYPAL_CLIENT_ID,
  :client_secret => PAYPAL_CLIENT_SECRET,
  :username => PAYPAL_USER,
  :password => PAYPAL_PASS,
  :signature => PAYPAL_SIGNATURE
)

PayPal Connect test accounts


Production Environment (`docs/production_environment.md`)

Setting Up Production Environment

This document provides guidance on setting up and configuring the production environment for Gumroad, including environment variables and running services.

Table of Contents

Environment Variables

Set up all environment variables listed in the .env.production.example file in your environment. These variables contain all the necessary configuration for database connections, caching, application servers, storage, payment processing, authentication, email services, and third-party integrations.

Running Services

The Gumroad production environment consists of several services that work together. Below is information on how to run each service.

Web Servers

Gumroad uses Puma as the application server.

Puma (Application Server)

Puma runs the Rails application and handles HTTP requests. Configuration:

  • Worker Processes: Set via PUMA_WORKER_PROCESSES (default: 1)
  • Threads: Set via RAILS_MAX_THREADS (default: 2)
  • Memory: Recommended 2GB per instance

We recommend running Nginx in front of Puma as a reverse proxy.

Sidekiq Workers

Sidekiq processes background jobs for the application.

Configuration

  • Threads: Set via RAILS_MAX_THREADS (default: 2)
  • Memory: Recommended 2GB per instance
  • Database Replicas: Set USE_DB_WORKER_REPLICAS=true to use read replicas for database operations

Push Notification Service (RPush)

RPush handles push notifications to mobile devices.

  • Requires specific Redis instance configured via RPUSH_REDIS_HOST
  • Requires certificates for Apple Push Notification Service (APN)
  • Requires Firebase configuration for Android notifications
  • Memory: Recommended 2GB per instance
  • Environment Variables:
    • INITIALIZE_RPUSH_APPS: "true"

Shipping (`docs/shipping.md`)

This document outlines the changes made to implement the UTM link tracking feature.

The seller interface for managing UTM links resides under the "Analytics" tab in the seller dashboard.

Completed Tasks:

  • Initial Layout & Listing Page: Added initial layout with a placeholder for the listing page, then implemented the actual listing page. This includes displaying the "Clicks" statistic for each link. (2 PRs)
  • Pagination & Sorting: Implemented pagination and column sorting support for the listing page. (2 PRs)
  • Delete Link: Added functionality to delete links from the listing page. (1 PR)
  • Link Details Modal: Added a modal view to display detailed information for each UTM link. (1 PR)
  • Search & Filtering: Implemented search and filtering capabilities on the listing page. (1 PR)
  • Create & Edit Pages: Built the frontend and backend for creating and editing UTM links, including a "Create link" page and an "Edit link" page. (2 PRs)
  • Permalink Regeneration: Added the ability to regenerate a new permalink for the short URL on the create page. (1 PR)
  • Validation & Saving: Implemented validation and saving logic for the "Create link" page. (1 PR)
  • Duplicate Link: Added the ability to duplicate an existing link. (1 PR)
  • UTM Parameter Uniqueness Validation: Added validation to prevent saving (creating/duplicating/updating) a link with UTM parameters that already exist for a given destination for that seller. (1 PR)

Database Migrations and Models

The following changes were made to the database:

Completed Tasks:

  • Database Tables & Indexes (3 PRs):
    • Created the utm_links table with the following fields and indices:
      • seller_id (bigint, required, indexed)
      • title (string, required)
      • target_resource_type (string, required, indexed)
      • target_resource_id (bigint, optional, indexed)
      • permalink (string, required, unique index)
      • utm_source (string, required, indexed as part of a composite unique index with other UTM fields and the target resource)
      • utm_medium (string, required, indexed as part of a composite unique index with other UTM fields and the target resource)
      • utm_campaign (string, required, indexed as part of a composite unique index with other UTM fields and the target resource and individually)
      • utm_term (string, optional, indexed as part of a composite unique index with other UTM fields and the target resource)
      • utm_content (string, optional, indexed as part of a composite unique index with other UTM fields and the target resource)
      • first_click_at (datetime)
      • last_click_at (datetime)
      • total_clicks (integer, default: 0)
      • unique_clicks (integer, default: 0)
      • ip_address (string)
      • browser_guid (string)
    • Created the utm_link_visits table with the following fields and indices:
      • utm_link_id (bigint, required, indexed)
      • user_id (bigint, optional, indexed)
      • referrer (string)
      • ip_address (string, required, indexed)
      • user_agent (string)
      • browser_guid (string, required, indexed)
      • country_code (string, required)
      • created_at (datetime, indexed)
    • Created the utm_link_driven_sales table with the following fields and indices:
      • utm_link_id (bigint, required, indexed)
      • utm_link_visit_id (bigint, required, indexed, and part of a unique composite index with purchase_id)
      • purchase_id (bigint, required, indexed, and part of a unique composite index with utm_link_visit_id)
  • Models, Relationships & Validations (1 PR):
    • Created corresponding models (UtmLink, UtmLinkVisit, UtmLinkDrivenSale) with appropriate relationships (belongs_to, has_many) and validations (presence, uniqueness, format, etc.).

Implemented UTM link click/visit tracking.

Completed Tasks:

  • Tracking Route: Created the route GET "/u/:permalink" routing to UtmLinkTrackingController#show. This route handles UTM link clicks and redirects.
  • Visit Tracking: Implemented visit tracking using the cookies[:_gumroad_guid] along with referrer, IP address, user agent, and country code, storing data in the UtmLinkVisit model.
  • UTM Link Stats Update: Implemented logic to update link statistics (total_clicks, unique_clicks) in the UtmLink model.
  • Redirect to Destination: Implemented redirection to the destination URL specified in the UTM link after tracking the visit.

Implemented UTM link sale conversion/attribution.

Completed Tasks:

  • Attribution Logic: Implemented attribution logic, considering only visits within a 7-day window prior to purchase and attributing only the latest visit for each UTM link, preventing multiple links getting credit for the same sale.
  • Attribution Filtering: Limited attribution to purchases that match the UTM link's specified criteria (product, variants, etc.).
  • Visit Lookup: Implemented visit lookup using the purchase's _gumroad_guid cookie.
  • Single Visit Attribution: Ensured that only one visit is attributed per purchase, even if multiple applicable links exist.

Additional Features and Improvements

  • Auto-creation of UTM Links: Implemented auto-creation of UTM links in the Gumroad dashboard when a non-existing UTM URL is accessed. The "Destination" and "Title" are automatically set based on the URL. Added validation to prevent duplicate UTM parameter combinations. Stored IP address and browser GUID during UTM link creation to prevent spam. (1 PR)
  • Enhanced UTM Link Listing: Added "Conversions (%)", "Number of sales", and "Sales volume ($)" to the UTM link listing page. (1 PR)
  • "Copy" Button for UTM URL: Added a "Copy" button for the generated UTM URL on the create/edit page. (1 PR)
  • Destination in Link Details: Added the destination to the link details drawer. (1 PR)
  • Destination Column on Listing Page: Replaced the "Date" column with "Destination" on the listing page, as destination is more relevant. (1 PR)
  • Unique UTM Parameters per Destination: Modified validation to allow creating a link with the same UTM parameters but with a different destination. (1 PR)

Miscellaneous

  • Addressed various bugs and performed optimizations, such as truncating text fields appropriately for display and using more efficient database queries where applicable.
  • Implemented frontend components to support the new functionality.
  • Updated documentation and help center content with information on the new UTM link tracking feature.

Sidekiq (`docs/sidekiq.md`)

Sidekiq

  • If you want to deduplicate a job (using https://github.com/mhenrixon/sidekiq-unique-jobs), 99% of the time, you’re looking for lock: :until_executed. It is fast because it works by maintaining a Redis Set of job digests: If a job digest is in this list (O(1)), running perform_async will be a noop and will return nil.
    Furthermore, you likely should NOT use on_conflict: :replace, because for it to remove an existing enqueued job, it needs to find it first, by scrolling through the Scheduled Set, which is CPU expensive and slow. It also means that perform_async will be as slow as the length of the queue, or fail entirely ⇒ you can break Sidekiq but just having one job like this enqueued too often.
  • Use scripts in the app/services/onetime folder

View the backtrace of a dead job of class ExportUserSalesDataWorker with the first argument as 123 in Sidekiq:

ruby
ds = Sidekiq::DeadSet.new
dead_jobs = ds.select { |job| job.klass == 'ExportUserSalesDataWorker' && job.args[0] == 123 }
pp dead_jobs.first.error_backtrace; nil

DANGER


Removing a lot of jobs from Sidekiq

Situation: You've queued a lot of Sidekiq jobs at once, but you're now realizing one of the following:

  • They're too slow
  • You put them in the wrong queue
  • There are too many of them

All of which can result in delays in the execution of more important jobs.

A solution may be to just remove those jobs.

For example, if you want to delete all jobs from the queue default for the worker class ElasticsearchIndexerWorker, for the index Purchase::Indices::V999, you can do this:

ruby
def delete_batch_of_jobs
  i = 0
  queue = Sidekiq::Queue.new('default')
  jobs = []
  queue.each do |job|
    if job.klass == "ElasticsearchIndexerWorker" && job.args[1]['class_name'] == 'Purchase::Indices::V999'
      i += 1
      jobs << job
      break if i == 500
    end
  end
  jobs.each(&:delete)
  i
end

def delete_jobs_with_running_total
  total = 0
  loop do
    deleted = delete_batch_of_jobs
    total += deleted
    puts "[#{Time.now}] Total deleted: #{total}"
    break if deleted == 0
  end
  total
end

delete_jobs_with_running_total

Origins: https://gumroad.slack.com/archives/C0B4VNR0B/p1591816778064300

Queuing batches of long running jobs

Situation: we need to run the AnnualPayoutExportWorker job for all creators that have received at least one payout last year, so we can send them out the year in review email afterward.

This script will enqueue jobs progressively so we can monitor the effect of enqueuing long running jobs and adapt live:


Support (`docs/support.md`)

Gumroad Admin Operations

This document contains various administrative operations for Gumroad.
To use, run these commands in the production console.

Payout operations

Process a PayPal payout for a specific user

ruby
PayoutUsersService.new(
  date_string: "2025-4-18",
  processor_type: "PAYPAL",
  user_ids: User.find_by(email: "[email protected]").id
).process

Process a Stripe/Bank payout for a specific user

ruby
PayoutUsersService.new(
  date_string: "2025-4-18",
  processor_type: "STRIPE",
  user_ids: User.find_by(email: "[email protected]").id
).process

Unpause payouts for a specific user

ruby
User.find_by(email: '[email protected]').update!(payouts_paused_internally: false)

Update payment address for a specific user

ruby
User.find_by(email: '[email protected]').update!(payment_address: "[email protected]")

Enable Stripe Connect for a specific user

ruby
User.find_by(email: '[email protected]').update!(can_connect_stripe: true)

Issue smaller amounts

Check unpaid balance up to specific dates to find the closest amount to what you want to pay out:

ruby
User.find_by(email: '[email protected]').unpaid_balance_cents_up_to_date("2025-4-23")
User.find_by(email: '[email protected]').unpaid_balance_cents_up_to_date("2025-4-22")
User.find_by(email: '[email protected]').unpaid_balance_cents_up_to_date("2025-4-21")
User.find_by(email: '[email protected]').unpaid_balance_cents_up_to_date("2025-4-20")

Once you've identified the appropriate date, issue a payout up to that date:

ruby
PayoutUsersService.new(
  date_string: "2025-4-20", # Use the date that gives the closest amount to what you want to pay out
  processor_type: "STRIPE",
  user_ids: User.find_by(email: "[email protected]").id
).process

Get details of last 5 payouts for a user

ruby
User.find_by(email: '[email protected]').payments.select(:id, :created_at, :processor, :amount_cents, :currency, :state).last(5)

Check last 2 failed payouts for a user

ruby
User.find_by(email: '[email protected]').payments.failed.last(2)

Cancel a payout

If it has not been triggered on Stripe/PayPal, can just mark it as failed:

ruby
Payment.find(PAYMENT_ID).mark_failed!

Or using payment external ID:

ruby
Payment.find_by_external_id("abcdefghijklmno==").mark_failed!

Mark unclaimed/canceled payout as returned

ruby
Payment.find(PAYMENT_ID).mark_returned!

Or using payment external ID:

ruby
Payment.find_by_external_id("abcdefghijklmno==").mark_returned!

Find PayPal Connect email for a user

ruby
User.find_by(email: "[email protected]").paypal_connect_account.paypal_account_details["primary_email"]

Look up PayPal merchant account by ID

ruby
PaypalIntegrationRestApi.new(nil, authorization_header: PaypalPartnerRestCredentials.new.auth_token).get_merchant_account_by_merchant_id("MERCHANT_ID")["primary_email"]

Payment operations

Find purchase ID by creator and customer email

ruby
User.find_by(email: '[email protected]').sales.successful.where(email: '[email protected]').pluck(:id, :created_at, :stripe_transaction_id, :total_transaction_cents)

Find purchase ID by customer email only (shows last 25)

ruby
Purchase.successful.where(email: '[email protected]').select(:id, :created_at, :stripe_transaction_id, :total_transaction_cents).last(25)

Process refund by purchase ID

ruby
Purchase.find(purchase_id).refund!(refunding_user_id: GUMROAD_ADMIN_ID)

Process refund by external ID

ruby
Purchase.find_by_external_id(purchase_external_id).refund!(refunding_user_id: GUMROAD_ADMIN_ID)

Find PayPal purchases from a charge ID

ruby
Charge.find_by_external_id("abcdefghijklmno==").purchases

Product management

Undelete a product

ruby
User.find_by_username("<username>").links.f("<permalink>").mark_undeleted!

Remove custom receipt text (using product URL)

ruby
product = Link.find_by_unique_permalink("<username>.gumroad.com/l/abcde")
product.update!(custom_receipt: nil)
ruby
product = Link.find_by(email: '[email protected]', custom_permalink: 'customname')
product.update!(custom_receipt: nil)

Alternative method:

ruby
user = User.find_by(email: '[email protected]')
product = user.links.find_by(unique_permalink: 'PERMALINK')
product.update!(custom_receipt: nil)

Remove rental option

ruby
user = User.find_by(email: '[email protected]')
product = user.links.find_by(unique_permalink: 'PERMALINK')
product.update(purchase_type: :buy_only)

User management

Restore refunding capability

ruby
u = User.find_by(email: '[email protected]')
u.update!(refunds_disabled: false)

Remove old email from checkout

Get the current email:

ruby
User.find_by(email: "[email protected]").alive_cart.email

Remove the email:

ruby
User.find_by(email: "[email protected]").alive_cart.update!(email: nil)

Get user's annual sales report URL

ruby
User.find_by(email: "[email protected]").financial_annual_report_url_for(year: 2024)

Find user ID

ruby
User.find_by(email: '[email protected]').id

Confirm user email

ruby
user = User.find_by(email: '[email protected]')
# or
user = User.find_by(id: USER_ID)
user.confirm

Refresh purchases in user's library

Link unlinked purchases:

ruby
user = User.find_by(email: "[email protected]")
if user.present?
  Purchase.where(email: user.email, purchaser_id: nil).update_all(purchaser_id: user.id)
else
  puts "No user found with email: [email protected]"
end

Restore purchases the buyer deleted from their library:

ruby
user = User.find_by(email: "[email protected]")
# The library hides is_deleted_by_buyer rows; clear the flag to restore them (mirrors the admin undelete).
user.purchases.for_library
  .where("purchases.flags & ? != 0", Purchase.flag_mapping["flags"][:is_deleted_by_buyer])
  .find_each { |purchase| purchase.update!(is_deleted_by_buyer: false) }

Resend all receipts

ruby
email = "[email protected]"

purchases = Purchase.successful
                .where(email: email)
                .not_is_gift_sender_purchase
                .not_is_bundle_product_purchase

purchases.find_each do |purchase|
  purchase.resend_receipt
end

Subscription management

Check subscription cancellation date

ruby
Subscription.find(SUBSCRIPTION_ID).user_requested_cancellation_at

CAUTION: Cancel all active subscriptions for a creator

ruby
User.find_by_email("[email protected]").links.each do |product|
    product.subscriptions.active.each do |subscription|
        subscription.cancel!(by_admin: true)
    end
end

Taxes (`docs/taxes.md`)

Taxes

Metadata

Written by: @curtiseinsmann

Last updated: 2025-01-30

Purpose

This document is intended to be a reference for Gumroad's tax collection and compliance requirements as of the date of this writing. It will cover:

  • A brief history of Gumroad's tax collection requirements
  • The current state of Gumroad's tax collection requirements
  • The future goals of Gumroad from a tax collection context

A Brief History of Gumroad's Tax Collection Requirements

Gumroad has collected taxes in a multitude of ways over the years. Each jurisdiction has its own unique requirements and Gumroad has had to adapt to these requirements over time.

Here are some examples of things that Gumroad has supported in the past, that it doesn't anymore, but where ghosts of the past may still haunt the business logic within the codebase:

  • Creator tax responsibility
  • Taxation based on whether or not a product is recommended
  • Allowing creators to set the tax rate for their products
  • Allowing creators to decide which tax jurisdictions they'd like to collect taxes from
  • Allowing creators to set tax exclusivity for their products
  • Having a distinction on whether taxes are Gumroad's responsibility or the creator's responsibility

The Current State of Gumroad's Tax Collection Requirements

As of 2025, Gumroad is a Merchant of Record. This means that Gumroad is responsible for collecting and remitting taxes on behalf of the creator. Gumroad will collect and remit taxes for all jurisdictions where Gumroad meets certain tax collection requirements. The creator no longer needs to worry about tax collection requirements.

Whether or not Gumroad qualifies for tax collection in a particular jurisdiction varies from jurisdiction to jurisdiction. These requirements can be quite complex, and are outside the scope of this document. Basically, from the engineer's perspective, if Steven Olson tells us that Gumroad needs to start collecting taxes in a particular jurisdiction, we should change Gumroad to do so. Steven Olson is in touch with various tax authorities, and is the point of contact for Gumroad's tax collection requirements.

Technical Details

Tax Collection

One should be encouraged to use the codebase as a source of truth for how Gumroad collects taxes. This section describes the high-level technical details.

The key field on the Purchase model is gumroad_tax_cents. This field is used to store the tax amount that Gumroad is responsible for. In order to calculate this field, Gumroad uses the SalesTaxCalculator class. It is within this class that the logic for tax calculation is implemented. After tax calculation, the result is stored in the gumroad_tax_cents field on the Purchase model.

The exact logic for tax calculation depends on which jurisdiction the sale goes into. Those will be described in the following jurisdiction-specific sections.

Tax Reporting

Gumroad is required to report transactions and taxes to certain authorities, in various different formats. To understand what kinds of reports exist, and how they are generated, see sidekiq_schedule.yml, and the various classes that are scheduled. Searching for report case-insensitive within sidekiq_schedule.yml will give you a good starting point.

Up to this point, reporting formats vary, and are not consistent across jurisdictions. Steven Olson has different requirements for different jurisdictions, and thus the result is different reporting formats. As we start adding more jurisdictions, we may want to consider consolidating these reporting formats into a more unified format, so that we can scale as we add more jurisdictions.

For certain states in the United States (Ohio, Wisconsin, North Carolina, New Jersey, Pennsylvania, and Washington), we auto-file taxes through TaxJar. This is done by creating a TaxJar transaction record in an async job called CreateDiscoverSalesReportsJob. In 2025, we will need to add more states to this list, as we've started collecting taxes in more states. We will also need to adjust this job to include a broader scope of purchases into these states, because we've started collecting taxes on more types of purchases, rather than just Discover sales.

United States

Gumroad collects taxes on sales into certain states. These are represented by TAXABLE_US_STATE_CODES in the codebase.

Gumroad uses TaxJar to calculate sales tax for the United States. TaxJar is a third-party tax calculation service.

Canada

Gumroad collects taxes on all sales into Canada. Gumroad uses TaxJar to calculate sales tax for Canada.

Tax on All Sales In Certain Countries

Gumroad collects taxes on all sales into certain countries. These countries include:

  • Australia
  • Singapore
  • Norway
  • Canada
  • All countries in the European Union, represented by EU_VAT_APPLICABLE_COUNTRY_CODES
  • Japan (by way of a feature flag, explained below - activated as of January 1, 2025)
  • India (by way of a feature flag, explained below - activated as of January 1, 2025)
  • Switzerland (by way of a feature flag, explained below - activated as of January 1, 2025)

Taxes for these countries are calculated by using ZipTaxRate entities as a lookup table.

Tax on All Digital Sales In Certain Countries

Gumroad collects taxes on all digital sales into certain countries. We assume that all sales are digital, except for physical products. Creators can no longer create physical products as of 2025. However, Gumroad still supports the sale of physical products that were created before 2025, and thus needs to support business logic that taxes these sales into account when making calculations.

These countries include:

  • South Korea (by way of a feature flag, explained below)
  • Mexico (by way of a feature flag, explained below)

Taxes for these countries are calculated by using ZipTaxRate entities as a lookup table.

Stripe Countries (and feature flags)

When we rolled out Merchant of Record on January 1, 2025, the goal was to:

  1. Collect taxes on sales into jurisdictions for which Gumroad has responsibility
  2. Be prepared to collect taxes on sales into jurisdictions for which Gumroad may have responsibility in the near future

To satisfy (2), we created a feature flag for each country where Gumroad may have responsibility in the near future. We took a snapshot of the countries listed on Stripe's supported countries page in late December 2024.

The feature flag looks like this: collect_tax_<country_code>. For example, the feature flag for Chile is collect_tax_cl.

The feature flag is used to determine whether Gumroad should collect taxes on sales into a jurisdiction. The feature flag is used in the SalesTaxCalculator class.

When Steven Olson tells us that Gumroad needs to start collecting taxes in a particular jurisdiction, we enable the feature flag for that jurisdiction. For good measure, the engineer should proofread the codebase and understand the implications of enabling the feature flag. Those implications should be confirmed with Steven Olson before the feature flag is enabled.

Tax Exemption

For certain jurisdictions, customers may be exempt from taxes if they provide some sort of valid tax identification number. To name a couple of examples, this would be the Australian Business Number (ABN) for Australia, or the GST Identification Number for Singapore.

Validation of these tax identification numbers is handled within the SalesTaxCalculator class, as well as a few other places. We use different validation services for different jurisdictions. Eventually, we will want to consolidate these validations into a single dependency, probably using Tax ID Pro, which is used for most jurisdictions as of this writing.

The Future Goals of Gumroad from a Tax Collection Context

The launch of Merchant of Record greatly simplified Gumroad's tax collection requirements, as well as the Gumroad offer to creators. It opened up the door for more simplification of the product. The following sections will enumerate over these opportunities.

All of these opportunities will only be possible if Gumroad is able to somehow remove its dependency on PayPal. PayPal was removed from Gumroad in late 2024, but has since been re-added.

Migration to Stripe Tax

Stripe Tax is a third-party tax calculation service that Stripe offers. It is a powerful tool that Gumroad could use to simplify its tax collection requirements.

A migration to Stripe Tax would open up the opportunity to:

  • Remove tax calculation logic from Gumroad's codebase
  • Remove the dependency on TaxJar

Tax Calculation Accuracy

A migration to Stripe Tax would also provide a better opportunity for more accurate tax calculation. In the current state, Gumroad assumes all products are digital, except for physical products. This is a simplification that may not always be accurate. Stripe Tax provides support for product tax codes. Integrating these tax codes into Gumroad would allow Gumroad to more accurately calculate taxes on sales.

Removal of creator-owned Merchant Accounts

Merchant of Record opens up the opportunity to remove creator-owned Merchant Account integrations from the platform.

With creator-owned Merchant Accounts removed, all transactions would be processed through Gumroad's Stripe account. This would simplify the Gumroad offer to creators, as well as the Gumroad codebase.


Testing (`docs/testing.md`)

Testing


Ruby Tests

The whole test suite:

bash
$ bin/rspec

A single file:

bash
$ bin/rspec spec/requests/dashboard_spec.rb

A specific test:

bash
$ bin/rspec spec/requests/dashboard_spec.rb:75

Dependencies

  • Run make local in a separate shell to spin up required services (e.g. db) through Docker.
  • Run RAILS_ENV=test bin/rails db:setup to set up the test database.
  • Run RAILS_ENV=test bin/rails js:export to update autogenerated JavaScript files for the test environment.

Fast local iteration

Run only the specs relevant to your local changes:

bash
$ bin/rspec-changed             # specs mapped from files changed vs origin/main (+ staged/unstaged/untracked)
$ bin/rspec-changed --dry-run   # print the spec list without running it
$ bin/rspec-changed --base main # diff against a different base ref
$ bin/rspec-changed --fail-fast # unknown flags are passed through to rspec

It maps app/<dir>/**/foo.rb → spec/<dir>/**/foo_spec.rb, lib/**/foo.rb → spec/lib/**/foo_spec.rb, controllers additionally to their spec/requests counterparts, and includes changed *_spec.rb files directly. Files without a matching spec (views, frontend, config) are skipped, so it's a pre-push sanity pass, not a substitute for CI.

bin/rspec is Spring-enabled: after the first (slow, full-boot) run, subsequent runs skip Rails boot entirely by forking from a preloaded application. Some Spring tips:

bash
$ bin/spring status         # is the preloader running?
$ bin/spring stop           # restart the preloader (after gem/env/initializer changes)
$ DISABLE_SPRING=1 bin/rspec spec/...   # bypass Spring for a pristine boot (e.g. when recording VCR cassettes)

Spring watches config/, .env* files, and Gemfile.lock and restarts itself when they change — but if test behavior looks stale or env vars don't take effect, bin/spring stop is the first thing to try.

On macOS, Spring's fork-based workers can crash inside Apple's Objective-C runtime (+[NSCharacterSet initialize] may have been in progress in another thread when fork() was called). If you hit that, set this in your shell profile:

bash
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES

Integration testing

We use capybara with selenium (webdriver) for our integration specs.

  • We add e2e (also called request) specs for new features.
    • They should cover at least the "happy path".
    • They should also cover errors that are to be expected (invalid inputs, etc).
    • Ideally, they would also cover some edge cases.
  • We also add e2e specs when making a change to an existing feature and we discover it doesn't have adequate specs.
  • When fixing a bug, it's better to create a failing e2e spec to reproduce it, then work on the fix. The fix should make the spec green.
  • We prefer to avoid unnecessary class name selectors in favor of locating elements by:
    • Text content
    • Input label
    • Input placeholder
    • ARIA attributes (might need to be added) for things that do not have a textual representation of their own
    • In case none of the above works, a class name selector might be used. Ideally, not a js-* one.
  • For clicks, click_on "Text" or, if that doesn't work, find_and_click "selector", text: "Text", are preferred.
  • We prefer fill_in "locator", with: "value" or find("locator").fill_in(with: "value") over x.native.send_keys("...").
  • If there's an existing test that can be updated, that's preferred over a new test (which takes more lines of code and test suite running time)

Google Chrome

For integration specs, we use Chrome and chromedriver.

For Linux:

bash
$ wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
$ sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
$ sudo apt-get update
$ sudo apt-get install google-chrome-stable

XQuartz

For macOS, you need to install XQuartz to run the request specs.

bash
$ brew install xquartz

Reproduce Buildkite test failures locally

https://github.com/gumroad/web/issues/8410#issuecomment-318496067

Preventing flaky specs in Capybara

There are some basic tips you can use to prevent flaky specs in Capybara

  • Rely on expect(page).to have_selector("selector") as much as possible. This is a smart method in Capybara that handles several situations. Do not use find if you can help it. have_selector is your friend!
  • Do not use sleep, it is hacky and will lead to flakiness.
  • Use wait_for_ajax if you need to make sure all ajax requests have finished.
  • Test new specs multiple times, below is an example of how to run a spec many times locally:
shell
for i in {1..10}; do
  echo "Run number $i\n-"
  bin/rspec spec/requests/product_creation_spec.rb:28
done

Troubleshooting

Failing specs?

If the spec is new or modified by your PR: It likely broke because of your changes. Try to fix it. Ask for help from the reviewer when you assign it for review if you can't fix it.

If you are sure the failing specs are unrelated to your PR: It could be a flaky spec. Confirm the spec passes multiple times locally but not on CI. Let the reviewer know when you assign it for review (example).

502 error navigating to checkout?

Try running this again:

bash
RAILS_ENV=test bin/rails js:export

The bin/dev script automatically generates JS constants for the development domain, so without this command the tests will try to navigate to that instead of the test domain.

Cannot get chromedriver connection?

If you're running an integration spec and it times out with this error:

text
Selenium::WebDriver::Error::WebDriverError:
       unable to obtain stable chromedriver connection in 60 seconds (127.0.0.1:7055)

Rack times out

If you're running an integration spec and it times out with this error:

text
<Rack::Timeout::RequestTimeoutError: Request ran for longer than 60000ms >

You can disable Rack timeout locally. Create a file .env.test.local with the following contents:

text
DISABLE_RACK_TIMEOUT="1"

For the new env variables to take effect, you might need to run bin/spring stop before running the tests again.

VCR Cassettes

We use VCR to record HTTP interactions (Stripe, PayPal, etc.) so specs replay recorded responses instead of hitting real APIs in CI. Cassettes are YAML files under spec/support/fixtures/vcr_cassettes/.

When to regenerate cassettes

If your code change causes a spec to follow a new HTTP code path (e.g., removing a guard clause that previously short-circuited before an API call, changing request parameters, or adding a new external call), the existing cassette won't cover the new interaction. You need to regenerate it.

How to regenerate

  1. Make your code change
  2. Run the affected spec locally:
    bash
    DISABLE_SPRING=1 bin/rspec spec/path/to_spec.rb
  3. VCR automatically records new HTTP interactions into .yml cassette files
  4. git add the new or updated cassettes under spec/support/fixtures/vcr_cassettes/
  5. Commit them with your PR

Rules

  • Do not stub external API calls to work around missing cassettes. Run the spec locally to record the real interaction.
  • Do not share cassettes across test files. Scope them to specific tests to avoid collisions.
  • Delete stale cassettes when the test or endpoint they cover no longer exists.
  • If the spec needs real API credentials to record, see the deployment repo for credential setup.

Common scenarios

Scenario What to do
Removed a guard clause (e.g., admin login block) Test now reaches an API call it didn't before. Run locally to record new cassette.
Changed API parameters Old cassette won't match. Delete it, run locally to re-record.
New test in an existing :vcr describe block Just run locally. VCR auto-records.
Test passes locally but fails in CI with VCR error You forgot to commit the cassette file.

Previewing Emails

You can preview emails locally at /rails/mailers

Custom domains

Setting up a custom domain on staging

Use domains-staging.gumroad.com instead of domains.gumroad.com in the DNS record.

Setting up a custom domain on local

  1. Add a host entry in /etc/hosts

    text
    127.0.0.1 sample-custom-domain.example.com
  2. Configure sample-custom-domain.example.com as a custom domain in localhost:3000/settings/advanced

  3. Remove Rails.env.development? || part from this line

  4. Restart the server and navigate to http://sample-custom-domain.example.com:3000

That will load the profile page of the creator through the custom domain.

Purchases

Stripe

When testing purchases, only use the test credit card numbers listed in Stripe's documentation.

Examples:

  • 4242 4242 4242 4242
  • 4000 0000 0000 0077

PayPal

We use Braintree as a gateway for PayPal as of writing this wiki.

Purchase flow (Buyer) - staging testing

Login credentials

Sandbox Gumroad account

Used for Payouts. No sandbox sales/transactions will show up here as Braintree does not integrate with the PayPal sandbox.

Credentials

IPN messages

We currently rely on IPN messages for:

  • Chargebacks and reversed chargebacks
  • Payouts completion notification, which also contains the payouts fee information.

Viewing IPN messages

Editing IPN endpoint

PayPal setting and other comments

  • No operation apart from payouts currently goes through the Gumroad PayPal app itself. We use user credentials w/ their classic API offerings to perform payouts.
  • Create and manage new sandbox accounts in the https://developer.paypal.com site. Use shared PayPal credentials or get an account created from someone who has payments admin access.

PayPal portal and IPN message encoding settings

Braintree

Used as a gateway for PayPal transactions. Only for transaction creation, user vaulting and refunds

Ask someone who has payments credentials for an account for Braintree in either sandbox or production.

Purchase flow (Buyer) - staging testing

  • Transactions made locally / staging will show up on Braintree only. Not in PayPal.

Debugging the overlay and embed widgets

The overlay and embed widgets both rely on iFrames and are best tested by running a host page on a separate web server. Sinatra is a great tool to get up and running quickly with a web page running on its own server.

Both widgets now detect the Rails application environment automatically -- simply include the script from whichever environment you wish to investigate, e.g. <script src="http://localhost:3000/js/gumroad.js"/>. The code snippets on the /widgets page also reflect the current environment.

Testing locally

There is a simple web app in our repository called web-overlay-test. Once you check it out, follow these instructions to test the overlay and/or embed.

  • Edit index.html to include the permalinks of your products
  • Run ruby web.rb
  • If you run into an error about missing sinatra run sudo gem install sinatra
  • Visit http://localhost:4567

Visual testing

We use QA Wolf for visual testing. QA Wolf is enabled by default on main branch and is not enabled by default on feature branches.

Interacting with local environment

Making a new subscription payment

ruby
# Run this in a rails console
sub = Subscription.last.charge!
sub.charge!

Note: If creating multiple subscription purchases in a short time, this method may run afoul of purchase model validations (e.g., Purchase#not_double_charged). In these cases you may comment out such validations for testing purposes.

Making purchases using Stripe Payment Request Button

Browser payment

Add a payment method to your browser (Eg: for Chrome, add the Stripe 4242 4242 4242 4242 card in chrome://settings/payments)

Apple Pay

  1. You need to set up Apple Pay with a real card on an iPhone or touch bar Macbook. This card will not be charged on local and staging environments.
  2. Add the domain to Stripe's list of Apple Pay Domains if testing on a deploy branch. discover.staging.gumroad.com is already added for staging. For local development see Apple Pay docs — the app must be exposed over HTTPS (e.g. via ngrok) since Stripe requires HTTPS for Apple Pay domain registration.

Google Pay

Set up wallet on Android phone and access website from mobile Chrome browser.

Common purchase failures

Browser GUID ban

After 3 failed purchase attempts from the same buyer, your browser GUID gets banned. You'll see the following message:

To overcome this:

ruby
# Run this in a rails console
purchase = Purchase.last
PlatformBlock.browser_guid.active.find_by(object_value: purchase.browser_guid).destroy
purchase.destroy

Making another purchase with a different buyer email should work now.


Users (`docs/users.md`)

Users & authentication

Users

Running bin/rails db:prepare seeds the database with the following users:

Email Password Team Role Internal Admin
[email protected] password Owner ✔
[email protected] password Admin ✘
[email protected] password Marketing ✘
[email protected] password Support ✘
[email protected] password Accountant ✘

Primary user

The primary user is [email protected]. This user also:

  • is an internal admin (can visit /admin)
  • has payout privilege
  • has risk privilege
  • is eligible to create service products (has an account older than User::MIN_AGE_FOR_SERVICE_PRODUCTS)

Team users

The other users allow testing specific team roles on primary user's store: admin, marketing, support, and accountant. They have no internal admin access and no special privileges.

(Note: [email protected] is a store admin, not an internal admin.)

Two-factor authentication

All non-production environments accept 000000 as a two-factor authentication code.


Development Windows (`docs/development/windows.md`)

Install Gumroad for development on Windows

Table of Contents


Getting Started

Prerequisites

Enable Long Paths in Git (One-time setup)

Run this in PowerShell as Administrator to avoid long file path issues:

bash
git config --system core.longpaths true

Install WSL and Ubuntu

  1. Open PowerShell as Administrator and run:

    bash
    wsl --install
  2. Restart if prompted.

  3. Choose a non-root username and password in the Ubuntu setup.

  4. Always launch Ubuntu for development work (not PowerShell or CMD).


For Windows (using WSL + Ubuntu)

Ruby

Install the version specified in .ruby-version (e.g., 3.4.3) using rbenv:

bash
# Base toolchain + rbenv
sudo apt update
sudo apt install -y \
  rbenv ruby-build git \
  build-essential autoconf bison libssl-dev zlib1g-dev \
  libreadline-dev libyaml-dev libffi-dev libgmp-dev

# One-time shell initialisation (also add to ~/.bashrc or ~/.zshrc)
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"

# Install Ruby
rbenv install 3.4.3
rbenv global 3.4.3

Node.js

Install the version specified in .node-version (e.g., 20.17.0) using nvm:

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20.17.0
nvm use 20.17.0

Docker

  1. Download Docker Desktop: https://www.docker.com/products/docker-desktop
  2. Open Docker → ⚙️ → Resources > WSL Integration
  3. Enable Ubuntu.
  4. Ensure Docker Desktop shows "Engine Running".

MySQL & Percona Toolkit

You don’t need to run MySQL locally — only its client libraries.

bash
sudo apt install libmysqlclient-dev mysql-client

For Percona Toolkit:

bash
sudo apt install percona-toolkit

Image Processing Libraries

Install the required dependencies:

bash
sudo apt update && sudo apt install -y \
  build-essential libxslt-dev libxml2-dev \
  imagemagick ffmpeg pdftk

Installation

Ruby Gems (via Bundler)

bash
gem install bundler
bundle install
gem install dotenv

Node.js Packages

bash
corepack enable
npm install

Configuration

Optional: Create `.env` file

Copy the example and fill in secrets (if needed):

bash
cp .env.example .env

Running Locally

Start Docker services

bash
LOCAL_DETACHED=true make local

Start the app

In a new terminal:

bash
sudo apt install libxslt-dev libxml2-dev
bin/rails db:prepare
bin/dev

Visit: http://localhost:3000. Seller subdomains and the asset/api hosts use *.localhost (e.g. http://seller.localhost:3000, http://api.localhost:3000).


Development

Logging In

Use:

See Users & authentication for other roles.

Reset Elasticsearch Indices

Run in Rails console:

ruby
DevTools.delete_all_indices_and_reindex_all

Common Tasks

Rails Console

bash
bin/rails c

Rake Tasks

bash
bin/rake <task_name>

Linting

We use ESLint (JS) and RuboCop (Ruby). You can enable pre-commit hooks:

bash
git config --local core.hooksPath .githooks

🧭 /etc/hosts Setup

*.localhost is auto-resolved to 127.0.0.1 by modern browsers and macOS, so no /etc/hosts edits are required for the standard development setup.


✅ Final Tips

  • Use WSL Ubuntu only, never PowerShell or CMD.
  • Use the versions from .ruby-version and .node-version.
  • If port :8080 is occupied, kill the process:
bash
sudo lsof -i :8080
kill -9 <PID>

Prompts Tailwindcss (`docs/prompts/tailwindcss.md`)

Tailwind CSS Migration Prompt

Use this prompt when asking AI assistants to help migrate CSS components to Tailwind:


Prompt

I'm migrating a CSS component to Tailwind CSS. Please help me convert the existing styles following these strict guidelines:

❌ Not Allowed

  1. No @apply directives - All Tailwind classes must be applied directly in the markup, not through CSS files
  2. No new utility classes - Only use Tailwind's built-in utilities; don't create custom ones
  3. No arbitrary values without justification - Avoid [#hex], [10px], etc. Use design system tokens. If you must use arbitrary values, explain why they're necessary
  4. No inline styles - All styling must be done through Tailwind classes

✅ Required Practices

Mobile-First Responsive Design

  • Design mobile-first and use sm:, md:, lg: prefixes only where values actually change
  • Don't prefix every utility with a breakpoint if the value is the same
    • ❌ Bad: hidden lg:flex lg:mx-2 lg:items-center lg:justify-between
    • ✅ Good: hidden lg:flex mx-2 items-center justify-between

Conditional Classes

  • Prefer using our classNames utility over cx, twMerge, or ternary operators

    tsx
    // Good
    import { classNames } from "$app/utils/classNames";
    classNames(
      "base-class",
      condition && "conditional-class",
    ) // Avoid
    `base-class ${condition ? "conditional-class" : ""}`;
    
    import cx from "classnames";
    cx("base-class", condition && "conditional-class");
    
    import { classNames } from "$app/utils/classNames";
    classNames("base-class", condition && "conditional-class");

Avoid `!important`

  • Avoid bang modifiers (mt-2!, flex!, etc.)
  • Instead, investigate the root cause of the specificity conflict and fix it properly
  • Refactor the cascade or component structure if needed

Typography

  • Consider using the prose plugin for content-heavy areas with paragraphs, lists, and rich text

Output Requirements

When providing the migrated code:

  1. Show the complete component with all Tailwind classes applied
  2. Explain any arbitrary values you had to use (with justification)
  3. Point out any specificity issues you encountered and how you resolved them
  4. Note any areas where the prose plugin might be beneficial

Usage

Copy the prompt above and paste it into your AI assistant when working on Tailwind migrations. Include the component code you're migrating after the prompt.


Runbooks Non Us Refund Fee Write Offs (`docs/runbooks/non-us-refund-fee-write-offs.md`)

Non-US refund fee write-offs

The recovery job writes off a fee when a non-US Stripe account has no safe transfer.
The job checks every page of transfers older than 120 days.
It also checks for unrecorded collections before the write-off.
Stripe lookup errors remain retryable.
Pinned transfers remain pending when their collection status is uncertain.

The write-off offsets the original ledger debit in USD and the holding currency.
The refund records the USD amount, UTC date, and reason.
The original credit and ledger debit remain available for audit.
The original fee remains in its payout history.
The write-off appears as a returned fee on its own payout.
The refund leaves the recovery job.

Pending and capped refunds

Run this procedure after deployment through the approved production admin tool.
The procedure can collect a fee if a safe transfer now exists.
The procedure does not reset the attempt count.

  1. Obtain the current refund IDs from the incident investigation.
  2. Preview those IDs with the following command.
ruby
Onetime::ReconcileNonUsRefundFees.process(refund_ids: ids)

The preview reads local records only.
The eligible field identifies non-US Stripe fees that still require reconciliation.
The preview does not prove that Stripe has no reversible transfer.

  1. Check the IDs and the pending USD total.
  2. Run the same IDs with writes enabled.
ruby
Onetime::ReconcileNonUsRefundFees.process(refund_ids: ids, dry_run: false)
  1. Check the returned collection IDs, write-offs, pending rows, and errors.
  2. Run the preview again to confirm the final totals.

Completed rows do not collect again or create another ledger offset.
Keep the incident open until the returned pending total reaches zero or each remaining row has an assigned action.

Aggregate write-offs

Use the refund timestamp to select a reporting period.
The following query returns the write-off count and total in USD cents.

ruby
Refund.written_off_fee_retention
  .where("json_data->>'$.fee_retention_written_off_at' >= ? AND json_data->>'$.fee_retention_written_off_at' < ?",
         starts_at.utc.iso8601, ends_at.utc.iso8601)
  .pick(Arel.sql("COUNT(*)"), Arel.sql("SUM(CAST(json_data->>'$.fee_retention_written_off_cents' AS SIGNED))"))

Runbooks Repair Capital Deduction (`docs/runbooks/repair-capital-deduction.md`)

Repair an unapplied Capital deduction

Use Onetime::RepairCapitalDeduction for a reconciled USD deduction with an existing Credit and BalanceTransaction.
The service applies that transaction to an explicit unpaid Balance.
It does not create another deduction, change paid balances, or issue a payout.

  1. Match the Stripe financing identifier and deduction amount to the Credit and BalanceTransaction.
  2. Reconcile the account's obligations and Stripe funds before selecting the target Balance.
  3. Run the service through the approved production repair workflow with dry_run: true.
  4. Review the returned record IDs and before/after amounts.
  5. Run the same arguments with dry_run: false after the required production approval.
  6. Verify the linked records, balance transaction sums, and Stripe reconciliation.
  7. Use the normal payout workflow to release eligible funds.
ruby
arguments = {
  credit_id: credit_id,
  balance_transaction_id: balance_transaction_id,
  balance_id: balance_id,
  stripe_loan_paydown_id: stripe_loan_paydown_id,
  expected_amount_cents: expected_amount_cents,
  expected_balance_cents: expected_balance_cents
}

Onetime::RepairCapitalDeduction.new(**arguments).process
Onetime::RepairCapitalDeduction.new(**arguments, dry_run: false).process

expected_amount_cents is the negative Capital deduction, not the difference between the ledger and Stripe funds.
expected_balance_cents is the target Balance amount before the repair.
Both issued and holding currencies must be USD.

The service locks the records and checks the account, amounts, financing identifier, transaction sums, and duplicate records.
It refuses changed amounts, mismatched records, partial links, and balances that are no longer unpaid.
A completed repair returns already_applied on a repeated call.
The live call logs the record IDs and amounts under RepairCapitalDeduction.
If any check fails, reconcile the new state before choosing another action.

Apply a deduction whose Stripe transfer has no source charge

Use Onetime::ApplySourcelessCapitalDeductions for the listed automatic withholdings whose Stripe transfer has no source_transaction.
Older code linked these credits to a seller purchase with no charge ID, which never succeeded, so RepairCapitalDeduction refuses them.
The task only accepts unique credit IDs from its CREDIT_IDS list.

ruby
Onetime::ApplySourcelessCapitalDeductions.new(credit_ids: batch).process
Onetime::ApplySourcelessCapitalDeductions.new(credit_ids: batch, dry_run: false).process
# Optional: leave a credit alone when it would end the seller's unpaid ledger below zero.
Onetime::ApplySourcelessCapitalDeductions.new(credit_ids: batch, skip_negative: true).process
# Optional: judge the ledger at one payout date instead of every date from the deduction's balance on.
Onetime::ApplySourcelessCapitalDeductions.new(credit_ids: batch, skip_negative: true, payout_date: Date.current).process

For each credit it:

  1. Reads the Stripe financing transaction, its linked payment, and that payment's source transfer.
    The financing transaction must be an automatic withholding in USD for the credit's amount and account, and the transfer must have no source_transaction.
  2. Locks the credit and checks the USD Stripe merchant account, the never-charged purchase link, and any existing balance transaction.
  3. In a dry run, returns the balance the deduction would land on (the earliest unpaid balance, or a new one dated to the Stripe deduction, which starts at 0) with before/after amounts.
    A transaction that an interrupted run already applied reports its own balance and links_applied_transaction: true.
  4. Refuses if the credit already stores a deduction time, payment ID, or transfer ID that differs from Stripe's, and never overwrites one.
    A credit cleared by an interrupted run must store all three.
  5. In a live run, clears the purchase link, records the Stripe payment and transfer IDs, and applies the deduction with Credit#apply_financing_paydown!.
    That reuses the existing balance transaction, or creates the missing one in USD.
    A transaction already applied to the seller's USD balance is only linked to the credit; the balance is not changed again.

Each credit returns dry_run, applied, already_applied, skipped, or refused with the reason; one refusal does not stop the batch.
A credit that already has a balance returns already_applied, and a credit left unlinked by an interrupted run resumes.
Run batches small enough to finish inside the console time limit, with the IDs written inline.

Read a dry run like this:

  • before_cents and after_cents are cumulative per target balance across the batch, in the order of credit_ids.
    A later credit on the same balance starts from the earlier credit's after_cents, so the last row shows the balance a live run of the same batch leaves.
    A credit whose transaction is already applied adds nothing itself, but its row still includes the earlier credits of the batch.
    Run the dry run and the live run with the same IDs in the same order.
    A credit's own effect is deduction_cents.
  • Payouts judges only the unpaid balances dated up to the payout date, so a newer positive balance cannot cover a deduction that an older payout date would still hit.
    The task judges the ledger the same way, at every date from the deduction's balance on, or only at payout_date when one is given.
    ledger_after_cents is the whole unpaid ledger after this credit (at payout_date when given).
    worst_ledger_cents is the lowest ledger across the dates judged, and negative_from is the first date at which this credit's ledger or its payout group is below zero, or nil.
    ends_negative is true when that happens at any judged date, and payout_held when the ledger is at zero or below, or any payout group is below zero, at any judged date.
    Groups follow Payouts: Stripe-held balances group by merchant account and currency, and Gumroad-held balances join the payout account's group.
    The figures ignore Payouts' exception for currencies an account cannot pay out, so a flag can be raised for a group Payouts would not hold.
    They also count only unpaid balances: a balance a payout has just claimed (processing) is not counted, so a payout that starts while a batch runs can change the outcome.
    Treat them as a guide, not a guarantee, and check payout_held and ends_negative together.
    Check every row with either flag before running live.
  • A transaction that an interrupted run already applied only gets linked to its credit.
    Its balance is not changed again, and its balance_state (paid, processing, or unpaid) is ignored, so a link to a paid balance does not reopen it.
    Such a credit adds nothing to the ledger, and skip_negative never skips it.
  • With skip_negative: true, a credit whose new deduction would end the ledger or its payout group below zero (ends_negative, not a ledger of exactly zero) returns skipped with ledger_after_cents and stays unapplied.
    Later credits are judged without it.
    With skip_negative: true, a live run holds the seller lock while it judges and applies each credit, so a payout cannot move the balances in between.
    That lock, and the Credit and Balance locks taken inside it, are held until the seller's transaction ends, so keep batches small.
  • new_balance_date is the date of the balance a live run opens when the seller has no unpaid one; every later row of the batch that lands on that balance repeats the first credit's date.
    The default applies every credit, negative or not; choosing between the two is a policy decision for the payout owner.

Setup Without Docker Mac (`docs/setup_without_docker/mac.md`)

Why?

Docker may lead to hogging of memory and resources, especially on older Macs. Direct installation of the required services helps to prevent that.

Required services

The below instructions will set up all services with the latest versions of the brew formulae. While this should usually work, you may run into issues due to a specific version not matching the docker version. In that case, you will have to manually install the respective version of that service.

For the versions required to match the docker setup, checkout docker/docker-compose-local.yml.

  • MySQL
  • Redis
  • DynamoDB
  • Elasticsearch

MySQL

text
brew install mysql percona-toolkit
brew link --force mysql

# to use Homebrew's `openssl`:
brew install openssl
bundle config --global build.mysql2 --with-opt-dir="$(brew --prefix openssl)"

# to set root password (use password="password"):
$(brew —-prefix mysql)/bin/mysqladmin -u root password <NEWPASSWORD>

Redis

  1. Install with Homebrew

    text
    brew install redis
  2. To have launchd start redis now and restart at login:

    text
    brew services start redis
  3. Test if Redis server is running.

    text
    redis-cli ping

    If it replies “PONG”, then it’s good to go!

DynamoDB

DynamoDB Local needs Java 17+.

text
brew install --cask temurin dynamodb-local

Run it on the port DYNAMODB_ENDPOINT expects (8002, not its 8000 default):

text
dynamodb-local -port 8002

Elasticsearch

  1. sh
    brew tap elastic/tap
    brew install elastic/tap/elasticsearch-full
  2. Add the following to your .bashrc/.zshrc file

    text
    export ES_JAVA_OPTS=-Xms512m -Xmx512m
    export discovery.type=single-node

Now follow the rest of the README.md for the installation process. Once done open http://localhost:3000 after running the foreman command and it should point to your Gumroad server. Seller subdomains and the asset/api hosts use *.localhost (e.g. http://seller.localhost:3000, http://api.localhost:3000) — modern browsers auto-resolve these to 127.0.0.1, so no /etc/hosts edits are needed.


--- METRICS ---