Tier Pricing Analysis for Brewery App
Status: Draft (pre-development)
Purpose: Base document for current and future analysis; to be discussed before implementation.
AI monetization note: the H2 AI backbone uses BYOK + existing paid workspace tier unlock (
premium | pro | pro_plus) rather than AI credits. Future managed-AI credits and theWorkspaceBillingAddonshape are described indocs/PLATFORM-ARCHITECTURE.md§7. This document remains the analysis of the base subscription tiers (free | premium | pro | pro_plus).
Current State
Data model
- Workspace = tenant (replaces "Account" in older docs).
- Recipe:
workspaceId,versionGroupId,version(0–99). - Recipe count: distinct
versionGroupIdper workspace (one "recipe" = one version group). - Version count: versions per
versionGroupId(0–99).
Auth
requireActiveWorkspace(req)→{ userId, activeWorkspaceId }.- All recipe endpoints use
workspaceId; membership checked viaWorkspacesService.assertMembership().
Existing limits
- Version limit: hardcoded 99 in
createRecipeVersionFromCurrent(recipesService.tslines 117–122). - Recipe limit: none.
- Billing: Stripe planned; no billing models in current Prisma schema.
Architecture docs (Rev00/Rev01)
Subscription(userId, provider, planCode, stripeCustomerId, stripeSubscriptionId, status).Entitlement(userId, planCode, features Json, limits Json, validUntil).StripeEvent(idempotent webhook storage).- Phase 7: "Feature gating and plan limits (API enforced; UI mirrored)".
1. Billing Scope: User vs Workspace
Recommendation: workspace-scoped billing
- Workspace = brewery/team.
- One subscription per workspace.
- All members share the same limits.
Alternative: user-scoped
- Each user has their own tier.
- Limits apply per user across workspaces they belong to.
- More complex and less common for team products.
2. Data Model
Option A: Entitlement on Workspace (recommended)
model Workspace {
// ... existing
entitlement Entitlement?
}
model Entitlement {
id String @id @default(uuid())
workspaceId String @unique @map("workspace_id")
planCode String @map("plan_code") // "free" | "pro" | "premium"
maxRecipes Int @map("max_recipes")
maxVersions Int @map("max_versions")
validUntil DateTime? @map("valid_until")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
workspace Workspace @relation(...)
}
- One row per workspace.
planCode+maxRecipes+maxVersionsdefine the tier.validUntilfor trials or grace periods.
Option B: Plan config + workspace reference
model Plan {
code String @id // "free", "pro", "premium"
maxRecipes Int
maxVersions Int
priceCents Int?
}
model WorkspaceEntitlement {
workspaceId String
planCode String
stripeCustomerId String?
stripeSubscriptionId String?
currentPeriodEnd DateTime?
// ...
}
- Central plan definitions.
- Workspace links to plan and Stripe IDs.
History / past tiers
- Store
Subscription(orWorkspaceSubscription) withplanCode,status,currentPeriodEnd,stripeSubscriptionId. - Keep
Entitlementas current effective tier. - History =
Subscriptionrows (or audit log) for past plans and status changes.
3. Enforcement Points (ACL / limits)
API layer
-
Recipe creation (
POST /recipes,duplicateRecipe):- Count distinct
versionGroupIdfor workspace. - Compare to
entitlement.maxRecipes. - Reject with
403/plan_limit_exceededif over.
- Count distinct
-
Version creation (
createRecipeVersionFromCurrent):- Count versions for the
versionGroupId. - Compare to
entitlement.maxVersions. - Reject if over (replace hardcoded 99).
- Count versions for the
-
Reads (list, get):
- No change; existing workspace scoping is enough.
Service pattern
// EntitlementsService
async assertRecipeLimit(workspaceId: string): Promise<void> {
const ent = await this.getEntitlement(workspaceId);
const count = await this.prisma.recipe.groupBy({
by: ["versionGroupId"],
where: { workspaceId },
});
if (count.length >= ent.maxRecipes) {
throw new ForbiddenError("plan_limit_recipes", "Recipe limit reached. Upgrade to add more.");
}
}
async assertVersionLimit(workspaceId: string, versionGroupId: string): Promise<void> {
const ent = await this.getEntitlement(workspaceId);
const agg = await this.prisma.recipe.aggregate({
where: { workspaceId, versionGroupId },
_count: true,
});
if (agg._count >= ent.maxVersions) {
throw new ForbiddenError("plan_limit_versions", "Version limit reached. Upgrade for more.");
}
}
- Call
assertRecipeLimitbefore create/duplicate. - Call
assertVersionLimitbefore create version.
4. Entitlement Resolution
- Default: new workspace →
planCode: "free",maxRecipes: 5,maxVersions: 2. - After Stripe: webhook updates
Entitlement(and optionallySubscription) when subscription changes. - Caching: entitlements change rarely; cache per workspace (e.g. Redis or in-memory) with short TTL.
5. Stripe Integration
Checkout
POST /api/billing/checkout(or similar):- Input:
planCode,successUrl,cancelUrl. - Create/retrieve Stripe Customer for workspace.
- Create Checkout Session for subscription.
- Return
{ url }for redirect.
- Input:
Webhooks
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paid/invoice.payment_failed(optional)
Flow:
- Store event in
StripeEvent(idempotent by event id). - Map Stripe Price/Product →
planCode. - Update
Entitlement(andSubscription) for the workspace. - Downgrade: set new limits; do not delete data; enforce limits on new actions.
6. Client (Web + Native)
Fetching limits
GET /api/workspaces/:idorGET /api/me(or/api/entitlements) returns:
{
"entitlement": {
"planCode": "free",
"maxRecipes": 5,
"maxVersions": 2,
"recipeCount": 3,
"canCreateRecipe": true,
"canCreateVersion": true
}
}
- Or a dedicated
GET /api/entitlementsthat returns this for the active workspace.
UI behavior
- Create recipe: disable or hide button when
!canCreateRecipe; show upgrade CTA. - Create version: same when
!canCreateVersion. - Upgrade CTA: link to billing/checkout.
- Recipe list: show "3/5 recipes" (or similar) for free tier.
Shared logic
- Put entitlement types and helpers in
packages/platform/contractsorpackages/platform/api-client. - Web and native both call the same API and use the same response shape.
7. Implementation Order
- Schema: add
Entitlement(and optionallySubscription) to Prisma; migration. - Seed: create default free entitlements for existing workspaces.
- EntitlementsService:
getEntitlement,assertRecipeLimit,assertVersionLimit. - RecipesService: call asserts before create/duplicate and before create version.
- API:
GET /api/entitlements(or extend workspace/me). - Stripe: Products/Prices, Checkout, webhook handler, entitlement updates.
- Clients: entitlement fetching, UI gating, upgrade CTA.
8. Files to Touch
| Area | Files |
|---|---|
| Schema | services/api/prisma/schema.prisma |
| Entitlements | New services/api/src/services/entitlementsService.ts |
| Recipes | services/api/src/services/recipesService.ts (add asserts) |
| Routes | New services/api/src/routes/billing.ts, extend workspace/me if needed |
| Web | Recipe list, create buttons, upgrade modal/page |
| Native | Same pattern in recipe screens |
| Shared | packages/platform/contracts or packages/platform/api-client for entitlement types |
9. Auth vs ACL
- Auth: already enforced via
requireActiveWorkspaceand session. - ACL: workspace membership + role (e.g.
brewery_admincan manage billing). - Tier limits: additional checks on top of auth; enforced in service layer before any write.
10. Downgrade Behavior
- Over limit: allow reads; block new recipes/versions until upgrade or deletion.
- Grace period: optional
validUntilfor temporary access after failed payment. - Data: never delete recipes/versions automatically; only enforce limits on new actions.
Tier Model (reference)
| Tier | Price | Recipes | Versions/recipe |
|---|---|---|---|
| Free | $0 | 5 | 2 |
| Pro | $20 | 99 | 5 |
| Premium | $60 | 1000 | 99 |