Files
famdone/ARCHITECTURE.md
T
2026-08-22 19:49:46 +01:00

20 KiB

FamChore v2 — Architecture & Developer Reference

Overview

Multi-tenant chore tracking SaaS. Families ("fams") are isolated tenant groups. Admins (parents) authenticate via email/password; members (children) join via invite OTP and get a server-derived password. No members collection — everyone is a users row scoped by famId.

Source of truth: AGENTS.md is the living reference. This doc captures the architecture.


1. Stack

Component Role Deploy Port
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 (SvelteKit server routes) — —

Deployment Topology

chores.app.com ────┬──► nginx (:3001)
                    │      ├── /*    ──► SvelteKit (:2080)
                    │      └── /api/* ──► SvelteKit (:2080)
                    │
pb.chores.app.com ──► PocketBase (:8090)
                     │      Admin UI at /_
                     │      Volume: /pb_data (persistence + backups)
                     │
stripe.com ─────────► SvelteKit /api/webhooks/stripe

2. Auth Model

2.1 Roles & Methods

Role Auth Session PB Entity
Admin (parent) PB email + password 24hr JWT pb_token cookie users (role parent)
Member (child) Invite OTP + server-derived password httpOnly pb_token cookie users (role child)
Superuser PB _superusers (server-side only) — _superusers

2.2 Details

  • Admins are users (role parent). Login via email/password → httpOnly pb_token cookie { id, name, username, role: "parent", famId, color }.
  • Members are users (role child); PB username = {famSlug}:{handle} (globally-unique; handle = whitespace-free lowercase name), URL segment = handleOf(username), name = display name. Password is derived server-side (MEMBER_SECRET + famSlug + handle). Join gated by a 20-min OTP in otp, then authWithPassword. Same httpOnly pb_token cookie. No members collection.
  • Superuser used only server-side by pb-admin.ts for cross-family queries (/admin) and OTP/signup writes.
  • Layout [fam]/+layout.server.ts derives isParent/role centrally from the session. pb_token is httpOnly, so the browser PB SDK is seeded from page.data.pbToken via initPb(token) in onMount.

2.3 PB Auth Rules

Every tenant-scoped collection has famId and enforces famId = @request.auth.famId; family-scoped collections have public listRule/viewRule so reads/subscribes work for both roles. Superuser bypasses via admin API.


3. Data Model (PB collections, all scoped by famId)

  • 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)
  • 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
  • weekly_history — famId, userId, weekStart, pointsEarned, moneyEarned
  • rewards — famId, userId, source, label, value, claimed, claimedAt, claimable, settleDate
  • 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 + 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.


4. Routes

SvelteKit

/                            Landing page (SaaS marketing)
/admin                       Platform super-admin stats dashboard
/login · /logout             Parent login / logout
/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
/{fam}/{username}/ledger     Rewards / chores / todos ledger
/{fam}/{username}/bonuses    Bonus configs & evaluation
/{fam}/{username}/preferences  User preferences
/{fam}/{username}/settings   Family admin settings (parent only) — Stripe connect/manage + pause
/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)

5. Data Flow

5.1 Reads (both roles)

  • famStore.init() fetches all collections via PB SDK, authenticated via the seeded pb_token. .subscribe() works for both roles.
  • TopNav season pills read from famStore.seasons (reactive).

5.2 Writes

  • 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 /pricing + settings Billing actions + /api/webhooks/stripe.
  • WhatsApp: not implemented.

5.3 Stripe Subscription (embedded Checkout)

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

Webhook events (/api/webhooks/stripe) update fams.stripeCustomerId, fams.active, fams.paymentMode from subscription lifecycle.

5.3.1 Payments architecture

┌──────────────────────────────────────────  SVELTEKIT APP  ──────────────────────────────────────────┐
│                                                                                                      │
│  Browser                                                                                             │
│  ┌──────────────────────────────┐   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_page)   │            │
│  └──────────────────────────────┘                        └──────────────┬──────────────┘            │
│                                                                         │ webhook events              │
│                                                                         ▼                            │
│  ┌──────────────────────────────────────────────────────────────────────────────────────────────┐   │
│  │  /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 + paymentMode='sub'       │   │
│  │    └ customer.subscription.*     → fams.active + paymentMode (sync by customer id)          │   │
│  │    └ customer.subscription.deleted → active=false + paymentMode='canceled'                  │   │
│  └──────────────────────────────────────────────────────┬─────────────────────────────────────┘   │
│                                                         │ pbAdmin (superuser)                     │
└─────────────────────────────────────────────────────────┼─────────────────────────────────────────┘
                                                          ▼
                                                ┌────────────────────┐
                                                │   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:
  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:
  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 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

  • Svelte $state / $derived / $effect.
  • PB SDK .subscribe() for realtime multi-user sync (public reads → both roles).
  • famStore.applyRecord() for instant optimistic feedback.
  • Rule: if data should update via SSE, use $derived (not $state — frozen snapshot). Only high-frequency member actions use $state + optimistic applyRecord.

6. Project Structure

/ (root)
  /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          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 /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
  AGENTS.md                  AI reference
  ARCHITECTURE.md            This document

7. Environment Variables

Env vars declared in frontend/src/env.ts via defineEnvVars (@sveltejs/kit/hooks). Only { public: true } are exposed client-side via $app/env/public; server-only via $app/env/private.

  • FRONTEND_PORT, PROXY_PORT, PB_PORT
  • PB_EMAIL, PB_PASSWORD (PB superuser, server-only)
  • DEBUG_RECORD_ID
  • STRIPE_SECRET_KEY (server-only)
  • DONATION_MODAL_INTERVAL
  • SERVER_IP (public, dev)

Values come from root .env (symlinked at frontend/.env -> ../.env). .env.example is the committed template. Ports also tracked in root config.ts.


8. Key Conventions

  • famId on every query — PB auth rules enforce famId = @request.auth.famId; superuser bypasses.
  • 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.
  • UI shell — [fam]/+layout.svelte (Sidebar, TopNav, Footer); ViewHeader + CardGrid/Card micro-layout; Button for CTAs; icons as SVG strings in lib/components/icons.ts.
  • Reactivity — never $effect to sync local state from famStore; use $derived for SSE-reactive data; $state + applyRecord only for optimistic member toggles.
  • Dev servers — never start your own; reuse running proxy (192.168.1.225:3456) + frontend (localhost:2080).

9. Open / Deferred

  • WhatsApp notifications — NotificationService plugin for the weekly CRON handler. Deferred.
  • 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.