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)mergesplans.limitswith trial overrides;PAST_DUE_GRACE_DAYSmoved here as the single source.assertQuotaguardsadd_feed/add_category/manual_digestbefore 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_eventsrow (model, tokens, cost) after, including usage carried byJsonCallError. 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 activeplan_pricesrow — price changes stay a data flip. Customer Portal route. Signature-verified webhook (invalid signature → 400 before anything is read). - Webhook state machine:
checkout.session.completedprovisions the tenant (tenant + membership + preference profile in one transaction, idempotent on redelivery);invoice.payment_failed→past_duewithpast_due_sinceset exactly once;invoice.paid→active(never revives canceled/expired);subscription.updatedsyncs incl.cancel_at_period_end→canceled;subscription.deleted→expired(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/expiredget 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)
| Criterion | Evidence |
|---|---|
| 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/success → checkout.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 stop | Tested: 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 message | Boundary 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-tested | Signature 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 gate | lint + 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) hastax_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 withtax_behavior: inclusivewhen 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.