Subscriptions & Billing

Charging tenants with Stripe, and capping what each plan can create

Two layers that work independently

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.

ObjectScopeWhat it holds
SubscriptionPlanGlobal catalog (not tenant-scoped)code, name, price, billing-cycle Stripe Price ids, a limits JSON, and isActive
SubscriptionOne per tenantLink to a plan, Stripe subscription id, status, billing cycle, current period, trial / cancel fields
InvoicePer tenantStripe invoice id, amount, status, paid-at, and hosted / PDF URLs
Tenant.stripeCustomerIdPer tenantCreated 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.

VariableDescriptionExample
STRIPE_SECRET_KEYStripe secret API key — enables all billing callssk_live_… / sk_test_…Optional
STRIPE_WEBHOOK_SECRETSigning secret for the webhook endpointwhsec_…Optional
STRIPE_PUBLISHABLE_KEYPublishable key (client-side references)pk_live_… / pk_test_…Optional
STRIPE_CURRENCYISO 4217 currency used when auto-pricing a planusdOptional
Billing is single-currency, platform-wide

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.

1

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.

2

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.

3

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.

No webhook means no plan sync

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.

MethodEndpointPurpose
GET/api/v1/super-admin/billing/plansList every plan, including inactive ones, with Stripe ids
POST/api/v1/super-admin/billing/plansCreate a plan (rejects a duplicate code)
PATCH/api/v1/super-admin/billing/plans/:planIdEdit a plan; a price change archives the old Stripe Price and creates a new one
GET/api/v1/billing/plansTenant-facing catalog — active plans only, no Stripe ids
Stripe Prices are immutable — that's why editing re-creates them

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.

ResourceChecked onRejection
StoresCreate store403 PLAN_LIMIT_EXCEEDED
UsersCreate user, or an HR employee provisioned with a login403 PLAN_LIMIT_EXCEEDED
ProductsCreate product403 PLAN_LIMIT_EXCEEDED
A login-less employee does not consume a user seat

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).

MethodEndpointWhat it does
GET/api/v1/billing/subscriptionThe tenant's current subscription
GET/api/v1/billing/invoicesThe tenant's invoice history (hosted + PDF links)
POST/api/v1/billing/checkout-sessionStart a Stripe Checkout for a chosen plan code
POST/api/v1/billing/portal-sessionOpen the Stripe customer portal to change or cancel
Why the Billing tab re-polls after you return from Stripe

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

TaskWhere
Edit the plan catalog / Stripe sync/admin/billing (SUPER_ADMIN)
Platform MRR + subscription status countsGET /api/v1/super-admin/billing/overview
One tenant's subscription + invoicesGET /api/v1/super-admin/tenants/:tenantId/subscription
Tenant self-service subscription/settings?tab=billing (ADMIN)
Usage vs. plan limits for a tenantGET /api/v1/tenants/:id/usage