Skip to content

Deploying the QBTime Manager Application

After deploying — commands to run

Quick reference for routine releases. The full step-by-step setup is in the sections below; this is the cheat sheet once everything is already configured.

1. Verify (run the test suite)

npm run typecheck; npm run lint; npm test

1a. Deploy Both Prod and Staging

npm run build
npm run deploy:prod
npm run deploy:staging

1b. Upgrade Both DB's

npm run db:migrate:prod
npm run db:migrate:staging

2. Deploy — Production (all in one)

npm run build
npm run db:migrate:prod
npm run deploy:prod
cd cron-worker
npx wrangler deploy --env production
cd ..

3. Deploy — Staging (all in one)

npm run build
npm run db:migrate:staging
npm run deploy:staging
cd cron-worker
npx wrangler deploy --env staging
cd ..

db:migrate:* is only needed when there are new migrations. Normal flow: deploy staging → verify on staging.qbtime.r2d2dev.com → deploy production.

Cron jobs (run every 15 minutes — :00, :15, :30, :45)

Set the secret once per environment (value lives in your password manager; it must match the app's INTERNAL_TASK_SECRET for that environment):

cd cron-worker
npx wrangler secret put INTERNAL_TASK_SECRET --env production
npx wrangler secret put INTERNAL_TASK_SECRET --env staging
cd ..

After each deploy, confirm the cron fires (watch for "*/15 * * * *" @ ... - Ok at the next quarter-hour; Ok = healthy, Error/403 = secret mismatch):

npx wrangler tail qbtime-cron-staging
npx wrangler tail qbtime-cron-prod

Deployinh the Sales Page

cd marketing
npx wrangler pages deploy . --project-name qbtime-marketing --branch main
cd ..

This guide puts the QBTime Manager application online — the actual app your team signs into at qbtime.r2d2dev.com. It assumes nothing is installed and nothing is set up yet, and explains every tool and command.

This is the most technical guide in the set. It involves a terminal and several commands. Take it slowly, do each step in order, and check the "you should see" notes. If something doesn't match, jump to Troubleshooting at the end before continuing.

This is separate from the documentation site — see "Deploying the Docs Site" for that.


What you're going to do (plain English)

  1. Install free tools on a Windows PC (one-time): Node.js and Git.
  2. Get the project code onto the PC.
  3. Create the cloud pieces the app needs on Cloudflare: a database (D1) and two storage areas (KV).
  4. Tell the app about them by pasting some IDs into a settings file.
  5. Publish the app once so Cloudflare creates the Pages project (it must exist before you can add secrets to it).
  6. Set the secret values (passwords/keys the app needs) in the Cloudflare dashboard.
  7. Confirm the database and storage are bound (they come from wrangler.toml, applied by the publish — nothing to add in the dashboard).
  8. Set up the database tables and re-publish so the app picks up the secrets.
  9. Connect the friendly web address (staging.qbtime.r2d2dev.com / qbtime.r2d2dev.com). Until then the app is reached at its *.pages.dev URL.
  10. Create the first administrator and connect QuickBooks Time.

You do this once for staging (a test copy) and once for production (the real one). They are completely separate.

Why publish before setting secrets? Cloudflare Pages will not let you add secrets to a project that doesn't exist yet, and the project is only created by the first publish. (Bindings come from wrangler.toml and are applied by the publish.) So the order is publish first, then set secrets, then re-publish — different from a Workers project.

You'll need: a Cloudflare account that manages qbtime.r2d2dev.com, access to the GitHub project, and (for email) a Microsoft 365 / Entra app you create (step 4d walks through it; its single sending mailbox is on your domain, e.g. reports@r2d2dev.com). Gather logins before starting.


Step 0 — Install the tools (one-time)

0a. Open PowerShell

  1. Press the Windows key.
  2. Type powershell and click Windows PowerShell.
  3. A dark window opens. You'll paste commands here: right-click (or Ctrl+V) to paste, then Enter to run. Keep this window open.

0b. Install Node.js

  1. Paste and Enter:
winget install OpenJS.NodeJS.LTS
  1. Click Yes if Windows asks. When done, close and reopen PowerShell (so Windows sees it).
  2. Confirm:
node --version

You should see v22.x or v24.x. If not, see Troubleshooting. (Could be higher versions)

0c. Install Git

  1. Paste and Enter:
winget install Git.Git
  1. Click Yes, accept defaults. Close and reopen PowerShell.
  2. Confirm:
git --version

Step 1 — Get the project code

If the project is already on this PC at C:\MSP\CowboyMSP\Dev\CCC-QBTime, skip to Step 2.

Otherwise, download it from GitHub. Paste and Enter (this puts it in your user folder):

cd $HOME
git clone https://github.com/rbettencourt-cbmsp/Quickbooks-Time-API.git CCC-QBTime

If asked to sign in to GitHub, do so. When it finishes you'll have a CCC-QBTime folder.


Step 2 — Go to the project folder and install dependencies

  1. Move into the project folder (adjust the path if yours is elsewhere):
cd C:\MSP\CowboyMSP\Dev\CCC-QBTime
  1. Confirm you're in the right place:
dir package.json

You should see package.json listed.

  1. Install the app's building blocks (one-time; takes a minute or two):
npm install

Wait until it finishes (it prints how many packages were added).

  1. Sanity-check that everything is healthy:
npm run typecheck
npm test

Both should finish without errors (tests show all passing). If they do, the code is sound and you can proceed.


Step 3 — Log in to Cloudflare

The app deploys to Cloudflare. Log in once on this PC:

npx wrangler login

A browser opens. Approve access for the Cloudflare account that manages qbtime.r2d2dev.com. Confirm it worked:

npx wrangler whoami

It prints your account email and Account ID — copy that Account ID into Notepad; you'll want it later.


Step 4 — Set up STAGING

"Staging" is a safe test copy. Do production later (Step 5) the same way.

4a. Create the database and storage

Run these one at a time. Each prints an ID — copy each ID into Notepad with a label.

npx wrangler d1 create qbtime_db_staging
npx wrangler kv namespace create KV_SESSIONS
npx wrangler kv namespace create KV_CACHE
  • The first prints a database_id.
  • The KV commands each print an id.

KV namespaces are not environment-specific at creation, so there's no --env here. You'll record their IDs in wrangler.toml in 4b; the deploy then binds them to the app automatically.

Already done for staging? The repo's wrangler.toml may already contain real staging IDs (D1 and both KV) under [env.preview]. If so, you can skip 4a/4b for staging and go to 4c — just confirm the IDs in [env.preview] are real (not REPLACE_WITH_*).

4b. Paste the IDs into the settings file

This is a Pages project, and wrangler.toml is the source of truth for bindings — the app reads D1/KV from this file at deploy time (the dashboard shows them read-only). So the IDs must be correct here.

  1. Open the project's wrangler.toml file in a text editor (Notepad works; right-click the file → Open with → Notepad).
  2. Find the [env.preview] section (this is staging — Pages calls it preview). You'll see the database_id and the two KV id values.
  3. If any are still placeholders (REPLACE_WITH_*), replace each with the matching ID from 4a (database id → the D1 one, etc.).
  4. Save the file.

4c. Publish once to create the Pages project

The Cloudflare Pages project must exist before you can add secrets, and it's only created by the first publish. So publish now — it's fine that secrets aren't set yet; the app won't be fully functional until you finish 4d–4h, but this step creates the project and applies the D1/KV bindings from wrangler.toml.

npm run build
npm run deploy:staging

deploy:staging runs wrangler pages deploy ./client/dist --branch staging. The first time, it creates a Pages project named qbtime, makes a Preview deployment (the staging branch is a preview branch; main will be Production in Step 5), and binds the [env.preview] D1/KV from wrangler.toml. Confirm it exists:

npx wrangler pages project list

You should now see qbtime in the list. If you don't, re-read the command output for errors before continuing.

4d. Prepare the secret values (Microsoft + generated)

Secrets are random keys the app needs. Generate them now and keep them in Notepad labeled — you'll enter them in the dashboard in 4e, and you should store them in a password manager afterward (Cloudflare won't show them again).

