Skip to main content

Juniro API Architecture

Version: 0.1 (Draft) Last Updated: January 2026 Status: Pre-implementation


Out of Scope for v0.1

The following features are explicitly not included in this version:

  • Cross-border bookings (US user booking India activity)
  • Multi-country providers (single provider operating in both regions)
  • Shared user accounts across regions
  • Mobile push notifications
  • Native mobile apps (iOS/Android)
  • Multi-language support (Hindi, etc.)
  • Provider payouts and settlements
  • Waitlist functionality
  • Recurring/subscription bookings
  • Gift cards or third-party bookings

Table of Contents

  1. Overview
  2. Requirements
  3. Assumptions
  4. Architecture
  5. Tech Stack
  6. Data Residency
  7. API Design
  8. Authentication
  9. Payments
  10. Production Safety
  11. Roadmap
  12. Engineering Rules

1. Overview

Juniro is a marketplace platform connecting parents with children's activity providers (sports, arts, STEM, camps). The platform operates in two independent regions: United States and India.

Core Principle

Juniro is built as a multi-region marketplace with strict data separation between the US and India, to comply with data protection and payment regulations.

  • Indian user data stays in India
  • US user data stays in the US
  • Only de-identified aggregates are shared globally

2. Requirements

2.1 Functional Requirements

CategoryRequirements
MarketplaceParents discover, browse, and book children's activities
ProvidersCreate business profiles, list classes, manage schedules, receive bookings
BookingsReal-time availability, booking confirmation, cancellation, rescheduling
PaymentsAccept payments in USD (US) and INR (India), provider payouts
ReviewsParents rate and review activities after attendance
SearchFilter by location, category, age, price, schedule
NotificationsBooking confirmations, reminders, provider updates

2.2 Non-Functional Requirements

RequirementTarget
Availability99.9% uptime
LatencyAPI response < 200ms (p95)
Data ResidencyIndia data stays in India, US data stays in US
ScalabilityHandle 10x traffic spikes (seasonal, back-to-school)
SecuritySOC 2 compliance path, PCI DSS for payments
GDPR/DPDPRight to deletion, data export, consent management

2.3 Primary Product Flows

Flow 1: Discovery → Booking
────────────────────────────
Parent lands on homepage
→ Selects country (US/India)
→ Browses/searches activities
→ Views activity details
→ Selects session/schedule
→ Creates account (if new)
→ Enters child details
→ Completes payment
→ Receives confirmation

Flow 2: Provider Onboarding
────────────────────────────
Provider signs up
→ Completes business profile
→ Adds location(s)
→ Creates first activity
→ Sets pricing & schedule
→ Submits for verification
→ Goes live

Flow 3: Post-Booking
────────────────────────────
Booking confirmed
→ Parent receives reminder (D-1)
→ Child attends session
→ Parent receives review prompt
→ Parent submits review
→ Provider responds (optional)

3. Assumptions

3.1 Business Assumptions

AssumptionImplication
US and India are independent marketsNo cross-border bookings in v1
Providers operate in one country onlySingle home_region per provider
Parents book for their own childrenNo gifting or third-party bookings in v1
English is primary languageLocalization (Hindi, etc.) is post-MVP

3.2 Technical Assumptions

AssumptionImplication
Auth provider supports regional projectsSupabase Auth (confirmed)
Vercel has global edge networkEdge PoPs for static/CDN; data residency enforced by in-region DB/storage (GCP)
GCP has India regionYes, asia-south1 (Mumbai) for Cloud SQL, GCS, Cloud Run
Same codebase, different deploymentsNo region-specific code branches
Mobile apps are future scopeAPI designed to support mobile clients

3.3 Compliance Assumptions

AssumptionImplication
India's DPDP ActTreated as policy posture (data localization), not guaranteed blanket law
Child data is sensitiveEnhanced protection, parental consent required
Payment data handled by processorsStripe/Razorpay are PCI compliant, we don't store card data

4. Architecture

