Skip to Content
📚 MyStoryFlow Docs — Your guide to preserving family stories

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

StepWhat existsStatusWhere
Plan cardsThree plans, one recommended, monthly/yearlyWorksapp/components/billing/PlanCards.tsx
Wizard plan stepShown when the paywall is onWorksapp/components/books/wizard-steps/ChoosePlanStep.tsx:35
Standalone plan pageShows the demo chapter above the pricesWorksapp/billing/plan/page.tsx
Start checkoutStripe Checkout, subscription modeBlocked on keysapp/api/billing/checkout/route.ts
Confirm paymentVerifies the session, writes subscriptionsBlocked on keysapp/api/billing/checkout/verify/route.ts
Entitlementsubscriptions only, with source and gifted_untilWorkslib/billing/entitlements.ts:80
Book gate402 when unpaidWorksapp/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

StepWhat existsStatusWhere
Start a demoAnonymous sign-in, one session per personBlocked — see belowapp/api/trial/start/route.ts
Record a storyThe existing recorder, unchangedWorksapp/trial/recording/page.tsx
Link the recordingSets trial_session_id, counts audioWorksapp/api/trial/attach/route.ts
Write the chapterOne gpt-4o call, grounded, one per sessionWorkslib/trial/demo-chapter.ts:130
Read the chapterHonest progress copy, then the storyWorksapp/trial/story/page.tsx
Keep this storyUpgrades the anonymous user in placeWorksapp/trial/story/page.tsx:118
Move it inScoped, idempotent, makes book + chapter 1Worksapp/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

StepWhat existsStatusWhere
Gift wizardSame steps, fake card fields deletedWorksapp/purchase-gift/page.tsx
PayStripe Checkout, payment mode, anonymous allowedBlocked on keysapp/api/gifts/checkout/route.ts
Create the giftWebhook only, idempotent on session idBlocked on keysapp/api/webhooks/stripe/route.ts
Email itQueued, then drained by cronWorkslib/gifts/gift-service.ts:118
Drain the queueCRON_SECRET-protected, every 10 minutesWorksapp/api/cron/email-queue/route.ts
Redeem pagePublic, shows giver and message firstWorksapp/gift/redeem/[code]/page.tsx
Grant + familySubscription, group, giver addedWorkslib/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.

ModeWizardProduct routesPOST /api/books
off (default)Two screens, no money mentionedOpenAllowed
onboardingAdds a plan screenOpen402 without a subscription
allAdds a plan screenSubscription required402 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_id is UNIQUE, 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

  1. Purchase. The wizard collects recipient, message and the buyer’s email, then POST /api/gifts/checkout opens Stripe Checkout in payment mode against STRIPE_PRICE_GIFT_ANNUAL (a one-time price; Stripe refuses a session mixing a one-time price with subscription mode). Anonymous buyers work by design.
  2. Webhook. checkout.session.completed with metadata.kind='gift' and a paid session creates the gifts row — the only place one is ever created. Idempotent on stripe_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.
  3. Email. Queued into email_send_queue, not sent inline, so a slow Resend cannot make the webhook time out. Codes are ten characters with no 0/O and no 1/I/L — these get read off a printed email and typed into a phone.
  4. Drain. GET /api/cron/email-queue every ten minutes, guarded by CRON_SECRET with 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.
  5. Redeem. /gift/redeem/[code] shows the giver and their message before asking for anything. Accepting writes a subscriptions row with source='gift', status='active' and a year on the clock.
  6. 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_invitations row 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

  1. Anonymous sign-ins are off in Supabase Auth — door 2’s front door. One setting.
  2. Stripe is a placeholder. Six plan prices, STRIPE_PRICE_GIFT_ANNUAL, and STRIPE_WEBHOOK_SECRET. Until then buying anything answers 503 in plain words.
  3. The family RLS migration above.
  4. Photos on a chapter — one field on the upload route.
  5. gifts still grants INSERT/UPDATE to anon with USING (true) service policies, from the 2025 migrations. With RLS off on that table it changes nothing today; it must not survive turning RLS on.
  6. /api/gifts/purchase now 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 of profiles.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.