Run each line; copy each output:

# In PowerShell, generate strong random values:
[Convert]::ToBase64String((1..48 | ForEach-Object {Get-Random -Max 256}))   # SESSION_SIGNING_KEY
[Convert]::ToBase64String((1..32 | ForEach-Object {Get-Random -Max 256}))   # DATA_ENCRYPTION_KEY (must be 32 bytes)
-join ((1..64) | ForEach-Object {'{0:x}' -f (Get-Random -Max 16)})          # INTERNAL_TASK_SECRET
-join ((1..64) | ForEach-Object {'{0:x}' -f (Get-Random -Max 16)})          # SETUP_BOOTSTRAP_SECRET

The other secrets come from Microsoft — an Entra app registration you (or your Microsoft 365 administrator) create. This app lets the SaaS send report email from one platform mailbox on your own domain; every customer's reports send from this single sender (e.g. reports@r2d2dev.com). There is no per-customer sending domain.

Create the Entra app (one-time): at entra.microsoft.comApplications → App registrations → New registration. On the Register an application form:

  1. Name — a label for you, not an email address. Enter QBTime Reports (or similar). This is just a display name and can be changed later; it does not affect sending.
  2. Supported account types — choose Single tenant only (your tenant). This controls which Entra directory may use this app to sign in — only your own backend does, so single tenant is correct. It does not limit who you can email: reports still go to recipients on any external domain. Do not pick a multitenant option (that would let other organizations' directories use this app registration, which you don't want).
  3. Redirect URIleave both boxes blank (skip the platform dropdown and the URL). Redirect URIs are only for apps that log users in interactively; this app uses the app-only (client-credentials) flow and has no redirect.
  4. Click Register.