4.1 High-Level Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ Web (Next.js) Mobile (Future) Third-party (Future) │
│ • juniro.com • iOS App • Partner APIs │
│ • juniro.in • Android App • Webhooks │
└─────────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│ GLOBAL LOAD BALANCER │
│ (Geo-routing to regional stacks) │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────┴─────────────────┐
▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ US REGION │ │ INDIA REGION │
│ (us-central1) │ │ (asia-south1) │
├─────────────────────────────────┤ ├─────────────────────────────────┤
│ ┌───────────┐ ┌───────────┐ │ │ ┌───────────┐ ┌───────────┐ │
│ │ CDN │ │ WAF │ │ │ │ CDN │ │ WAF │ │
│ └─────┬─────┘ └─────┬─────┘ │ │ └─────┬─────┘ └─────┬─────┘ │
│ ▼ ▼ │ │ ▼ ▼ │
│ ┌───────────────────────────┐ │ │ ┌───────────────────────────┐ │
│ │ Regional LB │ │ │ │ Regional LB │ │
│ └─────────────┬─────────────┘ │ │ └─────────────┬─────────────┘ │
│ ┌─────────┴─────────┐ │ │ ┌─────────┴─────────┐ │
│ ▼ ▼ │ │ ▼ ▼ │
│ ┌────────┐ ┌─────────┐ │ │ ┌────────┐ ┌─────────┐ │
│ │Web Apps│ │ API │ │ │ │Web Apps│ │ API │ │
│ │(Next.js│ │ (Hono) │ │ │ │(Next.js│ │ (Hono) │ │
│ └────────┘ └────┬────┘ │ │ └────────┘ └────┬────┘ │
│ ┌──────────────────┼───┐ │ │ ┌──────────────────┼───┐ │
│ ▼ ▼ ▼ │ │ │ ▼ ▼ ▼ │ │
│ ┌──────┐ ┌───────┐ ┌───┐ │ │ │ ┌──────┐ ┌───────┐ ┌───┐ │ │
│ │Postgres│ │ Redis │ │GCS│ │ │ │ │Postgres│ │ Redis │ │GCS│ │ │
│ └──────┘ └───────┘ └───┘ │ │ │ └──────┘ └───────┘ └───┘ │ │
│ ┌──────────────────────┐ │ │ │ ┌──────────────────────┐ │ │
│ │ Supabase Auth │ │ │ │ │ Supabase Auth │ │ │
│ │ (US Project) │ │ │ │ │ (India Project) │ │ │
│ └──────────────────────┘ │ │ │ └──────────────────────┘ │ │
│ ┌──────────────────────┐ │ │ │ ┌──────────────────────┐ │ │
│ │ Stripe US │ │ │ │ │ Razorpay (primary) │ │ │
│ └──────────────────────┘ │ │ │ └──────────────────────┘ │ │
└─────────────────────────────┘ └─────────────────────────────────┘
│ │
│ ❌ NO REPLICATION │
└───────────────────────────────────────┘

4.2 Domain Model

┌─────────────┐       ┌─────────────┐       ┌─────────────┐
│ User │ │ Provider │ │ Activity │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ auth_id │──────►│ user_id │◄──────│ provider_id │
│ email │ │ business_ │ │ title │
│ user_type │ │ name │ │ description │
│ home_region │ │ slug │ │ category │
│ created_at │ │ status │ │ age_min/max │
└─────────────┘ │ verified_at │ │ price │
│ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Parent │ │ Location │ │ Session │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ user_id │ │ provider_id │ │ activity_id │
│ first_name │ │ name │ │ location_id │
│ last_name │ │ address │ │ starts_at │
│ phone │ │ city │ │ capacity │
└─────────────┘ │ lat/lng │ │ enrolled │
│ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Child │ │ Review │◄──────│ Booking │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ parent_id │ │ booking_id │ │ session_id │
│ first_name │ │ rating │ │ parent_id │
│ birth_date │ │ comment │ │ child_id │
└─────────────┘ └─────────────┘ │ status │
└──────┬──────┘


┌─────────────┐
│ Payment │
├─────────────┤
│ id │
│ booking_id │
│ amount │
│ currency │
│ processor_id│
│ status │
└─────────────┘

4.3 Global Coordination Layer

A small global layer exists for non-PII coordination:

┌─────────────────────────────────────────┐
│ GLOBAL LAYER (non-PII only) │
├─────────────────────────────────────────┤
│ Global Postgres │
│ ├── slug_reservations (uniqueness) │
│ └── rating_aggregates (counts/avgs) │
├─────────────────────────────────────────┤
│ Global Redis │
│ ├── flag:* (feature flags) │
│ └── config:* (app config) │
└─────────────────────────────────────────┘

│ Both regions read/write
┌────┴────┐
▼ ▼
US Regional India Regional
(PII data) (PII data)

What Goes Where:

DataLocationReason
User profilesRegionalPII
ChildrenRegionalSensitive PII
BookingsRegionalPII + payments
PaymentsRegionalFinancial data
Reviews (text)RegionalMay contain PII
Provider detailsRegionalBusiness PII
Slug reservationsGlobalCross-region uniqueness
Rating aggregatesGlobalNon-PII, improves UX
Feature flagsGlobalOperational config
App configGlobalOperational config

Critical Rule: The global layer stores non-PII only and must never contain identifiers that can link back to individuals (no emails, phones, auth_user_id, user_id, child_id, or booking_id). Slug reservations are for provider/organization slugs only, not user slugs.


5. Tech Stack

5.1 Final Tech Stack

LayerTechnologyRationale
FrontendNext.js 14 (App Router)SSR, RSC, great DX
StylingTailwind CSS + shadcn/uiConsistent design system
StateTanStack QueryServer state management
ValidationZodShared schemas (frontend + backend)
API FrameworkHonoLightweight, portable (Vercel → GKE)
ORMPrismaType-safe, great migrations
DatabasePostgreSQL 15Reliable, full-featured
CacheRedis (Upstash → Memorystore)Sessions, rate limiting, cache
AuthSupabase AuthRegional projects, good DX
Payments (US)StripeIndustry standard
Payments (India)Razorpay (primary)UPI support, local compliance
StorageGCSRegional buckets
Hosting (MVP)VercelFast iteration
Hosting (Prod)GCP (Cloud Run → GKE)Scale, compliance
CDNCloud CDNEdge caching
WAFCloud ArmorDDoS, security rules
MonitoringCloud Monitoring + SentryMetrics, errors
AnalyticsPostHogProduct analytics
CI/CDGitHub Actions + Cloud BuildAutomated deployments

5.2 Why Hono for API

The API is built as a standard Node.js server that can deploy to multiple targets:

juniro-api/
├── src/
│ ├── index.ts ← Standard HTTP server entry
│ ├── app.ts ← Hono app (framework-agnostic)
│ ├── routes/
│ ├── services/
│ └── db/
├── Dockerfile ← Ready for containers
├── vercel.json ← Thin adapter for Vercel
└── package.json

Migration Path:

  • Now: vercel deploy (uses vercel.ts adapter)
  • Later: docker build + kubectl apply (uses Dockerfile)

No code changes required—just different deployment targets.


6. Data Residency

6.1 Country Selection vs Home Region

Critical Distinction:

ConceptPurposeMutable?Stored Where
Country SelectionWhich catalog to browseYes (UI toggle)Client state / cookie
home_regionWhere user data livesNo (set at signup)User record + JWT claim

6.2 Routing Logic

READ PATHS (browse, search, view)
─────────────────────────────────
Route by: Country Selection (UI toggle)

User in US viewing India catalog:
→ Request goes to api.juniro.in
→ Returns India activities
→ User can browse, but CANNOT book

WRITE PATHS (profile, children, bookings, payments)
───────────────────────────────────────────────────
Route by: home_region (from JWT)

User with home_region=us viewing India catalog:
→ POST /me/children → api.juniro.com (US)
→ POST /me/bookings → BLOCKED (can't book India activities)
→ All PII stays in US regardless of UI toggle

6.3 Signup Flow

1. User selects country (US / India) on signup page
2. User completes signup form
3. Backend assigns home_region based on selection
4. home_region is written to:
- users.home_region column (database)
- JWT custom claim (auth token)
5. home_region is IMMUTABLE after signup

6.4 Region Migration Policy (MVP)

┌─────────────────────────────────────────────────────────────────┐
│ MVP RULE │
├─────────────────────────────────────────────────────────────────┤
│ home_region is PERMANENT │
│ │
│ User wants to switch regions? │
│ → Create new account in the new region │
│ → Old account remains (can be deleted on request) │
│ → No data migration between regions │
└─────────────────────────────────────────────────────────────────┘

6.5 Data Classification

Data TypeClassificationResidencyReplication
User PII (name, email, phone)SensitiveRegionalNone
Child profilesHighly SensitiveRegionalNone
Auth credentialsSensitiveRegionalNone
Booking recordsSensitiveRegionalNone
Payment dataHighly SensitiveRegionalNone
Reviews (raw text)StandardRegionalNone
Ratings (aggregated)Non-PIIGlobalAllowed
Analytics (de-identified)Non-PIIGlobalAllowed

7. API Design

7.1 Base URLs

US:    https://api.juniro.com/v1
India: https://api.juniro.in/v1

7.2 Endpoints

Public Endpoints (no auth)

GET    /health                      Health check
GET /activities List activities (paginated, filterable)
GET /activities/:slug Get activity details
GET /activities/:id/sessions Get available sessions
GET /providers/:slug Get provider public profile
GET /categories List categories

Auth Endpoints (minimal)

POST   /auth/sync                   Sync Supabase user to Juniro DB
DELETE /auth/account Delete account (GDPR)

Note: All other auth operations (signup, login, logout, password reset) happen via Supabase SDK directly on the frontend.

Parent Endpoints (auth required)

GET    /me                          Get current user profile
PATCH /me Update profile fields

GET /me/children List children
POST /me/children Add child
PATCH /me/children/:id Update child fields
DELETE /me/children/:id Remove child

GET /me/bookings List my bookings
POST /me/bookings Create booking (idempotency key required)
GET /me/bookings/:id Get booking details
POST /me/bookings/:id/cancel Cancel booking (state transition)
POST /me/bookings/:id/reschedule Reschedule booking (state transition)

POST /reviews Submit review

Provider Endpoints (auth required)

GET    /provider/profile            Get my provider profile
PATCH /provider/profile Update profile

GET /provider/locations List my locations
POST /provider/locations Add location
PATCH /provider/locations/:id Update location
DELETE /provider/locations/:id Remove location

GET /provider/activities List my activities
POST /provider/activities Create activity
PATCH /provider/activities/:id Update activity
DELETE /provider/activities/:id Delete activity
POST /provider/activities/:id/publish Publish (state transition)
POST /provider/activities/:id/unpublish Unpublish (state transition)

GET /provider/activities/:id/sessions List sessions
POST /provider/activities/:id/sessions Create session
PATCH /provider/sessions/:id Update session
POST /provider/sessions/:id/cancel Cancel session (state transition)

GET /provider/bookings List bookings for my activities

Payment Endpoints

POST   /payments/checkout           Create checkout session (idempotency key required)
POST /payments/webhook/stripe Stripe webhook (US)
POST /payments/webhook/razorpay Razorpay webhook (India)

7.3 HTTP Method Guidelines

MethodUse ForExample
GETRead resourceGET /me/bookings/:id
POSTCreate resourcePOST /me/bookings
POSTState transition / actionPOST /me/bookings/:id/cancel
PATCHPartial update (fields)PATCH /me/children/:id
DELETERemove resourceDELETE /me/children/:id

7.4 Response Format

// Success
{
"success": true,
"data": { ... },
"meta": { "page": 1, "limit": 20, "total": 100 }
}

// Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Human readable message",
"details": [...]
}
}

8. Authentication

8.1 Auth Flow

Supabase handles authentication directly. The Juniro API validates tokens locally and syncs users to our database.

    User              Frontend            Supabase           Juniro API
│ │ │ │
│ Click "Sign Up" │ │ │
│─────────────────►│ │ │
│ │ signUp(email, │ │
│ │ password, meta: │ │
│ │ {home_region}) │ │
│ │──────────────────►│ │
│ │ │ │
│ │ JWT + user │ │
│ │◄──────────────────│ │
│ │ │ │
│ │ POST /auth/sync │ │
│ │ Authorization: │ │
│ │ Bearer <jwt> │ │
│ │──────────────────────────────────────►│
│ │ │ │
│ │ │ Validate JWT │
│ │ │ locally (JWKS) │
│ │ │ Upsert user │
│ │ │ │
│ │ { user profile } │ │
│ │◄──────────────────────────────────────│

8.2 Auth Middleware

Security Note: We validate JWTs locally using Supabase's JWKS/JWT secret instead of calling supabase.auth.getUser() on every request. This avoids a network call per request (latency + scaling bottleneck). We then fetch the user from our database to get authoritative home_region and role.

Security Note: home_region is stored in our database as source of truth, not in Supabase user_metadata (which is user-writable). This prevents users from bypassing region enforcement.

import { jwtVerify, createRemoteJWKSet } from 'jose'

const JWKS = createRemoteJWKSet(
new URL(`${process.env.SUPABASE_URL}/auth/v1/keys`)
)

// JWT issuer/audience can vary by Supabase project/environment
// Configure via env vars (set in Phase 0)
const JWT_ISSUER = process.env.SUPABASE_JWT_ISSUER
?? `${process.env.SUPABASE_URL}/auth/v1`
const JWT_AUDIENCE = process.env.SUPABASE_JWT_AUDIENCE
?? 'authenticated'

export async function authMiddleware(c: Context, next: Next) {
const authHeader = c.req.header('Authorization')

if (!authHeader?.startsWith('Bearer ')) {
return c.json({ success: false, error: { code: 'UNAUTHORIZED' } }, 401)
}

const token = authHeader.slice(7)

try {
// 1. Validate JWT locally (no network call to Supabase)
const { payload } = await jwtVerify(token, JWKS, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
})

const authUserId = payload.sub as string

// 2. Fetch user from OUR database (source of truth for home_region, role)
const user = await db.user.findUnique({
where: { authId: authUserId },
})

if (!user) {
return c.json({ success: false, error: { code: 'USER_NOT_FOUND' } }, 401)
}

c.set('authUser', {
id: user.id,
authId: authUserId,
email: user.email,
homeRegion: user.homeRegion, // From DB, not JWT
role: user.role, // From DB, not JWT
})

return next()
} catch (err) {
return c.json({ success: false, error: { code: 'INVALID_TOKEN' } }, 401)
}
}

