Business Model
Verified 2026-09-06 by real runs against the production database with throwaway accounts, on a dev server at :3000 and a second one at :3100 with NEXT_PUBLIC_PAYWALL_MODE=all. Every “works” below was driven through the browser, not reasoned about.
The three doors
Subscribe. Someone buys a membership for themselves. Three plans — Just me, My family, Everything — monthly or yearly, on lib/billing/plans.ts, sold through Stripe Checkout. Access is decided in one place, lib/billing/entitlements.ts, from the subscriptions table and nothing else.
Try, then convert. An anonymous visitor records a story or talks to Elena on the dedicated trial path. That becomes one readable chapter — a small demo, not a free tier. It is their first step into onboarding, and when they make an account the recording, the story and the book are already theirs. In the owner’s words: “without a user being paid, we don’t even want them to use our services other than this.”
Gift. An existing user or an outsider buys the app for someone else. The recipient gets an email with a code. The moment they use it, they have a year, a family group of their own, and the giver is already in it — so as the storyteller writes, the family can comment and share memories in the same place.
What is built, per door
Door 1 — Subscribe
| Step | What exists | Status | Where |
|---|---|---|---|
| Plan cards | Three plans, one recommended, monthly/yearly | Works | app/components/billing/PlanCards.tsx |
| Wizard plan step | Shown when the paywall is on | Works | app/components/books/wizard-steps/ChoosePlanStep.tsx:35 |
| Standalone plan page | Shows the demo chapter above the prices | Works | app/billing/plan/page.tsx |
| Start checkout | Stripe Checkout, subscription mode | Blocked on keys | app/api/billing/checkout/route.ts |
| Confirm payment | Verifies the session, writes subscriptions | Blocked on keys | app/api/billing/checkout/verify/route.ts |
| Entitlement | subscriptions only, with source and gifted_until | Works | lib/billing/entitlements.ts:80 |
| Book gate | 402 when unpaid | Works | app/api/books/route.ts:166 |
STRIPE_SECRET_KEY is still the .env.example placeholder, so checkout answers 503 payments_not_configured with a plain sentence rather than handing Stripe a bad key. The owner creates the products; nothing else here is waiting on code.
Door 2 — Try, then convert
| Step | What exists | Status | Where |
|---|---|---|---|
| Start a demo | Anonymous sign-in, one session per person | Blocked — see below | app/api/trial/start/route.ts |
| Record a story | The existing recorder, unchanged | Works | app/trial/recording/page.tsx |
| Link the recording | Sets trial_session_id, counts audio | Works | app/api/trial/attach/route.ts |
| Write the chapter | One gpt-4o call, grounded, one per session | Works | lib/trial/demo-chapter.ts:130 |
| Read the chapter | Honest progress copy, then the story | Works | app/trial/story/page.tsx |
| Keep this story | Upgrades the anonymous user in place | Works | app/trial/story/page.tsx:118 |
| Move it in | Scoped, idempotent, makes book + chapter 1 | Works | app/api/trial/convert/route.ts |
Four things were broken and are fixed. POST /api/recordings never persisted the trial_session_id the recorder has always sent, so recordings.trial_session_id was NULL for every demo ever made and generation failed with “insufficient content” every time; that route is owned elsewhere, so /api/trial/attach makes the link from the trial page instead. There was nowhere to read the story. /api/trial/convert looked trial_sessions up with no user_id filter, so any session id could be claimed by anyone, and nothing in the app called it at all. And the account was made with signUp, minting a new user id and stranding the demo’s work on the abandoned anonymous one.
Anonymous sign-ins are disabled on the Supabase project. POST /api/trial/start answers 400 Anonymous sign-ins are disabled, so the truly anonymous entry cannot run. This is one setting in Supabase Auth, not code. Everything after that first call is identical for an anonymous and a signed-in visitor and was verified with a real account: a 120-second recording became “The Summer My Father Taught Me to Swim”, 174 words, faithful to the transcript, as chapter 1 of “Rose Demo’s Stories”.
Door 3 — Gift
| Step | What exists | Status | Where |
|---|---|---|---|
| Gift wizard | Same steps, fake card fields deleted | Works | app/purchase-gift/page.tsx |
| Pay | Stripe Checkout, payment mode, anonymous allowed | Blocked on keys | app/api/gifts/checkout/route.ts |
| Create the gift | Webhook only, idempotent on session id | Blocked on keys | app/api/webhooks/stripe/route.ts |
| Email it | Queued, then drained by cron | Works | lib/gifts/gift-service.ts:118 |
| Drain the queue | CRON_SECRET-protected, every 10 minutes | Works | app/api/cron/email-queue/route.ts |
| Redeem page | Public, shows giver and message first | Works | app/gift/redeem/[code]/page.tsx |
| Grant + family | Subscription, group, giver added | Works | lib/gifts/gift-service.ts:243 |
What was here took no payment of any kind: the wizard collected a card number into React state and threw it away, and the purchase route wrote purchase_status: 'completed' unconditionally while granting the buyer a paid tier for free.
The flags, and what each mode gates
NEXT_PUBLIC_PAYWALL_MODE, read in lib/config/flags.ts. Inlined at build time, so changing it needs a rebuild.
| Mode | Wizard | Product routes | POST /api/books |
|---|---|---|---|
off (default) | Two screens, no money mentioned | Open | Allowed |
onboarding | Adds a plan screen | Open | 402 without a subscription |
all | Adds a plan screen | Subscription required | 402 without a subscription |
In mode all the open list is exactly the three doors: /, /trial/**, /auth/**, /auth-redirect, /billing/**, /gift/**, /purchase-gift/**, /help, the invitation landing pages, and the trial, billing, webhooks, gifts, auth and cron APIs. Everything else redirects an unpaid signed-in person to /billing/plan, or answers 402 JSON if it is an API call — redirecting a fetch to an HTML page is how a paywall turns into “something went wrong”.
Two deliberate choices. An anonymous Supabase user is gated: a trial visitor is “signed in” as far as ProtectedRoute is concerned, so without this the demo is a way into the whole product. A signed-out visitor is not gated here at all; doing so is the redirect loop already recorded in lib/supabase/middleware.ts.
profiles.subscription_status grants nothing, in any mode. It defaults to 'trial' on all 67 existing rows, so honouring it would make the gate decorative.
Verified on :3100: an unpaid user opening /dashboard landed on /billing/plan?next=%2Fdashboard showing his own demo chapter above the prices; /trial and /trial/story stayed reachable; GET /api/books returned 402 JSON; and a user holding a gifted subscription reached /dashboard untouched.
Trial limits
In lib/trial/limits.ts, all enforced server-side.
- A day per session (was 30 minutes — a countdown pressuring someone into signing up is the opposite of what a demo is for).
trial_sessions.user_idisUNIQUE, so an expired session is revived rather than duplicated. - One chapter per session. Enforced by returning the existing story, which also makes a double-clicked button harmless.
- Five sessions per IP per rolling day, counted in the table — an in-memory counter resets on every cold start. A failure to count allows the request; a transient error must not shut the top of the funnel.
- 20 minutes of audio and 12,000 characters into the prompt. This is the real spend cap.
The gift lifecycle
- Purchase. The wizard collects recipient, message and the buyer’s email, then
POST /api/gifts/checkoutopens Stripe Checkout inpaymentmode againstSTRIPE_PRICE_GIFT_ANNUAL(a one-time price; Stripe refuses a session mixing a one-time price with subscription mode). Anonymous buyers work by design. - Webhook.
checkout.session.completedwithmetadata.kind='gift'and a paid session creates thegiftsrow — the only place one is ever created. Idempotent onstripe_checkout_session_id, which is uniquely indexed, because Stripe retries this event and a second delivery would otherwise mean a second gift, a second code and a second email for one payment. - Email. Queued into
email_send_queue, not sent inline, so a slow Resend cannot make the webhook time out. Codes are ten characters with no0/Oand no1/I/L— these get read off a printed email and typed into a phone. - Drain.
GET /api/cron/email-queueevery ten minutes, guarded byCRON_SECRETwith a constant-time compare, claiming each row conditionally so two overlapping runs cannot send twice. This queue had no drainer at all since July 2025; the only endpoint that looked like one returns success without sending anything. - Redeem.
/gift/redeem/[code]shows the giver and their message before asking for anything. Accepting writes asubscriptionsrow withsource='gift',status='active'and a year on the clock. - Family. The recipient’s family group is created with them as owner, and the giver joins as a contributor — or, with no account, as a pending
family_invitationsrow against their email.
Verified end to end with two throwaway accounts, with the gift row created exactly as the webhook creates it: an active family subscription with source='gift' running to 2027-09-06, the group “Rose Demo’s family”, Rose as owner, and Dan added as a contributor.
What the family can do today
Read. A family member opens the book and its chapters read-only. GET /api/books/[id] already allowed family access; the book page shows no editing controls to them. Verified: the giver loaded the recipient’s book (200) with no “Add another chapter”.
Comment. A “Family notes” panel under each written chapter — story_comments, GET/POST /api/comments?storyId=, 18px text and 48px controls, names spelled out and dates written the long way. Verified: the giver left a note and the storyteller saw it, attributed and dated.
Messaging is these notes. There is no separate inbox, no direct messages and no notifications. Saying otherwise on a marketing page would be a lie.
Photos are not built. images.story_id exists in the database and no route writes it. The upload routes are owned elsewhere, so this is one field added there — not a new table. It is the smallest remaining piece of “share memories and photos so it is all together”.
The RLS migration, written and not applied
apps/web-app/supabase/migrations/202609060503_family_rls_policies.sql.
RLS is disabled on family_groups and family_members right now. Both carry policies — four and six — but a policy on a table with relrowsecurity = false is inert decoration. gifts is the same, with eighteen. So the database is not what keeps one family’s memoir out of another family’s account; lib/family/story-access.ts is, on every request, and /api/comments refuses without it.
It is unapplied because the owner’s rule is features before database, and turning RLS on across four tables at once is the likeliest single way to break every family page in production.
It must be applied before the second real family member exists. Today the only multi-member group was built entirely by one trusted server path during a gift redemption. The moment a real person invites a real relative, the API layer is the only guard, and one missed check in a future route becomes a data leak with no second line of defence. The file’s header carries the order to apply it in.
Still open
- Anonymous sign-ins are off in Supabase Auth — door 2’s front door. One setting.
- Stripe is a placeholder. Six plan prices,
STRIPE_PRICE_GIFT_ANNUAL, andSTRIPE_WEBHOOK_SECRET. Until then buying anything answers 503 in plain words. - The family RLS migration above.
- Photos on a chapter — one field on the upload route.
giftsstill grantsINSERT/UPDATEtoanonwithUSING (true)service policies, from the 2025 migrations. With RLS off on that table it changes nothing today; it must not survive turning RLS on./api/gifts/purchasenow answers 410. It was the last way to get a paid tier without paying: no auth, an unconditional'completed'gift, and a service-role write ofprofiles.subscription_tier. Both wizards go to Stripe now. The file can be deleted once the handful of test suites still importing the path are retired.