// store.jsx — Haven live data store (Supabase-native). // Single source of truth. Auth via Supabase; every domain read goes through // `Db` (RLS-gated select) and every mutation through `Rpc` (SECURITY DEFINER // functions in the `haven` schema) — see config.jsx. No more PIN, no more // localStorage document, no more NAS. // // Money: the backend stores CENTS; this store converts to DOLLARS on load // (and back on write) so screens/flows — written for the old dollar-based // shape — don't need to change for this pass. const centsToDollars = (c) => (Number(c) || 0) / 100; const dollarsToCents = (d) => Math.round((parseFloat(d) || 0) * 100); const CAT_STYLE = { core: { color: 'var(--core)', soft: 'var(--core-soft)' }, capacity: { color: 'var(--capacity)', soft: 'var(--capacity-soft)' }, capital: { color: 'var(--capital)', soft: 'var(--capital-soft)' }, }; // ── shape raw haven.get_state() JSON into the legacy dollar-based doc shape ── function shapeBudget(cats) { return (cats || []).map((c) => { const style = CAT_STYLE[c.key] || {}; const subs = (c.subs || []).map((s) => { const budget = centsToDollars(s.budget_cents), spent = centsToDollars(s.spent_cents); return { id: s.id, name: s.name, budget, spent, remaining: budget - spent, pct: budget ? spent / budget : 0 }; }); const budget = centsToDollars(c.budget_cents), spent = centsToDollars(c.spent_cents); return { key: c.key, name: c.name, plain: c.plain, ...style, budget, spent, remaining: budget - spent, pct: budget ? spent / budget : 0, subs }; }); } const shapeProvider = (p) => ({ id: p.id, name: p.name, short: p.short_name, cat: p.cat_key, sub: p.sub_name, rate: p.rate_text, agreementEnds: p.agreement_ends, agreementFile: p.agreement_file }); const shapeWorker = (w) => ({ id: w.id, name: w.name, role: w.role, rate: centsToDollars(w.rate_cents), initials: w.initials, cat: w.cat_key }); const shapeTimesheet = (t) => ({ id: t.id, worker: t.worker_id, date: t.work_date, hours: Number(t.hours), note: t.note, status: t.status }); const shapeClaim = (c) => ({ id: c.id, provider: c.provider, cat: c.cat_key, sub: c.sub_name, date: c.service_date, amount: centsToDollars(c.amount_cents), status: c.status, submitted: c.submitted_date, paid: c.paid_date, invoice: c.invoice_ref, reason: c.reason }); const shapeInvoice = (i) => ({ id: i.id, ref: i.ref, provider: i.provider, cat: i.cat_key, date: i.doc_date, amount: centsToDollars(i.amount_cents), kind: i.kind, claimed: i.claimed_claim_id, file: i.file }); const shapeGoal = (g) => ({ id: g.id, text: g.text, supports: g.supports, progress: Number(g.progress), note: g.note }); const shapeActivity = (a) => ({ id: a.id, date: a.event_date, text: a.text, amount: a.amount_cents != null ? centsToDollars(a.amount_cents) : null, type: a.type }); function shapeDoc(raw) { const review = raw.review || {}; return { participantId: raw.participantId, child: raw.child || {}, planPeriod: raw.planPeriod || {}, planDoc: review.planDoc || null, // legacy stores this at doc top-level too budget: shapeBudget(raw.budget), providers: (raw.providers || []).map(shapeProvider), workers: (raw.workers || []).map(shapeWorker), timesheets: (raw.timesheets || []).map(shapeTimesheet), claims: (raw.claims || []).map(shapeClaim), invoices: (raw.invoices || []).map(shapeInvoice), goals: (raw.goals || []).map(shapeGoal), activity: (raw.activity || []).map(shapeActivity), review: { k7: !!review.k7, planDoc: review.planDoc || null, notes: review.notes || { worked: '', change: '' } }, }; } function planOf(cats) { const total = cats.reduce((s, c) => s + c.budget, 0); const spent = cats.reduce((s, c) => s + c.spent, 0); return { total, spent, remaining: total - spent, pct: total ? spent / total : 0 }; } function syncPlanGlobals(doc) { const ps = new Date(doc.planPeriod.start || Date.now()), pe = new Date(doc.planPeriod.end || Date.now()); const total = Math.max(1, daysBetween(ps, pe)); const elapsed = Math.min(Math.max(0, daysBetween(ps, TODAY)), total); Object.assign(window, { PLAN_START: ps, PLAN_END: pe, PLAN_TOTAL_DAYS: total, PLAN_DAYS_ELAPSED: elapsed, PLAN_DAYS_LEFT: Math.max(0, daysBetween(TODAY, pe)), PLAN_TIME_PCT: Math.min(1, Math.max(0, elapsed / total)), }); } const LS_LAST_EMAIL = 'haven_last_email'; // UX nicety only — never used for auth const Store = { doc: null, // loading | need-auth | need-onboarding | synced | error status: 'loading', session: null, orgId: null, participantId: null, entitlements: null, // null = not loaded yet; {} = loaded, fail-closed _listeners: new Set(), async init() { Supa.auth.onAuthStateChange((_event, session) => { this._onAuthChange(session); }); const { data: { session } } = await Supa.auth.getSession(); await this._onAuthChange(session); return this; }, async _onAuthChange(session) { const hadUser = this.session && this.session.user && this.session.user.id; const nowUser = session && session.user && session.user.id; this.session = session; if (!session) { this.doc = null; this.orgId = null; this.participantId = null; this._setStatus('need-auth'); return; } if (hadUser === nowUser && this.doc) return; // token refresh etc. — no need to reload everything await this._loadEverything(); }, async _loadEverything() { try { this._setStatus('loading'); const { data: prof, error: profErr } = await Db.from('profiles').select('org_id').eq('id', this.session.user.id).maybeSingle(); if (profErr) throw profErr; if (!prof || !prof.org_id) { this.lastError = 'No profile/org was found for your account (id ' + this.session.user.id + '). This usually means sign-up didn\'t finish setting it up.'; this._setStatus('error'); return; } this.orgId = prof.org_id; const { data: par, error: parErr } = await Db.from('participants').select('id').eq('org_id', this.orgId).maybeSingle(); if (parErr) throw parErr; if (!par) { this._setStatus('need-onboarding'); return; } this.participantId = par.id; await this._loadState(); await this._loadEntitlements(); this._setStatus('synced'); } catch (e) { console.error('Haven: failed to load', e); this.lastError = (e && e.message) || String(e); this._setStatus('error'); } }, async _loadState() { const raw = await Rpc('get_state', { p_participant_id: this.participantId }); this.doc = shapeDoc(raw); syncPlanGlobals(this.doc); this.notify(); }, async _refresh() { await this._loadState(); }, async _loadEntitlements() { try { const r = await fetch(`${HAVEN_FUNCTIONS_BASE}/haven-check-features`, { headers: { Authorization: `Bearer ${this.session.access_token}` }, }); this.entitlements = r.ok ? await r.json() : {}; } catch (e) { this.entitlements = {}; } // fail closed this.notify(); }, hasFeature(key) { if (!this.entitlements) return false; const v = this.entitlements[key] ?? this.entitlements[`haven.${key}`]; return v === true || (typeof v === 'string' && v !== '' && v !== 'false') || (typeof v === 'number' && v > 0); }, subscribe(fn) { this._listeners.add(fn); return () => this._listeners.delete(fn); }, notify() { this._listeners.forEach((fn) => fn()); }, _setStatus(s) { this.status = s; this.notify(); }, get plan() { return this.doc ? planOf(this.doc.budget) : { total: 0, spent: 0, remaining: 0, pct: 0 }; }, get reminders() { if (!this.doc) return []; const out = []; const waiting = this.doc.invoices.filter((i) => !i.claimed); if (waiting.length) { const sum = waiting.reduce((s, i) => s + i.amount, 0); out.push({ id: 'r-claim', tone: 'amber', icon: 'receipt', title: `${waiting.length} invoice${waiting.length > 1 ? 's' : ''} ready to claim`, body: `${fmtMoney(sum, 2)} waiting to go to the NDIS. They'll usually land in your account within 2–3 days.`, cta: 'Review & claim', to: 'invoices' }); } const pend = this.doc.timesheets.filter((t) => t.status === 'pending'); if (pend.length) { const w = (id) => this.doc.workers.find((x) => x.id === id) || { rate: 0 }; const hrs = pend.reduce((s, t) => s + t.hours, 0); const amt = pend.reduce((s, t) => s + t.hours * w(t.worker).rate, 0); out.push({ id: 'r-pay', tone: 'amber', icon: 'people', title: `${pend.length} timesheet${pend.length > 1 ? 's' : ''} need approving`, body: `${pend.length} timesheet${pend.length > 1 ? 's' : ''} · ${hrs} hours · about ${fmtMoney(amt)} before pay run.`, cta: 'Open pay run', to: 'workers' }); } const soon = this.doc.providers .filter((p) => p.agreementEnds) .map((p) => ({ p, d: daysBetween(TODAY, new Date(p.agreementEnds)) })) .filter((x) => x.d <= 21).sort((a, b) => a.d - b.d)[0]; if (soon) { out.push({ id: 'r-agree', tone: 'alert', icon: 'doc', title: `${soon.p.short} agreement ends in ${soon.d} days`, body: `Renew it before ${fmtDateShort(soon.p.agreementEnds)} so claims keep flowing.`, cta: 'View agreement', to: 'providers' }); } out.push({ id: 'r-review', tone: 'primary', icon: 'spark', title: `Plan review in ${PLAN_DAYS_LEFT} days`, body: 'A little prep now saves a scramble later — your records are already gathered.', cta: 'Open review prep', to: 'review' }); return out; }, // ── auth ── async signIn(email, password) { const { error } = await Supa.auth.signInWithPassword({ email, password }); if (error) throw new Error(error.message); try { localStorage.setItem(LS_LAST_EMAIL, email); } catch (e) {} }, async signUp(email, password) { const r = await fetch(`${HAVEN_FUNCTIONS_BASE}/haven-auto-register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const j = await r.json().catch(() => ({})); if (!r.ok || !j.ok) { const msg = { not_subscriber: "We couldn't find an active Haven subscription for that email.", account_exists: 'An account already exists for that email — try signing in instead.', }[j.error] || j.error || 'Could not create your account.'; throw new Error(msg); } await this.signIn(email, password); }, async signOut() { await Supa.auth.signOut(); }, async createParticipant({ name, ndis, parent, planStart, planEnd }) { await Rpc('create_participant', { p_name: name, p_ndis_number: ndis || null, p_parent_label: parent || 'Mum', p_plan_start: planStart || null, p_plan_end: planEnd || null, }); await this._loadEverything(); }, // ── claims ── async addClaim({ provider, cat, sub, date, amount, invoice, invoiceId, file }) { const r = await Rpc('add_claim', { p_participant_id: this.participantId, p_provider: provider, p_cat: cat, p_sub: sub, p_date: date || null, p_amount_cents: dollarsToCents(amount), p_invoice: invoice || null, p_invoice_id: invoiceId || null, p_file: file || null, }); await this._refresh(); return r && r.id; }, async setClaimStatus(id, status) { await Rpc('set_claim_status', { p_claim_id: id, p_status: status }); await this._refresh(); }, async updateClaim(id, patch) { const p = { ...patch }; if ('amount' in p) { p.amount_cents = dollarsToCents(p.amount); delete p.amount; } await Rpc('update_claim', { p_claim_id: id, p_patch: p }); await this._refresh(); }, async deleteClaim(id) { await Rpc('delete_claim', { p_claim_id: id }); await this._refresh(); }, // ── invoice vault ── async addInvoice(inv) { const body = { ...inv }; if ('amount' in body) { body.amount_cents = dollarsToCents(body.amount); delete body.amount; } const r = await Rpc('add_invoice', { p_participant_id: this.participantId, p_invoice: body }); await this._refresh(); return r && r.id; }, async deleteInvoice(id) { await Rpc('delete_invoice', { p_invoice_id: id }); await this._refresh(); }, // ── support workers, timesheets, pay run ── async addTimesheet({ worker, date, hours, note }) { await Rpc('add_timesheet', { p_worker_id: worker, p_date: date, p_hours: parseFloat(hours) || 0, p_note: note || '' }); await this._refresh(); }, async approveTimesheet(id) { await Rpc('approve_timesheet', { p_timesheet_id: id }); await this._refresh(); }, async payApproved() { const r = await Rpc('pay_approved', { p_participant_id: this.participantId }); await this._refresh(); return centsToDollars(r && r.total_cents); }, // ── plan review prep ── async setReviewNote(key, v) { await Rpc('set_review_note', { p_participant_id: this.participantId, p_key: key, p_value: v }); await this._refresh(); }, async setReviewK7(v) { await Rpc('set_review_k7', { p_participant_id: this.participantId, p_value: !!v }); await this._refresh(); }, async setPlanDoc(file) { await Rpc('set_plan_doc', { p_participant_id: this.participantId, p_file: file }); await this._refresh(); }, // ── settings ── async setChild(patch) { await Rpc('set_child', { p_participant_id: this.participantId, p_patch: patch }); await this._refresh(); }, async setPlanPeriod(patch) { await Rpc('set_plan_period', { p_participant_id: this.participantId, p_patch: patch }); await this._refresh(); }, // budget sub CRUD — legacy callers pass (catKey, index); resolve to the real sub id here async subAdd(catKey) { await Rpc('sub_add', { p_participant_id: this.participantId, p_cat_key: catKey }); await this._refresh(); }, async subPatch(catKey, i, patch) { const cat = this.doc.budget.find((c) => c.key === catKey); const sub = cat && cat.subs[i]; if (!sub) return; const p = { ...patch }; if ('budget' in p) { p.budget_cents = dollarsToCents(p.budget); delete p.budget; } if ('spent' in p) { p.spent_cents = dollarsToCents(p.spent); delete p.spent; } await Rpc('sub_patch', { p_sub_id: sub.id, p_patch: p }); await this._refresh(); }, async subRemove(catKey, i) { const cat = this.doc.budget.find((c) => c.key === catKey); const sub = cat && cat.subs[i]; if (!sub) return; await Rpc('sub_remove', { p_sub_id: sub.id }); await this._refresh(); }, // generic list dispatch (providers/workers/goals) — routes to the specific RPC per table async listAdd(key, item) { const fn = { providers: 'add_provider', workers: 'add_worker', goals: 'add_goal' }[key]; if (!fn) throw new Error(`listAdd: unknown list "${key}"`); const body = key === 'workers' && 'rate' in item ? { ...item, rate_cents: dollarsToCents(item.rate), rate: undefined } : item; const r = await Rpc(fn, { p_participant_id: this.participantId, p_item: body }); await this._refresh(); return r && r.id; }, async listPatch(key, id, patch) { const fn = { providers: 'patch_provider', workers: 'patch_worker', goals: 'patch_goal' }[key]; if (!fn) throw new Error(`listPatch: unknown list "${key}"`); const body = key === 'workers' && 'rate' in patch ? { ...patch, rate_cents: dollarsToCents(patch.rate), rate: undefined } : patch; await Rpc(fn, { p_id: id, p_patch: body }); await this._refresh(); }, async listRemove(key, id) { const fn = { providers: 'remove_provider', workers: 'remove_worker', goals: 'remove_goal' }[key]; if (!fn) throw new Error(`listRemove: unknown list "${key}"`); await Rpc(fn, { p_id: id }); await this._refresh(); }, // ── export (read-only) ── downloadBackup() { if (!this.doc) return; const blob = new Blob([JSON.stringify(this.doc, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'haven-backup-' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); }, // ── not yet available in the hosted version — see README "Known gaps" ── async uploadFile() { throw new Error("File uploads aren't available yet — coming soon."); }, restore() { throw new Error("Restoring a backup isn't available yet."); }, startFresh() { throw new Error('Not available in the hosted version.'); }, loadSample() { throw new Error('Not applicable to a live account.'); }, }; function useHaven() { const [, force] = React.useState(0); React.useEffect(() => Store.subscribe(() => force((n) => n + 1)), []); return Store; } Store.init(); Object.assign(window, { Store, useHaven });