8.3 /auth/sync Endpoint

The sync endpoint is idempotent and creates/updates the user in our database.

Important: Do not rely on user_metadata from the JWT. Accept home_region as an explicit body parameter and validate it server-side.

// POST /auth/sync
// Body: { home_region: "us" | "in" }
const syncBodySchema = z.object({
home_region: z.enum(['us', 'in']),
})

app.post('/auth/sync', zValidator('json', syncBodySchema), async (c) => {
const authHeader = c.req.header('Authorization')
const token = authHeader?.slice(7)
const body = c.req.valid('json')

// Validate JWT and extract claims
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.SUPABASE_JWT_ISSUER,
audience: process.env.SUPABASE_JWT_AUDIENCE,
})
const authUserId = payload.sub as string
const email = payload.email as string

// Check if user already exists
const existingUser = await db.user.findUnique({
where: { authId: authUserId },
})

if (existingUser) {
// User exists: update lastSeenAt only, ignore body.home_region
const user = await db.user.update({
where: { authId: authUserId },
data: {
email, // Sync email if changed
lastSeenAt: new Date(),
// NOTE: homeRegion is NOT updated (immutable)
},
})
return c.json({ success: true, data: user })
}

// New user: set home_region from body (validated)
const user = await db.user.create({
data: {
authId: authUserId,
email,
homeRegion: body.home_region, // From validated body, set once
role: 'parent', // Default role
createdAt: new Date(),
lastSeenAt: new Date(),
},
})

