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
- https://www.ruby-lang.org/en/documentation/installation/
- Install the version listed in the .ruby-version file
Node.js
- https://nodejs.org/en/download
- Install the version listed in the .node-version file
Docker
We use Docker to setup the services for development environment.
- For MacOS: Download the Docker app from the Docker website
- For Linux:
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:
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]- For Linux:
- MySQL:
- https://dev.mysql.com/doc/refman/8.4/en/linux-installation.html
apt install libmysqlclient-dev
- Percona Toolkit: https://www.percona.com/doc/percona-toolkit/LATEST/installation.html
- MySQL:
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.
gem install bundlerInstall gems:
bundle installAlso make sure to install dotenv as it is required for some console commands:
gem install dotenvnpm and Node.js dependencies
Make sure the correct version of npm is enabled:
corepack enableInstall dependencies:
npm installConfiguration
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:
make localIf 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
bin/rails db:prepareFor Linux (Debian / Ubuntu) you might need the following:
apt install libxslt-dev libxml2-dev
Start the application
bin/devThis 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 rejectDomain=localhost, so cookies set onlocalhost:3000aren't sent toseller.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.testvia mkcert) whereDomain=.gumroad.testworks.
Testing
Run the full test suite:
bin/rspecRun a single file or specific test:
bin/rspec spec/requests/dashboard_spec.rb
bin/rspec spec/requests/dashboard_spec.rb:75Dependencies
Before running tests:
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 environmentIntegration 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:
# Run this in a rails console:
DevTools.delete_all_indices_and_reindex_allPush Notifications
To send push notifications:
INITIALIZE_RPUSH_APPS=true bundle exec rpush start -e development -fCommon Development Tasks
Rails console:
bin/rails cRake tasks:
bin/rake task_nameLinting
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
macOS Error When Running Tests (Related to `fork()`)
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.
export DISABLE_SPRING=1
bin/rspec spec/requests/balance_pages_spec.rbThis 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.
/* 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
CreateUsStateMonthlySalesReportsJob.perform_async("WA", 8, 2022)Backfill multiple states / months
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
endGenerate a monthly summary report of all US States
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:
GenerateSalesReportJob.perform_async("JP", "2025-1-1", "2025-1-31", "all_sales")- Pass
discover_salesinstead ofall_salesto 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:
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:
$ cd nomad
$ source nomad_proxy_functions.sh
$ proxy_on productionThen 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:
- Cannot find Purchase/User/Link with id=#
- 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.
- No space left on device
- 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.
- Following on point before, nomad might give some clues on (blue/green) web workers not processing traffic. EC2 Status ≠ Container status.
What To Do
- 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.
- 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.
- 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,
sshinto the worker and runsudo rm -rf /tmp/*. You may get errors saying "Operation not permitted", nothing to do about that. Then follow these steps:
grd
cd ..
ls -lTake note of the directory current links to. Then:
cd releases
ls -lYou 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.
- 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:
sudo rm -rf /tmp/*After that, run:
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
nc -z -w 5 [host] [port]where:
-ztells it to try connecting then tell us :+1: or :-1:-w 5only wait 5 seconds
Checking or getting SSL certs for a hostname
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
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
endSuspend users for fraud
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
endSuspend for fraud and refund user purchases
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
endMark users compliant (unsuspend users)
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
endPurchases
Refund purchases
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
endPayouts
Issue payouts
/* 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
- Install the Ruby extension.
- Install the vscode-rdbg extension.
- Start the supporting services by running
make localin a terminal. - Run the non-rails services by running
foreman start -f Procfile.debugin a terminal. - 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:
cd nomad/production && dotenv -f ../.env ./deploy_unattended.shIf 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:
cd nomad/production && ./lock_deployment.shManually unlock the deployment:
cd nomad/production && ./unlock_deployment.shHotfixes
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:
# 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-releaseThis 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:
git fetch --tags && git checkout -b comp-assets-hotfix-branch-name production-d6b4605/2018-05-02-13-16-03Make 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:
export DEPLOY_TAG="production-xxxxxxx"
cd nomad/production && dotenv -f ../.env ./deploy_unattended.shWhen 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 --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:
$ 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.nomadDeploying 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):
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:
cd nomad/staging/deploy_branch && \
BRANCH=<branch name> \
COMMAND='bundle exec rake "preview_qa:backdate_purchase[<purchase external_id>]"' \
./console.sh -wThe available tasks (all need the writable connection except inspect_subscription, which is read-only):
# 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:
bin/dev-lane 1Lane 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):
(
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
cd nomad/production && ./logs.shstaging
cd nomad/staging && ./logs.shMarketing 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: [...]}usingMarketing::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, withchannel=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; optionalcopyis validated by the shared model. Requiresidempotency_keyandconfirmation_tokenfrom 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 itsidempotency_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
GET /v2/products/:id/marketing/recommendationsafter publishing. Select a live channel and display itshandle,action.post_text, andaction.link_url. Do not approve automatically.- After the seller explicitly confirms,
POST /v2/marketing_actions/:action_id/approvewithidempotency_keyandconfirmation_tokenfrom that preview (and optional seller-editedcopy). POST /v2/marketing_actions/:action_id/executewith the keys from the approve response. Resolve retries with the same action ID;GET /v2/marketing_actions/:action_idshows the outcome.- Offer cancellation before execution via
POST /v2/marketing_actions/:action_id/cancel, withidempotency_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, URLhttps://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 onlyview_profile,view_sales,edit_products, andview_payouts; deselectaccountandview_public. - Verify the discovered inventory:
get_account,list_products,get_product,create_draft_product,publish_product,unpublish_product,list_sales,
andlist_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:
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:
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.mp4The 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
- Stop the OS recording and save outside tracked source, for example
tmp/mcp-review/YYYY-MM-DD/walkthrough.mov. - 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. - 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. - 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. - 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
Download and run the docker instance
bashdocker 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/bashRun ngrok in local machine
bashngrok http 3000Run puma in docker instance
bashscreen -S puma bash cd /app RAILS_ENV=<env> CUSTOM_DOMAIN=<ngrok domain> PUMA_WORKER_PROCESSES=<workers count> ./bin/memusg.sh ./docker/web/server.shRun Sidekiq in docker instance
bashscreen -S puma bash cd /app RAILS_ENV=<env> ./bin/memusg.sh ./docker/web/sidekiq_worker.shCreate products and make purchases in Gumroad through ngrok URL
Connect to screen and read memory usage
bashscreen -x puma Press Ctrl+c and note the peak memory usage.bashscreen -x sidekiq Press Ctrl+c and note the peak memory usage.
Operations in application
Repeat the following steps for 5 different products
/* 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 TABLEoperation
General rules
- Submit migrations in separate PRs, except for:
- Creating new tables
- Adding columns/indexes to normal tables
- Backward-compatible changes
- Use
change_table(table, bulk: true)for multiple changes to a table. - Avoid new foreign key constraints due to performance and logistical issues.
- Make one change per migration to prevent partial commits on failure.
- Consider alternatives to adding columns/indexes to large tables.
- Make new boolean columns
NOT NULLwhen possible. - 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
- Before removing a column:
- Remove all usages in code
- Add to
ignored_columns - Deploy
- Remove column and
ignored_columnsentry
Deploying
- Be present during deployment of your migration.
- Monitor:
- Sentry for recent production errors
- Cloudwatch Web dashboard (errors rate, successful purchases)
- Use
./logs.shinnomad/productionto view migration logs. - For large table migrations, ensure experienced support is available.
- Be aware of PT-OSC (pt-online-schema-change) process:
- Creates duplicate table
- Adds triggers for data sync
- Copies data in batches
- Renames tables
- If migration gets stuck:
- Check for queries "waiting for table metadata lock"
- Kill long-running queries if necessary
- To cancel a migration:
- Remove added triggers
- Empty and remove the temporary table
- Use
DISABLE_ALTERITY=1to bypass PT-OSC if needed.
Long-running tasks
- For long-running tasks, we can use
web_server_genericto run the script in production. Please ask in#engineeringSlack channel to make sure nobody else is using that instance before redeploying it. As of now, only oneweb_server_genericinstance can run at a time.
export DEPLOY_TAG=production-<revision>
cd nomad/production && ./start_generic_web.shThis 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:
INSTANCE_IP=10.1.x.x COMMAND="screen -adR" ./console.sh
bundle exec rails cThis 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:
cd nomad
source nomad_proxy_functions.sh
proxy_off production; proxy_on production
nomad_insecure_wrapper stop -purge web_server_generic
proxy_off productionHow 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>, seetest/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
- No FactoryBot in
test/. Everycreate(:thing)becomes a fixture row
or explicitModel.create!insetupwhen the object is genuinely
per-test. - 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. - Descriptive fixture names (
named_seller,accountant_for_named_seller), neverone/two. - 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. - 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 — forUserthat means at leastuser_risk_stateandrecommendation_type, or the firstsave!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. Forseller_idpointing atusers, writeseller_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(theowner_membership_must_existvalidation) — seetest/fixtures/team_memberships.yml.- RSpec's
stub_constdoesn't exist. Usewith_const(:NAME, value) { }
fromtest/support/constant_stubbing_helpers.rb. Minitest::Mock#expectwith kwargs needs the empty positional array:mock.expect(:exists?, true, [], index: "link-test").- Mailer tests that build
*_urls in assertions needinclude Rails.application.routes.url_helpersplus adefault_url_options
returning{ host: DOMAIN, protocol: PROTOCOL }.
Recipe for one file
- 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. - Add fixture rows. Grep the model for
attribute ... default:+validates ... inclusion:pairs and spell those columns out. - Write
test//<name>_test.rb.describe/it→test "...";expect(x).to eq(y)→assert_equal y, x; RSpecpermissions :a, :b do ... endfor policies →assert_policy_permits/refute_policy_permitsfromtest/support/policy_assertions.rb. - Run it:
RAILS_ENV=test bin/rails test test//<name>_test.rb.
(Run the wholetest/tree too — fixture changes can leak into sibling
tests.) git rmthe spec file. Run rubocop on the new files.- In the PR body: which files moved, spec-vs-test example counts (they should
match or the body explains why), and localrails testoutput.
Verification gate for autonomous merges
A migration PR is mergeable when all of:
- Diff touches only
test/**,test/fixtures/**, and deletes the
correspondingspec/**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_minitestand 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 — seetest/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
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
curl -v -X GET <https://api.paypal.com/v1/notifications/webhooks> \\
-H "Content-Type:application/json" \\
-H "Authorization: Bearer <access_token>"Register Webhook
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.
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
curl -v -X DELETE <https://api.paypal.com/v1/notifications/webhooks/><webhook-id> \\
-H "Content-Type:application/json" \\
-H "Authorization: Bearer <access_token>"Links related to Paypal's Instant Payment Notification(IPN) feature
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
IPN history
https://www.paypal.com/us/cgi-bin/webscr?cmd=_display-ipns-history
Orders API sample application setup Instructions
- Clone repo - https://github.com/sharang-d/orders
- Create file
paypal.rbin config/initializers/ - Add POST endpoint in the application to handle webhook notifications
- Change webhook URL in Paypal Developer account.
- Login [email protected] account
- Go to dashboard
- Click on
My Apps & Credentials - Under
REST API appssection Click onGumroadApplication. - Under
Sandbox Webhookssection Add webhook URL of your application.
paypal.rb
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
)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=trueto 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`)
UTM Link Tracking Feature Implementation
This document outlines the changes made to implement the UTM link tracking feature.
Seller Interface for UTM Links
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_linkstable 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_visitstable 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_salestable 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 withpurchase_id)purchase_id(bigint, required, indexed, and part of a unique composite index withutm_link_visit_id)
- Created the
- Models, Relationships & Validations (1 PR):
- Created corresponding models (
UtmLink,UtmLinkVisit,UtmLinkDrivenSale) with appropriate relationships (belongs_to, has_many) and validations (presence, uniqueness, format, etc.).
- Created corresponding models (
UTM Link Click/Visit Tracking
Implemented UTM link click/visit tracking.
Completed Tasks:
- Tracking Route: Created the route
GET "/u/:permalink"routing toUtmLinkTrackingController#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 theUtmLinkVisitmodel. - UTM Link Stats Update: Implemented logic to update link statistics (
total_clicks,unique_clicks) in theUtmLinkmodel. - Redirect to Destination: Implemented redirection to the destination URL specified in the UTM link after tracking the visit.
UTM Link Sale Conversion/Attribution
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_guidcookie. - 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)), runningperform_asyncwill be a noop and will returnnil.
Furthermore, you likely should NOT useon_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 thatperform_asyncwill 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/onetimefolder
View the backtrace of a dead job of class ExportUserSalesDataWorker with the first argument as 123 in Sidekiq:
ds = Sidekiq::DeadSet.new
dead_jobs = ds.select { |job| job.klass == 'ExportUserSalesDataWorker' && job.args[0] == 123 }
pp dead_jobs.first.error_backtrace; nilDANGER
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:
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_totalOrigins: 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
PayoutUsersService.new(
date_string: "2025-4-18",
processor_type: "PAYPAL",
user_ids: User.find_by(email: "[email protected]").id
).processProcess a Stripe/Bank payout for a specific user
PayoutUsersService.new(
date_string: "2025-4-18",
processor_type: "STRIPE",
user_ids: User.find_by(email: "[email protected]").id
).processUnpause payouts for a specific user
User.find_by(email: '[email protected]').update!(payouts_paused_internally: false)Update payment address for a specific user
User.find_by(email: '[email protected]').update!(payment_address: "[email protected]")Enable Stripe Connect for a specific user
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:
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:
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
).processGet details of last 5 payouts for a user
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
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:
Payment.find(PAYMENT_ID).mark_failed!Or using payment external ID:
Payment.find_by_external_id("abcdefghijklmno==").mark_failed!Mark unclaimed/canceled payout as returned
Payment.find(PAYMENT_ID).mark_returned!Or using payment external ID:
Payment.find_by_external_id("abcdefghijklmno==").mark_returned!Find PayPal Connect email for a user
User.find_by(email: "[email protected]").paypal_connect_account.paypal_account_details["primary_email"]Look up PayPal merchant account by ID
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
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)
Purchase.successful.where(email: '[email protected]').select(:id, :created_at, :stripe_transaction_id, :total_transaction_cents).last(25)Process refund by purchase ID
Purchase.find(purchase_id).refund!(refunding_user_id: GUMROAD_ADMIN_ID)Process refund by external ID
Purchase.find_by_external_id(purchase_external_id).refund!(refunding_user_id: GUMROAD_ADMIN_ID)Find PayPal purchases from a charge ID
Charge.find_by_external_id("abcdefghijklmno==").purchasesProduct management
Undelete a product
User.find_by_username("<username>").links.f("<permalink>").mark_undeleted!Remove custom receipt text (using product URL)
product = Link.find_by_unique_permalink("<username>.gumroad.com/l/abcde")
product.update!(custom_receipt: nil)Remove custom receipt text (using custom permalink)
product = Link.find_by(email: '[email protected]', custom_permalink: 'customname')
product.update!(custom_receipt: nil)Alternative method:
user = User.find_by(email: '[email protected]')
product = user.links.find_by(unique_permalink: 'PERMALINK')
product.update!(custom_receipt: nil)Remove rental option
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
u = User.find_by(email: '[email protected]')
u.update!(refunds_disabled: false)Remove old email from checkout
Get the current email:
User.find_by(email: "[email protected]").alive_cart.emailRemove the email:
User.find_by(email: "[email protected]").alive_cart.update!(email: nil)Get user's annual sales report URL
User.find_by(email: "[email protected]").financial_annual_report_url_for(year: 2024)Find user ID
User.find_by(email: '[email protected]').idConfirm user email
user = User.find_by(email: '[email protected]')
# or
user = User.find_by(id: USER_ID)
user.confirmRefresh purchases in user's library
Link unlinked purchases:
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]"
endRestore purchases the buyer deleted from their library:
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
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
endSubscription management
Check subscription cancellation date
Subscription.find(SUBSCRIPTION_ID).user_requested_cancellation_atCAUTION: Cancel all active subscriptions for a creator
User.find_by_email("[email protected]").links.each do |product|
product.subscriptions.active.each do |subscription|
subscription.cancel!(by_admin: true)
end
endTaxes (`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:
- Collect taxes on sales into jurisdictions for which Gumroad has responsibility
- 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
- Previewing emails
- Custom domains
- Purchases
- Debugging the overlay and embed widgets
- Visual testing
- Interacting with local environment
Ruby Tests
The whole test suite:
$ bin/rspecA single file:
$ bin/rspec spec/requests/dashboard_spec.rbA specific test:
$ bin/rspec spec/requests/dashboard_spec.rb:75Dependencies
- Run
make localin a separate shell to spin up required services (e.g. db) through Docker. - Run
RAILS_ENV=test bin/rails db:setupto set up the test database. - Run
RAILS_ENV=test bin/rails js:exportto update autogenerated JavaScript files for the test environment.
Fast local iteration
Run only the specs relevant to your local changes:
$ 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 rspecIt 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:
$ 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:
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YESIntegration 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"orfind("locator").fill_in(with: "value")overx.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:
$ 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-stableXQuartz
For macOS, you need to install XQuartz to run the request specs.
$ brew install xquartzReproduce 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 usefindif you can help it. have_selector is your friend! - Do not use
sleep, it is hacky and will lead to flakiness. - Use
wait_for_ajaxif 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:
for i in {1..10}; do
echo "Run number $i\n-"
bin/rspec spec/requests/product_creation_spec.rb:28
doneTroubleshooting
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:
RAILS_ENV=test bin/rails js:exportThe 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:
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:
<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:
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
- Make your code change
- Run the affected spec locally:bash
DISABLE_SPRING=1 bin/rspec spec/path/to_spec.rb - VCR automatically records new HTTP interactions into
.ymlcassette files git addthe new or updated cassettes underspec/support/fixtures/vcr_cassettes/- 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
Add a host entry in
/etc/hoststext127.0.0.1 sample-custom-domain.example.comConfigure sample-custom-domain.example.com as a custom domain in localhost:3000/settings/advanced
Remove
Rails.env.development? ||part from this lineRestart 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 42424000 0000 0000 0077
PayPal
We use Braintree as a gateway for PayPal as of writing this wiki.
Purchase flow (Buyer) - staging testing
Login credentials
- Email:
[email protected](any email) - Password:
password(any string will work)
Sandbox Gumroad account
Used for Payouts. No sandbox sales/transactions will show up here as Braintree does not integrate with the PayPal sandbox.
Credentials
- Email:
[email protected] - Password: Saved in 1Password
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
- Sandbox: https://www.sandbox.paypal.com/us/cgi-bin/webscr?cmd=_display-ipns-history
- Production: https://www.paypal.com/us/cgi-bin/webscr?cmd=_display-ipns-history
Editing IPN endpoint
- Sandbox: https://www.sandbox.paypal.com/cgi-bin/customerprofileweb?cmd=_profile-ipn-notify
- Production: https://www.paypal.com/cgi-bin/customerprofileweb?cmd=_profile-ipn-notify
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.htmlto 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
# 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
- 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.
- Add the domain to Stripe's list of Apple Pay Domains if testing on a deploy branch.
discover.staging.gumroad.comis 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:
# Run this in a rails console
purchase = Purchase.last
PlatformBlock.browser_guid.active.find_by(object_value: purchase.browser_guid).destroy
purchase.destroyMaking 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:
| 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:
git config --system core.longpaths trueInstall WSL and Ubuntu
Open PowerShell as Administrator and run:
bashwsl --installRestart if prompted.
Choose a non-root username and password in the Ubuntu setup.
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:
# 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.3Node.js
Install the version specified in .node-version (e.g., 20.17.0) using nvm:
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.0Docker
- Download Docker Desktop: https://www.docker.com/products/docker-desktop
- Open Docker → ⚙️ → Resources > WSL Integration
- Enable Ubuntu.
- Ensure Docker Desktop shows "Engine Running".
MySQL & Percona Toolkit
You don’t need to run MySQL locally — only its client libraries.
sudo apt install libmysqlclient-dev mysql-clientFor Percona Toolkit:
sudo apt install percona-toolkitImage Processing Libraries
Install the required dependencies:
sudo apt update && sudo apt install -y \
build-essential libxslt-dev libxml2-dev \
imagemagick ffmpeg pdftkInstallation
Ruby Gems (via Bundler)
gem install bundler
bundle install
gem install dotenvNode.js Packages
corepack enable
npm installConfiguration
Optional: Create `.env` file
Copy the example and fill in secrets (if needed):
cp .env.example .envRunning Locally
Start Docker services
LOCAL_DETACHED=true make localStart the app
In a new terminal:
sudo apt install libxslt-dev libxml2-dev
bin/rails db:prepare
bin/devVisit: 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:
- Email:
[email protected] - Password:
password - 2FA:
000000
See Users & authentication for other roles.
Reset Elasticsearch Indices
Run in Rails console:
DevTools.delete_all_indices_and_reindex_allCommon Tasks
Rails Console
bin/rails cRake Tasks
bin/rake <task_name>Linting
We use ESLint (JS) and RuboCop (Ruby). You can enable pre-commit hooks:
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-versionand.node-version. - If port
:8080is occupied, kill the process:
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
- No
@applydirectives - All Tailwind classes must be applied directly in the markup, not through CSS files - No new utility classes - Only use Tailwind's built-in utilities; don't create custom ones
- No arbitrary values without justification - Avoid
[#hex],[10px], etc. Use design system tokens. If you must use arbitrary values, explain why they're necessary - 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
- ❌ Bad:
Conditional Classes
Prefer using our
classNamesutility overcx,twMerge, or ternary operatorstsx// 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
proseplugin for content-heavy areas with paragraphs, lists, and rich text
Output Requirements
When providing the migrated code:
- Show the complete component with all Tailwind classes applied
- Explain any arbitrary values you had to use (with justification)
- Point out any specificity issues you encountered and how you resolved them
- Note any areas where the
proseplugin 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.
- Obtain the current refund IDs from the incident investigation.
- Preview those IDs with the following command.
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.
- Check the IDs and the pending USD total.
- Run the same IDs with writes enabled.
Onetime::ReconcileNonUsRefundFees.process(refund_ids: ids, dry_run: false)- Check the returned collection IDs, write-offs, pending rows, and errors.
- 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.
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.
- Match the Stripe financing identifier and deduction amount to the Credit and BalanceTransaction.
- Reconcile the account's obligations and Stripe funds before selecting the target Balance.
- Run the service through the approved production repair workflow with
dry_run: true. - Review the returned record IDs and before/after amounts.
- Run the same arguments with
dry_run: falseafter the required production approval. - Verify the linked records, balance transaction sums, and Stripe reconciliation.
- Use the normal payout workflow to release eligible funds.
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).processexpected_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.
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).processFor each credit it:
- 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 nosource_transaction. - Locks the credit and checks the USD Stripe merchant account, the never-charged purchase link, and any existing balance transaction.
- 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 andlinks_applied_transaction: true. - 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. - 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_centsandafter_centsare cumulative per target balance across the batch, in the order ofcredit_ids.
A later credit on the same balance starts from the earlier credit'safter_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 isdeduction_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 atpayout_datewhen one is given.ledger_after_centsis the whole unpaid ledger after this credit (atpayout_datewhen given).worst_ledger_centsis the lowest ledger across the dates judged, andnegative_fromis the first date at which this credit's ledger or its payout group is below zero, or nil.ends_negativeis true when that happens at any judged date, andpayout_heldwhen 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 checkpayout_heldandends_negativetogether.
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 itsbalance_state(paid,processing, orunpaid) is ignored, so a link to a paid balance does not reopen it.
Such a credit adds nothing to the ledger, andskip_negativenever 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) returnsskippedwithledger_after_centsand stays unapplied.
Later credits are judged without it.
Withskip_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_dateis 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
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
Install with Homebrew
textbrew install redisTo have launchd start redis now and restart at login:
textbrew services start redisTest if Redis server is running.
textredis-cli pingIf it replies “PONG”, then it’s good to go!
DynamoDB
DynamoDB Local needs Java 17+.
brew install --cask temurin dynamodb-localRun it on the port DYNAMODB_ENDPOINT expects (8002, not its 8000 default):
dynamodb-local -port 8002Elasticsearch
- sh
brew tap elastic/tap brew install elastic/tap/elasticsearch-full Add the following to your
.bashrc/.zshrcfiletextexport 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 ---
- Files Extracted: 27
- Estimated Token Budget: ~32469 tokens
- Canonical Reference: https://codewiki.google/github.com/antiwork/gumroad