Owly Post
About Owly PostHow it was madePhase reports

Phase 2: accounts, billing, access

What phase 2 delivered, with acceptance evidence, deviations and open items

Status: ✅ complete — including the end-to-end test-mode checkout (2026-07-08, with Esmee's sandbox keys). Phase 3 started on Esmee's go. Repo: 2 commits on top of Phase 1 (feat(entitlements), feat(billing)).

What was built

  • Entitlements (§5, golden rule 4): getEntitlements(tenant) merges plans.limits with trial overrides; PAST_DUE_GRACE_DAYS moved here as the single source. assertQuota guards add_feed / add_category / manual_digest before any core invocation — the manual digest button additionally has a 5-minute cooldown independent of the daily cap ([REVIEW]), and "today" follows the tenant timezone.
  • Metered LLM: every real model call goes through meteredLlm — kill-switch + month-to-date budget check before, usage_events row (model, tokens, cost) after, including usage carried by JsonCallError. Pricing per model in cents/MTok (haiku-4-5: 100/500, sonnet-4-6: 300/1500); unknown models meter at the highest known price so a model swap can never under-bill.
  • Auth (Supabase SSR): signup with email confirmation, login, password reset + update, session-refresh middleware, sign-out. Functional UI — polish is Phase 3 by design.
  • Stripe: Checkout (subscription mode, trial_period_days: 14, payment method upfront, allow_promotion_codes, card/iDEAL/SEPA/Bancontact — the promo-code field was removed again in D-29, the method list in D-30) built from the active plan_prices row — price changes stay a data flip. Customer Portal route. Signature-verified webhook (invalid signature → 400 before anything is read).
  • Webhook state machine: checkout.session.completed provisions the tenant (tenant + membership + preference profile in one transaction, idempotent on redelivery); invoice.payment_failedpast_due with past_due_since set exactly once; invoice.paidactive (never revives canceled/expired); subscription.updated syncs incl. cancel_at_period_endcanceled; subscription.deletedexpired (covers trial expiry without payment). Every transition declares its allowed from-set, so out-of-order and duplicate deliveries are recorded no-ops.
  • Access gating: home shell requires session → tenant → status; past_due/canceled/expired get lifecycle banners; users without a tenant land on /billing/start (a tenant only exists once a payment method does — SPEC §6.2).

Acceptance evidence (SPEC §12, Phase 2)

CriterionEvidence
Stranger can sign up, pay (test mode), get access✅ Run for real in the browser: fresh signup (stranger-e2e@test.local) → /billing/start → hosted Stripe Checkout ("Try Early Owl", 14 days free, €3.95/mo after, card/iDEAL/Bancontact/SEPA shown) → test card 4242 → redirect to /billing/successcheckout.session.completed via stripe listen → tenant provisioned (trialing, trial ends 2026-07-22, membership + profile) → dashboard shows "plan status: trialing". Bonus find: Stripe delivered the €0 trial invoice's invoice.paid before checkout.session.completed — the state machine ignored it correctly, and handlers now also ignore zero-amount invoices outright so a late-arriving trial invoice can never promote a tenant to active (regression-tested)
Payment stopped → digests stopTested: expired tenant is never selected by selectDueTenants, even in its own digest window; past_due beyond grace already covered by Phase 1 dispatch tests
Every quota path blocks with a clear messageBoundary tests: feeds/categories exactly at the cap and one over; manual digest cooldown vs daily cap; trial overrides; LLM budget block; kill switch — all with user-readable messages
Webhook handlers unit-testedSignature happy path + tampered payload + missing header (via generateTestHeaderString); status mapping; full lifecycle provision → past_due (clock set once) → active (clock cleared) → canceled → expired plus out-of-order stragglers ignored
Full gatelint + core-table gate ✅ (the gate even caught provisioning's profile insert — moved into the adapter layer) · typecheck ✅ · unit 17/17 · leak 9/9 · db 8/8 · next build

E2E setup used (for reruns)

Local run against Stripe's sandbox: a forwarded webhook listener supplies the signing secret, and the sandbox Price ("Early Owl") is wired into plan_prices by a script — so switching to live mode later is a key swap plus a data flip, not a code change. All credentials live in env config only.

Deviations (full entries in DECISIONS_LOG.md, P2-1…P2-8)

Highlights: tenants are provisioned by the webhook, not at signup (no tenants without payment method); trial_ends_at approximated as now+14d at provisioning, then synced by subscription events; LLM budget month = UTC calendar month; model pricing lives as a constants map in lib/metering.ts (cost accounting, not a plan limit).

[REVIEW] items touched

  • Manual-digest cooldown: 5 minutes (SPEC asks for a rate limit, value unspecified).
  • Past-due grace 7 days — now sourced from lib/entitlements.ts.
  • Launch-price copy on /billing/start ("€3.95/month (launch price)").

Open items

  • First npm publish of @owlypost/core (carry-over; needs npm auth). Dropped entirely on 2026-07-09 — see DECISIONS_LOG P4-5/P4-6.
  • Netlify deploy + real webhook endpoint (Phase 4 territory).
  • Supabase production email templates/SMTP for confirmations (Phase 4).
  • The sandbox test Price (wired into plan_prices) has tax_behavior: unspecified — Stripe Sandboxes can't activate Stripe Tax, so it can't be set there. Harmless while testing (Tax is off). Launch checklist (Phase 4): create the live Price with tax_behavior: inclusive when activating Stripe Tax (SPEC decision 9).

Go / no-go

Esmee gave the go on 2026-07-08 — Phase 3 (polish + onboarding wizard + starter bundles) is underway.

On this page