return c.json({ success: true, data: user })
})

Key behaviors:

  • home_region comes from request body (not JWT), validated with Zod
  • If user exists: ignore body home_region (immutable after first write)
  • If new user: set home_region from body
  • role defaults to parent, can be upgraded to provider via separate flow
  • Idempotent: safe to call multiple times

8.4 Region Enforcement Middleware

// Helper: map region code to API domain
function getRegionApiDomain(region: 'us' | 'in'): string {
const domains = {
us: 'api.juniro.com',
in: 'api.juniro.in',
}
return domains[region]
}

function enforceHomeRegion(c: Context, next: Next) {
const user = c.get('authUser')
const serverRegion = process.env.REGION as 'us' | 'in'

if (isWriteMethod(c.req.method)) {
if (user.homeRegion !== serverRegion) {
const correctDomain = getRegionApiDomain(user.homeRegion)
return c.json({
success: false,
error: {
code: 'REGION_MISMATCH',
message: `Your account is registered in ${user.homeRegion.toUpperCase()}. ` +
`Write operations must go to ${correctDomain}`
}
}, 403)
}
}

return next()
}

8.5 Role Enforcement Middleware

type Role = 'parent' | 'provider' | 'admin'

function requireRole(...allowedRoles: Role[]) {
return (c: Context, next: Next) => {
const user = c.get('authUser')

if (!allowedRoles.includes(user.role)) {
return c.json({
success: false,
error: {
code: 'FORBIDDEN',
message: `This action requires one of: ${allowedRoles.join(', ')}`
}
}, 403)
}

return next()
}
}