You land on the app's Overview page. Now collect the four values:

  • GRAPH_TENANT_ID — app Overview page → Directory (tenant) ID.
  • GRAPH_CLIENT_ID — app Overview page → Application (client) ID.
  • GRAPH_CLIENT_SECRETCertificates & secrets → New client secret → copy the Value (shown only once; not the Secret ID). Use a separate secret per environment so a staging leak can't affect production, and label each secret by environment (staging / production) — after you leave the page they're told apart only by description and expiry. Client secrets expire (Entra's max is 24 months); set the longest expiry and see the rotation reminder below.
  • GRAPH_SENDER — the mailbox on your domain all reports send from (e.g. reports@r2d2dev.com). A shared mailbox is recommended: it needs no M365 license, so it costs nothing, and it exists as a real Exchange Online mailbox the app can send as. Create it in the Exchange admin center (Recipients → Mailboxes → Add a shared mailbox) before setting this value.

Then grant send permission: API permissions → Add a permission → Microsoft Graph → Application permissions → Mail.Send → Add, then Grant admin consent. Configure SPF/DKIM/DMARC on the sending domain (see Troubleshooting).

⚠️ Rotate before expiry — add a calendar reminder now. Client secrets expire (24-month max). When a secret expires, the app silently stops sending email — no error until a report fails. As soon as you create each secret, add a calendar event ~30 days before its expiry date titled e.g. "Rekey QBTime GRAPH_CLIENT_SECRET (staging/production)". To rotate: create a new secret in Certificates & secrets, update GRAPH_CLIENT_SECRET in the Cloudflare dashboard (Pages → qbtimeSettings → Environment variables, for the Preview and/or Production environment), re-publish, confirm email sends, then delete the old secret. Do this per environment.

Lock down Mail.Send to the one mailbox (do this)

The Mail.Send application permission is tenant-wide by default: it lets the app send as any mailbox in your tenant, not just reports@r2d2dev.com. Scope it down to the single sender using RBAC for Applications in Exchange Online (the modern replacement for Application Access Policies). Run in Exchange Online PowerShell (Connect-ExchangeOnline), substituting your app's client ID and a name for the mailbox group.

Find the two IDs (don't confuse them): in Entra there are two Object IDs for the same app. New-ServicePrincipal -ObjectId needs the Enterprise application's Object ID, not the App registration's. Go to Entra → Applications → Enterprise applications (not App registrations) → open QBTime ReportsOverview → Object ID — that GUID is <ENTERPRISE_APP_OBJECT_ID>. The Enterprise application is created automatically when you register the app, so it's already there. <GRAPH_CLIENT_ID> is the Application (client) ID (same value on either page).

# 1. Group holding only the mailbox(es) the app may send as.
$grp = New-DistributionGroup -Name "QBTime-Mail-Send-Scope" `
  -Type Security -Members reports@r2d2dev.com

# 2. Register the Entra app as an Exchange service principal (use its App/client ID + object ID
#    from Entra > Enterprise applications).
New-ServicePrincipal -AppId "<GRAPH_CLIENT_ID>" -ObjectId "<ENTERPRISE_APP_OBJECT_ID>" `
  -DisplayName "QBTime Reports"

# 3. Grant the built-in "Application Mail.Send" role, scoped to that group only.
New-ManagementRoleAssignment -App "<GRAPH_CLIENT_ID>" `
  -Role "Application Mail.Send" `
  -RecipientGroupScope $grp.Name

After this, a sendMail call for any address outside the group is rejected by Exchange. To verify, the app should be able to send as reports@r2d2dev.com and fail (403) for any other mailbox. Re-run for production with its own app/client ID if you use a separate Entra app per environment.

Validate this is locked down to reports email address only Run: Get-DistributionGroupMember -Identity "QBTime-Mail-Send-Scope"

should see something like: Name RecipientType ---- ------------- Reports20260602125711 UserMailbox

Then run: Get-Mailbox -Identity reports@r2d2dev.com | Format-List Name,DisplayName,PrimarySmtpAddress,RecipientTypeDetails

Should see: Name : Reports20260602125711 DisplayName : Reports PrimarySmtpAddress : reports@r2d2dev.com RecipientTypeDetails : SharedMailbox

This app is locked down to the 1 shared mailbox we setup To add more people just add them to the group we created above (Example I added my email address just to show how it works)

Name RecipientType ---- ------------- 9c357a24-5c58-4fce-a1b3-4d0921fd9351 UserMailbox Reports20260602125711 UserMailbox

Name : 9c357a24-5c58-4fce-a1b3-4d0921fd9351 DisplayName : Robert Bettencourt PrimarySmtpAddress : rbettencourt@cowboymsp.com RecipientTypeDetails : UserMailbox

4e. Set the secrets in the Cloudflare dashboard (staging = Preview)

Pages secrets are set in the dashboard, per environment, not with wrangler secret put (that's a Workers command and will error on a Pages project). The CLI's wrangler pages secret put can't target a single environment, so use the dashboard to keep staging and production separate.

  1. In the Cloudflare dashboard: Workers & Pages → qbtime → Settings → Environment variables.
  2. Under the Preview environment (this is staging), click Add variable for each name below. Check the "Encrypt" box on every one so it's stored as a secret (you can't read it back after).
  3. Add these ten:
Variable Value
SESSION_SIGNING_KEY generated in 4d
DATA_ENCRYPTION_KEY generated in 4d
INTERNAL_TASK_SECRET generated in 4d
SETUP_BOOTSTRAP_SECRET generated in 4d
GRAPH_TENANT_ID from your Entra app (4d)
GRAPH_CLIENT_ID from your Entra app (4d)
GRAPH_CLIENT_SECRET the staging client secret (4d)
GRAPH_SENDER reports@r2d2dev.com
STRIPE_SECRET_KEY your Stripe test secret key sk_test_… — see "Where the Stripe keys come from" in the Billing section for exactly where to find it
STRIPE_WEBHOOK_SECRET whsec_… — does not exist yet; it's created in the Billing → Register the webhook step. Set a placeholder now (whsec_pending) and return to update it.
  1. Click Save.

Don't have your Stripe keys yet? See "Where the Stripe keys come from" in the Billing (Stripe) setup section below — it walks through getting the secret key and creating the webhook secret. You can skip the two Stripe rows for now and come back, or set placeholders and continue.

Stripe note: STRIPE_SECRET_KEY you have right now (copy it from Stripe). STRIPE_WEBHOOK_SECRET does not exist until you register the webhook endpoint in the Billing (Stripe) setup section below — which needs the app deployed first. So set a placeholder for it here, finish the deploy, then return to this screen and paste the real whsec_… value. Billing only works once both are set — the rest of the app runs fine without them.

Keep DATA_ENCRYPTION_KEY backed up in a password manager. It encrypts stored QuickBooks Time access. If it's lost and changed later, previously connected companies must reconnect.

4f. Confirm the database and storage bindings

The D1/KV bindings come from wrangler.toml ([env.preview]) and were applied by the 4c deploy — there's nothing to add in the dashboard. Just verify them:

  1. In the dashboard: Workers & Pages → qbtime → Settings → Bindings, Preview environment.
  2. Confirm you see DB (→ qbtime_db_staging), KV_SESSIONS, and KV_CACHE. They'll be read-only here — that's expected, because wrangler.toml is the source of truth.

If a binding is missing, the IDs in wrangler.toml [env.preview] are wrong or were placeholders at deploy time. Fix them in 4b and re-run npm run deploy:staging.

4g. Set up the database tables, then re-publish

npm run db:migrate:staging
npm run deploy:staging

db:migrate:staging creates the tables in qbtime_db_staging (it targets the [env.preview] database). This applies all migrations, including the billing tables (migration 0003: billing_plans, stripe_events, setup_help_requests, and the billing columns on customers). database). The second deploy:staging re-publishes so the running app picks up the secrets you added in 4e. When it finishes, wrangler prints a URL — copy it. Until you set up the custom domain (4h), that printed https://staging.qbtime.pages.dev address (the staging branch alias) is how you reach the staging site. The friendly staging.qbtime.r2d2dev.com does not work yet.

If the site loaded after 4c but errored, that's expected — it had no secrets yet (bindings were already applied from the file). After this re-publish it should work.

4h. Connect the staging custom domain (staging.qbtime.r2d2dev.com)

By default a Pages custom domain points at the production branch. To make staging.… point at the staging branch instead, there's one extra DNS tweak — this is the part that isn't obvious.

Prerequisite: you must already have a successful staging-branch deploy (you do, from 4g).

  1. In the dashboard: Workers & Pages → qbtime → Custom domains → Set up a custom domain.
  2. Enter staging.qbtime.r2d2dev.comContinueActivate domain. Cloudflare creates a CNAME DNS record named staging in the r2d2dev.com zone, pointing at qbtime.pages.dev. (As created, this would serve the production branch — the next step fixes that.)
  3. Go to DNS for the r2d2dev.com zone (dashboard → your account → r2d2dev.com → DNS → Records). Find the CNAME record named staging.
  4. Edit its target: change qbtime.pages.dev to staging.qbtime.pages.dev (add the staging. branch prefix). Save. This branch prefix is what routes the domain to the staging branch instead of production.
  5. Confirm the record's proxy status is Proxied (orange cloud). This is required — if it's DNS-only (grey cloud) or on an external DNS provider, Cloudflare ignores the branch alias and the domain silently serves production instead.
  6. Wait 1–2 minutes, then open https://staging.qbtime.r2d2dev.com — it should show the staging build. From here on you can use the friendly URL instead of the .pages.dev one.

Production uses the same flow later (Step 5), but without the branch prefix: the custom domain qbtime.r2d2dev.com points at qbtime.pages.dev as-is, because production is the default branch a custom domain serves.

4i. First-run: create the first administrator

  1. In a browser, go to /setup on your staging site — https://staging.qbtime.r2d2dev.com/setup if you finished 4h, otherwise the https://staging.qbtime.pages.dev/setup URL from 4g.
  2. Enter the SETUP_BOOTSTRAP_SECRET you generated, plus a username, email, and strong password for the first admin.
  3. Click Create administrator, then sign in and set up MFA (see the Admin Guide).
  4. Remove the bootstrap secret now that it's used: in the dashboard, Workers & Pages → qbtime → Settings → Environment variables → Preview, delete SETUP_BOOTSTRAP_SECRET, and Save. (Re-publishing isn't required just to drop this secret.)

