Billing
Take test payments with a Stripe sandbox, change your plans and prices in code, understand how a payment becomes a plan, handle disputes, refunds and fraud warnings, and prepare Stripe for going live.
At the end of this guide, you can pay for a plan in your local app with a Stripe test card, and watch the workspace move to that plan. You will also know how to change plans and prices, and what to do before taking real money.
Neatship bills per workspace: each workspace has at most one Stripe subscription. Stripe hosts the payment pages (Stripe Checkout) and the self-service billing pages (Stripe's customer portal). Your server never sees a card number.
Two modes: none or stripe
The server's BILLING_PROVIDER variable, in packages/app-server/.env, is the only switch. The web app reads the mode from the API.
BILLING_PROVIDER | What people see |
|---|---|
none (the default) | The Billing page shows the plans and prices. The buttons say "Soon". Everything else works, with no Stripe account. |
stripe | "Upgrade" opens Stripe Checkout. "Manage billing" opens Stripe's customer portal. STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are then required, and checked when the server starts. The catalog command below only needs STRIPE_SECRET_KEY. |
Why a separate Stripe sandbox
A Stripe sandbox is an isolated test environment inside your Stripe account, with its own keys, customers, products and settings. No real money moves.
Create one sandbox per developer machine, and one for CI. Don't use the account's shared test data. The catalog command below rewrites products, prices and the customer portal settings, and stripe listen receives every event of the sandbox. With one sandbox each, your test customers and catalog changes never mix with someone else's.
Set up Stripe locally
You need the Stripe CLI (Stripe's command-line tool, the stripe command) installed.
In the Stripe Dashboard, create a sandbox. Copy its secret key (
sk_test_...), or better, create a restricted key (rk_test_...): a key limited to the permissions the app needs. It needs write access to Customers, Checkout Sessions, Customer portal, Subscriptions, Products and Prices, and read access to Invoices, Payment Disputes, and Charges and Refunds.Get the webhook signing secret that the Stripe CLI will use. It starts with
whsec_, and stays the same each time you runstripe listenwith this key:bashstripe listen --print-secret --api-key <sandbox key>In
packages/app-server/.env, set:envBILLING_PROVIDER=stripe STRIPE_SECRET_KEY=rk_test_... STRIPE_WEBHOOK_SECRET=whsec_...Create your plans in the sandbox:
bashnpx nx run app-server:billing:sync-catalogForward Stripe's webhooks to your local server, and keep this command running in its own terminal:
bashstripe listen --forward-to localhost:3100/webhooks/stripe --api-key <sandbox key>Stop
yarn startand start it again. It runs the server, the web app and the worker that processes Stripe's events.Open Settings → Billing, click "Upgrade" on a plan, and pay with a test card.
Note: The catalog command (step 4) only needs
BILLING_PROVIDERandSTRIPE_SECRET_KEY: it receives no webhooks. The server (step 6) also needsSTRIPE_WEBHOOK_SECRET. Without it, it stops at start-up withSTRIPE_WEBHOOK_SECRET: required by the API when BILLING_PROVIDER=stripe, followed by the command of step 2.
Test cards
Use any future expiry date and any 3-digit CVC. These numbers only work in test mode and sandboxes.
| Card number | What happens |
|---|---|
4242 4242 4242 4242 | The payment succeeds |
4000 0025 0000 3155 | The bank asks for authentication (3D Secure) first |
4000 0000 0000 9995 | The payment is declined: insufficient funds |
4000 0000 0000 0259 | The payment succeeds, then is disputed as fraud (see "Disputes, refunds and fraud warnings") |
4000 0000 0000 5423 | The payment succeeds, then gets an early fraud warning |
Stripe lists many more at https://docs.stripe.com/testing. To simulate renewals and failed payments without waiting, use stripe trigger <event> or Stripe's test clocks.
Your plans are code
The plans live in PLANS, in packages/app-shared/src/constants/Plans.ts:
export const PLANS: readonly Plan[] = [
{ key: 'free', monthlyPriceInCents: 0, yearlyPriceInCents: 0, memberLimit: 3, isHighlighted: false },
{ key: 'pro', monthlyPriceInCents: 1200, yearlyPriceInCents: 12000, memberLimit: 20, isHighlighted: true },
{ key: 'business', monthlyPriceInCents: 2900, yearlyPriceInCents: 29000, memberLimit: null, isHighlighted: false },
];- Prices are in cents:
1200is 12.00. - The currency is
PLAN_CURRENCYinpackages/app-shared/src/constants/PlanCurrency.ts:EUR. memberLimit: nullmeans no member limit.isHighlightedputs a "Popular" tag on the plan's card.- The order of the list is the order of the plans: a plan further down is an upgrade.
- A plan with both prices at 0 is free and never sent to Stripe. The web app expects the free plan's key to be
free.
npx nx run app-server:billing:sync-catalog makes the Stripe sandbox match this list:
| In Stripe | Made from your code |
|---|---|
| One product per paid plan | id <product name>_<plan key> (for example neatship_pro), name "Neatship Pro" |
| Two prices per product | a monthly and a yearly price, found by their lookup keys (pro_monthly, pro_yearly) |
| The customer portal settings | the plans customers may switch to, immediate charge with proration on plan changes, cancellation at the end of the paid period, card update, invoices, billing details |
Running it again changes nothing and says so. Otherwise it prints one line per change.
Change a price
- Edit the amount in
PLANS. - Rebuild the shared package:
npx nx build app-shared --skip-nx-cache. - Run
npx nx run app-server:billing:sync-catalog.
Stripe prices can't change amount, so the command creates a new price, moves the lookup key to it and archives the old one. Existing subscribers keep their old price until they change plan. The same happens when you change PLAN_CURRENCY.
Add, rename or remove a plan
- Change the list of keys in
PlanKey(packages/app-shared/src/types/PlanKey.ts), thenPLANS. - Run
npx nx run-many -t typecheck. The compiler lists every place that must handle the new key, such as the plan names and descriptions shown on the Billing page (getPlanLabel.tsandgetPlanDescription.tsinpackages/app-front/src/modules/settings/billing/utils/). - Run the catalog command.
A plan removed from PLANS is left untouched in Stripe: archive it in the Stripe Dashboard yourself.
Tip: The product ids in Stripe include PRODUCT_NAME. Rename your product (see Design and branding) before you sync the catalog for the first time. Renaming it afterwards creates new products and prices in Stripe and leaves the old products behind.
Run the catalog command once per Stripe environment (each sandbox, then live), and again after every change to PLANS. On the production server, the same command runs as a one-off service of the production stack (billing-sync-catalog in packages/app-docker/docker-compose.prod.yml, which adds --live):
bash packages/app-utils/prod-compose.sh run --rm billing-sync-catalogFree trials
BILLING_TRIAL_PERIOD_DAYS sets a free trial, in days, on a workspace's first checkout. The default is 0: no trial. A workspace gets one trial ever.
What the Billing page does
Open Settings → Billing.
- Current plan shows the workspace's plan, the subscription status (Active, Trial, Payment failed, Payment needed, Paused, Canceled) and the next date that matters: renewal, cancellation or end of trial. "Manage billing" opens Stripe's customer portal: change plan, cancel, update the card, download invoices, edit billing details.
- Plans shows every plan, with a monthly or yearly switch. Each card says what you can do: "Upgrade", "Switch" (the same plan billed the other way, or another paid plan), "Downgrade" (back to free: this opens the portal, where the subscription is canceled at the end of the paid period) or "Current plan".
- A workspace without a subscription goes to Stripe Checkout. A workspace that already pays goes to the portal's "Confirm your updates" page for the chosen price, which shows the prorated amount. A workspace never gets a second subscription.
- Back from Stripe, the page shows "Payment confirmed, updating your plan…" and checks again every 2 seconds until the new plan is in force. After a minute it stops and explains that the change can take a few minutes.
- Members without the
org:billing:managepermission (every non-admin) see the plan and its status, with the buttons disabled and the line "Only admins can change the plan."
Access comes from webhooks only
Paying on Stripe changes nothing by itself. The success page never grants access: anyone could open that address.
What changes the plan is Stripe's webhook: a signed message Stripe sends to POST /webhooks/stripe. The server checks the signature, stores the event, and the background worker processes it. The worker doesn't trust the event's content: it asks Stripe for the customer's subscriptions as they are now, and writes that. So an event that arrives late, twice or out of order gives the same result. Details: Background jobs and webhooks.
This is why the worker must run (yarn start runs it). Without it, events wait in Redis and the plan doesn't change.
The plan a workspace may use
Stripe reports a status for each subscription. Neatship turns it into the effective plan, the plan the workspace may use now:
| Subscription status | Effective plan |
|---|---|
active or trialing | the plan paid for |
past_due (a renewal failed and Stripe is retrying) | the plan paid for, during a grace period of BILLING_PAST_DUE_GRACE_PERIOD_DAYS days (default 14) after the unpaid invoice was issued; then free |
anything else (canceled, unpaid, incomplete, incomplete_expired, paused) or no subscription | free |
In the web app, useCurrentWorkspace().planKey is the effective plan; billing.planKey is the plan paid for. On the server, WorkspaceBillingService.getEffectivePlanKey(workspace) gives the effective plan. Use the effective plan to decide what a workspace may do.
Plan limits
One limit is built in: the number of members. It is an example you can keep, change or turn off, all in one file: PLANS, in packages/app-shared/src/constants/Plans.ts.
memberLimit: 3allows 3 members in a workspace on that plan.memberLimit: nullmeans no limit. Set every plan'smemberLimittonullto turn member limits off.- After a change, rebuild the shared package (
npx nx build app-shared --skip-nx-cache) and restart the server. Every workspace gets its new limit on its next request.
How the limit is applied:
- with Clerk, it becomes the organization's member limit in Clerk, so Clerk's invitation screens refuse members beyond it;
- with the mock, it is only recorded on the workspace.
The server records on each workspace the limit it applied last (memberLimit; 0 means "no limit", the value Clerk uses). On every signed-in request it compares that record with the limit of the workspace's effective plan. When they differ, a background job (ApplyWorkspaceMemberLimitJob) applies the plan's limit. So:
- a new workspace gets the free plan's limit on its first request (Clerk's own default for a new organization is 5 members; the free plan allows 3);
- after a plan change, the limit follows at once: the Stripe sync queues the same job;
- when a
past_duegrace period ends, the free plan's limit applies at the workspace's next request.
The comparison only reads values already loaded for the request: no extra database or network call. The job runs only when something changed.
To limit something else (projects per workspace, for example), read the effective plan in your service and refuse the action with a domain error.
Disputes, refunds and fraud warnings
Sometimes money comes back, or may. Neatship receives three kinds of Stripe events for this, records each one in the billingIncident table (with the workspace it concerns) and alerts you: in the logs, and by email through Sentry when error alerts are on (see Deploy, "Error alerts"). Each alert links to the right page of the Stripe Dashboard.
| What happened | Alert | What you should do |
|---|---|---|
| A dispute is opened: the customer asked their bank to take a payment back (a "chargeback"). Stripe takes the amount and a dispute fee from your balance. | error, with the deadline | Before the deadline in the alert (usually 7 to 21 days), open the dispute in the Stripe Dashboard and choose Accept dispute or Counter dispute. To counter, send proof: the invoice, the customer's sign-up and usage, your messages with them. Without an answer, the dispute is lost. |
| A dispute is closed | error if lost, warning if won | Lost: the money stays with the customer. If you don't want to serve this customer anymore, cancel their subscription. Won: the money comes back; nothing to do. |
| An early fraud warning: the card's bank reports a payment as probably made with a stolen card. About 4 in 10 become disputes. | error | Unless you know this customer is genuine, refund the payment in the Stripe Dashboard (choose the reason "Fraudulent"), then cancel the subscription. A fully refunded payment can't be disputed, so you avoid the dispute fee. Stripe suggests not refunding automatically when the payment is much larger than the dispute fee: decide case by case. |
| A payment is refunded, by you or by someone with access to your Stripe account | warning | Usually nothing: you made it. If the refund should end the customer's access, cancel their subscription. |
Access never changes by itself. Stripe keeps a subscription running when its payment is disputed or refunded, and so does Neatship. When you cancel the subscription in the Stripe Dashboard (the customer → the subscription → Cancel subscription), Stripe's webhook moves the workspace to the free plan, as for any cancellation.
Tip: Stripe can cancel the subscription for you when a card payment is disputed in full: Settings → Billing → Subscriptions and emails → Manage disputed payments (dashboard.stripe.com/settings/billing/automatic). Choose "cancel the subscription immediately without prorating" if you never want to keep serving a customer who disputes.
Try it with a sandbox: pay with the test card 4000 0000 0000 0259 (the payment is disputed as fraud a moment later) or 4000 0000 0000 5423 (it gets an early fraud warning), or refund a test payment in the Dashboard. The worker's log then shows an Alert: line.
These events need two read permissions on your restricted key, listed in "Set up Stripe locally" above: Payment Disputes and Charges and Refunds. Without them, processing fails with Stripe's permission error and you get a server fault alert.
Tax
Stripe Tax collects nothing until a tax registration is active. The steps, in order:
- Set your head-office address in Stripe and add your tax registration.
- Turn on
automatic_taxandtax_id_collectionincreateCheckoutSession(packages/app-server/src/engine/core-modules/billing/services/billing-checkout.service.ts), withcustomer_update: { address: 'auto', name: 'auto' }. This is a code change. - Set your products' tax code.
Cross-border rules, prices with or without VAT and e-invoicing obligations depend on your country: confirm them with an accountant.
Going live
From docs/conventions/10-auth-and-billing.md §4.8:
- Account: activate your Stripe account (business details, bank account). Set the public business name, support email and branding: Checkout and the portal show them.
- Key: create a restricted live key with write access to Customers, Checkout Sessions, Customer portal, Subscriptions, Products and Prices, and read access to Invoices, Payment Disputes, and Charges and Refunds. Put it in the production
STRIPE_SECRET_KEY. - Webhook endpoint: add
https://<your api server>/webhooks/stripein the Dashboard, with API version2026-08-26.dahliaand the events below. Put its signing secret in the productionSTRIPE_WEBHOOK_SECRET. - Catalog: create the plans in live mode, once, then after every change to
PLANS. On the production server:bash packages/app-utils/prod-compose.sh run --rm billing-sync-catalog(the compose service passes--livefor you). Elsewhere,npx nx run app-server:billing:sync-catalog --livewith the production configuration. Without--live, the command refuses a live key, so a copied.envcan't change your real catalog by accident. - Payment methods and recovery: choose the payment methods in the Dashboard. Set Smart Retries and what happens after the last retry (Billing → Revenue recovery), consistent with
BILLING_PAST_DUE_GRACE_PERIOD_DAYS. Turn on the customer emails (receipts, failed payments). - Tax: registration first, then the steps above.
- Disputes, refunds, fraud warnings: they are handled (see "Disputes, refunds and fraud warnings" above). Send their events to the endpoint (the list below), turn on error alerts so their emails reach you, and decide whether Stripe cancels subscriptions after a dispute (Manage disputed payments).
- Clerk: set up the production Clerk webhook, so that deleted workspaces stop paying (see Authentication).
The events to send to the webhook endpoint:
checkout.session.completed
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
customer.subscription.paused
customer.subscription.resumed
customer.subscription.trial_will_end
invoice.paid
invoice.payment_failed
invoice.payment_action_required
invoice.finalization_failed
charge.dispute.created
charge.dispute.updated
charge.dispute.closed
charge.refunded
radar.early_fraud_warning.createdThe Stripe API version is pinned: STRIPE_API_VERSION must be 2026-08-26.dahlia, the version the installed stripe library is written for, or the server refuses to start. Upgrading it is a code change.