// Usage:
app.get('/provider/profile', authMiddleware, requireRole('provider', 'admin'), ...)
app.post('/admin/users', authMiddleware, requireRole('admin'), ...)

9. Payments

9.1 Payment Processors

RegionPrimaryFallbackNotes
USStripeIndustry standard
IndiaRazorpayStripe IndiaRazorpay has better UPI support; Stripe India is invite-only

Data Residency Rule: India payment transaction records, webhook payloads, and logs must remain in India. Do not export India payment data to US-based analytics tools, logging services, or data warehouses. Use regional observability tools or de-identify before any global aggregation.

9.2 Payment State Machine

                                    ┌──────────────┐
│ CREATED │
└──────┬───────┘
│ checkout created

┌──────────────┐
┌─────────│ PENDING │─────────┐
│ └──────────────┘ │
│ │
webhook: │ │ webhook:
payment_failed │ │ payment_success
▼ ▼
┌──────────────┐ ┌──────────────┐
│ FAILED │ │ PAID │
└──────────────┘ └──────┬───────┘
│ refund

┌──────────────┐
│ REFUNDED │
└──────────────┘

Critical Rules:

  • Only webhooks can transition payment to "paid". Never trust frontend redirect success.
  • Webhook signature verification is mandatory before processing any webhook event.