4j. Point QuickBooks Time at the staging callback

When connecting a company on staging, the QuickBooks Time API Add-On's redirect URL must be: https://staging.qbtime.r2d2dev.com/api/v1/companies/connect/callback (use the matching .pages.dev URL instead if you haven't connected the custom domain yet).


Step 5 — Set up PRODUCTION

Same order as Step 4, but for the Production environment. Production is the main branch (Pages Production); staging was the staging branch (Pages Preview). It's the same qbtime Pages project — you're just configuring its Production side. Use fresh, different secret values from staging.

Do the substeps in this order — publish creates/updates the Production deployment before you set secrets, same reason as staging. (Bindings come from wrangler.toml, applied by the publish.)

5a. Create the production database and storage

npx wrangler d1 create qbtime_db_prod
npx wrangler kv namespace create KV_SESSIONS
npx wrangler kv namespace create KV_CACHE

Copy each printed ID into Notepad, labeled (these are production resources, separate from staging).

5b. Paste the IDs into the settings file

Open wrangler.toml, find the [env.production] section, and replace REPLACE_WITH_PROD_D1_ID, REPLACE_WITH_PROD_KV_SESSIONS_ID, and REPLACE_WITH_PROD_KV_CACHE_ID with the IDs from 5a. Save. (As with staging, this file is the source of truth for production's bindings.)

5c. Publish to Production once

npm run build
npm run deploy:prod

deploy:prod runs wrangler pages deploy ./client/dist --branch main, creating the Production deployment of the qbtime project. (The project already exists from staging; this adds/updates its Production side.)

5d. Prepare fresh secret values

Generate new SESSION_SIGNING_KEY, DATA_ENCRYPTION_KEY, INTERNAL_TASK_SECRET, and SETUP_BOOTSTRAP_SECRET (same PowerShell as 4d) — do not reuse staging's. For Graph, reuse the same GRAPH_TENANT_ID/GRAPH_CLIENT_ID/GRAPH_SENDER, but use the production GRAPH_CLIENT_SECRET (the second secret you created on the Entra app for production).

5e. Set the secrets in the dashboard (Production)

Workers & Pages → qbtime → Settings → Environment variables → Production. Add the same ten variables as in 4e (with Encrypt checked), using the production values from 5d. Save.

Stripe (Production): add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET here too. In test mode these are the same test keys as staging; the webhook secret is created when you register the Production webhook endpoint (Billing section), so set a whsec_pending placeholder now and update it after. To go live (real charges) — production on live keys while staging stays on test — follow the "Going live" section (Billing → section 6); it's more than swapping a key (live mode has different Price ids, so you re-seed). When you later go live, swap in the live sk_live_… key and the live webhook secret.

5f. Confirm the database and storage bindings (Production)

Bindings came from wrangler.toml [env.production] via the 5c publish. Verify in Workers & Pages → qbtime → Settings → Bindings → Production: you should see DB (→ qbtime_db_prod), KV_SESSIONS, and KV_CACHE (read-only). If any are missing, fix the IDs in [env.production] (5b) and re-run npm run deploy:prod.

5g. Migrate and re-publish

npm run db:migrate:prod
npm run deploy:prod

5h. Connect the production custom domain (qbtime.r2d2dev.com)

Production is simpler than staging — no branch-prefix DNS edit, because a custom domain serves the production branch by default.

  1. Workers & Pages → qbtime → Custom domains → Set up a custom domain.
  2. Enter qbtime.r2d2dev.comContinueActivate domain. Cloudflare creates the CNAME (target qbtime.pages.dev) — leave the target as-is (it already points to the production branch).
  3. Confirm the DNS record is Proxied (orange cloud).
  4. Wait 1–2 minutes, then open https://qbtime.r2d2dev.com — it should show the production build.

Until this domain is active, production is reachable at its https://qbtime.pages.dev URL (printed by deploy:prod).

Seed Pricing data npx wrangler d1 execute qbtime_db_prod --remote --file=./seed_plans.sql

5i. First-run and QuickBooks Time callback

Do first-run setup at https://qbtime.r2d2dev.com/setup (enter the production SETUP_BOOTSTRAP_SECRET). Then delete SETUP_BOOTSTRAP_SECRET from the dashboard's Production environment variables. When connecting a company, use the production callback URL https://qbtime.r2d2dev.com/api/v1/companies/connect/callback in the QuickBooks Time API Add-On.

Don't forget the production Mail.Send lockdown. The RBAC scoping you ran for staging covers the shared mailbox, but if you created a separate production Entra client secret, the app identity is the same (one app), so the existing scope still applies. No extra RBAC step is needed unless you made a separate production app registration.


????

Scheduled reports (the cron Worker)

Cloudflare Pages can't run anything on a timer — it only responds when someone visits. But the app needs a timer for two things: sending the daily attendance reports, and daily billing upkeep (locking overdue accounts, etc.). So there's a tiny separate cron Worker that wakes every 15 minutes and pings the app to do those jobs.

You don't write any code — the cron Worker already exists in the repo, in the cron-worker/ folder. You just deploy it. Do this once for staging, and again for production.

Optional for now. Skip this if you're not sending real reports yet — the app works fine without it; scheduled reports and billing upkeep just won't fire on their own until it's deployed.

No file edits needed. cron-worker/wrangler.toml now defines two named environments — [env.staging] and [env.production] — each with the correct Worker name and APP_BASE_URL already set. You pick the environment with the --env flag; do not hand-edit the file or pass --name. (Older instructions had you edit name/APP_BASE_URL by hand for production — that's what caused a staging Worker to point at the production URL. The --env flag avoids that entirely.)

Deploy it for STAGING

From the repo root:

cd cron-worker
npx wrangler deploy --env staging
npx wrangler secret put INTERNAL_TASK_SECRET --env staging
cd ..
  • The deploy output should print the binding APP_BASE_URL ("https://staging.qbtime.r2d2dev.com") and schedule: */15 * * * *. If APP_BASE_URL shows the production URL, you forgot --env staging.
  • When secret put asks for a value, paste the same INTERNAL_TASK_SECRET you set in the app's Cloudflare env vars for Preview/staging. If it doesn't match, the app rejects the pings (403) and nothing happens. The secret persists across deploys, so you only set it again if you rotate it.
  • Any WARNING lines about workers.dev / Preview URLs are harmless — ignore them.

Deploy it for PRODUCTION

Identical, just swap the environment. Paste the production INTERNAL_TASK_SECRET (a different value from staging) when prompted:

cd cron-worker
npx wrangler deploy --env production
npx wrangler secret put INTERNAL_TASK_SECRET --env production
cd ..
  • The deploy output should print APP_BASE_URL ("https://qbtime.r2d2dev.com") and schedule: */15 * * * *.

Verify it's actually firing (tail the live logs)

Deploying registers the schedule, but you should confirm the Worker truly wakes up every 15 minutes and that the app accepts its ping. Tail the live logs and wait for the next quarter-hour boundary (:00, :15, :30, :45):

# staging:
npx wrangler tail qbtime-cron-staging
# production:
npx wrangler tail qbtime-cron-prod

You'll first see Connected to qbtime-cron-<env>, waiting for logs.... At the next boundary a line like this appears:

"*/15 * * * *" @ 6/3/2026, 6:45:39 AM - Ok
  • Ok → success: the cron fired and the app accepted the ping. The whole chain works.
  • Error (or a 403 in the detail) → the Worker's INTERNAL_TASK_SECRET doesn't match the app's for that environment. Re-run the secret put step above with the correct value.

The report runner only logs activity when something is actually due, so a healthy fire on an empty schedule is a quiet Ok with an {evaluated:0, due:0, ...} summary — a single clean invocation is the success signal. To prove a real send end-to-end, create a report-config set to the current day/time, then watch the next fire. Press Ctrl+C to stop tailing.

Quick app-side check (optional). You can confirm the internal endpoint + secret independently of the Worker by calling it yourself. In PowerShell, curl is an alias for Invoke-WebRequest, so use the native form (or curl.exe):

Invoke-WebRequest -Uri "https://staging.qbtime.r2d2dev.com/api/v1/internal/run-due-reports" `
  -Method POST -Headers @{ "X-Internal-Secret" = "<staging INTERNAL_TASK_SECRET>" } |
  Select-Object -ExpandProperty Content

A 200 with a {evaluated, due, sent, skipped, failed} summary means the endpoint and secret are good. A 403 means the secret is wrong.


Billing (Stripe) setup

The app charges customers via Stripe (monthly subscription + a one-time onboarding fee that EVERY new account pays, self-serve and assisted alike). Use TEST mode first — nothing charges real money until you switch to live keys.

Where the Stripe keys come from

You'll need a free Stripe account (dashboard.stripe.com). There are two values, from two different places.

Toggle TEST mode first. Top of the Stripe dashboard, flip the Test mode switch ON (you'll see a "Test mode" badge). Keys then start with sk_test_ / whsec_ and no real card is charged.

1. STRIPE_SECRET_KEY — the API key the app uses to call Stripe. - Easiest: go straight to https://dashboard.stripe.com/test/apikeys (this opens your test API keys; drop /test for live keys later). - Or from the dashboard: the Developers button is in the top-right corner (not the left sidebar in the current design) → API keys. You can also use the dashboard search bar and type "API keys". - Under Standard keys, find the Secret key, click Reveal test key, and copy it (sk_test_...). Not the Publishable key — you want the Secret key. - Paste this into STRIPE_SECRET_KEY in 4e/5e.

2. STRIPE_WEBHOOK_SECRET — proves webhooks really came from Stripe. - It does not exist yet — it's created when you register the webhook endpoint (section 3 below), which needs the app already deployed. That's why you set a whsec_pending placeholder in 4e/5e. - After section 3, Stripe shows the endpoint's Signing secret (whsec_...); come back and paste it over the placeholder, then re-publish.

These go ONLY in the Cloudflare dashboard env vars (encrypted) — never in the repo.

1. Stripe secrets (set with the other secrets, in 4e/5e)

You already added STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET to the dashboard alongside the other secrets (steps 4e for Preview, 5e for Production). At that point STRIPE_SECRET_KEY was real but STRIPE_WEBHOOK_SECRET was a placeholder.

  • STRIPE_SECRET_KEY — already set (Stripe → Developers → API keys, sk_test_... for now).
  • STRIPE_WEBHOOK_SECRET — you set a whsec_pending placeholder. After step 3 below (registering the webhook), come back to Settings → Environment variables and replace the placeholder with the real whsec_... signing secret, then re-publish.

2. Create Prices and seed billing_plans

There are 18 Prices to create — a recurring monthly Price and a one-time onboarding Price for each of the 9 tiers (Starter → Enterprise Max). You don't have to click through them by hand: a script creates them all via the Stripe API and writes the resulting ids into seed_plans.sql for you.

Run the price-setup script (from the repo root) with your Stripe secret key in the environment:

$env:STRIPE_SECRET_KEY = "sk_test_..."   # your TEST secret key (use the live key later for live mode)
node scripts/setup-stripe-prices.mjs

It creates a Product + monthly Price + onboarding Price per tier (idempotent — re-running finds existing Prices by their lookup_key instead of duplicating), then fills the price_REPLACE_* / price_ONBOARD_* placeholders in seed_plans.sql with the real ids.

Prefer to do it by hand? You can instead create the 18 Prices in Stripe → Product catalog, then paste each id into seed_plans.sql yourself. The script just saves the clicking.

Then seed the database with the now-filled seed_plans.sql (18 rows = 9 tiers × self-serve + assisted; self-serve and assisted share a tier's monthly Price, and every account gets the onboarding Price):

npx wrangler d1 execute qbtime_db_staging --remote --file=./seed_plans.sql
# production: same command with qbtime_db_prod

INSERT OR REPLACE makes the seed safe to re-run. For live mode you re-run the script with your live key (it creates live Prices and rewrites the file), then seed qbtime_db_prod again.

Confirm it worked: npx wrangler d1 execute qbtime_db_staging --remote --command "SELECT tier, billing_mode, stripe_price_id FROM billing_plans;" — you should see 8 rows with real price ids.

3. Register the webhook

The app must already be deployed and live at its URL before this works — Stripe needs to reach the endpoint. Do steps 4c–4h (staging) / 5c–5h (production) first.

Stripe moved this into Workbench (it replaced the old "Developers → Webhooks" page).

  1. Go straight to https://dashboard.stripe.com/test/workbench/webhooks (test mode). Or: click the Developers button in the top-right, which opens Workbench, then the Webhooks tab.
  2. Click Add destination (a.k.a. Create an event destination).
  3. Select events — add these six (search each by name and check it; leave API version at default):
  4. invoice.paid
  5. invoice.payment_failed
  6. invoice.payment_succeeded
  7. customer.subscription.created
  8. customer.subscription.updated
  9. customer.subscription.deleted
  10. Continue, then choose Webhook endpoint as the destination type, and Account (not Connect). Name: QBTime Manager — staging OR Name QBTime Manager — production
  11. Endpoint URL: https://staging.qbtime.r2d2dev.com/api/v1/webhooks/stripe (for production, use https://qbtime.r2d2dev.com/api/v1/webhooks/stripe). Name/description optional.
  12. Click Add endpoint / Create destination.
  13. On the endpoint's page, find Signing secretReveal → copy the whsec_... value.
  14. That whsec_... is your STRIPE_WEBHOOK_SECRET: in the Cloudflare dashboard → Pages → qbtimeSettings → Environment variables (Preview for staging / Production for prod), replace the whsec_pending placeholder with it, Save, and re-publish.

---------?????????-----------

4. Billing maintenance cron

The same cron Worker that runs reports must also hit the billing maintenance endpoint: POST /api/v1/internal/run-billing-maintenance (header X-Internal-Secret: INTERNAL_TASK_SECRET). Call it daily for grace-expiry lockout, webhook reconciliation, and the 90-day data purge; add ?usage=1 on the 1st of the month to also push usage quantities to Stripe. Add a second scheduled fetch in the cron Worker alongside the run-due-reports call.

5. Test mode dry-run (before going live)

With test keys, run the whole flow: provision a customer -> pay the first invoice (Stripe test card 4242 4242 4242 4242) -> trigger a failed payment (card 4000 0000 0000 0341) -> confirm the past-due banner -> let grace expire (or set grace_until in the past) -> confirm lock -> pay -> confirm unlock. Only switch to live sk_live_... / live webhook secret after this passes.

6. Going live (production on LIVE mode, preview stays on TEST)

You can — and should — run preview/staging on Stripe TEST keys and production on Stripe LIVE keys at the same time. Cloudflare stores secrets per environment, so they don't conflict:

Cloudflare env Stripe mode Keys
Preview (staging) Test sk_test_..., test-mode whsec_...
Production Live sk_live_..., live-mode whsec_...

LIVE mode is a separate world in Stripe — different keys AND different Price ids from test mode. So switching production to live is more than swapping a key. Do ALL of this:

  1. In Stripe, toggle Test mode OFF (live mode). Re-create every Price (the 9 monthly Prices
  2. 9 onboarding Prices) in live mode — the test price_... ids do NOT exist in live.
  3. Make a live copy of the seed with the live price ids, and run it against production:
    npx wrangler d1 execute qbtime_db_prod --remote --file=./seed_plans.sql
    
    (Edit seed_plans.sql first so its price_... values are the live ids.)
  4. In Stripe live mode, register a live webhook at https://qbtime.r2d2dev.com/api/v1/webhooks/stripe (same events as section 3). Copy its live Signing secret (whsec_...).
  5. In the Cloudflare dashboard → Production env vars, set STRIPE_SECRET_KEY = sk_live_... and STRIPE_WEBHOOK_SECRET = the live signing secret. Re-publish production.
  6. Leave Preview on the test keys — staging keeps charging fake cards so you can keep testing safely.

After this, a real customer's real card is charged on production. Test mode (4242 cards) no longer works on production. Do a small real-card test (you can refund it in Stripe) to confirm end-to-end.


Billing go-live checklist (do in this order)

Once the app itself is deployed (Steps 4/5), turn on billing:

  1. Migratenpm run db:migrate:staging already created the billing tables (4g). Confirm billing_plans exists (it will be empty until step 3).
  2. Stripe secrets — set STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET in the dashboard (section 1).
  3. Seed plans — edit and run seed_plans.sql with your real Stripe Price ids (section 2).
  4. Webhook — register the endpoint in Stripe and copy its signing secret (section 3).
  5. Cron Worker — deploy the cron-worker/ folder (see its README) so billing maintenance and reports actually fire on schedule. Without it, nothing scheduled runs.
  6. Re-publish the app (npm run deploy:staging) so it has the Stripe secrets.
  7. Dry-run in test mode (section 5) before switching to live keys.

Provision your first paying customer (assisted)

For a white-glove (assisted) customer you onboard yourself:

  1. In the DevOps console, create the customer and their first customer_admin account.
  2. Do the technical setup: connect their QuickBooks Time company (QBT add-on + OAuth), configure report schedules.
  3. Provision billing: POST /api/v1/billing/provision with { customerId, tier, mode: 'assisted', interval: 'month', email, name }. This returns a Checkout URL.
  4. Send the customer the Checkout URL. They pay (onboarding fee + first month on one invoice).
  5. On payment, the Stripe webhook flips the customer to active automatically — their account unlocks. Confirm billing_status = 'active' in the customers table.

Self-serve customers do steps 1-4 themselves via the public /signup page; you only get involved if they click "We'll set it up for me" (which files a setup-help request).

Plan limits & usage billing (next-cycle model)

Each billing_plans tier has max_companies and max_employees limits. When a customer's usage outgrows their tier, billing reconciles without any mid-cycle charge — the new rate starts next cycle, and the customer is warned in advance by a dashboard banner.

How reconciliation works (reconcileUsageBilling):

  • Monthly rate — next cycle, no proration. If usage no longer fits the current tier, a pending_tier (the smallest fitting tier) is recorded. The new monthly price is applied at the next billing cycle by applyPendingTier (run in the monthly maintenance pass); the dashboard banner shows "your plan moves to at $X/mo next cycle" until then.
  • Onboarding fee — trued-up to the highest tier ever reached. Each customer has a highest_onboarding_tier high-water mark (seeded to their signup tier). When usage requires a tier above that mark, the one-time onboarding delta (new mark's fee − old mark's fee) is added to the next invoice, and the mark advances. This catches the "sign up cheap, then add 20 companies" case whenever it happens — day 1 or day 91 — while never re-charging a customer for growth they've already paid the onboarding for. A customer who shrinks and re-grows is not charged again (the mark only moves up).
  • Exceeds the largest plan: adding a company that would exceed the largest tier is hard-blocked at create time ("contact us for a custom tier"); we can't bill beyond the top plan.

What triggers reconciliation: adding a company (immediately, at create time) and a daily billing-maintenance sweep (catches employee growth, which is only known from report data). Neither charges mid-cycle.

"Active employees" is a 7-day count, not point-in-time or a long union. The employee count (getUsage) is the distinct scheduled-AND-active people across the last 7 days of report snapshots. Snapshots already exclude deactivated users, and a 7-day window captures a full weekly rotation without counting staff who churned out weeks ago — so a customer with turnover isn't falsely pushed over their limit. (A 35-day union would over-count; a single day would under-count part-week staff.)

Stripe prerequisite

Tier changes swap to another tier's Stripe Price, so every tier's stripe_price_id in seed_plans.sql must be a real Price in your account (including the extended Pro → Enterprise Max tiers). The onboarding-fee delta is a one-time invoiceitem (no pre-made Product needed). Stripe computes proration automatically — though in this model the monthly change is applied at a clean cycle boundary, so there is normally nothing to prorate.

Disclose it in your signup terms

State in the signup terms that exceeding plan limits moves the customer to the fitting tier (new rate next cycle) and trues-up the onboarding fee. The advance banner + the terms together make the change defensible.

Branding & chargeback prevention (IMPORTANT)

The #1 cause of card disputes is a customer not recognizing the charge. The product is QBTime Manager, but the legal/billing entity is CowboyMSP (Stripe account name "Cowboy MSP", invoices from cowboymsp.com). To stop "I don't recognize this charge" chargebacks:

  1. Set the Stripe statement descriptor — company prefix + per-product suffix. The CowboyMSP Stripe account also bills non-QBTime products (e.g. Website Design), so use Stripe's prefix/suffix model rather than a single account-wide string. Two parts:

  2. Account prefix (shared by all products). Stripe Dashboard → Settings → Business → Public details → set the Shortened descriptor (the prefix) to COWBOYMSP (2–10 chars). This is the recognizable company stem on every product's charges.

  3. Per-product suffix (QBTime only). Stripe Dashboard → Product catalog → open each QBTime price/product (Starter, Standard, Growth, Business, Pro, Scale, Enterprise, Enterprise Plus, Enterprise Max) → More optionsStatement descriptor → enter QBTIME. Stripe labels this "Overrides default descriptors. Only used for subscription payments." — correct here, since QBTime bills as a subscription and the suffix applies to this product only. Other products keep their own labels.

Stripe concatenates them, so QBTime charges read COWBOYMSP* QBTIME on the customer's statement. Keep the whole thing ≤ 22 chars (incl. the * and space — COWBOYMSP* QBTIME = 17). Set the suffix on every QBTime tier, in both Stripe test mode and live mode.

  1. In-app disclosure (already shipped). The signup page and the Billing screen both state "QBTime Manager is operated by CowboyMSP; charges appear as COWBOYMSP QBTIME and are billed via cowboymsp.com" so customers connect the names before* paying.

  2. Email sender name (already shipped). Report/notification emails send with the display name QBTime Manager (CowboyMSP) (override with the EMAIL_SENDER_NAME env var), so recipients see the product name even though the address is on r2d2dev.com.

  3. Put it in the signup terms too (see below) — the descriptor + in-app disclosure + terms together make any charge defensible if disputed.

Discounts & promo codes (free / reduced-cost access)

All customers go through the same signup → Stripe Checkout flow, and Checkout's "Add promotion code" box is enabled (allow_promotion_codes: true). So discounts are created entirely in the Stripe Dashboard — no code or deploy needed per code.

Two pieces in Stripe: a Coupon is the discount rule; a Promotion code is the friendly code (e.g. WELCOME50) the customer types, pointing at a coupon. Create the coupon first, then a promotion code for it (Dashboard → Product catalog → Coupons → Create, then Promotion codes).

Recipes (set the coupon's Duration field):

Goal Coupon setup
First N months reduced ("pay X for a while, then full price") Percent or amount off, Duration = Repeating, Duration in months = N. Reverts to full price automatically after N invoices.
Permanent discount (partner/friends rate) Duration = Forever.
Free trial / first period free 100% off, Duration = Once (first invoice free) or Repeating for N months. (Or use a Stripe subscription trial period instead.)
Waive the onboarding fee only Create the coupon, then under "Apply to specific products" select ONLY the onboarding Product. Otherwise a coupon discounts the whole checkout (monthly + onboarding).

Limits you can set on a promotion code: max redemptions, expiration date, first-time-customer-only, minimum amount. Use these to keep a campaign code from being shared indefinitely.

Test it: in Stripe test mode, create a coupon + code, run the signup flow, enter the code in Checkout, and confirm the discount + the correct revert-to-full behavior on the next invoice (use Stripe's test clocks to fast-forward).

The discount shows on the Stripe invoice/receipt the customer gets, so it's transparent — which also helps avoid "why was I charged full price?" confusion when a repeating coupon ends.

What happens when a discounted customer's usage changes: a Stripe coupon attaches to the customer/subscription, not to a specific price. Our tier changes only swap the subscription's price, so the discount carries over automatically:

  • Lifetime free (100% forever): grows or shrinks → tier changes, monthly stays $0. They are never charged by a tier change.
  • Repeating (e.g. 50% off, 3 months): grows → the discount keeps applying to the new tier's price for the remaining duration, then reverts to the full new-tier rate.
  • Onboarding fee: the one-time onboarding true-up is a separate invoice item a subscription coupon would NOT cover — so the app skips the onboarding delta entirely for any customer with an active discount. "Free/discounted" therefore means no surprise onboarding charge.

No Stripe re-authorization needed for any of this. Promo codes, the statement descriptor, and coupons are Stripe Dashboard settings; the onboarding-delta skip and price swaps use the existing STRIPE_SECRET_KEY and standard Stripe API. No new API scopes/permissions or keys are required.

Changing thresholds

Edit seed_plans.sql and re-run it (see Create Prices and seed billing_plans).


Updating the app later (new version)

The app does not auto-publish when code changes — that's deliberate, so releases are on purpose. To release an update, from the project folder:

npm run build
npm run db:migrate:staging   # only if there are new database changes
npm run deploy:staging
# check staging.qbtime.r2d2dev.com, then:
npm run db:migrate:prod      # only if there are new database changes
npm run deploy:prod

Pushing code to GitHub runs automatic checks (does it build/lint/test) but does not deploy the app. Deploying is always the manual commands above.


Troubleshooting

node / npm / git "not recognized." The tool isn't installed, or PowerShell wasn't reopened after installing. Close and reopen PowerShell. If still missing, re-run the matching winget install from Step 0 and reopen PowerShell.

npm install fails part-way (errors mentioning files in use). If the project is in a OneDrive folder, OneDrive may be locking files. Pause OneDrive (click its cloud icon → Pause), delete the half-made node_modules folder, and run npm install again.

npm test or npm run typecheck reports errors before you've changed anything. The local copy may be incomplete. Make sure npm install finished cleanly; if needed delete node_modules and the package-lock.json, then npm install again.

wrangler commands say you're not authenticated. Run npx wrangler login again and approve in the browser. Confirm with npx wrangler whoami.

"It looks like you've run a Workers-specific command in a Pages project." You used wrangler secret put (Workers). This is a Pages project. Set secrets in the dashboard per environment (Step 4e/5e), not from the CLI. (wrangler pages secret put exists but can't target one environment, which is why the dashboard is used here.)

A db:migrate or d1 command says a binding/ID is missing. A placeholder in wrangler.toml wasn't replaced. Re-open wrangler.toml, confirm every REPLACE_WITH_* in the env you're targeting has been swapped for a real ID, and save. (This file drives the migration commands; the running app's bindings come from the dashboard — see the next entry.)

The deployed site errors with "DB is not defined" / KV missing at runtime. The bindings in wrangler.toml for that environment have placeholder or wrong IDs, so the deploy didn't bind them. Open wrangler.toml, confirm [env.preview] (staging) or [env.production] has real database_id and KV id values (no REPLACE_WITH_*), save, and re-run npm run deploy:staging / deploy:prod. Then verify under Workers & Pages → qbtime → Settings → Bindings (read-only there).

Deploy fails: "environment names that are not supported by Pages" or "does not support triggers." wrangler.toml had a Workers-style [env.staging] or a [triggers]/crons block. Pages allows only [env.preview] and [env.production] and no cron triggers. Use [env.preview] for staging, and run scheduled reports from a separate Worker (see "Scheduled reports" below).

db:migrate targets the wrong database.