Internal · Cost & pricing research

How we priced Neni

The cost analysis behind the six tiers on /plans. Includes service-by-service unit costs, per-child economics, edge cases the naïve model misses, and what to instrument before billing ships.

← Back to plans

Current state of the business

Neni is multi-tenant by kindergartenId. Every tenant is currently on an implicit Free plan — there is no Plan, Subscription, or Invoice model in the Prisma schema, and there is no Stripe SDK in the workspace. Self-serve billing is scheduled for Phase 5 (Weeks 19+) per the roadmap.

Naming gotcha that will bite billing. Kindergarten.monthlyFeeCents is the kindergarten's fee charged to parents — NOT Neni's fee to the kindergarten. The maintainer analytics already treat monthlyFeeCents × childrenCount as the tenant's revenue. When billing lands, the price Neni charges must live on a new Subscription.priceCents field. Do not overload the existing column.

Where money actually goes

Ten paid services contribute to cost-of-goods today or in Phase 3 once worker AI lands. Items 6 and 7 (face recognition + speech-to-text) are currently mocked — they will dominate marginal cost the moment they go live.

#ServiceLive today?Unit cost (USD)What drives it
1S3 / MinIO storageYes$0.023/GB-mo · $0.09/GB egress · $0.005/1k PUTPhoto & video size × retention
2Supabase PostgresYes$25–$599/mo + storageConnection cap is the real ceiling
3Redis ×2 (BullMQ + Upstash KV)YesUpstash $0.20/100k req · ElastiCache ~$15–50/mo5 queues, OTP & refresh-token store
4Vercel (API + 3 dashboards)Yes$20/seat + $0.60/1M invocations + $40/100GB egressFunction invocations
5Vercel AI Gateway · GPT-4o-miniYes$0.15/1M input · $0.60/1M outputUp to 6 tool steps per chat turn
6Face recognition (Rekognition target)Mocked$0.001 per image search · $0.001 per index1–N calls per photo (per face)
7Speech-to-text (Whisper / Azure target)MockedWhisper $0.006/min · AWS Transcribe $0.024/minAudio minutes per child
8Resend (transactional email)Yes~$0.0004 per emailOTPs, invites, password reset
9Telegram Gateway (phone OTP)Yes$0.01–0.05 per OTP (returned by API)Login events
10FCM pushStubbedFreeTODO in worker; cost = $0