9.3 Webhook Signature Verification

// POST /payments/webhook/stripe
app.post('/payments/webhook/stripe', async (c) => {
const sig = c.req.header('stripe-signature')
const body = await c.req.text()

let event: Stripe.Event
try {
// MUST verify signature before processing
event = stripe.webhooks.constructEvent(
body,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return c.json({ error: 'Invalid signature' }, 400)
}

// Process verified event...
})

// POST /payments/webhook/razorpay
app.post('/payments/webhook/razorpay', async (c) => {
const sig = c.req.header('x-razorpay-signature')
const body = await c.req.text()

const expectedSig = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(body)
.digest('hex')

if (sig !== expectedSig) {
return c.json({ error: 'Invalid signature' }, 400)
}

// Process verified event...
})

9.4 Payment Table Schema

CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES bookings(id),
amount_cents INT NOT NULL,
currency VARCHAR(3) NOT NULL, -- 'USD' or 'INR'
processor VARCHAR(20) NOT NULL, -- 'stripe' or 'razorpay'
processor_payment_id VARCHAR(255),
processor_checkout_id VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'created',
status_reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
pending_at TIMESTAMP,
paid_at TIMESTAMP,
failed_at TIMESTAMP,
refunded_at TIMESTAMP,
idempotency_key VARCHAR(255) UNIQUE,

CONSTRAINT valid_status CHECK (
status IN ('created', 'pending', 'paid', 'failed', 'refunded')
)
);

9.5 Future: Ledger & Payouts (Post-MVP)

