Subscriptions & Billing
Charging tenants with Stripe, and capping what each plan can create
Billing in POSVelo is two features that you can adopt separately. Plan limits cap how many stores, users, and products a tenant can create — and work with no Stripe account at all. Stripe billing adds real payment: checkout, a self-service customer portal, and invoices. A tenant on a free $0 plan gets limit enforcement and never touches Stripe.
How a Tenant Gets a Plan
Every tenant carries a plan label and (once they subscribe) a Subscription
row linked to a SubscriptionPlan in a global catalog. The plan's limits
JSON decides what they may create; Stripe decides whether they paid.
| Object | Scope | What it holds |
|---|---|---|
| SubscriptionPlan | Global catalog (not tenant-scoped) | code, name, price, billing-cycle Stripe Price ids, a limits JSON, and isActive |
| Subscription | One per tenant | Link to a plan, Stripe subscription id, status, billing cycle, current period, trial / cancel fields |
| Invoice | Per tenant | Stripe invoice id, amount, status, paid-at, and hosted / PDF URLs |
Tenant.stripeCustomerId | Per tenant | Created and cached the first time the tenant starts checkout |
Step 1 — Configure Stripe (Optional)
Skip this entire step if you only want plan limits on free plans. To take
payment, add your Stripe keys to the backend .env and restart.
| Variable | Description | Example | |
|---|---|---|---|
| STRIPE_SECRET_KEY | Stripe secret API key — enables all billing calls | sk_live_… / sk_test_… | Optional |
| STRIPE_WEBHOOK_SECRET | Signing secret for the webhook endpoint | whsec_… | Optional |
| STRIPE_PUBLISHABLE_KEY | Publishable key (client-side references) | pk_live_… / pk_test_… | Optional |
| STRIPE_CURRENCY | ISO 4217 currency used when auto-pricing a plan | usd | Optional |
STRIPE_CURRENCY (default usd) is the one currency
every plan is priced in when the server auto-creates its Stripe Price. This is
the platform's billing currency and is independent of the per-tenant display
currency a shop uses for its own sales.
Wire up the webhook
Stripe confirms payments asynchronously, so the backend learns the result from a webhook, not the browser redirect.
Add the endpoint in Stripe
In the Stripe Dashboard, add a webhook pointing at
POST https://your-api-domain/api/v1/webhooks/stripe. For local
testing, run stripe listen --forward-to localhost:5000/api/v1/webhooks/stripe.
Subscribe to the right events
The handler reacts to checkout.session.completed,
customer.subscription.created/updated/deleted, and
invoice.paid / invoice.payment_failed. Any other event is
acknowledged (200) and ignored.
Copy the signing secret
Put the endpoint's whsec_… secret in
STRIPE_WEBHOOK_SECRET. The webhook route verifies the raw
request bytes against it — an unsigned or mis-signed call is rejected.
Plan changes only land in POSVelo when the matching Stripe webhook is
delivered. If STRIPE_WEBHOOK_SECRET is unset, or the Dashboard
has no endpoint pointing at /api/v1/webhooks/stripe, a tenant can
pay at Stripe but their plan will never update in the app. The Billing screen
briefly polls after a checkout to bridge the few seconds before the webhook
arrives — it cannot substitute for the webhook being configured.
Step 2 — Build the Plan Catalog
A SUPER_ADMIN manages plans from the platform console at
/admin/billing, or via the API. Creating or editing a plan with a
non-zero price (and Stripe configured) automatically creates the matching
Stripe Product and monthly/yearly Prices; a $0 plan never calls Stripe.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/v1/super-admin/billing/plans | List every plan, including inactive ones, with Stripe ids |
| POST | /api/v1/super-admin/billing/plans | Create a plan (rejects a duplicate code) |
| PATCH | /api/v1/super-admin/billing/plans/:planId | Edit a plan; a price change archives the old Stripe Price and creates a new one |
| GET | /api/v1/billing/plans | Tenant-facing catalog — active plans only, no Stripe ids |
You cannot change the amount on an existing Stripe Price. When you change a
plan's price, POSVelo archives the old Price (active: false) and
creates a new one under the same Stripe Product. Saving a plan that predates
your Stripe setup also self-heals — it creates the missing Stripe Product /
Prices on that save.
Step 3 — Plan Limits
Independent of payment, each plan caps how many of three resources a tenant may create. The check runs before the row is written, so a tenant at the ceiling is stopped cleanly rather than half-creating something.
| Resource | Checked on | Rejection |
|---|---|---|
| Stores | Create store | 403 PLAN_LIMIT_EXCEEDED |
| Users | Create user, or an HR employee provisioned with a login | 403 PLAN_LIMIT_EXCEEDED |
| Products | Create product | 403 PLAN_LIMIT_EXCEEDED |
Plan limits count login users. Adding an HR employee record without provisioning a login does not use a seat; provisioning a login for them does. This lets a tenant keep records for staff who never sign in.
A plan's limits JSON overrides the defaults field-by-field — any field it
omits keeps the static default, and null means unlimited. An unseeded catalog
behaves exactly like earlier versions (the static PLAN_LIMITS map applies).
The Tenant's Billing Experience
Tenant admins manage their own subscription under Settings › Billing
(/settings?tab=billing). Behind it sit four tenant-facing endpoints,
all gated by tenant:manage (ADMIN).
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/v1/billing/subscription | The tenant's current subscription |
| GET | /api/v1/billing/invoices | The tenant's invoice history (hosted + PDF links) |
| POST | /api/v1/billing/checkout-session | Start a Stripe Checkout for a chosen plan code |
| POST | /api/v1/billing/portal-session | Open the Stripe customer portal to change or cancel |
Stripe redirects the browser back the instant checkout completes, but the
subscription only updates when the webhook lands a few seconds later. The
return URLs carry a billing_return=1 marker; on landing the tab
strips it and briefly re-polls (a bounded handful of times) so the new plan
appears without a manual refresh. A plain cancel carries no marker, so no
needless “confirming” state is shown.
Dunning & Suspension
POSVelo does not run its own grace-period timer. It defers to Stripe's retry and
dunning lifecycle: when Stripe ultimately ends a delinquent subscription it
emits customer.subscription.deleted, and the webhook downgrades the
tenant accordingly. Separately, a SUPER_ADMIN can suspend a tenant directly —
suspension takes effect on the tenant's very next request (see
Platform Administration).
Quick Reference
| Task | Where |
|---|---|
| Edit the plan catalog / Stripe sync | /admin/billing (SUPER_ADMIN) |
| Platform MRR + subscription status counts | GET /api/v1/super-admin/billing/overview |
| One tenant's subscription + invoices | GET /api/v1/super-admin/tenants/:tenantId/subscription |
| Tenant self-service subscription | /settings?tab=billing (ADMIN) |
| Usage vs. plan limits for a tenant | GET /api/v1/tenants/:id/usage |