Scales with child count: storage, egress, face recognition (1–N per photo), STT, notifications (multiplied by parent-count).
Does NOT scale with child count: Vercel base, OTP volume (logins), Redis BullMQ, Supabase connection cap (it's a ceiling, not a meter).

Per child, per month

The model is sensitive to a handful of inputs. The team needs to validate these against real pilot data — anything off by 2× swings the answer.

AssumptionConservativeRealisticAggressive
Photos / child / day uploaded2510
Average photo size (compressed)0.6 MB1.0 MB1.5 MB
Lifetime parent views per photo3510
Retention (locked)6 mo6 mo6 mo
Parents per child1.51.82.5
Push notifications / child / day246
Audio minutes / child / day (P3)015
Faces detected per photo124

Marginal cost per child / month (6-month retention)

Cost linePhase 1 (today)Phase 3 (face on)Phase 3 full AI
Storage @ 6 mo retention$0.02$0.02$0.02
Egress (S3 → parents, no CDN)$0.07$0.07$0.07
Face recognition (2 faces × 5 photos × 30d)$0.30$0.30
Speech-to-text (1 min/day × Whisper)$0.18
Push (FCM, free)$0$0$0
Email (Resend)$0.003$0.003$0.003
Telegram OTP (amortized)$0.01$0.01$0.01
Database allocation$0.01$0.01$0.02
Redis allocation$0.001$0.001$0.001
Vercel allocation$0.02$0.02$0.02
LLM dashboard chat (staff-amortized)$0.05$0.05$0.15
Observability (PostHog)$0.05$0.05$0.05
Marginal / child / mo~$0.25~$0.55~$0.85

Plus a fixed cost per tenant (Vercel seat slice, Supabase share, monitoring base): $15–30/mo.

The retention decision saved real money. Halving retention from 12 months to 6 months drops steady-state per-child storage from 1.8 GB → 0.9 GB and the storage cost line from $0.04 → $0.02. Implement via S3 lifecycle rule + a job on the existing DATA_CLEANUP queue.
Egress is now the silent killer. At Scale (199 kids), egress alone is ~$14/mo. At Network (799 kids), it's ~$56/mo. Strong recommendation to put CloudFront in front of S3, or migrate to Cloudflare R2 (zero egress) before the larger tiers launch. R2 alone can shave 60-80% off the egress line.

What it costs Neni per tenant

Min = light usage (Phase 1, no AI, 2 photos/day). Max = heavy usage (Phase 3 with face + STT + LLM chat, 10 photos/day).

TierTop of bandMin $/moMax $/mo
Starter49$20$180
Growth99$25$332
Scale199$40$643
Pro249$45$800
Chain499$75$1,497
Network799$110$2,312

For a typical 199-child tenant on full Phase-3 AI, the cost composition is striking:

Cost line$/child/mo (heavy)Why
Egress$0.40No CDN — direct S3 reads
Face recognition$1.20Rekognition per face per image
Speech-to-text$0.90Optional Phase-3 feature
LLM chat$0.30GPT-4o-mini, 6-step tool loop
Storage (6mo retention)$0.06Halved by the retention decision
Everything else$0.19Email, OTP, DB, Redis, Vercel, observability
Cost lever to model in Phase 3. Switching from Rekognition to a self-hosted face-rec model on a worker GPU cuts the $1.20/child/mo line to ~$0.20/child/mo. Worth a real cost study before committing to Rekognition.

Min / max retail prices per tier

The min price is aggressive market-entry (~25-30% margin even on heavy users with AI add-on). The max price is premium / international list (~75-80% margin on typical usage). Recommendation: launch at the midpoint, watch margin data for 90 days, adjust.

USD — international / reference

TierChildrenMin $/moMax $/moAI Pack add-on
Starter0–49$39$99+$19/mo flat
Growth50–99$89$229+$39/mo flat
Scale100–199$169$399+$0.40/child/mo
Pro200–249$249$549+$0.35/child/mo
Chain250–499$429$899+$0.30/child/mo
Network500–799$649$1,399+$0.25/child/mo
800+Custom$1.00/child$1.75/childNegotiated

UZS — Uzbekistan (≈12,500 UZS/USD)

TierChildrenMin UZS/moMax UZS/moAI Pack
Starter0–49300,000750,000+150,000
Growth50–99650,0001,700,000+300,000
Scale100–1991,250,0002,950,000+3,000/child
Pro200–2491,850,0004,100,000+2,500/child
Chain250–4993,200,0006,650,000+2,200/child
Network500–7994,800,00010,400,000+1,900/child

UZS prices are discounted ~40% from USD to reflect Uzbek kindergarten ARR. A typical KG charging 1.5–3M UZS per parent per month gets to ~3-6% of revenue spent on tech — well inside the budget envelope.

What naïve models miss

Edge caseRiskMitigation
A child has 2–3 parents (ChildParentLink)Push / email / Telegram scale with parent countMeter parent events; cap free parents at 2, charge for extras
Video uploadsA 30s clip ≈ 30 MB ≈ 30 photos in storage + egressBlock on Starter; monthly video-GB allowance + overage on Growth+
Archived children consume storage6-month retention helps but cohort data still piles upAuto-archive to Glacier / Intelligent-Tiering after 60 days
MediaFile.sizeBytes is nullableStorage billing is unauditable todayBackfill from S3 HeadObject in MediaProcessingJobService — blocking
Brand asset 7-day signed URLsSchool logo re-fetched per page loadMove brand assets behind a CDN
Multi-branch tenantsOne paying tenant with N × childrenCount children across all branches under one Kindergarten
AI suggestions vs confirmationsCost incurred even when staff rejectsBill on attempts; surface rejection rate to help staff tune
Supabase 15-connection capConcurrency limit, not $; blocks Enterprise scaleMove off Session Pooler before Enterprise launch
Chat-happy tenant with LLMGPT-4o-mini × 6 tool steps × big system prompt = bill spikePer-tenant monthly token cap; consider Claude Haiku for cheaper input
No CDNEgress is ~25% of marginal costCloudFront or R2 migration before Chain tier
Stripe doesn’t serve UZ retail wellCannot collect from Uzbek customersIntegrate Click, Payme, Uzcard / Humo (3-4 weeks)
Uzbekistan VAT (12%)Net vs gross pricing confusionShow VAT-inclusive in UZS UI; track VAT line on invoice
Trial → conversionFree-tier load with no revenue30-day Growth trial; auto-downgrade to Starter on trial end
GDPR data exportEgress spike on offboardingInclude in churn cost model; cap export size or charge

Minimum viable billing tables

When Phase 5 lands, the schema needs four new models. Design now so the metering you start collecting today fits without rewrites.

model Plan {
  id                   String   @id @default(cuid())
  code                 String   @unique  // starter | growth | scale | pro | chain | network | custom
  displayName          String
  childMin             Int
  childMax             Int?     // null = unlimited
  baseFeeCents         Int
  perChildOverageCents Int      @default(0)
  retentionDays        Int      // 180 for the 6-month policy
  includesAi           Boolean  @default(false)
  isPublic             Boolean  @default(true)
  archivedAt           DateTime?
}

model Subscription {
  id                 String   @id @default(cuid())
  kindergartenId     String   @unique
  planId             String
  currency           String   // USD | UZS
  status             SubscriptionStatus  // TRIAL | ACTIVE | PAST_DUE | PAUSED | CANCELED
  trialEndsAt        DateTime?
  currentPeriodStart DateTime
  currentPeriodEnd   DateTime
  cancelAtPeriodEnd  Boolean  @default(false)
  priceOverrideCents Int?     // negotiated Enterprise deals
  kindergarten       Kindergarten @relation(fields: [kindergartenId], references: [id])
  plan               Plan @relation(fields: [planId], references: [id])
  invoices           Invoice[]
  @@index([status])
}

model UsageRecord {
  id             String   @id @default(cuid())
  kindergartenId String
  metric         UsageMetric  // CHILDREN_ACTIVE | STORAGE_BYTES | AI_FACE_CALLS |
                              // AI_STT_MINUTES | LLM_TOKENS | EMAIL_SENT |
                              // OTP_SENT | EGRESS_BYTES
  value          BigInt
  recordedAt     DateTime @default(now())
  periodStart    DateTime
  periodEnd      DateTime
  @@index([kindergartenId, metric, periodStart])
}

model ProviderCost {
  id             String   @id @default(cuid())
  kindergartenId String?  // null = unattributed fixed cost
  provider       String   // s3 | rekognition | whisper | resend | telegram |
                          // supabase | vercel-ai
  costCents      Int
  currency       String   @default("USD")
  occurredAt     DateTime
  refType        String?  // MediaFile | FaceRecognitionSuggestion | JobLog
  refId          String?
  @@index([kindergartenId, provider, occurredAt])
  @@index([provider, occurredAt])
}

Key insight: UsageRecord and ProviderCost are independent. Usage drives billing; ProviderCost drives gross-margin reporting. You want both.

What to instrument before Stripe

You don't need a billing engine to start collecting the data that will validate this pricing model. Add these now — cheap, and de-risks the Phase-5 launch with 60-90 days of actuals.

  1. 1Fix MediaFile.sizeBytes. Backfill from S3 HeadObject in MediaProcessingJobService. Without this, storage billing is dead on arrival.
  2. 2Log Telegram request_cost. Already returned by the API — write it to ProviderCost. Free win.
  3. 3Log every AI call. Even mocked ones, with costCents=0. Gives you a call-count distribution per tenant.
  4. 4Daily snapshot of active child count per tenant into UsageRecord(CHILDREN_ACTIVE). Cron at midnight UTC. Drives tier-band detection.
  5. 5LLM token usage. Vercel AI Gateway returns usage on every response — capture and persist as UsageRecord(LLM_TOKENS).
  6. 6Weekly S3 bucket inventory → per-tenant storage bytes. The key layout ${kindergartenId}/... makes this trivial.
  7. 7Surface a per-tenant cost-to-serve widget in the maintainer console. Extend the existing storageSummary to pull from ProviderCost.

Uzbekistan-specific considerations

  • Payments. Stripe is essentially unavailable for B2B UZS billing. Integrate Click, Payme, and Uzcard / Humo domestically; Stripe stays as a secondary rail for the rare international customer. Each processor has a 1-3% fee — bake into pricing.
  • VAT. Uzbekistan VAT is 12%. Show base prices VAT-inclusive in UZS-facing UI; track net + VAT separately on the invoice.
  • Cash basis. Many small KGs want annual prepay with a discount (10–15%). Build billingInterval = MONTHLY | ANNUAL from day one.
  • Pause-not-cancel. KGs are seasonal (summer dips). Allow SubscriptionStatus.PAUSED — saves churn.
  • Receipts. Uzbek tax may require акт сверки / счёт-фактура. Plan PDF generation now — don't bolt on later.
  • KindergartenOffer. The existing model is marketing-only. Repurpose as PromoCode for Phase-5 discounting.

Inputs that move the model

These are the variables most likely to invalidate the assumptions above.

  1. 1Photos / child / day in pilot kindergartens. Model assumes 5; could plausibly be 2 or 15.
  2. 2Parent retention expectation. Are they OK with 6 months and an export-on-graduation, or do they expect lifetime access?
  3. 3Market split. Uzbekistan only, or CIS + international? Drives currency strategy and payments rails.
  4. 4Is Phase-3 AI an add-on or bundled? Add-on is recommended; product reasons may override.
  5. 5Competitor benchmarks. BrightWheel, HiMama, local Uzbek alternatives — what do they charge?
  6. 6Trial length and tier. 30-day Growth trial → auto-downgrade to Starter? Or 14-day Scale trial?
Want to go deeper? The pricing card values live in apps/website-v2/src/lib/plans.ts — edit them there and the /plans page updates automatically.
Plans research — internal — Neni