Note: The payments table handles customer payments. For provider payouts and financial reconciliation, we will need additional tables in a future phase:

-- Future: Ledger for double-entry accounting
CREATE TABLE transactions (
id UUID PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- 'credit' | 'debit'
amount_cents INT NOT NULL,
currency VARCHAR(3) NOT NULL,
account_type VARCHAR(20) NOT NULL, -- 'provider' | 'platform' | 'customer'
account_id UUID NOT NULL,
reference_type VARCHAR(20), -- 'payment' | 'payout' | 'refund'
reference_id UUID,
created_at TIMESTAMP DEFAULT NOW()
);

-- Future: Provider payouts
CREATE TABLE payouts (
id UUID PRIMARY KEY,
provider_id UUID NOT NULL REFERENCES providers(id),
amount_cents INT NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- 'pending' | 'processing' | 'completed' | 'failed'
processor_payout_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);

Do not cram payout logic into the payments table. Keep them separate.


10. Production Safety

10.1 Idempotency Keys

Problem: User double-clicks "Book Now" → two bookings, two charges

Solution: Client generates idempotency key, server dedupes

Required on:

  • POST /me/bookings
  • POST /payments/checkout
// Frontend
const idempotencyKey = `${userId}-${sessionId}-${Date.now()}`

await fetch('/v1/me/bookings', {
headers: {
'Idempotency-Key': idempotencyKey,
},
})

10.2 Transactional Capacity Locking

Problem: 10 spots left, 15 people click "Book" simultaneously

Solution: Check + increment in single transaction with row lock + database constraint as defense-in-depth.

Database Constraint (defense-in-depth):

CREATE TABLE sessions (
id UUID PRIMARY KEY,
activity_id UUID NOT NULL REFERENCES activities(id),
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
capacity INT NOT NULL,
enrolled INT NOT NULL DEFAULT 0,

-- Defense-in-depth: DB will reject if enrolled > capacity
CONSTRAINT enrolled_within_capacity CHECK (enrolled >= 0 AND enrolled <= capacity)
);

Application Code:

// Type for the row lock query result
type SessionLock = { enrolled: number; capacity: number }

return await db.$transaction(async (tx) => {
// Lock the session row (prevents concurrent modifications)
// Only select needed columns to avoid schema drift issues
const rows = await tx.$queryRaw<SessionLock[]>`
SELECT enrolled, capacity FROM sessions
WHERE id = ${sessionId}::uuid
FOR UPDATE
`

const session = rows[0]
if (!session) {
throw new NotFoundError('Session not found')
}

if (session.enrolled >= session.capacity) {
throw new ConflictError('Session is full')
}

// Increment enrolled count
// Even if app logic fails, DB constraint prevents overbooking
await tx.session.update({
where: { id: sessionId },
data: { enrolled: { increment: 1 } },
})

// Create booking
return await tx.booking.create({ ... })
}, {
timeout: 5000,
})

Isolation Level Notes:

  • Default Read Committed + FOR UPDATE row lock is usually sufficient
  • Serializable provides stronger guarantees but reduces throughput
  • Start with Read Committed; upgrade to Serializable only if you observe anomalies
  • The CHECK constraint is the ultimate safety net regardless of isolation level

Why both row lock + constraint?

  • FOR UPDATE lock prevents race conditions at the application level
  • CHECK constraint is a safety net if app logic has bugs

10.3 Webhook-Driven Payment State

Problem: Frontend redirect says "success" but payment actually failed

Solution: Only webhooks can transition payment to "paid"

// POST /payments/webhook/stripe
app.post('/payments/webhook/stripe', async (c) => {
const event = stripe.webhooks.constructEvent(...)

switch (event.type) {
case 'checkout.session.completed':
await handlePaymentSuccess(event.data.object)
break
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object)
break
}

return c.json({ received: true })
})

10.4 Safety Summary

MechanismPrevents
Idempotency KeysDouble bookings, double charges
Capacity LockOverbooking
Webhook State MachineFalse success, missed failures

11. Roadmap

11.1 Development Sequence

