Files
hackerspace.sh/app/(app)/financials/page.tsx
T
Moheeb Zara ea8d7a5f91 feat: governance kernel, deployment hardening, local-dev stack
Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.

Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".

Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.

Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.

Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.

Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.

Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.

Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
2026-05-14 04:52:11 -07:00

195 lines
7.6 KiB
TypeScript

import { redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import type { FinancialVisibility, Payment, Space } from '@/lib/types'
export const dynamic = 'force-dynamic'
const DOLLAR = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
function isVisible(viz: FinancialVisibility, role: string | null): boolean {
if (viz === 'all_members_visible') return true
if (viz === 'board_visible') return role === 'admin' || role === 'board' || role === 'treasurer'
// treasurer_only
return role === 'admin' || role === 'board' || role === 'treasurer'
}
export default async function FinancialsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('space_id, role')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.maybeSingle()
if (!member?.space_id) redirect('/signup')
const { data: spaceRaw } = await supabase
.from('spaces')
.select('id, name, financial_visibility')
.eq('id', member.space_id)
.maybeSingle()
const space = spaceRaw as unknown as Pick<Space, 'id' | 'name'> & { financial_visibility: FinancialVisibility } | null
if (!space) {
return <PageShell title="Financials"><p className="font-sans text-sm">Space not found.</p></PageShell>
}
if (!isVisible(space.financial_visibility, member.role)) {
return (
<PageShell title="Financials">
<div className="bg-card rounded border border-border p-6 text-center">
<p className="font-sans text-sm text-foreground mb-2">Financial data is not visible to your role.</p>
<p className="font-mono text-[10px] text-muted-foreground">
Visibility is currently set to <strong>{space.financial_visibility.replace(/_/g, ' ')}</strong>.
Ask an admin to change it from Settings if your space wants broader transparency.
</p>
</div>
</PageShell>
)
}
const { data: paymentsRaw } = await supabase
.from('payments')
.select('*')
.eq('space_id', member.space_id)
.order('transaction_date', { ascending: false })
const payments = (paymentsRaw ?? []) as unknown as Payment[]
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const startOfYear = new Date(now.getFullYear(), 0, 1)
let lifetime = 0
let thisYear = 0
let thisMonth = 0
let linked = 0
let unlinked = 0
const byPlatform: Record<string, number> = {}
const byMonth: Record<string, number> = {}
for (const p of payments) {
const amount = Number(p.amount ?? 0)
lifetime += amount
const tx = p.transaction_date ? new Date(p.transaction_date) : null
if (tx) {
if (tx >= startOfYear) thisYear += amount
if (tx >= startOfMonth) thisMonth += amount
const key = `${tx.getFullYear()}-${String(tx.getMonth() + 1).padStart(2, '0')}`
byMonth[key] = (byMonth[key] ?? 0) + amount
}
if (p.link_status === 'linked') linked++
else unlinked++
const platform = String(p.platform ?? 'unknown')
byPlatform[platform] = (byPlatform[platform] ?? 0) + amount
}
const monthlyRows = Object.entries(byMonth)
.sort(([a], [b]) => b.localeCompare(a))
.slice(0, 12)
const platformRows = Object.entries(byPlatform).sort(([, a], [, b]) => b - a)
const maxPlatform = Math.max(1, ...platformRows.map(([, v]) => v))
return (
<PageShell title={`Financials — ${space.name}`}>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
Visibility: {space.financial_visibility.replace(/_/g, ' ')}
{member.role === 'admin' && (
<>
{' · '}
<Link href="/settings" className="text-primary hover:underline normal-case tracking-normal">
change in Settings
</Link>
</>
)}
</p>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
<StatCard label="This Month" value={DOLLAR.format(thisMonth)} sub="received" />
<StatCard label="This Year" value={DOLLAR.format(thisYear)} sub={`${now.getFullYear()}`} />
<StatCard label="Lifetime" value={DOLLAR.format(lifetime)} sub="all payments" />
<StatCard
label="Reconciliation"
value={`${linked}/${linked + unlinked}`}
sub={`${unlinked} unlinked`}
warn={unlinked > 0}
/>
</div>
<section className="bg-card rounded border border-border p-5">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
By platform
</p>
{platformRows.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">No payments recorded yet.</p>
) : (
<ul className="space-y-2">
{platformRows.map(([platform, total]) => {
const pct = Math.round((total / maxPlatform) * 100)
return (
<li key={platform}>
<div className="flex items-center justify-between mb-1">
<span className="font-sans text-sm capitalize text-foreground">{platform}</span>
<span className="font-mono text-xs text-muted-foreground">{DOLLAR.format(total)}</span>
</div>
<div className="h-1.5 bg-border rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${pct}%` }} />
</div>
</li>
)
})}
</ul>
)}
</section>
<section className="bg-card rounded border border-border p-5">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
Last 12 months
</p>
{monthlyRows.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">No monthly breakdown yet.</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{monthlyRows.map(([month, total]) => {
const [y, m] = month.split('-')
const label = new Date(Number(y), Number(m) - 1, 1).toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
return (
<div key={month} className="flex items-center justify-between bg-background rounded border border-border px-3 py-2">
<span className="font-sans text-sm text-foreground">{label}</span>
<span className="font-mono text-xs text-muted-foreground">{DOLLAR.format(total)}</span>
</div>
)
})}
</div>
)}
</section>
</PageShell>
)
}
function PageShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3">
<h1 className="text-white font-sans text-lg font-semibold">{title}</h1>
</div>
<div className="p-4 md:p-6 space-y-6 max-w-4xl">{children}</div>
</div>
)
}
function StatCard({ label, value, sub, warn }: { label: string; value: string; sub: string; warn?: boolean }) {
return (
<div className="bg-card rounded border border-border p-4 md:p-5">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">{label}</p>
<p className={`text-2xl font-sans font-bold ${warn ? 'text-orange-500' : 'text-primary'}`}>{value}</p>
<p className="font-sans text-xs text-muted-foreground mt-1">{sub}</p>
</div>
)
}