0.2.39: bitcoin-denominated view, first-login flow, unfunded + tax center

- BTC prices: btc_prices table, CSV upload on Import page (auto-detected
  date/close columns, upsert by date), entities.close_date as the BTC entry
  mark; statements carry btc_price_cents (as-of) + btc_close_price_cents.
  LP capital blocks show paid-in vs current value in bitcoin terms.
- First login: accounts on the shared default password are flagged
  (must_change_password) and blocked behind a full-screen password change;
  external accounts then get a one-time welcome tour with a 2FA offer
  (users.onboarded_at).
- LP portal: Unfunded (callable commitment) metric; Tax documents center
  aggregating K-1/tax docs across funds, grouped by year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-12 13:01:50 +02:00
co-authored by Claude Fable 5
parent 0822eca887
commit eac3262f29
22 changed files with 823 additions and 12 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached
// Bump CACHE on each release so old entries are purged.
const CACHE = 'ten31-portal-0.2.38'
const CACHE = 'ten31-portal-0.2.39'
self.addEventListener('install', () => self.skipWaiting())
+5
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { isInternal } from "./api";
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
import Layout from "./components/Layout";
import Login from "./pages/Login";
import EntitiesList from "./pages/EntitiesList";
@@ -50,6 +51,7 @@ function ExternalApp() {
return (
<PortalLayout>
{user?.role === "fund_administrator" ? <FundAdminHome /> : <InvestorHome />}
{user && !user.onboarded_at && <WelcomeFlow />}
</PortalLayout>
);
}
@@ -87,6 +89,9 @@ function ProtectedRoutes() {
return offline ? <OfflineNotice onRetry={retry} /> : <Login />;
}
// Still on the shared default password → nothing else until they set their own.
if (user.must_change_password) return <ForcePasswordChange />;
return isInternal(user.role) ? <InternalApp /> : <ExternalApp />;
}
+32
View File
@@ -45,6 +45,8 @@ export interface User {
is_service_admin: boolean;
primary_account_id: number | null;
totp_enabled: boolean;
must_change_password: boolean;
onboarded_at: string | null;
created_at: string;
}
@@ -154,6 +156,18 @@ export interface BatchCapitalImportResult {
total_statements: number;
}
export interface BtcPricesStatus {
count: number;
first_date: string | null;
last_date: string | null;
latest_price_cents: number | null;
}
export interface BtcPricesImportResult extends BtcPricesStatus {
imported: number;
skipped_rows: number;
}
export interface CapitalAccount {
id: number;
entity_id: number;
@@ -168,6 +182,9 @@ export interface CapitalAccount {
document_id: number | null;
created_at: string;
exited_on: string | null;
// BTC/USD marks for the bitcoin-denominated view (null = no price data / no close date)
btc_price_cents: number | null;
btc_close_price_cents: number | null;
}
export interface Entity {
@@ -178,6 +195,7 @@ export interface Entity {
fund_size_cents: number | null;
status: EntityStatus;
linked_user_id: number | null;
close_date: string | null;
created_at: string;
}
@@ -514,6 +532,20 @@ export const api = {
if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed");
return data;
},
// BTC prices (bitcoin-denominated view)
btcPricesStatus: () => request<BtcPricesStatus>("/api/import/btc-prices"),
importBtcPrices: async (file: File): Promise<BtcPricesImportResult> => {
const form = new FormData();
form.set("file", file);
const res = await fetch("/api/import/btc-prices", { method: "POST", body: form });
const data = await res.json().catch(() => ({ detail: res.statusText }));
if (!res.ok) throw new ApiError(res.status, data.detail || "Price import failed");
return data;
},
markOnboarded: () =>
request<{ status: string }>("/api/auth/onboarded", { method: "POST" }),
getUser: (id: number) => request<UserDetail>(`/api/users/${id}`),
createUser: (data: {
name: string;
+130
View File
@@ -0,0 +1,130 @@
import { useState } from "react";
import { api } from "../api";
import { useAuth } from "../context/AuthContext";
import PasswordInput from "./PasswordInput";
import TwoFactorModal from "./TwoFactorModal";
/** Full-screen gate shown while the account is still on the shared default password.
* Nothing else is reachable until a personal password is set. */
export function ForcePasswordChange() {
const { user, retry, logout } = useAuth();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const save = async () => {
setError("");
if (next.length < 8) return setError("New password must be at least 8 characters.");
if (next !== confirm) return setError("New passwords don't match.");
setBusy(true);
try {
await api.changePassword(current, next);
retry(); // refreshes the user; must_change_password is now false
} catch (e: any) {
setError(e.message || "Could not change password");
setBusy(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-full max-w-md">
<div className="flex items-center gap-2.5 mb-4">
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
<h1 className="text-xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""}</h1>
</div>
<p className="text-sm text-gray-600 mb-5">
Your account is using a temporary password. Choose your own to continue only you
will know it.
</p>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-700 mb-1">Temporary password</label>
<PasswordInput value={current} onChange={setCurrent} autoComplete="current-password" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">New password</label>
<PasswordInput value={next} onChange={setNext} placeholder="minimum 8 characters" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
<PasswordInput value={confirm} onChange={setConfirm} />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
onClick={save}
disabled={busy || !current || !next}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Saving…" : "Set password and continue"}
</button>
<button
onClick={logout}
className="w-full text-xs text-gray-400 hover:text-gray-600"
>
Sign out
</button>
</div>
</div>
</div>
);
}
/** One-time welcome overlay for external accounts: a 20-second orientation plus the 2FA
* offer. Dismissing it (either way) stamps onboarded_at so it never shows again. */
export function WelcomeFlow() {
const [done, setDone] = useState(false);
const [enrolling, setEnrolling] = useState(false);
const finish = () => {
setDone(true);
api.markOnboarded().catch(() => {}); // best-effort; shows again next visit if it failed
};
if (done) return null;
if (enrolling) {
return <TwoFactorModal onClose={() => { setEnrolling(false); finish(); }} />;
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md p-6">
<div className="flex items-center gap-2.5 mb-3">
<img src="/ten31-logo.png" alt="" className="w-8 h-8 rounded-lg" />
<h3 className="text-lg font-semibold text-gray-900">Welcome to Ten31 Portal</h3>
</div>
<ul className="text-sm text-gray-600 space-y-2 mb-4">
<li>
<span className="font-medium text-gray-800">Your capital accounts</span> commitment,
paid-in, and current balance for each fund, updated as statements arrive.
</li>
<li>
<span className="font-medium text-gray-800">Documents</span> K-1s and fund documents
live here permanently; new ones are badged.
</li>
<li>
<span className="font-medium text-gray-800">Self-hosted by Ten31</span> your data
stays on our own infrastructure, never a third-party service.
</li>
</ul>
<p className="text-sm text-gray-600 mb-5">
One more thing worth doing: add two-factor authentication, so your account stays
safe even if your password is ever guessed.
</p>
<div className="flex justify-end gap-2">
<button onClick={finish} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
Maybe later
</button>
<button
onClick={() => setEnrolling(true)}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
>
Set up two-factor
</button>
</div>
</div>
</div>
);
}
+13
View File
@@ -234,6 +234,7 @@ function EditEntityForm({
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
);
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
const [closeDate, setCloseDate] = useState(entity.close_date ?? "");
const [investors, setInvestors] = useState<User[]>([]);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
@@ -265,6 +266,7 @@ function EditEntityForm({
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
: null,
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
close_date: closeDate || null,
};
const updated = await api.updateEntity(entity.id, data);
onSaved(updated);
@@ -312,6 +314,17 @@ function EditEntityForm({
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
</div>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
Close date (BTC entry mark paid-in capital is valued at the BTC price on this day)
</label>
<input
type="date"
className={inputCls}
value={closeDate}
onChange={(e) => setCloseDate(e.target.value)}
/>
</div>
{isGp && (
<div>
<label className="block text-sm text-gray-700 mb-1">
+78
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import {
api, isAdmin,
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
type BtcPricesStatus,
} from "../api";
import { useAuth } from "../context/AuthContext";
import { formatDate, formatMoneyExact } from "../format";
@@ -383,6 +384,8 @@ export default function Import() {
{!memberPv && !result && <BatchBackfill entities={entities} />}
{!memberPv && !result && <BtcPrices />}
{confirmReplace && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
@@ -542,3 +545,78 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
</div>
);
}
// BTC/USD price history for the bitcoin-denominated LP view: upload a CSV (date + close
// price); statements are valued at the newest price on or before their date, and paid-in
// at the price on each fund's close date (set on the fund's overview page).
function BtcPrices() {
const [status, setStatus] = useState<BtcPricesStatus | null>(null);
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
useEffect(() => {
api.btcPricesStatus().then(setStatus).catch(() => {});
}, []);
async function run() {
if (!file) return;
setError("");
setMsg("");
setBusy(true);
try {
const r = await api.importBtcPrices(file);
setStatus(r);
setMsg(
`Loaded ${r.imported} price${r.imported === 1 ? "" : "s"}` +
(r.skipped_rows ? ` (${r.skipped_rows} unreadable row${r.skipped_rows === 1 ? "" : "s"} skipped)` : "") +
".",
);
setFile(null);
} catch (e: any) {
setError(e.message || "Price import failed");
} finally {
setBusy(false);
}
}
return (
<div className="mt-10 border-t border-gray-200 pt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-1">Bitcoin prices</h2>
<p className="text-sm text-gray-500 mb-3">
Upload a CSV of BTC/USD prices (a date column and a close/price column). Investors
then see their capital in bitcoin terms; paid-in is valued at the price on each
fund's close date set that on the fund's overview page.
</p>
{status && status.count > 0 && (
<p className="text-xs text-gray-400 mb-3">
{status.count} prices on file
{status.first_date && status.last_date && (
<> · {formatDate(status.first_date)} {formatDate(status.last_date)}</>
)}
{status.latest_price_cents != null && (
<> · latest ${(status.latest_price_cents / 100).toLocaleString()}</>
)}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
accept=".csv"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="text-sm"
/>
<button
onClick={run}
disabled={busy || !file}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Loading…" : "Load prices"}
</button>
</div>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
{msg && <p className="text-sm text-green-700 mt-2">{msg}</p>}
</div>
);
}
+133
View File
@@ -5,6 +5,13 @@ import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
const pct = (p: number) => `${p >= 0 ? "+" : ""}${Math.abs(p).toFixed(1)}%`;
// Dollars-at-cents ÷ BTC-price-at-cents, formatted to a sensible precision for the size.
const formatBtc = (usdCents: number, priceCents: number) => {
const btc = usdCents / priceCents;
const dp = btc >= 10 ? 2 : btc >= 1 ? 3 : 4;
return `${btc.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })}`;
};
// The investor-facing portal, rendered purely from data. Used by the investor's own home
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
export default function InvestorPortalView({
@@ -52,6 +59,7 @@ export default function InvestorPortalView({
exitedCount={exitedPositions}
/>
)}
<TaxCenter docs={docs} entities={entities} />
{ordered.map((e) => (
<FundSection
key={e.id}
@@ -139,6 +147,83 @@ function PortfolioSummary({
);
}
// Every K-1 and tax document across the LP's funds, gathered in one place and grouped by
// year — so tax season is one stop instead of a hunt through fund cards.
function TaxCenter({ docs, entities }: { docs: PortalDocument[]; entities: Entity[] }) {
const [open, setOpen] = useState(false);
const taxDocs = useMemo(
() => docs.filter((d) => d.category === "k1" || d.category === "tax"),
[docs],
);
const entityName = useMemo(
() => new Map(entities.map((e) => [e.id, e.name])),
[entities],
);
const byYear = useMemo(() => {
const groups = new Map<number, PortalDocument[]>();
for (const d of taxDocs) {
const y = new Date(d.created_at).getFullYear();
const g = groups.get(y) ?? [];
g.push(d);
groups.set(y, g);
}
return [...groups.entries()].sort((a, b) => b[0] - a[0]);
}, [taxDocs]);
if (taxDocs.length === 0) return null;
const hasNew = taxDocs.some((d) => d.is_new);
return (
<section className="border border-gray-200 rounded-lg p-5 bg-white">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex items-center gap-2 w-full text-left"
>
<span className={`text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}></span>
<h2 className="text-base font-semibold text-gray-900">Tax documents</h2>
<span className="text-xs text-gray-400">
{taxDocs.length} document{taxDocs.length === 1 ? "" : "s"}
</span>
{hasNew && (
<span className="text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
New
</span>
)}
</button>
{open && (
<div className="mt-4 space-y-3">
{byYear.map(([year, ds]) => (
<div key={year}>
<p className="text-xs font-medium text-gray-400 mb-1">{year}</p>
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
{ds.map((d) => (
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
<span className="text-gray-900">{d.title}</span>
{d.is_new && (
<span className="ml-2 text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
New
</span>
)}
<span className="ml-2 text-xs text-gray-400">
{entityName.get(d.entity_id) ?? ""}
</span>
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
Download
</a>
</li>
))}
</ul>
</div>
))}
</div>
)}
</section>
);
}
function FundSection({
entity,
accounts,
@@ -302,6 +387,13 @@ function CapitalBlock({
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
{latest.commitment_cents > latest.contributions_cents && (
<Metric
label="Unfunded"
value={formatMoneyWhole(latest.commitment_cents - latest.contributions_cents)}
sub="callable commitment"
/>
)}
{latest.distributions_cents > 0 && (
<Metric
label="Distributions"
@@ -326,11 +418,52 @@ function CapitalBlock({
</p>
)}
<BtcTerms latest={latest} />
<HistorySection history={history} />
</div>
);
}
// The bitcoin-denominated view: paid-in capital valued at the BTC price on the fund's close
// (the entry mark set by the admin), current value at the newest statement's price. Hidden
// entirely until the admin has uploaded prices and set the fund's close date.
function BtcTerms({ latest }: { latest: CapitalAccount }) {
const closePrice = latest.btc_close_price_cents;
const asofPrice = latest.btc_price_cents;
if (!closePrice || !asofPrice || latest.contributions_cents <= 0) return null;
const paidInBtc = latest.contributions_cents / closePrice;
const totalNowBtc = (latest.ending_balance_cents + latest.distributions_cents) / asofPrice;
const btcPct = paidInBtc > 0 ? (totalNowBtc / paidInBtc - 1) * 100 : null;
return (
<div className="mt-4 border border-accent-100 bg-accent-50/50 rounded p-3">
<p className="text-xs font-medium uppercase text-gray-500">In bitcoin terms</p>
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 mt-1.5">
<span className="text-sm text-gray-600">
Paid-in{" "}
<span className="font-medium text-gray-900">
{formatBtc(latest.contributions_cents, closePrice)}
</span>{" "}
<span className="text-xs text-gray-400">at close</span>
</span>
<span className="text-sm text-gray-600">
Now{" "}
<span className="font-medium text-gray-900">
{formatBtc(latest.ending_balance_cents + latest.distributions_cents, asofPrice)}
</span>
</span>
{btcPct != null && (
<span className={`text-sm font-medium ${btcPct >= 0 ? "text-accent-600" : "text-red-600"}`}>
{pct(btcPct)}
</span>
)}
</div>
</div>
);
}
// Collapsible capital-over-time chart + statement table. Collapsed by default so the portal
// opens clean; the investor expands the trend per fund. SPVs get sparse event-driven points,
// funds quarterly ones — the chart plots whatever statements exist.
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI.
// If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served.
export const APP_VERSION = "0.2.38";
export const APP_VERSION = "0.2.39";