Stripe catalog setup
Guide for Stripe products and prices. The app treats Stripe as the source of truth for plan definitions and syncs them into Django on server startup.
Overview
You do not create plans in Django admin. With STRIPE_SECRET_KEY set, the entrypoint runs seed_stripe_catalog (creates/updates Basic, Pro, Enterprise in Stripe) then sync_stripe_catalog (imports into Django). You can still create or edit products manually in the Dashboard; seed is idempotent and matches plans by metadata.code.
Before make start
Complete these steps in order:
-
Set Stripe keys in
server/.envandclient/.env:# server/.env STRIPE_SECRET_KEY=sk_test_... # STRIPE_WEBHOOK_SECRET is set automatically by `make start` # client/.env — publishable key for Stripe.js / Elements STRIPE_PUBLISHABLE_KEY=pk_test_... -
Start the project — seed, catalog sync, and local webhook forwarding all run when
STRIPE_SECRET_KEYis set:make start -
Verify — open Billing in the app or Django admin → Catalog products. You should see Basic, Pro, and Enterprise with monthly/yearly prices and features.
If STRIPE_SECRET_KEY is empty, startup skips seed/sync and webhook forwarding, and the billing page shows no plans.
Seed default products (API)
python manage.py seed_stripe_catalog creates or updates three plans via the Stripe API. No Dashboard setup is required for local/dev.
| Plan | code | Monthly (EUR) | Yearly (EUR) | Seats |
|---|---|---|---|---|
| Basic | BASIC | €9 / seat | €90 / seat | 1–5 |
| Pro | PRO | €20 / seat | €200 / seat | 5–15 |
| Enterprise | ENTERPRISE | €50 / seat | €500 / seat | 10–100 |
Prices use Stripe lookup keys (BASIC_MONTH_EUR, PRO_YEAR_EUR, …). Re-running seed updates product metadata/name and leaves matching prices unchanged. Amount changes create a new price and transfer the lookup key (Stripe prices are immutable).
Manual run (after the stack is up):
docker compose exec server python manage.py seed_stripe_catalog
docker compose exec server python manage.py sync_stripe_catalog
To customize plans, edit DEFAULT_STRIPE_CATALOG in server/billing/services/stripe_catalog_service.py, or manage products in the Dashboard (see below) and skip relying on the seeded amounts.
Create products in Stripe (manual)
Optional if you prefer the Dashboard over seed. In the Stripe Dashboard go to Product catalog → Products → Add product.
For each plan (e.g. Basic, Pro, Enterprise):
| Field | Guidance |
|---|---|
| Name | Display name shown in the app (e.g. Pro). |
| Description | Optional marketing copy; synced to the plan card. |
| Image | Optional; not used by the app today. |
| Product tax code | Set per your tax setup. |
| Status | Keep Active. Inactive products are not synced. |
Leave Stripe’s built-in Features section empty. This project reads plan features from Metadata instead (see below).
Recommended plan set
The app expects multiple tiers with ascending capability. A typical setup:
| Plan | code metadata | tier metadata | min_seats | max_seats |
|---|---|---|---|---|
| Basic | BASIC | 10 | 1 | 5 |
| Pro | PRO | 20 | 5 | 15 |
| Enterprise | ENTERPRISE | 30 | 10 | 100 |
tier is a positive integer used to order plans and for future feature-gating logic. Higher number = higher tier.
Required product metadata
These keys apply to subscription plans (Basic / Pro / Enterprise). Other active Stripe products that only set code (for example a one-time storefront SKU) are skipped by sync_stripe_catalog and are not imported into CatalogProduct.
On each plan product, open More options → Metadata (or the Metadata panel on the product detail page) and add exactly these keys:
| Key | Required | Format | Purpose |
|---|---|---|---|
code | Yes | Uppercase string, unique per product (e.g. PRO) | Stable internal identifier. Used in APIs, entitlements, and generated price codes. |
tier | Yes | Integer as string (e.g. 20) | Plan rank. Higher tiers unlock more capability. |
min_seats | Yes | Positive integer as string (e.g. 5) | Minimum purchasable seats for the plan. |
max_seats | Yes | Positive integer as string (e.g. 15) | Maximum purchasable seats for the plan. Must be >= min_seats. |
features | Yes | JSON array of entitlement codes | Machine-readable plan entitlements copied to workspace entitlements on subscribe. |
plan_description | Recommended | JSON object of locale → bullet arrays | Localized “What’s included” copy shown on plan cards. |
features format (entitlements)
Paste a JSON array of entitlement codes as a single metadata value — no extra outer quotes:
["manage_roles"]
Known entitlement codes today:
| Code | Unlocks |
|---|---|
manage_roles | Custom role create/edit/delete (still also requires the user’s role permission workspaces.manage_roles). Assigning existing roles to members is not gated by this entitlement. |
Use [] for plans with no gated entitlements. The sync command also accepts comma-separated text as a fallback, but JSON is recommended.
Do not use Stripe’s separate Features product field; only metadata features is read.
plan_description format (marketing bullets)
Paste a JSON object keyed by locale:
{"en":["Minimum 3 team members","Up to 10 team members","Unlimited workspaces"],"hr":["Najmanje 3 člana","Do 10 članova","Neograničeno radnih prostora"]}
The billing UI picks the active UI language (en / hr), falling back to en then the first available locale.
Example metadata (copy-paste)
Basic
| Key | Value |
|---|---|
code | BASIC |
tier | 10 |
min_seats | 1 |
max_seats | 5 |
features | [] |
plan_description | {"en":["Up to 5 team members","1 workspace","Email support"],"hr":["Do 5 članova","1 radni prostor","Podrška e-poštom"]} |
Pro
| Key | Value |
|---|---|
code | PRO |
tier | 20 |
min_seats | 5 |
max_seats | 15 |
features | ["manage_roles"] |
plan_description | {"en":["Up to 15 team members","5 workspaces","Custom roles"],"hr":["Do 15 članova","5 radnih prostora","Prilagođene uloge"]} |
Enterprise
| Key | Value |
|---|---|
code | ENTERPRISE |
tier | 30 |
min_seats | 10 |
max_seats | 100 |
features | ["manage_roles"] |
plan_description | {"en":["Up to 100 team members","Unlimited workspaces","Custom roles","Priority support"],"hr":["Do 100 članova","Neograničeni radni prostori","Prilagođene uloge","Prioritetna podrška"]} |
If code is missing, sync derives one from the product name (e.g. Basic Plan → BASIC_PLAN). Set code explicitly so price codes and entitlements stay predictable.
Create prices in Stripe
Add recurring prices on each product. The billing UI toggles between monthly and yearly intervals.
For each product, create:
| Price | Billing period | Notes |
|---|---|---|
| Monthly | Recurring, every 1 month | Mark one price as default if Stripe asks. |
| Yearly | Recurring, every 1 year | Enables the annual toggle on the plan catalog. |
Guidelines:
- Use the same currency across all plans (e.g. EUR) unless you intentionally support multi-currency.
- Keep prices Active. Removed or archived prices are deactivated in Django on the next sync.
- Price nickname and lookup key in Stripe are optional; the app generates its own internal codes.
Internal price codes (generated on sync)
The sync command builds Django price codes automatically:
{PRODUCT_CODE}_{INTERVAL}_{CURRENCY}
Examples for product code PRO in EUR:
PRO_MONTH_EUR— monthlyPRO_YEAR_EUR— yearly
You do not configure these in Stripe; they appear in Django admin and subscription records after sync.
How the app uses synced data
| Synced field | Where it appears |
|---|---|
| Product name, description | Plan cards on the Billing page |
metadata.plan_description → CatalogProduct.plan_description | “What’s included” list on each plan (localized) |
metadata.features → CatalogProduct.features | Entitlement codes; copied to workspace entitlements on subscribe |
| Prices (amount, interval, currency) | Plan pricing and checkout |
metadata.code | Current-plan detection, entitlement product_code |
metadata.tier | Catalog ordering / future access rules |
metadata.min_seats / metadata.max_seats | Seat picker bounds, invite gating, purchased seat validation |
When a workspace subscribes, Stripe webhooks update Entitlement.features from the product’s effective features (Stripe-synced entitlement codes, unless overridden in Django admin via features_override). Creating/editing/deleting roles requires both the role permission workspaces.manage_roles and the entitlement code manage_roles. Assigning an existing role to a member only requires the role permission.
Syncing the catalog
Automatic (default)
On every server container start, after migrations:
[entrypoint] Seeding Stripe catalog (create/update default products)...
[entrypoint] Syncing Stripe catalog...
Skipped when STRIPE_SECRET_KEY is not set.
Manual
docker compose exec server python manage.py seed_stripe_catalog
docker compose exec server python manage.py sync_stripe_catalog
Django admin
Admin → Billing → Catalog products → Sync from Stripe (top of the list).
Re-run sync after you change products, prices, or metadata in Stripe. Sync is idempotent: existing records are updated, missing Stripe prices are deactivated locally.
Local webhooks (Stripe CLI)
Catalog sync only imports products and prices. Live subscription state (who is on which plan, entitlements, invoices) comes from Stripe webhooks.
For local development, webhook forwarding is part of make start when STRIPE_SECRET_KEY is set — no ngrok, no Dashboard endpoint, no second terminal.
What make start does for billing
- Fetches the Stripe CLI signing secret (
stripe listen --print-secret) and writesSTRIPE_WEBHOOK_SECRETtoserver/.env. - Starts the stack with Compose profile
stripe, including astripe-webhookscontainer. - That container runs Stripe CLI
listenand forwards events tohttp://server:8000/api/v1/billing/webhook/stripe/.
make start
Requires STRIPE_SECRET_KEY in server/.env. You do not need stripe login.
| Event | Purpose |
|---|---|
customer.subscription.created | New subscription → entitlement |
customer.subscription.updated | Plan changes, renewals, cancellation flags |
customer.subscription.deleted | Subscription ended (+ cancellation email if payment retries exhausted) |
customer.deleted | Stripe customer removed → local billing cleanup |
invoice.paid | Invoice history |
invoice.payment_failed | Failed payment invoice record + branded recovery email |
invoice.finalized | Invoice ready / updated |
Failed payments (Dashboard)
Automatic retries and when access ends are controlled in Stripe, not in app code. Configure these in live (and test, if you want to exercise the flow):
- Settings → Billing → Manage failed payments — enable Smart Retries (or a custom retry schedule). For the cancellation email below, set the end state to Cancel the subscription (or mark unpaid). Entitlements stay active while the subscription is
active,trialing, orpast_due(grace access during dunning). Access drops when the subscription becomescanceled/unpaid. - Settings → Customer emails — disable Stripe’s “Failed payment” (and any Stripe “subscription canceled” / unpaid notices you don’t want) so the workspace owner only receives the app’s branded emails. Keep receipt emails if you want.
- Hosted Invoice Page — leave enabled. The recovery email’s Retry payment button uses
hosted_invoice_url. - Production webhook endpoint — include at least the events in the table above (
invoice.payment_failed,invoice.paid,invoice.finalized,customer.subscription.*). Localmake startalready forwards them.
Emails the app sends
| When | |
|---|---|
invoice.payment_failed on a renewal / mid-cycle invoice (not initial checkout) | Recovery: amount, card last4, single Go to billing management CTA → /workspace/billing |
Subscription moves from entitled (active / trialing / past_due) to canceled / unpaid after payment failure (cancellation_details.reason=payment_failed, or past_due → terminal) | Cancellation: access ended, CTA to Billing to resubscribe |
On the billing page past-due banner, users can Retry payment in-app (POST /billing/retry-payment/ → Invoice.pay) or Update payment information (setup intent; setting a new default card also retries the open invoice).
Voluntary cancels (cancellation_requested) do not send the payment-failed cancellation email.
Do not enable the Stripe Customer Portal for this flow; payment-method updates and retries stay in-app.
Verify delivery
make logs
# or only webhooks:
docker compose --profile stripe logs -f stripe-webhooks
After a test checkout or subscription change, you should see forwarded events in the stripe-webhooks logs, and entitlements/subscription status should update in the app.
Without webhook forwarding, checkout can still open in Stripe, but entitlements and subscription status in the app will not update.
Optional helpers
| Command | Purpose |
|---|---|
make stripe-webhooks | Re-sync the signing secret and tail stripe-webhooks logs |
./server/scripts/stripe-webhooks.sh --listen | Foreground listen outside Compose (debug) |
Notes
- Production/staging still needs a real HTTPS webhook endpoint in the Stripe Dashboard (or your deploy automation); the Compose service is for local development only.
- The CLI signing secret is stable for a given Stripe test key;
make startonly rewrites.envwhen it changes. - If you add
STRIPE_SECRET_KEYafter the stack is already running, runmake stop && make startso the secret is synced and thestripe-webhooksservice starts.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Billing page: “No plans available” | Empty catalog or sync skipped | Set STRIPE_SECRET_KEY, restart server (seed + sync run automatically), or run the seed/sync commands manually |
| Features list empty on a plan | Invalid or missing plan_description metadata | Use locale JSON object format; re-sync |
| Role management prompts to upgrade | Plan features missing manage_roles | Add "manage_roles" to that product’s metadata features; re-sync |
| Yearly toggle missing prices | No year interval price on product | Add a yearly recurring price in Stripe, re-sync |
| Wrong plan marked as current | code mismatch | Ensure metadata.code matches what entitlements expect; avoid renaming codes after launch |
| Sync errors on startup | Invalid Stripe key or API error | Check make logs; verify test vs live keys match your Stripe mode |
| Subscription stuck on “incomplete” / no entitlement | Webhooks not forwarded locally | Set STRIPE_SECRET_KEY, then make stop && make start; see Local webhooks |
| Webhook returns 400 | Host: server rejected, or stale/missing STRIPE_WEBHOOK_SECRET | Entrypoint adds server to ALLOWED_HOSTS automatically; run make stop && make start. Check stripe-webhooks logs for [400] vs reconnect noise |
No stripe-webhooks container | Empty STRIPE_SECRET_KEY | Set a Stripe test secret key in server/.env, then make start |
Session expired, reconnecting... in stripe-webhooks logs | Normal Stripe CLI idle reconnect | Ignore unless events also return [400] / never show [200] |
Logs: make logs or docker compose logs server — entrypoint lines are prefixed with [entrypoint].