Phase 0: Foundation
───────────────────
□ Finalize domain model
□ Create database schema (Prisma)
□ Set up juniro-api repo (Hono + TypeScript)
□ Configure Supabase Auth (US project first)
□ Set up CI/CD (GitHub Actions → Vercel)
□ Deploy /health endpoint

Phase 1: Read Paths (Browse)
────────────────────────────
□ GET /activities (list, filter, paginate)
□ GET /activities/:slug (details)
□ GET /activities/:id/sessions (availability)
□ GET /providers/:slug (provider profile)
□ Search functionality

Phase 2: Auth & Profiles
────────────────────────
□ Auth middleware (verify Supabase JWT)
□ GET/PATCH /me (user profile)
□ Child management endpoints
□ Role-based access control

Phase 3: Write Paths (Booking)
──────────────────────────────
□ POST /me/bookings (with idempotency + locking)
□ Booking status state machine
□ Cancel/reschedule flows
□ Booking expiration job

Phase 4: Payments (US)
──────────────────────
□ Stripe integration
□ Webhook handling
□ Payment state machine

Phase 5: Provider Operations
────────────────────────────
□ Activity CRUD
□ Session/schedule management
□ Provider dashboard

Phase 6: Reviews & Ratings
──────────────────────────
□ Review submission
□ Rating aggregation (global layer)

Phase 7: India Region
─────────────────────
□ Supabase Auth (India project)
□ Deploy to India region
□ Razorpay integration
□ Test regional isolation

Phase 8: Admin Portal
─────────────────────
□ User management
□ Provider verification
□ Review moderation

Phase 9: Production Hardening
─────────────────────────────
□ Rate limiting
□ Security review
□ Load testing
□ Monitoring & alerting

Phase 10: GCP Migration
───────────────────────
□ Set up GCP projects
□ Cloud SQL + Cloud Run
□ GLB + Cloud CDN
□ DNS cutover

11.2 Infrastructure Phases

PhaseEnvironmentHostingDatabase
MVPDevelopmentVercelNeon
GrowthStagingVercelNeon
ScaleProductionCloud RunCloud SQL
EnterpriseProductionGKECloud SQL HA

12. Engineering Rules

12.1 Must Do

  • Same schema in both regional databases
  • Same codebase, different env vars per region
  • Validate all inputs with Zod
  • Use parameterized queries (Prisma handles this)
  • Log with request IDs for tracing
  • Return consistent API response format
  • Write integration tests for critical paths
  • Use idempotency keys for booking/payment creation
  • Use transactions with row locks for capacity management
  • Drive payment status from webhooks only

12.2 Must Not Do

  • Never replicate PII across regions
  • Never store India payment data in US
  • Never move child data across regions
  • Never log secrets, tokens, or PII
  • Never trust client-side data without validation
  • Never skip auth middleware on protected routes
  • Never use string concatenation for SQL
  • Never trust payment redirect success (only webhooks)

Appendix A: Open Decisions

DecisionOptionsStatus
Auth providerClerk / Supabase / Firebase✅ Supabase
API frameworkRoute Handlers / Hono✅ Hono
Background jobsCloud Tasks / Trigger.dev / BullMQPending
SearchPostgres FTS / Algolia / TypesenseStart with Postgres

Appendix B: Search Upgrade Path

Search is intentionally simple in v0.1. Here's the planned evolution:

Phase 1: Basic Filters (v0.1)
─────────────────────────────
- Indexed columns: category, city, age_min, age_max, price
- Basic WHERE clauses with AND/OR
- Pagination with LIMIT/OFFSET
- Good enough for < 10k activities per region

Phase 2: Full-Text Search (v0.2)
────────────────────────────────
- Postgres FTS with tsvector/tsquery
- Search across title, description
- Weighted ranking (title > description)
- Denormalized search table or materialized view for performance

Phase 3: Dedicated Search (if needed)
─────────────────────────────────────
- Migrate to Algolia, Meilisearch, or Typesense
- Triggers: > 100k activities, or need for:
- Typo tolerance
- Faceted search
- Geo-radius search
- Instant search (< 50ms)

Do not over-engineer search in v0.1. Start with indexed filters. Add FTS when users complain. Add Algolia when Postgres can't keep up.