diff --git a/.env.example b/.env.example index 8904f35..c72a0e8 100644 --- a/.env.example +++ b/.env.example @@ -28,4 +28,4 @@ PUBLIC_STRIPE_PUBLISHABLE_KEY= STRIPE_PRICE_TRIAL= -# Dev-only: gate for /account/webhook/simulate. Leave unset in prod. +# Dev-only: gate for /api/webhooks/stripe/simulate. Leave unset in prod. diff --git a/AGENTS.md b/AGENTS.md index dac3422..06405ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,10 @@ ## Stack -- SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001) +- SvelteKit monolith (SSR frontend + all services, internal :2080; nginx in prod container :3001). The Hono proxy was deleted — everything lives in SvelteKit server routes/services. - PocketBase (separate Coolify service at `pb.chores.app.com`, :8090) -- Stripe payments (subscriptions) — **planned in SvelteKit server routes** (`/account` + `/account/webhook`), NOT the Hono proxy. Not yet implemented (only `settings.webhookUrl` + `fams.stripeCustomerId` exist). +- Stripe payments (subscriptions) — implemented in **SvelteKit server routes** (public `/pricing` + inline signup checkout via `PricingPlans`, billing portal from settings Billing section, `/api/webhooks/stripe`). Dev webhook listener: `pnpm stripe:listen` (root script). Embedded Checkout needs a secure context (HTTPS/localhost) — over Tailscale/LAN HTTP use `ssh -L 2080:localhost:2080`. +- Access gating — `fams.paymentMode` (`none|code|sub|canceled`) + `fams.active`; codes in superuser-only `accesscodes` (seeded `dev123`). Core logic in `frontend/src/lib/server/access.ts`, exposed as `data.famAccess` from `[fam]/+layout.server.ts`; disabled fams get a blurred overlay + locked member kanban (frontend-only); `/settings` stays unlocked so admins can apply a code. - Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`) - Deployment: Coolify, Cloudflare DNS @@ -34,7 +35,9 @@ - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) - `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`) -- `fams` — name, slug, stripeCustomerId, featureFlags +- `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes) +- `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code"). +- `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt - `chore_templates` — famId, name, defaultValue, defaultFrequency - `assigned_chores` — famId, userId, templateId, frequency, value - `completions` — famId, userId, assignedChoreId, date @@ -51,8 +54,8 @@ / Landing (SaaS marketing) /admin Platform super-admin stats dashboard (and any donations) /login · /logout Parent email/password login / logout -/signup Parent + family signup -/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code= +/signup Parent + family signup (wizard: fam → child → code → plan) +/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code= /{fam} Fam dashboard /{fam}/{username} Parent → admin overview, Child → member kanban (role from session) /{fam}/{username}/chores Chore templates & assignment grid @@ -60,10 +63,9 @@ /{fam}/{username}/bonuses Bonus configs & evaluation /{fam}/{username}/preferences User preferences (parent→users, member→users) /{fam}/{username}/settings Family admin settings (parent only) — includes Stripe connect/manage + pause -/account Account/billing — payment setup & subscription management (Stripe) -/subscriptions 3-tier plan page (trial | monthly | yearly), access via settings -/account/webhook Stripe webhook handler (server route) -/api/* Hono proxy (data layer; CRON not implemented) +/pricing 3-tier public plan page (trial | monthly | yearly), entry via settings or logged-out +/api/webhooks/stripe Stripe webhook handler (server route) +/api/* SvelteKit API endpoints (data layer; CRON not implemented) ``` ## Data Flow @@ -76,12 +78,12 @@ ### Writes -- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer `) -- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`) -- **Member updates:** Browser → Hono proxy → PB (auth via `Bearer `) -- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met +- **Chore toggle:** Browser → SvelteKit `/api/completions/toggle` → PB (session cookie auth) +- **Admin CRUD:** Form actions / `/api/admin/*` endpoints → PB via services (`servicesFor(event)`); PB collection rules are the security boundary +- **Member updates:** Browser → SvelteKit `/api/*` routes → PB +- **Reward creation:** After completion toggle, service layer creates reward if threshold met - **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented. -- **Stripe:** implemented in SvelteKit server routes — `/account` (setup/manage subscription) + `/account/webhook`. Lives in the frontend app, NOT the Hono proxy. **WhatsApp:** not implemented. +- **Stripe:** implemented in SvelteKit server routes — `/pricing` (public plan picker; logged-in users checkout inline) + settings Billing section (billing portal) + `/api/webhooks/stripe`. **WhatsApp:** not implemented. ### UI reactivity @@ -203,20 +205,19 @@ All admin and member pages use the following pattern: - Every collection query includes `famId = @request.auth.famId` filter - Super admin bypasses famId filter (access via PB admin API) - Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets. -- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`) -- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server -- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono +- **Server data access**: `servicesFor(event)` / `createServices(pb)` in `$lib/server/services/`; superuser ops via `pbAdmin` facade (`$lib/server/pocketbase.ts`) +- **Browser data access**: fetch to same-origin `/api/*` SvelteKit endpoints; httpOnly `pb_token` cookie is the auth - **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix - **Dates**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Use the shared `formatDDMMYY()` helper in `frontend/src/lib/format.ts`. Never render raw `YYYY-MM-DD` to users. Exception: single human-readable dates like todo **due dates** should use `formatShortDate()` (also in `format.ts`, renders `5 Aug` / `5 Aug 26`) — the compact DDMMYY code is ambiguous and bad UI for those. - `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars - `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template - Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase) -- Nginx routes in prod: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`) +- Nginx routes in prod: `/*` → SvelteKit (`:2080`), `/pb/*` → PocketBase - Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved) - **Dev servers: NEVER start your own.** Always reuse the running dev servers — proxy `192.168.1.225:3456` (tsx watch, reloads on edit), frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev` / `tsx watch` / extra vite instances. Only restart when the user explicitly asks. - Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL` - Seed via JSON dump (portable for dev) -- Monorepo: SvelteKit in `frontend/`, Hono in `proxy/`, two Dockerfiles +- Monorepo: SvelteKit in `frontend/` (+ root `shared/`), single app Dockerfile + PB Dockerfile.dev - Decisions tracked in `MEMORY.md` ## Build Phases (must validate each before next) @@ -247,7 +248,7 @@ All admin and member pages use the following pattern: 3.5 Reward claim flow + admin CRUD 3.6 Monthly bonus evaluation 3.7 CRON handler (Coolify → Hono) -3.8 Stripe checkout + webhook (SvelteKit `/account` server routes, not Hono) +3.8 Stripe checkout + webhook (SvelteKit server routes, not Hono) 3.9 Notification interface (WhatsApp deferred) ### Phase 4 — Frontend App diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d40e98c..a78b04e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,23 +12,22 @@ Multi-tenant chore tracking SaaS. Families ("fams") are isolated tenant groups. | Component | Role | Deploy | Port | |---|---|---|---| -| SvelteKit | SSR frontend, all UI + Stripe server routes | Coolify Docker (nginx) | :2080 internal, :3001 external | -| Hono proxy | data layer (`/api/*`); CRON | Same container, proxied via nginx `/api/*` | :3456 internal | +| SvelteKit | SSR frontend + all services + Stripe server routes (`/api/*`) | Coolify Docker (nginx) | :2080 internal, :3001 external | | PocketBase | DB, auth, realtime, storage, Admin UI | Coolify service (pb.chores.app.com) | :8090 | -| Stripe | subscriptions (in SvelteKit, NOT Hono) | — | — | +| Stripe | subscriptions (SvelteKit server routes) | — | — | ### Deployment Topology ``` chores.app.com ────┬──► nginx (:3001) │ ├── /* ──► SvelteKit (:2080) - │ └── /api/* ──► Hono proxy (:3456) + │ └── /api/* ──► SvelteKit (:2080) │ pb.chores.app.com ──► PocketBase (:8090) │ Admin UI at /_ │ Volume: /pb_data (persistence + backups) │ -stripe.com ─────────► SvelteKit /account/webhook +stripe.com ─────────► SvelteKit /api/webhooks/stripe ``` --- @@ -60,7 +59,9 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) - `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour on `users.color`) -- `fams` — name, slug, stripeCustomerId, featureFlags +- `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes) +- `platform` — label (`global` singleton), flags (json) — platform feature flags; public read (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggled from the `/admin` Platform Flags card (`debug` gates dev-only CTAs like settings "Revoke code"). Replaces the deprecated per-fam `fams.featureFlags`. +- `fams` — name, slug, stripeCustomerId, paymentMode (`none`|`code`|`sub`|`canceled`), active, accessCodeId, accessCodeEnteredAt - `chore_templates` — famId, name, defaultValue, defaultFrequency - `assigned_chores` — famId, userId, templateId, frequency, value - `completions` — famId, userId, assignedChoreId, date @@ -69,7 +70,7 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl -> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` bootstraps a fresh/wiped PB (idempotent). The native `users` auth fields/rules + superuser-only `otp` are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`). Data is disposable — schema change = update `SCHEMA_PLAN` + wipe PB + reboot. +> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` bootstraps a fresh/wiped PB (idempotent). The native `users` auth fields/rules + the superuser-only `otp`/`accesscodes` and public-read `platform` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensureAccessCodes`/`ensurePlatform`); `ensureFamFields()` hardens existing installs with newer `fams` fields. Data is disposable — schema change = update `SCHEMA_PLAN` + wipe PB + reboot. --- @@ -81,8 +82,8 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f / Landing page (SaaS marketing) /admin Platform super-admin stats dashboard /login · /logout Parent login / logout -/signup Parent + family signup -/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code= +/signup Parent + family signup (wizard: fam → child → code → plan) +/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code= /{fam} Fam dashboard /{fam}/{username} Parent → admin overview, Child → member kanban /{fam}/{username}/chores Chore templates & assignment grid @@ -90,10 +91,10 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f /{fam}/{username}/bonuses Bonus configs & evaluation /{fam}/{username}/preferences User preferences /{fam}/{username}/settings Family admin settings (parent only) — Stripe connect/manage + pause -/account Account/billing — payment setup & subscription management -/subscriptions 3-tier plan page (trial | monthly | yearly), access via settings -/account/webhook Stripe webhook handler (server route) -/api/* Hono proxy (data layer; CRON not implemented) +/settings (Billing group) Subscription status, change plan, open billing portal +/pricing 3-tier public plan page (trial | monthly | yearly), entry via settings or logged-out +/api/webhooks/stripe Stripe webhook handler (server route) +/api/* SvelteKit API endpoints (data layer; CRON not implemented) ``` --- @@ -107,30 +108,36 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f ### 5.2 Writes -- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer `). -- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`). -- **Member updates:** Browser → Hono proxy → PB (`Bearer `). -- **Reward creation:** after completion toggle, Hono proxy creates reward if threshold met. +- **Chore toggle:** Browser → SvelteKit `/api/completions/toggle` → PB (session cookie auth). +- **Admin CRUD:** Form actions / `/api/admin/*` endpoints → PB via services; PB collection rules are the security boundary. +- **Member updates:** Browser → SvelteKit `/api/*` routes → PB. +- **Reward creation:** after completion toggle, service layer creates reward if threshold met. - **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) not implemented. -- **Stripe:** SvelteKit server routes `/account` + `/account/webhook` (frontend app, NOT Hono). +- **Stripe:** SvelteKit server routes `/pricing` + settings Billing actions + `/api/webhooks/stripe`. - **WhatsApp:** not implemented. ### 5.3 Stripe Subscription (embedded Checkout) ``` -Parent picks a tier on /subscriptions (trial | monthly | yearly) - → SvelteKit server action (subscriptions) creates Embedded Checkout Session - createEmbeddedCheckoutSession() → ui_mode: "embedded" → client_secret - → returns { clientSecret } to the browser - → @stripe/stripe-js createEmbeddedCheckoutPage({ clientSecret }) mounts in-page - → Parent completes payment inside the embedded Stripe page - → Stripe sends checkout.session.completed → SvelteKit /account/webhook - handleStripeEvent() → pbAdmin.update fams.stripeCustomerId + active = true - → Subsequent customer.subscription.* webhooks keep fams.active in sync - → Parent returns to /account?checkout=return +PUBLIC PRICING SIGNUP WIZARD AFTER +───────────── ───────────── ───── +/pricing ── logged out ──► /signup?plan=X + └─ logged in ──► embedded checkout (existing behavior) + + 1. fam create family + parent + 2. child add child / skip + 3. code "Have an access code?" + ├─ apply valid ──► 5. done (fam active) + └─ skip ─────────► 4. plan + 4. plan PricingPlans component + ├─ ?plan=X pre-highlights that tier + ├─ pick tier ──► embedded checkout mounts INLINE + └─ trial tier hidden (codes live at step 3) + 5. done "Go to dashboard" + webhook sets paymentMode=sub → overlay lifts ``` -Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (writes `fams.active` directly). +Webhook events (`/api/webhooks/stripe`) update `fams.stripeCustomerId`, `fams.active`, `fams.paymentMode` from subscription lifecycle. ### 5.3.1 Payments architecture @@ -138,26 +145,27 @@ Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (wri ┌────────────────────────────────────────── SVELTEKIT APP ──────────────────────────────────────────┐ │ │ │ Browser │ -│ ┌──────────────────────────────┐ POST ?/checkout ┌─────────────────────────────────────────┐ │ -│ │ /subscriptions (+page.svelte)│ ───────────────────► │ subscriptions/+page.server.ts (action) │ │ -│ │ • tier cards │ │ • resolves famId + parent email (PB) │ │ -│ │ • createEmbeddedCheckoutPage│ ◄─── clientSecret ─── │ • createEmbeddedCheckoutSession() │ │ -│ │ • mounts embedded Stripe UI │ └───────────────┬─────────────────────────┘ │ +│ ┌──────────────────────────────┐ POST ?/choose ┌─────────────────────────────────────────┐ │ +│ │ /pricing (+page.svelte) │ ───────────────────► │ pricing/+page.server.ts (action) │ │ +│ │ • PricingPlans component │ │ • logged-out: redirect /signup?plan=X │ │ +│ │ • createEmbeddedCheckoutPage │ ◄─── clientSecret ─── │ • logged-in: createEmbeddedCheckout... │ │ +│ │ • mounts embedded Stripe UI │ └───────────────┬─────────────────────────┘ │ │ └──────────────┬───────────────┘ │ stripe SDK (secret) │ │ │ createEmbeddedCheckoutPage(clientSecret) ▼ │ │ ▼ ┌─────────────────────────────┐ │ │ ┌──────────────────────────────┐ │ STRIPE API │ │ │ │ Embedded Checkout (Stripe │ card + pay │ checkout.sessions.create │ │ -│ │ hosted iframe, in-page) │ ───────────────────► │ (ui_mode: embedded) │ │ +│ │ hosted iframe, in-page) │ ───────────────────► │ (ui_mode: embedded_page) │ │ │ └──────────────────────────────┘ └──────────────┬──────────────┘ │ │ │ webhook events │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │ -│ │ /account/webhook (+server.ts) │ │ +│ │ /api/webhooks/stripe (+server.ts) │ │ │ │ • verify stripe-signature (CLI secret in dev, dashboard in prod) │ │ │ │ • handleStripeEvent() → stripe-events.ts │ │ -│ │ └ checkout.session.completed → fams.stripeCustomerId + active = true │ │ -│ │ └ customer.subscription.* → fams.active (sync by customer id) │ │ +│ │ └ checkout.session.completed → fams.stripeCustomerId + active + paymentMode='sub' │ │ +│ │ └ customer.subscription.* → fams.active + paymentMode (sync by customer id) │ │ +│ │ └ customer.subscription.deleted → active=false + paymentMode='canceled' │ │ │ └──────────────────────────────────────────────────────┬─────────────────────────────────────┘ │ │ │ pbAdmin (superuser) │ └─────────────────────────────────────────────────────────┼─────────────────────────────────────────┘ @@ -166,25 +174,60 @@ Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (wri │ POCKETBASE │ │ fams.stripeCustomerId │ │ fams.active (bool) │ + │ fams.paymentMode │ └────────────────────┘ +SIGNUP WIZARD (inline checkout at step 4): + /signup?plan=X + 1. fam create family + parent (no code field) + 2. child add child / skip + 3. code "Have an access code?" → apply or skip + 4. plan PricingPlans component (hideTrial), selecting a plan + → ?/choose action → createEmbeddedCheckoutSession → mount embedded inline + 5. done "Go to dashboard" — webhook flips paymentMode=sub, overlay lifts + Management: - /account (+page.server.ts) - • billing action → createBillingPortalSession(customerId) → Stripe Customer Portal - (update card, cancel / reactivate subscription) - • togglePause action → pbAdmin.update fams.active (hard pause, independent of Stripe) + Settings → Billing group (+page.server.ts ?/billingPortal) + • createBillingPortalSession(customerId) → Stripe Customer Portal + (update card, cancel / reactivate subscription; returns to /{fam}?checkout=return) + Gating derives from fams.paymentMode alone (none = gated). No local pause flag. Dev-only: - stripe CLI: stripe listen -e ... --forward-to http://127.0.0.1:2080/account/webhook - (sets STRIPE_CLI_WEBHOOK_SECRET for local signature verification) + pnpm stripe:listen (root script) + = stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed + --forward-to http://127.0.0.1:2080/api/webhooks/stripe + (sets STRIPE_CLI_WEBHOOK_SECRET for local signature verification) + +Embedded Checkout needs a secure context (HTTPS or localhost). Over Tailscale/LAN HTTP the +checkout iframe hangs silently — port-forward instead: ssh -L 2080:localhost:2080 ``` **Key decisions** -- **Payments live in SvelteKit, not Hono** — the app already owns SSR + server actions; Hono stays a pure data layer. Stripe secret never reaches the client. -- **`fams.active`** is the single app-level gate: webhooks (subscription lifecycle) and the pause toggle both write it. It disables interactions + payments when `false`. -- **Embedded Checkout** (in-page, no redirect) via `createEmbeddedCheckoutPage` — needs a same-origin `return_url`; subscriptions require a `customer` (created with `customer_creation: 'always'` + `customer_email` if the fam has none yet). -- **Trial** is app-side: a code maps to `trial_period_days` on the subscription; the trial Stripe price is a `$0` plan. Real-world codes should move to a PB collection. -- **Webhook secrets** — `STRIPE_CLI_WEBHOOK_SECRET` (dev) overrides `STRIPE_WEBHOOK_SECRET` (prod/dashboard); `verifyStripeEvent` picks the CLI one when set. Dev testing uses the Stripe CLI (`stripe listen --forward-to http://127.0.0.1:2080/account/webhook`) which forwards real signed events; a real checkout carries the `famId` and drives the DB write end-to-end. +- **Payments live in SvelteKit server routes** — the app owns SSR + server actions end-to-end. Stripe secret never reaches the client. +- **`fams.paymentMode` + `fams.active`** gate the platform (see 5.3.2). Webhooks write `paymentMode`; `ensureFamAccess()` recomputes and persists `active` on every `[fam]` layout load. +- **Embedded Checkout** (in-page, no redirect) via `createEmbeddedCheckoutPage` with `ui_mode: 'embedded_page'` (`'embedded'` is deprecated) — needs a same-origin `return_url`. No `customer_creation` (subscription mode only; Stripe auto-creates the customer from `customer_email`). +- **Access codes are a real PB collection** (`accesscodes`, superuser-only) — entered at signup or via settings; replaces the earlier app-side trial-code idea. +- **Webhook secrets** — `STRIPE_CLI_WEBHOOK_SECRET` (dev) overrides `STRIPE_WEBHOOK_SECRET` (prod/dashboard); `verifyStripeEvent` picks the CLI one when set. Dev testing uses the Stripe CLI (`pnpm stripe:listen`) which forwards real signed events; a real checkout carries the `famId` and drives the DB write end-to-end. + +### 5.3.2 Access gating (`fams.paymentMode` / `accesscodes`) + +``` +computeFamAccess(fam, code?) → { disabled, reason } lib/server/access.ts + none → disabled ("no_access") fresh signup, no code/sub + code → valid while accesscodes.active && !expired && !durationExhausted + (duration/expiry 0 = continuous/never; months measured from + fam.accessCodeEnteredAt / code.createdAt) + sub → follows webhook-maintained fams.active + canceled → disabled ("canceled") +ensureFamAccess(famId): reads fam+code, persists drifted fams.active, returns {fam, access} +applyAccessCode(famId, value): validates + sets paymentMode='code' + entry stamp +``` + +- Exposed to all fam pages as `data.famAccess` from `[fam]/+layout.server.ts`. +- Disabled UX: layout blurs page content behind an overlay card + admin TopNav announcement (`/settings` is exempt so admins can apply a code / manage billing); member kanban renders empty locked columns and `toggle()` early-returns (frontend-only by decision). +- Entry points: optional code field at signup, Access card in settings (`?/applyCode`). Webhooks flip `paymentMode` to `sub`/`canceled`. +- Debug revoke: with the platform `debug` flag ON, settings shows a "Revoke code" CTA (`?/revokeCode`) that clears the applied code (back to `none`/gated). Server-side flag check is the boundary. +- Seeded dev code: `dev123` (developer, duration 0, expiry 0). ### 5.4 UI reactivity @@ -202,11 +245,10 @@ Dev-only: /shared/pb/schema.ts SCHEMA_PLAN — source of truth for base collections /frontend SvelteKit app (:2080) /src/env.ts declareEnvVars — client/server env - /src/lib/server pb-admin, migrate.ts, services, hono.ts (sessionHeaders) + /src/lib/server pocketbase.ts (pbAdmin), migrate.ts, access.ts, platform.ts, services/ /src/lib/client api.ts (memberApi), stores (famStore) /src/lib/components UI components (re-exported from index.ts) - /src/routes SvelteKit file-based routing (incl /account, /subscriptions) - /proxy Hono proxy (:3456) + /src/routes SvelteKit file-based routing (incl /pricing, /signup wizard, /api/webhooks/stripe) /docker Dockerfile (prod multi-stage + nginx), Dockerfile.dev (PB) /config.ts dev/build-time shared config (ports) /MEMORY.md decisions log @@ -234,8 +276,8 @@ Values come from root `.env` (symlinked at `frontend/.env -> ../.env`). `.env.ex ## 8. Key Conventions - **`famId` on every query** — PB auth rules enforce `famId = @request.auth.famId`; superuser bypasses. -- **Member → Proxy (server/browser):** `memberApi.*` in `$lib/client/api.ts`; `BASE_URL` resolves to Hono port on server, empty in browser (Vite proxies `/api/*`). -- **Admin → Proxy:** `hono.admin.*` in `$lib/server/hono.ts` — `sessionHeaders(event)` (server-only, requires `RequestEvent`). +- **Server data access:** `servicesFor(event)` / `createServices(pb)` in `$lib/server/services/`; superuser ops via `pbAdmin` facade. +- **Browser data access:** same-origin fetch to `/api/*` SvelteKit endpoints; httpOnly `pb_token` cookie is the auth. - **Child passwords derived** — `MEMBER_SECRET + famSlug + username`; join gate is a transient OTP. Never log raw tokens/secrets. - **`$page`** — from `$app/state` (not `$app/stores`); no `$` prefix. - **Dates** — user-facing via `formatDDMMYY()` (compact `040826`); human-readable due dates use `formatShortDate()`. Never render raw `YYYY-MM-DD`. @@ -248,5 +290,5 @@ Values come from root `.env` (symlinked at `frontend/.env -> ../.env`). `.env.ex ## 9. Open / Deferred - **WhatsApp notifications** — `NotificationService` plugin for the weekly CRON handler. Deferred. -- **Stripe payments** — flow not yet implemented. Only `fams.stripeCustomerId` + `settings.webhookUrl` exist. Building in SvelteKit `/account` + `/account/webhook`. Trial via codes (app-side validation + `trial_period_days`) — TBD. +- **Stripe payments** — implemented in SvelteKit (`/pricing` public picker + inline signup checkout, settings Billing group with billing portal, `/api/webhooks/stripe` → `stripe-events.ts`; `?checkout=return` lands on the fam dashboard with a welcome notice). Remaining: platform-admin UI for managing `accesscodes`, prod webhook secret wiring, optional Stripe-level pause (see TODO.md). - **Weekly CRON** (`/api/weekly-cron`, Coolify) — not implemented; settlement is manual via `complete-week`/`simulateEow`. \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md index 1f96f32..82d007c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -260,3 +260,69 @@ - **Username convention (composite + handle):** PB `users.username` is the composite `{famSlug}:{handle}` for BOTH parents and children — globally unique (PB auth-identity needs a single-column unique index) even though the URL segment is per-family. `handle(name)` = lowercase, strips all non-`[a-z0-9]` (`"Jakey Boy"` → `jakeyboy`); `slugify()` (hyphenated) is kept only for fam slugs. URL segment = `handleOf(username)` (part after the last `:`) → `/{famSlug}/{handle}`. `name` keeps the raw display name, read from DB via `authRefresh` (not plucked into the cookie). Parent's handle captured at signup step 1 (`yourName`) → `username = famUsername(famSlug, handle(yourName))`; parents authenticate email+password and land on the fam dashboard `/{famSlug}` (not username-routed). Children authenticate via OTP → `authWithPassword(famUsername(...), derivePassword(famSlug, handle))`. Redirects in `login/+page.server.ts`, `[fam]/[username]/+page.server.ts` (parent + child branches) and `preferences/+page.server.ts` use `session.username` (the handle). Member-list URLs in `settings`, `[fam]/+page.svelte`, `[fam]/[username]/+page.svelte` build `/{famSlug}/{handleOf(m.username)}`. `handle`/`handleOf`/`famUsername` live in `shared/slugify.ts` (`@shared/slugify`). - **Restored intended multi-step signup** (from the guide, adapted to current OTP model): `/signup` steps — (1) `?/signup` familyName/yourName/email/password → create fam + parent (name=yourName, username=famUsername(famSlug, handle(yourName))) + settings, set `pb_token`; (2) `?/child` optional child → `issueAccess` returns `{ code, joinUrl }`; (3) show OTP join code + "Go to dashboard" link. Uses named actions + `use:enhance` (callback typed `any` to avoid the pre-existing canary `$types` SubmitFunction error). - **Typecheck:** frontend `svelte-check` stays at 20 pre-existing errors (no new in edited files). + +### 2026-08-21 — Stripe embedded checkout live + access-code gating (`fams.paymentMode`) + +- **Embedded Checkout fixes** (`frontend/src/lib/server/stripe.ts`): `ui_mode: 'embedded'` → `'embedded_page'` (Stripe deprecated `'embedded'`); removed `customer_creation: 'always'` — only valid in `payment` mode; subscription mode auto-creates the customer from `customer_email`. Client side already used `createEmbeddedCheckoutPage({ clientSecret })`. +- **Secure-context gotcha:** embedded Checkout requires HTTPS or localhost. Dev host is reached over Tailscale IP via plain HTTP → checkout hangs silently (the `muid/guid/sid` JSON from `m.stripe.com` is Radar device fingerprinting, not an error; Stripe CLI websocket errors are benign). Fix: SSH port-forward `ssh -L 2080:localhost:2080` and use `http://localhost:2080`. Webhook listener is now a root script: `pnpm stripe:listen` (= `stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/account/webhook`). +- **Gating model:** the platform is gated. `fams.paymentMode` = `none | code | sub | canceled`; `fams.active` (bool) is the derived "usable now" flag, re-persisted by `ensureFamAccess()` when it drifts. New superuser-only `accesscodes` collection (all rules null like `otp`, so it lives in `migrate.ts` not `SCHEMA_PLAN`): `value` (unique, required), `name`, `duration` (months from entry date; 0=continuous), `expiry` (months after the code's own `createdAt`; 0=never), `active` failsafe, `createdAt`. Idempotently seeded with `dev123` / developer / 0 / 0. +- **Core module** `frontend/src/lib/server/access.ts`: `addMonthsUTC`, `codeIsValid`, `computeFamAccess` (mode → `{disabled, reason}`; reasons `no_access|code_expired|code_disabled|subscription_inactive|canceled`), `ensureFamAccess(famId)` (reads fam+code, persists drifted `active`, returns `{fam, access}`), `applyAccessCode(famId, value)` (sets mode=code + accessCodeId + accessCodeEnteredAt). Wired into `[fam]/+layout.server.ts` load → `data.famAccess` (both roles). +- **UI gating:** `[fam]/+layout.svelte` blurs `.page-content.locked` behind a non-blocking overlay card + admin TopNav announcement (sidebar/chat stay usable). Member kanban gate is **frontend-only by decision**: `[fam]/[username]/+page.svelte` derives `accessDisabled` from `page.data.famAccess?.disabled`, early-returns in `toggle()`, and renders three empty locked columns instead of the board. Signup takes an optional code (blank → gated fam; invalid → 400); settings has an Access card (`?/applyCode`). Webhooks maintain `paymentMode`: checkout completed / subscription sync → `sub`; subscription deleted → `canceled`. +- **Bug found while verifying:** the live `fams` collection was missing the `active` bool entirely (schema.ts declared it; this PB predated it) → `active` writes were silently dropped. Fixed by adding it to `ensureFamFields()` (idempotent; runs outside `ensureSchema`'s early-return alongside `ensureAccessCodes`) and patching the live collection. Verified end-to-end against PB: fam with valid `dev123` → `active=true`; fam with empty mode → `active=false` (gated). +- **PB curl gotcha:** single-record endpoints are `/api/collections/{name}/records/{id}` — omitting `/records/` returns PB's `"File not found."` 404 which masquerades as a missing record. The JS SDK always builds the correct path (an earlier "fams by-id 404" scare was a bad curl URL, not an app bug). + +### 2026-08-22 — Routes reshuffle: `[famSlug]`→`[fam]` merge, `/pricing` public, signup wizard with inline checkout + +- **Join route merged:** `[famSlug]/join/{username}` → `[fam]/join/{username}` (same URL shape, single `fam` param). Fixed `params.famSlug`→`params.fam` in join page files. +- **Public pricing page:** `/subscriptions` → `/pricing` (untracked dir renamed). New `PricingPlans.svelte` component (reusable tier cards; props: `action`, `hideTrial`, `selected`, `error`, `onsubmit` handler). `/pricing` is public: logged-out "Choose monthly" → redirect `/signup?plan=monthly`; logged-in → existing embedded checkout. +- **Signup wizard rewritten** as state machine (`fam → child → code → plan → done`): + - Step 1 (fam): family + parent creation (access code field REMOVED from here) + - Step 2 (child): add child or skip (unchanged) + - Step 3 (code): "Have an access code?" Apply (→ done) or Skip (→ plan) + - Step 4 (plan): `PricingPlans` embedded (`hideTrial=true`); selecting mounts embedded checkout INLINE (user authenticated); `?plan=X` from /pricing pre-highlights tier + - Step 5 (done): "Go to dashboard" — webhook flips `paymentMode=sub`, overlay lifts + - Server actions: `signup` (no code), `child` (unchanged), `access` (reuses `applyAccessCode`), `choose` (embedded checkout session) +- **Webhook moved** to `/api/webhooks/stripe` (machine-to-machine endpoint belongs in `/api/*` namespace). `pnpm stripe:listen` forward URL updated. +- **Links updated:** account "Change plan", settings "Plans", `stripe.ts` cancel_url → `/pricing`. +- **Docs updated:** AGENTS.md routes, ARCHITECTURE.md (routes, Stripe flow, architecture diagram, project structure), MEMORY.md this entry. + +### 2026-08-21 — Platform feature flags (`platform` collection) + debug-gated revoke CTA + +- **`fams.featureFlags` deprecated** (removed from SCHEMA_PLAN, `Fam` type, live PB; field dropped). Replaced by a global **`platform`** collection: single record `label='global'`, json `flags`. Rules: list/view = `""` (public read — the one rule shape `col()` CAN express), create/update/delete = null (superuser-only) → created in `migrate.ts` (`ensurePlatform` + idempotent `seedPlatform`, like otp/accesscodes). +- **Public load:** new root `frontend/src/routes/+layout.server.ts` exposes `page.data.platformFlags` on every page via `getPlatformFlags()` (`lib/server/platform.ts`, 10s TTL cache; `setPlatformFlag` for superuser writes). +- **`debug` flag gates dev-only UI**: settings "Revoke code" CTA (`?/revokeCode`) — clears an applied code (paymentMode→none, accessCodeId/EnteredAt→'', active=false, fam re-gates). Server action checks the flag itself (hidden CTA is not the boundary). Settings' old per-fam `featureFlags.debugMode` Debug Tools card now keys off `page.data.platformFlags.debug`. +- **`/admin` Platform Flags card** replaces the per-fam Debug column: `?/togglePlatformFlag` toggles any flag on the global record. Dev PB seeded with `debug: true`. +- Also: `[fam]/+layout.svelte` exempts `/settings` from the paused blur overlay (admins can apply a code while gated) and `disabled`/`accessReason` are `$derived` so applying/revoking updates the overlay without a refresh. + +### 2026-08-22 — Settings reorg: Accordion groups, paymentMode-only billing, notices system + +- **Settings grouped into 4 Accordions** (Family / App / Invites / Billing). `Accordion.svelte` rewritten as a styled snippet wrapper + new self-contained `AccordionItem` (own open state, `$bindable`, `{@render children()}`) — no items-array API. +- **Gating model simplified (user decision):** `fams.paymentMode` alone drives the FE (`none` = gated/paused; `code` valid = active; `sub` follows webhooks; `canceled` = gated). No `paused` field added; the local pause toggle was removed entirely. `fams.active` remains an internal derived flag maintained by `ensureFamAccess`/webhooks only. +- **Access card:** shows countdown from `accessCodeEnteredAt` + code `duration` months (days when <1 month, "never expires" when duration=0) — settings load now fetches the `accesscodes` record (`data.accessCode`). Once a code is applied the input/Apply are hidden and **Revoke is always visible** (debug-flag requirement dropped); revoke just sets `paymentMode:'none'` + clears code fields (fam-scoped only — global code management is a platform-admin concern). +- **Billing card** replaces `/account` (route deleted): sub → Change plan (/pricing) + Open billing portal (Stripe Customer Portal; dummy mode opens returned URL); code → Switch to subscription; none/canceled → Choose plan. Portal + checkout both return to `/{fam}?checkout=return`. +- **Checkout-return welcome notice:** `[fam]/+page.svelte` `$effect` watches `?checkout=return` → fires a success notice via the new **notices store** (`lib/stores/notices.ts`: typed add/success/info/warning/error + auto-dismiss helper) rendered by global `` in the root layout; query param scrubbed via `history.replaceState` so refresh doesn't re-fire. +- Gotchas fixed along the way: duplicate NoticeDialog export; Svelte 5 forbids `class:` directives on components unless declared (Card got `selected` prop instead); second ` -
- {#each items as item, i} -
- - {#if openIndex === i} -
- {@render item.content()} -
- {/if} -
- {/each} +
+ {@render children()}
+ \ No newline at end of file diff --git a/frontend/src/lib/components/AccordionItem.svelte b/frontend/src/lib/components/AccordionItem.svelte new file mode 100644 index 0000000..a95f800 --- /dev/null +++ b/frontend/src/lib/components/AccordionItem.svelte @@ -0,0 +1,36 @@ + + +
+ + {#if open} +
+ {@render children()} +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte index 84ff417..8d2371e 100644 --- a/frontend/src/lib/components/Card.svelte +++ b/frontend/src/lib/components/Card.svelte @@ -4,14 +4,16 @@ title, accent, scrollX = false, - children - }: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any } = $props(); + children, + class: className, + selected = false + }: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props();
diff --git a/frontend/src/lib/components/NoticeDialog.svelte b/frontend/src/lib/components/NoticeDialog.svelte new file mode 100644 index 0000000..4c44d7c --- /dev/null +++ b/frontend/src/lib/components/NoticeDialog.svelte @@ -0,0 +1,112 @@ + + +{#if notices.list.length > 0} +
+ {#each notices.list as notice (notice.id)} +
+
+
{icon(notice.type)}
+
+

{notice.title}

+ {#if notice.message} +

{notice.message}

+ {/if} +
+
+ {#if notice.action} + {notice.action.label} + {/if} + {#if notice.dismissible} + + {/if} +
+ {/each} +
+{/if} + + \ No newline at end of file diff --git a/frontend/src/lib/components/PricingPlans.svelte b/frontend/src/lib/components/PricingPlans.svelte new file mode 100644 index 0000000..ad7b82b --- /dev/null +++ b/frontend/src/lib/components/PricingPlans.svelte @@ -0,0 +1,152 @@ + + + + {#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier} + +

+ {tier.price} + {tier.period} +

+

{tier.desc}

+ + {#if tier.id === 'trial'} +
+ + + + +
+ {:else} +
+ + +
+ {/if} + + {#if error} +

{error}

+ {/if} +
+ {/each} +
+ + \ No newline at end of file diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index fba39d3..dbb4956 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -15,7 +15,7 @@ let collapsed = $state(false); - let famSlug = $derived(page.params.fam); + let famSlug = $derived(page.data.famSlug ?? page.params.fam); let memberName = $derived(session?.memberName || page.params.username || ''); function toggle() { diff --git a/frontend/src/lib/components/index.ts b/frontend/src/lib/components/index.ts index 37cd171..ee68227 100644 --- a/frontend/src/lib/components/index.ts +++ b/frontend/src/lib/components/index.ts @@ -6,5 +6,8 @@ export { default as Card } from './Card.svelte'; export { default as CardGrid } from './CardGrid.svelte'; export { default as Button } from './Button.svelte'; export { default as Accordion } from './Accordion.svelte'; +export { default as AccordionItem } from './AccordionItem.svelte'; export { default as Chat } from './Chat.svelte'; export { default as AuthShell } from './AuthShell.svelte'; +export { default as NoticeDialog } from './NoticeDialog.svelte'; +export { default as PricingPlans } from './PricingPlans.svelte'; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index ee67950..7140c70 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -32,3 +32,19 @@ export function formatHumanDate(dateStr: string | undefined): string { const sameYear = d.getFullYear() === new Date().getFullYear(); return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`; } + +// Add months to a date (UTC), handling month overflow correctly. +export function addMonthsUTC(date: Date, months: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, date.getUTCDate())); + return d; +} + +// Short human date: "5 Aug" / "5 Aug 26" +export function formatShortDate(dateStr: string | Date | undefined): string { + if (!dateStr) return ''; + const d = dateStr instanceof Date ? dateStr : new Date(dateStr); + if (Number.isNaN(d.getTime())) return ''; + const mon = d.toLocaleDateString('en-GB', { month: 'short' }); + const sameYear = d.getFullYear() === new Date().getFullYear(); + return `${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`; +} diff --git a/frontend/src/lib/server/access.ts b/frontend/src/lib/server/access.ts new file mode 100644 index 0000000..b31606f --- /dev/null +++ b/frontend/src/lib/server/access.ts @@ -0,0 +1,101 @@ +import { pbAdmin } from '$lib/server/pocketbase'; + +// How a family has access. 'none' = signed up with no code/sub yet (gated). +export type PaymentMode = 'none' | 'code' | 'sub' | 'canceled'; + +export interface FamAccess { + disabled: boolean; + mode: PaymentMode; + reason: '' | 'no_access' | 'canceled' | 'subscription_inactive' | 'code_disabled' | 'code_expired'; +} + +// UTC timestamp `months` months after `iso`. Used for the code's global expiry +// (from the code's createdAt) and the duration clock (from entry date). +export function addMonthsUTC(iso: string | Date, months: number): number { + const d = new Date(iso); + d.setUTCMonth(d.getUTCMonth() + months); + return d.getTime(); +} + +// A code is usable iff it exists, is not globally disabled, is not past its own +// createdAt+expiry window (expiry 0 = never), and the duration clock from the +// fam's entry date hasn't run out (duration 0 = continuous). +export function codeIsValid(code: any, enteredAt?: string): boolean { + if (!code) return false; + if (code.active === false) return false; + const now = Date.now(); + const expiryMonths = Number(code.expiry) || 0; + if (expiryMonths > 0 && now >= addMonthsUTC(code.createdAt, expiryMonths)) return false; + const durationMonths = Number(code.duration) || 0; + if (durationMonths > 0 && enteredAt) { + if (now >= addMonthsUTC(enteredAt, durationMonths)) return false; + } + return true; +} + +// Deterministic decision from a fam + its linked code (no I/O). `active` on the +// fam is the single source of truth for "usable right now". +export function computeFamAccess(fam: any, code: any): FamAccess { + const mode: PaymentMode = fam.paymentMode || 'none'; + switch (mode) { + case 'code': { + if (code?.active === false) return { disabled: true, mode, reason: 'code_disabled' }; + return codeIsValid(code, fam.accessCodeEnteredAt) + ? { disabled: false, mode, reason: '' } + : { disabled: true, mode, reason: 'code_expired' }; + } + case 'sub': + return fam.active === false + ? { disabled: true, mode, reason: 'subscription_inactive' } + : { disabled: false, mode, reason: '' }; + case 'canceled': + return { disabled: true, mode, reason: 'canceled' }; + case 'none': + default: + return { disabled: true, mode, reason: 'no_access' }; + } +} + +// Read the fam + linked code and persist `active` if it drifted. Called on every +// [fam] layout load (both roles) and wherever access state must be re-evaluated. +export async function ensureFamAccess(famId: string): Promise<{ fam: any; access: FamAccess }> { + const fam = await pbAdmin.getOne('fams', famId); + if (!fam) return { fam: null, access: { disabled: true, mode: 'none', reason: 'no_access' } }; + let code: any = null; + if (fam.accessCodeId) { + try { + code = await pbAdmin.getOne('accesscodes', fam.accessCodeId); + } catch { + code = null; + } + } + const access = computeFamAccess(fam, code); + if (fam.active !== !access.disabled) { + await pbAdmin.update('fams', famId, { active: !access.disabled }); + } + return { fam: { ...fam, active: !access.disabled }, access }; +} + +// Apply an access code value to a fam. Automatic (no approval) — validates +// against the accesscodes collection and sets paymentMode='code' on success. +export async function applyAccessCode(famId: string, value: string) { + const val = String(value || '').trim(); + if (!val) return { error: 'Enter an access code' }; + const list = await pbAdmin.getList('accesscodes', `value = '${val}'`); + const code = list?.[0]; + if (!code) return { error: 'That access code is not recognised' }; + if (code.active === false) return { error: 'That access code is disabled' }; + const now = new Date().toISOString(); + const valid = codeIsValid(code, now); + await pbAdmin.update('fams', famId, { + paymentMode: 'code', + accessCodeId: code.id, + accessCodeEnteredAt: now, + active: valid + }); + return { + ok: true, + active: valid, + code: { name: code.name, value: code.value, duration: code.duration, expiry: code.expiry } + }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index d44f79f..a714756 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,4 +1,5 @@ import { redirect } from '@sveltejs/kit'; +import { clearLegacyCookies } from '$lib/server/session'; import type { RequestEvent } from '@sveltejs/kit'; import { pbAdmin } from '$lib/server/pocketbase'; @@ -17,7 +18,7 @@ export function requireAuth(event: RequestEvent) { export function clearSession(event: RequestEvent) { event.cookies.delete('session', { path: '/' }); event.cookies.delete('pb_token', { path: '/' }); - event.cookies.delete('device_token', { path: '/' }); + clearLegacyCookies(event.cookies); } // Resolve the fam slug + admin display name used for the post-login redirect. diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 6b25dd9..4ae8943 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -154,6 +154,122 @@ async function ensureOtp(ids: Record): Promise { }); } +// Platform access codes — the codes that enable access to the platform. They're +// global (not fam-scoped) and managed via the platform admin page (superuser +// only), so all rules are null like `otp`. A code grants a family a subscription +// for `duration` months (0 = continuous); `expiry` is months-after-createdAt +// (0 = never expires); `active` is a failsafe toggle. Entered at create-family +// and in admin settings. +async function ensureAccessCodes(): Promise { + if (await getCollection("accesscodes")) { + await ensureAccessCodeFields(); + await seedAccessCodes(); + return; + } + await createCollection({ + name: "accesscodes", + type: "base", + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: "value", type: "text", required: true, unique: true }, + { name: "name", type: "text", required: true }, + { name: "duration", type: "number", required: false }, + { name: "expiry", type: "number", required: false }, + { name: "active", type: "bool", required: false }, + // When set (>0) this code is a TRIAL code: maps to Stripe + // trial_period_days at checkout instead of platform access. + { name: "trialDays", type: "number", required: false }, + { name: "createdAt", type: "date", required: false }, + ], + }); + await seedAccessCodes(); +} + +// Idempotent field-add for installs where accesscodes predates a field. +async function ensureAccessCodeFields(): Promise { + const col = await getCollection("accesscodes"); + if (!col) return; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + if (!has("trialDays")) { + await updateCollection(col.id, { + ...col, + fields: [...col.fields, { name: "trialDays", type: "number", required: false }], + }); + } +} + +// Idempotent seeds — developer code + an example trial code. +async function seedAccessCodes(): Promise { + const t = await auth(); + const seeds = [ + { value: "dev123", name: "developer", duration: 0, expiry: 0, active: true, trialDays: null }, + { value: "FAM3MONTHS", name: "3-month trial", duration: null, expiry: null, active: true, trialDays: 90 }, + ]; + for (const seed of seeds) { + const res = await fetch( + `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`, + { headers: { Authorization: `Bearer ${t}` } }, + ); + const data = await res.json(); + if (data?.items?.length) continue; + const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }), + }); + const c = await created.json(); + if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`); + console.log(` ✓ Seeded access code: ${seed.name} (${seed.value})`); + } +} + +// Platform settings — a single global record holding the platform feature +// flags (replaces the per-fam fams.featureFlags). Publicly readable (empty +// list/view rules) so every client can deduce flags on app load; writes stay +// superuser-only (null rules), so like otp/accesscodes this lives in +// migrate.ts rather than SCHEMA_PLAN. +async function ensurePlatform(): Promise { + if (!(await getCollection("platform"))) { + await createCollection({ + name: "platform", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: "label", type: "text", required: true }, + { name: "flags", type: "json", required: false }, + ], + }); + } + await seedPlatform(); +} + +// Idempotent seed — create the singleton 'global' settings record if missing. +async function seedPlatform(): Promise { + const t = await auth(); + const res = await fetch( + `${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, + { headers: { Authorization: `Bearer ${t}` } }, + ); + const data = await res.json(); + if (data?.items?.length) return; + const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ label: "global", flags: { debug: false } }), + }); + const c = await created.json(); + if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); + console.log(" ✓ Seeded platform settings (global)"); +} + // Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if // `fams` already exists (data is disposable; there is no incremental migration // history). @@ -177,8 +293,38 @@ async function ensureSchema(): Promise { console.log("[migrate] Schema bootstrapped."); } +// Add the access-gating fields to `fams` on installs where it already exists +// (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing +// fields. +async function ensureFamFields(): Promise { + const famsCol = await getCollection("fams"); + if (!famsCol) return; + const has = (n: string) => famsCol.fields.some((f: any) => f.name === n); + const needed: any[] = []; + if (!has("active")) { + needed.push({ name: "active", type: "bool", required: false }); + } + if (!has("paymentMode")) { + needed.push({ name: "paymentMode", type: "select", required: false, values: ["none", "code", "sub", "canceled"], maxSelect: 1 }); + } + if (!has("accessCodeId")) { + needed.push({ name: "accessCodeId", type: "text", required: false }); + } + if (!has("accessCodeEnteredAt")) { + needed.push({ name: "accessCodeEnteredAt", type: "date", required: false }); + } + if (needed.length) { + await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] }); + } +} + export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); await ensureSchema(); + // Runs even when the schema already exists (unlike ensureSchema's early + // return) so new platform collections/fields/seed land on existing installs. + await ensureFamFields(); + await ensureAccessCodes(); + await ensurePlatform(); console.log("[migrate] Done"); } \ No newline at end of file diff --git a/frontend/src/lib/server/platform.ts b/frontend/src/lib/server/platform.ts new file mode 100644 index 0000000..81f0a5f --- /dev/null +++ b/frontend/src/lib/server/platform.ts @@ -0,0 +1,36 @@ +import { pbAdmin } from '$lib/server/pocketbase'; + +// Platform-level feature flags, stored on the singleton `platform` record +// (label='global'). Replaces the deprecated per-fam fams.featureFlags. +export type PlatformFlags = Record; + +let cache: { flags: PlatformFlags; at: number } | null = null; +const TTL_MS = 10_000; + +async function findGlobal(): Promise { + const recs = (await pbAdmin.getList('platform', `label = 'global'`)) as any[]; + return recs[0] || null; +} + +// Public read used by loads. Cached briefly so per-request layout loads don't +// hammer PB; flag changes propagate within the TTL. +export async function getPlatformFlags(): Promise { + if (cache && Date.now() - cache.at < TTL_MS) return cache.flags; + try { + const rec = await findGlobal(); + cache = { flags: rec?.flags || {}, at: Date.now() }; + } catch { + if (!cache) cache = { flags: {}, at: Date.now() }; + } + return cache.flags; +} + +// Superuser write (platform admin dashboard / server-side only). +export async function setPlatformFlag(key: string, value: boolean): Promise { + const rec = await findGlobal(); + if (!rec) throw new Error('platform settings record missing'); + const flags: PlatformFlags = { ...(rec.flags || {}), [key]: value }; + await pbAdmin.update('platform', rec.id, { flags }); + cache = { flags, at: Date.now() }; + return flags; +} diff --git a/frontend/src/lib/server/session.ts b/frontend/src/lib/server/session.ts index 71d5008..bc6f7d5 100644 --- a/frontend/src/lib/server/session.ts +++ b/frontend/src/lib/server/session.ts @@ -21,4 +21,12 @@ export function setSessionCookie(cookies: Cookies, token: string) { export function clearSessionCookie(cookies: Cookies) { cookies.delete(SESSION_COOKIE, { path: '/' }); +} + +// Legacy pre-PB-auth child cookie. No longer issued anywhere; these deletes +// exist only to scrub it from browsers that still carry one. +const LEGACY_DEVICE_COOKIE = 'device_token'; + +export function clearLegacyCookies(cookies: Cookies) { + cookies.delete(LEGACY_DEVICE_COOKIE, { path: '/' }); } \ No newline at end of file diff --git a/frontend/src/lib/server/stripe-events.ts b/frontend/src/lib/server/stripe-events.ts index 2eef47e..91632bd 100644 --- a/frontend/src/lib/server/stripe-events.ts +++ b/frontend/src/lib/server/stripe-events.ts @@ -1,9 +1,8 @@ import { pbAdmin } from '$lib/server/pocketbase'; import type Stripe from 'stripe'; -// Shared Stripe event handling. Both the real webhook (/account/webhook) and -// the dev-only simulator (/account/webhook/simulate) route through here so the -// DB effects are identical. +// Shared Stripe event handling. The real webhook (/api/webhooks/stripe) routes +// through here so the DB effects are identical. export async function handleStripeEvent(event: Stripe.Event): Promise { switch (event.type) { case 'checkout.session.completed': { @@ -12,19 +11,28 @@ export async function handleStripeEvent(event: Stripe.Event): Promise { if (famId && session.customer) { await pbAdmin.update('fams', famId, { stripeCustomerId: String(session.customer), - active: true + active: true, + paymentMode: 'sub' }); } break; } case 'customer.subscription.created': case 'customer.subscription.updated': - case 'customer.subscription.deleted': case 'customer.subscription.paused': { const sub = event.data.object as Stripe.Subscription; await setActiveFromSubscription(sub); break; } + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription; + const fams = await pbAdmin.getList('fams', `stripeCustomerId = '${sub.customer}'`); + const fam = fams[0]; + if (fam) { + await pbAdmin.update('fams', fam.id, { active: false, paymentMode: 'canceled' }); + } + break; + } } } @@ -37,5 +45,5 @@ async function setActiveFromSubscription(sub: Stripe.Subscription) { // Active only while the sub is trialing/active (not past_due/canceled/paused). const active = sub.status === 'trialing' || sub.status === 'active' || sub.status === 'past_due'; - await pbAdmin.update('fams', fam.id, { active }); + await pbAdmin.update('fams', fam.id, { active, paymentMode: 'sub' }); } \ No newline at end of file diff --git a/frontend/src/lib/server/stripe.ts b/frontend/src/lib/server/stripe.ts index 8c3c6ae..94fbb25 100644 --- a/frontend/src/lib/server/stripe.ts +++ b/frontend/src/lib/server/stripe.ts @@ -28,17 +28,18 @@ export const PLAN_IDS: Record<'trial' | 'monthly' | 'yearly', string> = { yearly: String(STRIPE_PRICE_YEARLY) || 'price_dummy_yearly' }; -// Trial codes (app-side). In practice these should live in a PB collection; -// for the dummy flow a static map is enough. Maps code -> trial days. -export const TRIAL_CODES: Record = { - FAM3MONTHS: 90, - FAMTRIAL: 30 -}; - -export function resolveTrialDays(code?: string): number | null { +// Trial codes live in PB (`accesscodes` with trialDays > 0, active) so the +// platform admin can manage them. A matching active code maps to Stripe +// trial_period_days at checkout; anything else is not a trial code. +export async function resolveTrialDays(code?: string): Promise { if (!code) return null; - const days = TRIAL_CODES[code.trim().toUpperCase()]; - return typeof days === 'number' ? days : null; + const { pbAdmin } = await import('$lib/server/pocketbase'); + const recs = (await pbAdmin.getList( + 'accesscodes', + `value = '${code.trim().toUpperCase()}' && active = true` + )) as any[]; + const days = Number(recs?.[0]?.trialDays) || 0; + return days > 0 ? days : null; } export function verifyStripeEvent(rawBody: string, signature: string): Stripe.Event { @@ -68,7 +69,7 @@ export async function createCheckoutSession(opts: { mode: 'subscription', metadata: { famId: opts.famId, plan: opts.plan }, success_url: `${opts.origin}/account?checkout=success`, - cancel_url: `${opts.origin}/subscriptions?checkout=cancelled` + cancel_url: `${opts.origin}/pricing?checkout=cancelled` }; // Attach customer if we already have a Stripe customer id for this fam. @@ -93,10 +94,10 @@ export async function createCheckoutSession(opts: { } // Stripe Billing portal session for managing/cancelling the subscription. -export async function createBillingPortalSession(customerId: string, origin: string) { +export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') { return stripe.billingPortal.sessions.create({ customer: customerId, - return_url: `${origin}/account` + return_url: `${origin}/${famSlug}?checkout=return` }); } @@ -108,6 +109,7 @@ export async function createEmbeddedCheckoutSession(opts: { plan: PlanId | 'price' | 'trial'; priceId?: string; famId: string; + famSlug: string; email?: string | null; customerId?: string | null; trialDays?: number | null; @@ -124,7 +126,7 @@ export async function createEmbeddedCheckoutSession(opts: { mode: 'subscription', ui_mode: 'embedded_page', metadata: { famId: opts.famId, plan: opts.plan }, - return_url: `${opts.origin}/account?checkout=return` + return_url: `${opts.origin}/${opts.famSlug}?checkout=return` }; if (opts.customerId) { diff --git a/frontend/src/lib/stores/notices.svelte.ts b/frontend/src/lib/stores/notices.svelte.ts new file mode 100644 index 0000000..f66d177 --- /dev/null +++ b/frontend/src/lib/stores/notices.svelte.ts @@ -0,0 +1,54 @@ +// App-wide notices — Svelte 5 rune store (.svelte.ts). Rendered globally by +// in the root layout. +export type NoticeType = 'info' | 'success' | 'warning' | 'error'; + +export interface Notice { + id: string; + type: NoticeType; + title: string; + message?: string; + action?: { label: string; href: string }; + dismissible: boolean; +} + +let idCounter = 0; + +class NoticeStore { + list = $state([]); + + add(notice: Omit): string { + const id = `notice-${Date.now()}-${idCounter++}`; + this.list.push({ ...notice, id }); + return id; + } + + remove(id: string) { + this.list = this.list.filter((n) => n.id !== id); + } + + clear() { + this.list = []; + } + + success(title: string, message?: string, action?: Notice['action']) { + return this.add({ type: 'success', title, message, action, dismissible: true }); + } + info(title: string, message?: string, action?: Notice['action']) { + return this.add({ type: 'info', title, message, action, dismissible: true }); + } + warning(title: string, message?: string, action?: Notice['action']) { + return this.add({ type: 'warning', title, message, action, dismissible: true }); + } + error(title: string, message?: string, action?: Notice['action']) { + return this.add({ type: 'error', title, message, action, dismissible: true }); + } +} + +export const notices = new NoticeStore(); + +// Fire-and-forget helper — auto-dismisses after `duration` ms. +export function addAutoDismissNotice(notice: Omit, duration = 5000): string { + const id = notices.add({ ...notice, dismissible: true }); + setTimeout(() => notices.remove(id), duration); + return id; +} \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 02ddd51..adc0dfe 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -41,7 +41,6 @@ export interface Fam { name: string; slug: string; stripeCustomerId?: string; - featureFlags: Record; payday?: number; paydayTime?: string; timezone?: string; diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..3908002 --- /dev/null +++ b/frontend/src/routes/+layout.server.ts @@ -0,0 +1,8 @@ +import type { LayoutServerLoad } from './$types'; +import { getPlatformFlags } from '$lib/server/platform'; + +// Platform settings are public (read-only): feature flags ride along with +// every page's data so any component can deduce them via page.data.platformFlags. +export const load: LayoutServerLoad = async () => { + return { platformFlags: await getPlatformFlags() }; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0d8eb03..ab623b2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,9 +1,11 @@ {@render children()} + diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 865fc05..c1728cf 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -1,5 +1,6 @@ -import { pbAdmin, createPbClient } from '$lib/server/pocketbase'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices, type ChatActor } from '$lib/server/services'; +import { ensureFamAccess } from '$lib/server/access'; async function paydayCheck(famId: string, pbToken: string) { try { @@ -48,19 +49,30 @@ export async function load(event) { let famId = ''; let chat: { famId: string; actor: ChatActor } | null = null; + let fam: any = null; + let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' }; if (session && pbToken) { famId = session.famId; await paydayCheck(famId, pbToken); chat = await resolveChatIdentity(session, pbToken); + // fams is superadmin-only (non-realtime). Fetched server-side for both + // roles; also recomputes + persists the derived `active` flag. + const res = await ensureFamAccess(famId).catch(() => null); + if (res) { + fam = res.fam; + famAccess = res.access; + } } return { + // Canonical fam slug — from the URL param ([fam] routes). Client code + // reads page.data.famSlug; never copy it into local $state. + famSlug: event.params.fam || '', session: session ? { famId: session.famId, userId: session.id, - famSlug: event.params.fam, memberName: session.name, memberColor: session.color || '', role: session.role @@ -71,9 +83,7 @@ export async function load(event) { famId, chat, pbToken, - // fams is superadmin-only (non-realtime). Fetched server-side for both roles. - fam: famId - ? await pbAdmin.getOne('fams', famId).catch(() => null) - : null + fam, + famAccess }; } \ No newline at end of file diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 34ed100..d71b011 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -1,9 +1,11 @@ - - -

- This is the name shown to your family. The address stays at - /{famSlug} even if you rename it — links you've shared keep - working. -

-
- - - -
- {#if fam?.slug} -

- Family page: /{fam.slug} -

- {/if} -
- - -
-
-

Add a child. They'll pick their own colour after joining.

-
- - - + + + + + +

+ This is the name shown to your family. The address stays at + /{famSlug} even if you rename it — links you've shared keep + working. +

+ + + + -
- -
    - {#each members as m} -
  • - - - - {m.name} - /{famSlug}/{handleOf(m.username)} - - - - -
    - - -
    -
    -
  • - {/each} -
-
-
- - -
{ - return async ({ result }) => { - if (result.type === 'error') { - alert(result.error || 'Failed to update payday'); - } - // One-way: no invalidation — local state is already correct, - // and the PB subscription handles cross-device sync. - }; - }} - class="payday-form" - > -
- - - - -
-
- - -
- -
-

- Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone - follows each device. -

-
- - -
{ - return async ({ formData, result }) => { - if (result.type === 'success' && result.data?.ok) { - showQR = false; - qrDataUrl = ''; - issued = { - otp: result.data.otp, - joinUrl: result.data.joinUrl, - name: String(formData.get('name') || '') - }; - } else if (result.type === 'success' && result.data?.error) { - alert(result.data.error); - } - }; - }} - class="invite-form" - > - - - -
-

- Generates a 6-digit code valid for 20 minutes. The child enters it at the join link. -

- - {#if issued?.otp} -
-

- Code for {issued.name} (valid 20 min): -

-

- {issued.otp} -

-

{invitePath}

-
- - -
- {#if showQR && qrDataUrl} -
- QR Code -
+ {#if fam?.slug} +

+ Family page: /{fam.slug} +

{/if} -
- {/if} -
+ - -

Send an email invitation for another parent to join as an admin.

-
- - - -
-

They will set up their own password on first login.

-
- - -

Group chores into seasons. Toggle seasons on/off from the top nav.

- -
- - -
- - -
- -
- -
    - {#each data.seasons as s} -
  • - - {s.name} - -
  • - {/each} -
-
- - - {#if deletingSeason} -
(deletingSeason = null)} role="presentation"> - - {/if} + {/if} + - -

- {data.fam?.active === false - ? 'Your subscription is paused — interactions and payments are disabled.' - : 'Manage your plan, billing details, or pause your subscription.'} -

-
- - -
-
+ +

Send an email invitation for another parent to join as an admin.

+
+ + + +
+

They will set up their own password on first login.

+
+ + - -
- - -
-
- - {#if data.fam?.featureFlags?.debugMode} - -

Debug mode is enabled. These tools are for development and testing only.

-
-
{ + + + + + {#if hasCode} +

+ Access active via access code + {#if data.accessCode?.duration} + — {formatCountdown(codeEntryDate, data.accessCode.duration)} + (expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))}) + {:else} + — never expires + {/if}. +

+ { + return async ({ result, update }) => { + if (result.type === 'success') accessMsg = 'Access code revoked.'; + else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.'; + await update(); + }; + }} + > + + + {:else} +

No access applied. Enter a valid access code to enable your family.

+
{ return async ({ result, update }) => { - if (result.type === 'success') alert('Week completed!'); + if (result.type === 'success' && result.data) { + const d = result.data as { error?: string; ok?: boolean }; + accessMsg = d.error || 'Code applied — access enabled.'; + accessCodeInput = ''; + } await update(); }; - }} - > - -
-
{ - return async ({ result, update }) => { - if (result.type === 'success') alert('Test data generated!'); - await update(); - }; - }} - > - - -
-
-
- {/if} - + }}> + + + + {/if} + {#if accessMsg} +

{accessMsg}

+ {/if} + + +

+ {fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' : + fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` : + fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'} +

+ + {#if fam?.paymentMode === 'sub'} +
+ +
{ + return async ({ result, update }) => { + if (result.type === 'success' && (result.data as any)?.portalUrl) { + window.open((result.data as any).portalUrl, '_blank'); + } else if (result.type === 'failure') { + accessMsg = (result.data as any)?.error || 'Failed to open billing portal'; + } + await update(); + }; + }}> + +
+
+

+ To pause your plan, cancel in the billing portal — you keep your data and can resubscribe + anytime. Cancelling takes effect at period end. +

+ {:else if fam?.paymentMode === 'code'} +
+ +
+ {:else} +
+ +
+ {/if} +
+ + + + + + {#if page.data.platformFlags?.debug} + +

Debug mode is enabled. These tools are for development and testing only.

+
+
{ + return async ({ result, update }) => { + if (result.type === 'success') alert('Week completed!'); + await update(); + }; + }}> + +
+
{ + return async ({ result, update }) => { + if (result.type === 'success') alert('Test data generated!'); + await update(); + }; + }}> + + +
+
+
+ {/if} + +
+ + +
+
+
+
+ + + + + diff --git a/frontend/src/routes/[famSlug]/join/[username]/+page.server.ts b/frontend/src/routes/[fam]/join/[username]/+page.server.ts similarity index 65% rename from frontend/src/routes/[famSlug]/join/[username]/+page.server.ts rename to frontend/src/routes/[fam]/join/[username]/+page.server.ts index f05325e..aba40fd 100644 --- a/frontend/src/routes/[famSlug]/join/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/join/[username]/+page.server.ts @@ -1,10 +1,10 @@ import { fail, redirect } from '@sveltejs/kit'; import { redeemOtp } from '$lib/server/member-otp'; -import { setSessionCookie } from '$lib/server/session'; +import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; export const actions = { default: async (event) => { - const famSlug = event.params.famSlug; + const fam = event.params.fam; const username = event.params.username; const fd = await event.request.formData(); const otp = (fd.get('otp') || '').toString().trim(); @@ -12,13 +12,13 @@ export const actions = { if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' }); try { - const token = await redeemOtp({ famSlug, username, otp }); - event.cookies.delete('device_token', { path: '/' }); + const token = await redeemOtp({ famSlug: fam, username, otp }); + clearLegacyCookies(event.cookies); setSessionCookie(event.cookies, token); } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Join failed' }); } - throw redirect(303, `/${famSlug}/${encodeURIComponent(username)}`); + throw redirect(303, `/${fam}/${encodeURIComponent(username)}`); } }; \ No newline at end of file diff --git a/frontend/src/routes/[famSlug]/join/[username]/+page.svelte b/frontend/src/routes/[fam]/join/[username]/+page.svelte similarity index 97% rename from frontend/src/routes/[famSlug]/join/[username]/+page.svelte rename to frontend/src/routes/[fam]/join/[username]/+page.svelte index 27dc511..98e4728 100644 --- a/frontend/src/routes/[famSlug]/join/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/join/[username]/+page.svelte @@ -3,7 +3,7 @@ import { enhance } from '$app/forms'; import { Button } from '$lib/components'; - const famSlug = page.params.famSlug; + const famSlug = page.params.fam; const username = page.params.username; let otp = $state(page.url.searchParams.get('code') || ''); let { form } = $props(); diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts index 20411ae..886a6c9 100644 --- a/frontend/src/routes/admin/+page.server.ts +++ b/frontend/src/routes/admin/+page.server.ts @@ -1,16 +1,17 @@ import { pbAdmin } from '$lib/server/pocketbase'; import { redirect, fail } from '@sveltejs/kit'; import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; +import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ cookies }) => { const session = cookies.get('platform_session'); if (!session) { - return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 }; + return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} }; } try { - const fams = await pbAdmin.getList('fams'); + const [fams, platformFlags] = await Promise.all([pbAdmin.getList('fams'), getPlatformFlags()]); const famsWithStats = await Promise.all(fams.map(async (fam: any) => { const [members, rewards, parents] = await Promise.all([ pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`), @@ -23,7 +24,6 @@ export const load: PageServerLoad = async ({ cookies }) => { requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length, totalRewards: rewards.length, parentEmail: (parents as any[])?.[0]?.email || '', - featureFlags: fam.featureFlags || {}, }; })); @@ -31,9 +31,9 @@ export const load: PageServerLoad = async ({ cookies }) => { const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0); const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0); - return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards }; + return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards, platformFlags }; } catch { - return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 }; + return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} }; } }; @@ -60,19 +60,18 @@ export const actions: Actions = { throw redirect(303, '/admin'); }, - toggleFeatureFlag: async ({ request, cookies }) => { + // Toggles a platform-level feature flag on the singleton platform record. + togglePlatformFlag: async ({ request, cookies }) => { const session = cookies.get('platform_session'); if (!session) return fail(401, { error: 'Not authenticated' }); const fd = await request.formData(); - const famId = fd.get('famId') as string; const flag = fd.get('flag') as string; + if (!flag) return fail(400, { error: 'Flag required' }); try { - const fam = await pbAdmin.getOne('fams', famId); - const flags = fam.featureFlags || {}; - flags[flag] = !flags[flag]; - await pbAdmin.update('fams', famId, { featureFlags: flags }); + const flags = await getPlatformFlags(); + await setPlatformFlag(flag, !flags[flag]); return { success: true }; } catch (e) { return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' }); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 9276ded..b80d734 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -49,6 +49,19 @@
+ +

Global feature flags (public read-only for fams; toggled here).

+
+ debug +
+ + +
+
+
+ @@ -57,7 +70,6 @@ - @@ -65,7 +77,7 @@ {#each data.fams as fam} @@ -78,20 +90,7 @@ {/if} - @@ -211,6 +210,18 @@ background: #059669; color: white; } + .flag-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0; + } + .flag-name { + font-family: monospace; + font-size: 0.85rem; + color: #374151; + } .link { color: #2563eb; text-decoration: none; diff --git a/frontend/src/routes/api/webhooks/stripe/+server.ts b/frontend/src/routes/api/webhooks/stripe/+server.ts new file mode 100644 index 0000000..8603d6f --- /dev/null +++ b/frontend/src/routes/api/webhooks/stripe/+server.ts @@ -0,0 +1,30 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { verifyStripeEvent, isDummyStripe } from '$lib/server/stripe'; +import { handleStripeEvent } from '$lib/server/stripe-events'; + +// Stripe webhook: updates fams.stripeCustomerId + fams.active + fams.paymentMode +// from subscription lifecycle events. Lives under /api/webhooks/stripe per the +// architecture — machine-to-machine endpoints live in /api/*, not under UI routes. +export const POST: RequestHandler = async ({ request }) => { + const rawBody = await request.text(); + const signature = request.headers.get('stripe-signature'); + + // Dummy mode: no webhook secret configured — accept the event without + // verification so the flow is testable before the account is connected. + if (isDummyStripe || !signature) { + return json({ received: true, dummy: true }); + } + + let event; + try { + event = verifyStripeEvent(rawBody, signature); + } catch (e) { + return json( + { error: e instanceof Error ? e.message : 'Webhook signature verification failed' }, + { status: 400 } + ); + } + + await handleStripeEvent(event); + return json({ received: true }); +}; \ No newline at end of file diff --git a/frontend/src/routes/logout/+page.server.ts b/frontend/src/routes/logout/+page.server.ts index d21f3d9..e5cf63e 100644 --- a/frontend/src/routes/logout/+page.server.ts +++ b/frontend/src/routes/logout/+page.server.ts @@ -1,10 +1,10 @@ import { redirect } from '@sveltejs/kit'; -import { clearSessionCookie } from '$lib/server/session'; +import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session'; function signOut(event: { cookies: any }) { clearSessionCookie(event.cookies); event.cookies.delete('session', { path: '/' }); - event.cookies.delete('device_token', { path: '/' }); + clearLegacyCookies(event.cookies); } export function load(event) { diff --git a/frontend/src/routes/pricing/+page.server.ts b/frontend/src/routes/pricing/+page.server.ts new file mode 100644 index 0000000..34af3bf --- /dev/null +++ b/frontend/src/routes/pricing/+page.server.ts @@ -0,0 +1,70 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { pbAdmin } from '$lib/server/pocketbase'; +import { createEmbeddedCheckoutSession, resolveTrialDays, PLAN_IDS } from '$lib/server/stripe'; +import type { PlanId } from '$lib/server/stripe'; + +function famOf(event: RequestEvent) { + if (!event.locals.user) throw redirect(303, '/login'); + return event.locals.user.famId; +} + +export const load: PageServerLoad = async (event) => { + const authenticated = !!event.locals.user; + let famSlug: string | null = null; + if (authenticated) { + const fam = await pbAdmin.getOne('fams', event.locals.user!.famId).catch(() => null); + famSlug = fam?.slug || null; + } + return { + authenticated, + famSlug, + plans: { monthly: PLAN_IDS.monthly, yearly: PLAN_IDS.yearly } + }; +}; + +export const actions: Actions = { + choose: async (event) => { + const fd = await event.request.formData(); + const plan = fd.get('plan') as PlanId; + const code = (fd.get('code') as string) || ''; + + if (!['trial', 'monthly', 'yearly'].includes(plan)) { + return fail(400, { error: 'Unknown plan' }); + } + + // Trial requires a valid app-side code (which maps to trial days). + let trialDays: number | null = null; + if (plan === 'trial') { + trialDays = await resolveTrialDays(code); + if (!trialDays) return fail(400, { error: 'Invalid trial code' }); + } + + // Not logged in → redirect to signup with plan preselected. + if (!event.locals.user) { + throw redirect(303, `/signup?plan=${plan}`); + } + + const famId = famOf(event); + const fam = await pbAdmin.getOne('fams', famId); + const parents = await pbAdmin.getList('users', `famId = '${famId}' && role = 'parent'`); + const email = (parents[0] as { email?: string } | undefined)?.email || null; + + try { + const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({ + plan, + famId, + famSlug: fam.slug, + email, + customerId: fam.stripeCustomerId || null, + trialDays, + origin: event.url.origin + }); + return { success: true, clientSecret, sessionId, plan }; + } catch (e) { + if (e instanceof redirect) throw e; + return fail(500, { error: e instanceof Error ? e.message : 'Failed to start checkout' }); + } + } +}; \ No newline at end of file diff --git a/frontend/src/routes/pricing/+page.svelte b/frontend/src/routes/pricing/+page.svelte new file mode 100644 index 0000000..6b8b2b9 --- /dev/null +++ b/frontend/src/routes/pricing/+page.svelte @@ -0,0 +1,99 @@ + + + + +{#if !showCheckout} + +{:else} + + +
+ +
+
+

+ You can close and go to your dashboard any time — access unlocks once payment completes. +

+ Go to dashboard +
+
+{/if} + + \ No newline at end of file diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts index d039af5..2a01dd9 100644 --- a/frontend/src/routes/signup/+page.server.ts +++ b/frontend/src/routes/signup/+page.server.ts @@ -5,6 +5,9 @@ import { createPbClient } from '$lib/server/pocketbase'; import { setSessionCookie } from '$lib/server/session'; import { issueAccess } from '$lib/server/member-otp'; import { slugify, handle, famUsername, handleOf } from '@shared/slugify'; +import { applyAccessCode } from '$lib/server/access'; +import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe'; +import type { PlanId } from '$lib/server/stripe'; class SignupError extends Error {} @@ -32,11 +35,14 @@ export const actions = { const username = famUsername(slug, handleName); try { - const fam = await pbAdmin.create('fams', { + const famData: Record = { name: famName, slug, - timezone: 'auto' - }); + timezone: 'auto', + paymentMode: 'none', + active: false + }; + const fam = await pbAdmin.create('fams', famData); const user = await pbAdmin.create('users', { username, name: parentName, @@ -61,7 +67,6 @@ export const actions = { .catch(() => null); if (authResult?.token) setSessionCookie(event.cookies, authResult.token); - // `username` here is the handle (URL segment), not the composite. return { success: true, famSlug: slug, username: handleName }; }, @@ -71,11 +76,14 @@ export const actions = { const fd = await event.request.formData(); const name = ((fd.get('member') as string) || '').trim(); + // No [fam] URL param here (signup isn't fam-scoped) — resolve the slug + // from DB. Everywhere else, the slug comes from event.params.fam / + // page.data.famSlug ([fam] layout load) — never copy it into state. const fam = await pbAdmin.getOne('fams', user.famId); const famSlug = fam?.slug || user.famId; if (!name) { - return { success: true, famSlug, username: handleOf(user.username) }; + return { success: true, famSlug, username: handleOf(user.username || '') }; } const { otp, joinUrl } = await issueAccess({ @@ -84,11 +92,57 @@ export const actions = { name }); - return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) }; + return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username || '') }; + }, + + // Step 3 — apply an access code (or skip via client-side navigation). + access: async (event: RequestEvent) => { + const user = requireUser(event); + const fd = await event.request.formData(); + const code = ((fd.get('code') as string) || '').trim(); + + if (!code) { + return { ok: true, skipped: true }; + } + + const result = await applyAccessCode(user.famId, code); + if (result.error) return fail(400, { error: result.error }); + + return { ok: true, ...result }; + }, + + // Step 4 — choose a plan, start embedded checkout. + choose: async (event: RequestEvent) => { + const user = requireUser(event); + const fd = await event.request.formData(); + const plan = fd.get('plan') as PlanId; + + if (!['monthly', 'yearly'].includes(plan)) { + return fail(400, { error: 'Unknown plan' }); + } + + const fam = await pbAdmin.getOne('fams', user.famId); + const parents = await pbAdmin.getList('users', `famId = '${user.famId}' && role = 'parent'`); + const email = (parents[0] as { email?: string } | undefined)?.email || null; + + try { + const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({ + plan, + famId: user.famId, + famSlug: fam.slug, + email, + customerId: fam.stripeCustomerId || null, + origin: event.url.origin + }); + return { success: true, clientSecret, sessionId, plan }; + } catch (e) { + if (e instanceof redirect) throw e; + return fail(500, { error: e instanceof Error ? e.message : 'Failed to start checkout' }); + } } }; function requireUser(event: RequestEvent) { if (!event.locals.user) throw redirect(303, '/signup'); return event.locals.user; -} +} \ No newline at end of file diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte index 51f61d0..76894b5 100644 --- a/frontend/src/routes/signup/+page.svelte +++ b/frontend/src/routes/signup/+page.svelte @@ -1,40 +1,116 @@ + {#if form?.message}

{form.message}

{/if} @@ -42,7 +118,8 @@

{localError}

{/if} - {#if step === 1} + + {#if step === 'fam'}
@@ -201,28 +306,20 @@ text-align: center; } .alt a { color: #4338ca; text-decoration: none; font-weight: 500; } - .code { - background: #eef2ff; - border: 1px dashed #a5b4fc; - border-radius: 10px; - padding: 1rem; - text-align: center; - margin: 0 0 0.75rem; - } - .code-text { - font-family: ui-monospace, monospace; - font-size: 1.6rem; - letter-spacing: 0.35em; - font-weight: 700; + .skip-link { + background: none; + border: none; color: #4338ca; + font-weight: 500; + font-size: 0.85rem; + cursor: pointer; + padding: 0; } - .inline-code { - font-family: ui-monospace, monospace; - font-size: 0.85em; - background: #f3f4f6; - border-radius: 4px; - padding: 0.1em 0.35em; - color: #374151; + .skip-link:hover { text-decoration: underline; } + .access-msg { + margin-top: 0.5rem; + font-size: 0.85rem; + color: #059669; } .actions { margin-top: 1.25rem; diff --git a/package.json b/package.json index f42a0ff..0b21306 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "pnpm --filter frontend dev", "start": "pnpm dev", - "build": "pnpm --filter frontend build" + "build": "pnpm --filter frontend build", + "stripe:listen": "stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/api/webhooks/stripe" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index f966322..91e8662 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -5,10 +5,12 @@ // Relations reference collections by name; the `ids` map maps collection // name -> runtime id (filled as each collection is created). // -// NOTE: the native `users` auth collection and the superuser-only `otp` -// collection are NOT in SCHEMA_PLAN — they're applied separately in -// migrate.ts (users is PB's built-in auth model; `otp` needs null rules, -// which the `col()` builder can't express). Everything else lives here. +// NOTE: the native `users` auth collection and the superuser-only `otp` + +// `accesscodes` collections are NOT in SCHEMA_PLAN — they're applied separately +// in migrate.ts (users is PB's built-in auth model; `otp`/`accesscodes` need +// null rules, which the `col()` builder can't express). The public-read +// `platform` settings collection is also applied there (needs null write +// rules). Everything else lives here. export interface FieldDef { name: string; @@ -133,11 +135,19 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ uniqueText("slug"), text("stripeCustomerId"), bool("active"), - jsonField("featureFlags"), number("payday"), text("lastIssued"), text("paydayTime"), text("timezone"), + // Access gating. paymentMode: how this fam has access — a code, a + // Stripe subscription, canceled, or none (no access). `active` is the + // derived "usable right now" flag recomputed by the access check on + // every layout load (and by the Stripe webhook for subs). `accessCodeId` + // + `accessCodeEnteredAt` back the 'code' mode (the duration clock + // starts at entry; global expiry is the code createdAt + expiry months). + select("paymentMode", ["none", "code", "sub", "canceled"]), + text("accessCodeId"), + date("accessCodeEnteredAt"), ], { listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM }, )(ids), diff --git a/stripe-next b/stripe-next new file mode 120000 index 0000000..cb099ef --- /dev/null +++ b/stripe-next @@ -0,0 +1 @@ +/home/threejjjs/development/stripe-next \ No newline at end of file
Parent Members ClaimsDebug Actions
- {fam.name} + {fam.name} /{fam.slug} {fam.parentEmail || '—'} -
- - - -
-
- Dashboard + Dashboard View