Plans, pricing and paid access
Author the catalog once and enforce your paid feature on the correct workspace.
A subscription belongs to a workspace
Each workspace has one separately paid subscription. All members share its product entitlement. Owners/admins manage billing; ordinary members do not access financial details or the portal. A person can belong to both a paid workspace and an unpaid one without the payment transferring between them.
The starter's infrastructure is available across its example tiers. You add product differences deliberately. There are no seat charges, recurring AI grants, top-ups or usage billing. AI starts with one workspace credit grant independent of Stripe events.
Initial seed and later authoring
config/billing-seed.json defines the initial missing catalog. Kickstart creates/reuses the template-owned test products and monthly/yearly prices. Once initialized, the owned Stripe test catalog is the authoring source. The app's shared catalog reader uses installed data; unconnected UI can show seed values with purchase controls disabled.
Do not maintain another editable price list in the frontend or this manual. Generated STRIPE_*_PRODUCT_ID_* fields bind the installation to resources; editing an ID is not a pricing change. Keep the forma_catalog and forma_plan metadata that provides stable identity across display-name edits.
Change a catalog price
In the intended Stripe test account, locate the owned plan. For a display-name/description edit, change those fields on that product. For an amount change, create a replacement flat recurring price for the intended interval, then archive the superseded active variant after reviewing existing subscribers.
Keep exactly one active monthly and one active yearly price per plan, and one currency across the supported catalog. Multiple active eligible prices make setup ambiguous and should stop reconciliation. Run the relevant setup check/reconciliation and inspect the app's Billing tab.
New prices do not silently move existing subscribers. Existing subscriptions retain their price until explicitly changed through the supported portal/provider flow. Deployment copies the current owned test catalog into live resources; it does not turn a test price ID into a live price ID. Stripe price management.
Display is not enforcement
The header's plan tag is a member-safe summary: Free means no current paid entitlement; a catalog lookup can yield Paid or Plan unavailable according to state. Hiding a button based on that tag does not authorize a paid operation.
requireSubscription() in lib/billing/server.ts checks the currently selected workspace using workspace_subscription and the server-selected billing mode. It takes no workspace argument in this source snapshot. Do not invent requireSubscription(orgId) or use the cookie-selected workspace to authorize a stale form targeting another workspace.
Make note creation paid
This optional variant builds on workspace notes. It leaves existing notes readable but makes creation require an active subscription for the explicit form workspace.
First apply this new migration after the base notes migration:
-- Optional paid-create variant. Existing notes remain readable through RLS.
revoke insert(id, org_id, created_by, body) on public.workspace_notes from authenticated;
drop policy notes_create on public.workspace_notes;
grant select, insert on public.workspace_notes to service_role;
-- Every create now goes through the verified server action and its paid check.
Revoking direct insert closes the ordinary Data API bypass. A paid check only in a button or action would be insufficient while the base table still allowed unpaid clients to insert directly. Keep the SELECT policy for member reads.
Then replace app/actions/notes.ts with this complete optional action:
'use server';
import { revalidatePath } from 'next/cache';
import { getWorkspace, requireUser } from '@/lib/auth';
import { createAdminClient } from '@/lib/supabase/admin';
import { billingMode } from '@/lib/billing/environment.mjs';
import { noteInput, type NoteState } from '@/lib/notes-schema';
export async function createNote(_previous: NoteState, form: FormData): Promise<NoteState> {
const parsed = noteInput.safeParse({ request_id: form.get('request_id'), org_id: form.get('org_id'), body: form.get('body') });
if (!parsed.success) return { error: 'Enter a note of 1–2,000 characters.' };
const { user, supabase } = await requireUser();
const { org_id, request_id, body } = parsed.data;
try {
const { role } = await getWorkspace(org_id);
if (role === 'member') return { error: 'Only owners and admins can create notes.' };
} catch { return { error: 'Workspace access is no longer available.' }; }
const subscription = await supabase.rpc('workspace_subscription', { target: org_id, target_mode: billingMode() });
if (subscription.error || subscription.data?.paid !== true) return { error: 'An active subscription for this workspace is required.' };
// Narrow privileged write: caller and explicit tenant were checked above.
// Ordinary clients have no INSERT grant after the paid migration.
const admin = createAdminClient();
const result = await admin.from('workspace_notes').insert({ id: request_id, org_id, created_by: user.id, body });
if (result.error?.code === '23505') {
const existing = await admin.from('workspace_notes').select('body,created_by').eq('id', request_id).eq('org_id', org_id).maybeSingle();
if (existing.error || existing.data?.created_by !== user.id || existing.data?.body !== body) return { error: 'This request was already used. Start a new note.' };
} else if (result.error) return { error: 'The note could not be saved. Retry with the same request.' };
revalidatePath('/dashboard/notes');
return { success: 'Note saved.' };
}
The action authenticates, validates the explicit workspace, checks current manager role and calls the existing member-safe subscription RPC with that same target and server-chosen mode. Only then does a narrow privileged insert occur. Do not accept paid, Stripe IDs or billing mode from the browser.
The elevated client here has one justified write boundary because ordinary clients cannot insert after the optional migration. Keep it server-only, scope replay lookups to the same workspace, and do not reuse it for the notes list or unrelated customer queries. An already-authorized request may finish during a concurrent entitlement change; stronger transactional entitlement/role locking would require a dedicated database operation and matching tests.
Paid states and existing subscribers
Recognized active and trialing subscriptions grant entitlement. The default product does not create a trial, but the state reader recognizes it. Past-due, unpaid, paused, incomplete, canceled or unknown subscriptions do not grant access; no additional app grace period is invented.
Cancellation normally takes effect at period end, so a still-active canceling subscription remains paid until its state changes. Checkout return URLs never grant access themselves. Only synchronized provider state does. Keep auth, account settings and billing management accessible so an unpaid user can resolve payment or manage data.
Verify the gate
Test an unpaid workspace, a paid workspace, a Member in a paid workspace and a user belonging to paid A/unpaid B. Submit a stale form for B while A is selected: it must check B. Attempt direct authenticated inserts after the optional migration: they must fail even for owners. Replay an accepted request and a changed body with the same request ID.
Static sample validation does not prove paid synchronization. The optional action and grants need hosted tests with real synchronized Stripe state. No payment, SQL migration or entitlement mutation was performed while writing this manual. Continue with Checkout and webhooks.
Template 0.0.1 · September 2026 source snapshot