Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

13 files+756−650/13 viewed
+13−1
2222 first (at limit, declined, near limit, high spend, growing, established,
2323 first payment), with its owners, the reason in a sentence, the figure,
2424 and its sales stage and owner at g1t. Filter by why and by whose
25− (everyone's, unassigned, mine). Each row opens the workspace's Sales.
25+ (everyone's, unassigned, mine), or show only **follow-ups due** (a next
26+ step due today or earlier, on a deal not won or lost; one per
27+ workspace). Rows show the next step and its day. Each row opens the
28+ workspace's Sales.
29+- **Invoices** (`/invoices`): every invoice g1t has sent, workspaces' and
30+ enterprises', newest first (`admin_invoices`, at most 200). Filter by
31+ status and month; totals for what is listed (amount, paid, outstanding);
32+ each links to its workspace or enterprise and to Stripe's page (https
33+ only).
34+- **Audit log** (`/audit`): every change made in sudo, and what Stripe told
35+ billing, newest first, 100 a page with "Older" (`admin_audit`). Filter by
36+ staff email and kind of change; each line links to its workspace or
37+ enterprise.
2638 - **Workspaces** (`/workspaces`): every workspace, newest first, 50 to a
2739 page, with its owners, members, who it is billed to, its terms, this
2840 month's usage against its limit, what it was charged and what it cost
+3−14
1010 import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
1111
1212 import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
13+import { actionLabel } from "~/lib/ledgers";
1314 import { dollarsField, usd } from "~/lib/money";
1415 import type { Review, SectionError } from "~/lib/review";
1516
438439 );
439440 }
440441
441−const ACTION: Record<string, string> = {
442− terms: "Terms changed",
443− create: "Enterprise created",
444− attach: "Workspace added",
445− detach: "Workspace removed",
446− credit: "Credit issued",
447− billing_link: "Billing link made",
448− billing_email: "Invoice email set",
449− invoice: "Invoice sent",
450− dispute: "Payment disputed",
451− stripe: "Stripe",
452− webhook: "Webhook registered",
453−};
454442
443+
455444 export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
456445 return (
457446 <Section title="Audit log" description={description}>
465454 {audit.map((entry) => (
466455 <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
467456 <p className="flex flex-wrap items-center gap-x-2 text-sm">
468− <span className="font-medium text-merged">{ACTION[entry.action] ?? entry.action}</span>
457+ <span className="font-medium text-merged">{actionLabel(entry.action)}</span>
469458 <span className="text-xs text-faint">
470459 <When at={entry.createdAt} time />
471460 </span>
+97−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ accountName,
6+ accountPath,
7+ actionLabel,
8+ auditHref,
9+ invoiceTotals,
10+ invoicesHref,
11+ olderBefore,
12+ parseAction,
13+ parseBefore,
14+ parseBy,
15+ parseInvoiceStatus,
16+ parseMonth,
17+ safeUrl,
18+} from "./ledgers.ts";
19+
20+const M = 1_000_000;
21+
22+test("invoice filters are checked", () => {
23+ assert.equal(parseInvoiceStatus("overdue"), "overdue");
24+ assert.equal(parseInvoiceStatus("late"), null);
25+ assert.equal(parseInvoiceStatus(null), null);
26+ assert.equal(parseMonth("2026-10"), "2026-10");
27+ assert.equal(parseMonth("2026-13"), null);
28+ assert.equal(parseMonth("2026-1"), null);
29+ assert.equal(parseMonth(""), null);
30+});
31+
32+test("totals: void counts for nothing; open, overdue and failed are outstanding", () => {
33+ const totals = invoiceTotals([
34+ { amountMicros: 100 * M, status: "paid" },
35+ { amountMicros: 50 * M, status: "open" },
36+ { amountMicros: 20 * M, status: "failed" },
37+ { amountMicros: 5 * M, status: "overdue" },
38+ { amountMicros: 999 * M, status: "void" },
39+ ]);
40+ assert.deepEqual(totals, { count: 5, amountMicros: 175 * M, paidMicros: 100 * M, outstandingMicros: 75 * M, outstanding: 3 });
41+ assert.deepEqual(invoiceTotals([]), { count: 0, amountMicros: 0, paidMicros: 0, outstandingMicros: 0, outstanding: 0 });
42+});
43+
44+test("links keep only the filters that are set", () => {
45+ assert.equal(invoicesHref({}), "/invoices");
46+ assert.equal(invoicesHref({ status: "open", month: "2026-10" }), "/invoices?status=open&month=2026-10");
47+ assert.equal(auditHref({}), "/audit");
48+ assert.equal(auditHref({ by: "me@g1t.sh", before: "2026-10-01T00:00:00Z" }), "/audit?by=me%40g1t.sh&before=2026-10-01T00%3A00%3A00Z");
49+});
50+
51+test("only https links are followed", () => {
52+ assert.equal(safeUrl("https://invoice.stripe.com/i/x"), "https://invoice.stripe.com/i/x");
53+ assert.equal(safeUrl("javascript:alert(1)"), null);
54+ assert.equal(safeUrl("http://invoice.stripe.com"), null);
55+ assert.equal(safeUrl(null), null);
56+});
57+
58+test("account ids link to their pages; anything else does not", () => {
59+ assert.equal(accountPath("ws_acme"), "/workspaces/acme");
60+ assert.equal(accountPath("ent_bigco"), "/enterprises/ent_bigco");
61+ assert.equal(accountPath("stripe"), null);
62+ assert.equal(accountPath("acme"), null);
63+ assert.equal(accountPath("ws_-bad"), null);
64+ assert.equal(accountPath(""), null);
65+ assert.equal(accountPath(null), null);
66+});
67+
68+test("accounts are named by slug, or the enterprise's name when known", () => {
69+ assert.equal(accountName("ws_acme"), "acme");
70+ assert.equal(accountName("ent_bigco", new Map([["ent_bigco", "BigCo Inc"]])), "BigCo Inc");
71+ assert.equal(accountName("ent_bigco"), "an enterprise");
72+ assert.equal(accountName("stripe"), "stripe");
73+});
74+
75+test("actions read plainly, known or not", () => {
76+ assert.equal(actionLabel("credit"), "Credit issued");
77+ assert.equal(actionLabel("stripe"), "From Stripe");
78+ assert.equal(actionLabel("webhook"), "Stripe webhook registered");
79+ assert.equal(actionLabel("price_change"), "Price change");
80+});
81+
82+test("an older page only when this one was full", () => {
83+ const page = Array.from({ length: 100 }, (_, index) => ({ createdAt: `2026-10-01T00:00:${String(index % 60).padStart(2, "0")}Z` }));
84+ assert.equal(olderBefore(page), page[99].createdAt);
85+ assert.equal(olderBefore(page.slice(0, 99)), null);
86+ assert.equal(olderBefore([]), null);
87+});
88+
89+test("audit filters are checked", () => {
90+ assert.equal(parseBy(" Me@G1t.sh "), "me@g1t.sh");
91+ assert.equal(parseBy("two words"), null);
92+ assert.equal(parseBy(""), null);
93+ assert.equal(parseAction("billing_link"), "billing_link");
94+ assert.equal(parseAction("drop table"), null);
95+ assert.equal(parseBefore("2026-10-01T00:00:00Z"), "2026-10-01T00:00:00Z");
96+ assert.equal(parseBefore("yesterday"), null);
97+});
+137−0
1+/**
2+ * The Invoices and Audit log pages' arithmetic and wording: filters read
3+ * from the address, totals, links from billing's account ids to sudo's
4+ * pages, and paging. No Workers imports, so it can be tested under Node.
5+ */
6+import type { AdminAction, InvoiceSummary } from "@g1t/contracts";
7+
8+// --- Invoices -----------------------------------------------------------------
9+
10+export const INVOICE_STATUSES = ["open", "overdue", "failed", "paid", "void"] as const;
11+export type InvoiceStatusFilter = (typeof INVOICE_STATUSES)[number];
12+
13+export function parseInvoiceStatus(value: string | null): InvoiceStatusFilter | null {
14+ return INVOICE_STATUSES.find((status) => status === value) ?? null;
15+}
16+
17+/** A month as `<input type="month">` sends it: `2026-10`. */
18+export function parseMonth(value: string | null): string | null {
19+ if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return null;
20+ return value;
21+}
22+
23+/** Not paid and not void: what is still owed. */
24+export function isOutstanding(status: string): boolean {
25+ return status === "open" || status === "overdue" || status === "failed";
26+}
27+
28+export type InvoiceTotals = { count: number; amountMicros: number; paidMicros: number; outstandingMicros: number; outstanding: number };
29+
30+export function invoiceTotals(invoices: Pick<InvoiceSummary, "amountMicros" | "status">[]): InvoiceTotals {
31+ const totals: InvoiceTotals = { count: invoices.length, amountMicros: 0, paidMicros: 0, outstandingMicros: 0, outstanding: 0 };
32+ for (const invoice of invoices) {
33+ if (invoice.status === "void") continue;
34+ totals.amountMicros += invoice.amountMicros;
35+ if (invoice.status === "paid") totals.paidMicros += invoice.amountMicros;
36+ if (isOutstanding(invoice.status)) {
37+ totals.outstandingMicros += invoice.amountMicros;
38+ totals.outstanding += 1;
39+ }
40+ }
41+ return totals;
42+}
43+
44+/** A link to the invoices list with these filters; the defaults are left out. */
45+export function invoicesHref({ status, month }: { status?: string | null; month?: string | null }): string {
46+ const params = new URLSearchParams();
47+ if (status) params.set("status", status);
48+ if (month) params.set("month", month);
49+ const query = params.toString();
50+ return query ? `/invoices?${query}` : "/invoices";
51+}
52+
53+/** Only https links to Stripe are followed. */
54+export function safeUrl(url: string | null | undefined): string | null {
55+ return url && url.startsWith("https://") ? url : null;
56+}
57+
58+// --- Accounts -------------------------------------------------------------------
59+
60+const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
61+const ENTERPRISE = /^ent_[a-z0-9_-]{1,80}$/;
62+
63+/**
64+ * The sudo page for one of billing's account ids: `ws_<slug>` is the
65+ * workspace's, `ent_…` the enterprise's. Null for anything else, such as
66+ * Stripe's own lines.
67+ */
68+export function accountPath(account: string | null | undefined): string | null {
69+ const id = (account ?? "").trim().toLowerCase();
70+ if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`;
71+ if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`;
72+ return null;
73+}
74+
75+/** What to call an account: a workspace by its slug, an enterprise by its name when known. */
76+export function accountName(account: string, names: Map<string, string> = new Map()): string {
77+ if (names.has(account)) return names.get(account) as string;
78+ if (account.startsWith("ws_")) return account.slice(3);
79+ if (ENTERPRISE.test(account)) return "an enterprise";
80+ return account;
81+}
82+
83+// --- Audit ----------------------------------------------------------------------
84+
85+/** Each kind of change, as staff read it. */
86+export const AUDIT_ACTIONS: Record<string, string> = {
87+ terms: "Terms changed",
88+ create: "Enterprise created",
89+ attach: "Workspace added",
90+ detach: "Workspace removed",
91+ credit: "Credit issued",
92+ billing_link: "Billing link made",
93+ billing_email: "Invoice email set",
94+ invoice: "Invoice sent",
95+ dispute: "Payment disputed",
96+ sales: "Sales record changed",
97+ note: "Sales note added",
98+ stripe: "From Stripe",
99+ webhook: "Stripe webhook registered",
100+};
101+
102+export function actionLabel(action: string): string {
103+ return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase());
104+}
105+
106+export const AUDIT_PAGE = 100;
107+
108+/** Where the next, older page starts: after the last line, if this page was full. */
109+export function olderBefore(actions: Pick<AdminAction, "createdAt">[], page = AUDIT_PAGE): string | null {
110+ return actions.length >= page ? (actions.at(-1)?.createdAt ?? null) : null;
111+}
112+
113+/** A staff email filter: lowercased, at most one address's length. */
114+export function parseBy(value: string | null): string | null {
115+ const by = (value ?? "").trim().toLowerCase();
116+ return by && by.length <= 254 && !/\s/.test(by) ? by : null;
117+}
118+
119+export function parseAction(value: string | null): string | null {
120+ const action = (value ?? "").trim();
121+ return /^[a-z_]{1,40}$/.test(action) ? action : null;
122+}
123+
124+/** An RFC 3339 time to page from. */
125+export function parseBefore(value: string | null): string | null {
126+ if (!value || value.length > 40 || Number.isNaN(new Date(value).getTime())) return null;
127+ return value;
128+}
129+
130+export function auditHref({ by, action, before }: { by?: string | null; action?: string | null; before?: string | null }): string {
131+ const params = new URLSearchParams();
132+ if (by) params.set("by", by);
133+ if (action) params.set("action", action);
134+ if (before) params.set("before", before);
135+ const query = params.toString();
136+ return query ? `/audit?${query}` : "/audit";
137+}
+5−4
3939
4040 test("the built pages are not marked soon", () => {
4141 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
42− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/stripe"]);
42+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invoices", "/stripe", "/audit"]);
4343 });
4444
4545 test("every soon page says what it will do, why, and what it will have", () => {
4646 const soon = soonItems();
47− assert.ok(soon.length >= 10);
47+ assert.ok(soon.length >= 9);
4848 for (const item of soon) {
4949 assert.ok(item.soon.summary.length >= 1 && item.soon.summary.length <= 4, item.label);
5050 assert.ok(item.soon.plans.length >= 3 && item.soon.plans.length <= 6, item.label);
6161 });
6262
6363 test("soonFor finds a placeholder by its path, and nothing else", () => {
64− assert.equal(soonFor("/invoices")?.label, "Invoices");
65− assert.equal(soonFor("/invoices/")?.label, "Invoices");
64+ assert.equal(soonFor("/prices")?.label, "Plans & prices");
65+ assert.equal(soonFor("/prices/")?.label, "Plans & prices");
66+ assert.equal(soonFor("/invoices"), null);
6667 assert.equal(soonFor("/workspaces"), null);
6768 assert.equal(soonFor("/nope"), null);
6869 });
+0−30
107107 to: "/invoices",
108108 icon: "invoices",
109109 about: "Every invoice g1t has sent, to workspaces and enterprises.",
110− soon: {
111− summary: [
112− "Every invoice g1t has sent, in one list: workspaces' monthly and threshold invoices, and enterprises' net-30 ones. Finance needs to see what is open, what is overdue and what failed without opening each customer in turn.",
113− "Billing keeps each workspace's and each enterprise's invoices today, but has no way to list them all at once. Once it does, this is where month-end close and collections happen.",
114− ],
115− plans: [
116− "Filter by status (open, paid, overdue, failed, void), by month, and by workspace or enterprise",
117− "Totals for what is open and overdue, and what was collected this month",
118− "Each invoice's lines, with links to Stripe's hosted page and PDF",
119− "Retry a failed charge, or void an invoice, with a confirmation and a note",
120− "Export a month for the books",
121− ],
122− meanwhile: {
123− text: "A workspace's invoices are on its page, under Invoices; an enterprise's are on its page.",
124− to: "/enterprises",
125− link: "Enterprises",
126− },
127− },
128110 },
129111 {
130112 label: "Plans & prices",
332314 to: "/audit",
333315 icon: "audit",
334316 about: "Every change made in sudo, by whom, across every customer.",
335− soon: {
336− summary: [
337− "Every change made in sudo, across every customer, newest first: terms, credits, enterprise moves, billing links, invoices, webhooks, and sales notes. Billing already records each one with the staff member's email; it keeps them per account, and has no way yet to list them all together.",
338− "One log makes reviews possible: what changed this week, everything one person did, every credit over a hundred dollars.",
339− ],
340− plans: [
341− "Every change, filterable by staff member, kind and customer",
342− "Links back to the workspace or enterprise each change was about",
343− "Export for a review or an audit",
344− ],
345− meanwhile: { text: "Each workspace's and enterprise's page has its own audit log, under Billing.", to: "/workspaces", link: "Workspaces" },
346− },
347317 },
348318 ],
349319 },
+23−1
33
44 import type { Signal } from "@g1t/contracts";
55
6−import { bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref, signalMeta, stageMeta } from "./signals.ts";
6+import { bySignalUrgency, countByKind, filterSignals, followUpsDue, isFollowUpDue, isSignalKind, parseWho, reachOutHref, signalMeta, stageMeta, today } from "./signals.ts";
77
88 function signal(workspace: string, kind: string, valueMicros = 0, owner: string | null = null): Signal {
99 return { workspace, kind: kind as Signal["kind"], detail: `${workspace} ${kind}`, valueMicros, stage: null, owner };
7676 assert.equal(stageMeta("churn_risk").label, "Churn risk");
7777 assert.equal(stageMeta("odd").label, "odd");
7878 });
79+
80+test("follow-ups due: today or earlier, open deals only, one per workspace, oldest first", () => {
81+ const s = (workspace: string, nextAt: string | null, stage: string | null = "contacted", kind = "growing"): Signal => ({
82+ ...signal(workspace, kind),
83+ nextAt,
84+ stage,
85+ nextStep: nextAt ? "Call" : null,
86+ });
87+ const on = "2026-10-04";
88+ assert.ok(isFollowUpDue(s("a", "2026-10-04"), on));
89+ assert.ok(isFollowUpDue(s("a", "2026-10-01"), on));
90+ assert.ok(!isFollowUpDue(s("a", "2026-10-05"), on));
91+ assert.ok(!isFollowUpDue(s("a", null), on));
92+ assert.ok(!isFollowUpDue(s("a", "2026-10-01", "won"), on));
93+ assert.ok(!isFollowUpDue(s("a", "2026-10-01", "lost"), on));
94+ assert.ok(isFollowUpDue(s("a", "2026-10-01", null), on));
95+ const due = followUpsDue([s("b", "2026-10-03"), s("a", "2026-09-30", "contacted", "at_limit"), s("a", "2026-09-30", "contacted", "high_spend"), s("c", "2026-10-09")], on);
96+ assert.deepEqual(due.map((x) => `${x.workspace}:${x.kind}`), ["a:at_limit", "b:growing"]);
97+ assert.equal(today(new Date("2026-10-04T23:59:00Z")), "2026-10-04");
98+ assert.equal(reachOutHref({ due: true }), "/reach-out?due=1");
99+ assert.equal(reachOutHref({ due: true, who: "mine" }), "/reach-out?due=1&who=mine");
100+});
+27−1
6666 });
6767 }
6868
69+/** Today, as billing compares follow-up days: `YYYY-MM-DD`, UTC. */
70+export function today(now = new Date()): string {
71+ return now.toISOString().slice(0, 10);
72+}
73+
74+/** A next step due today or earlier, on a deal that is still open. */
75+export function isFollowUpDue(signal: Pick<Signal, "nextAt" | "stage">, on = today()): boolean {
76+ if (!signal.nextAt || signal.stage === "won" || signal.stage === "lost") return false;
77+ return signal.nextAt.slice(0, 10) <= on;
78+}
79+
80+/**
81+ * Follow-ups due, one per workspace (a workspace with several signals has
82+ * one sales record), the longest overdue first.
83+ */
84+export function followUpsDue(signals: Signal[], on = today()): Signal[] {
85+ const seen = new Set<string>();
86+ return signals
87+ .filter((signal) => isFollowUpDue(signal, on))
88+ .filter((signal) => (seen.has(signal.workspace) ? false : (seen.add(signal.workspace), true)))
89+ .map((signal, index) => ({ signal, index }))
90+ .sort((a, b) => (a.signal.nextAt ?? "").localeCompare(b.signal.nextAt ?? "") || a.index - b.index)
91+ .map(({ signal }) => signal);
92+}
93+
6994 /** How many signals of each kind, for the filter's counts. */
7095 export function countByKind(signals: Signal[]): Record<string, number> {
7196 const counts: Record<string, number> = {};
7499 }
75100
76101 /** A link to the queue with these filters; the defaults are left out. */
77−export function reachOutHref({ kind, who }: { kind?: string | null; who?: Who }): string {
102+export function reachOutHref({ kind, who, due }: { kind?: string | null; who?: Who; due?: boolean }): string {
78103 const params = new URLSearchParams();
104+ if (due) params.set("due", "1");
79105 if (kind) params.set("kind", kind);
80106 if (who && who !== "all") params.set("who", who);
81107 const query = params.toString();
+2−0
1010 route("enterprises/new", "routes/new-enterprise.tsx"),
1111 route("enterprises/:id", "routes/enterprise.tsx"),
1212 route("reach-out", "routes/reach-out.tsx"),
13+ route("invoices", "routes/invoices.tsx"),
1314 route("stripe", "routes/stripe.tsx"),
15+ route("audit", "routes/audit.tsx"),
1416 // What sudo will have: one placeholder page each, from lib/nav.ts.
1517 ...soonItems().map((item) => route(item.to.slice(1), "routes/soon.tsx", { id: `soon${item.to.replace(/\//g, "-")}` })),
1618 // Before sudo was organised around workspaces, everything was an account.
+171−0
1+import { ChevronRight, UserRound } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { AdminAction } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/audit";
7+import { Button, EmptyState, Input, Notice, PageHeader, Select, When } from "~/components/ui";
8+import {
9+ AUDIT_ACTIONS,
10+ AUDIT_PAGE,
11+ accountName,
12+ accountPath,
13+ actionLabel,
14+ auditHref,
15+ olderBefore,
16+ parseAction,
17+ parseBefore,
18+ parseBy,
19+} from "~/lib/ledgers";
20+import { admin } from "~/lib/services.server";
21+import { settle } from "~/lib/settle";
22+import { requireStaff } from "~/lib/staff";
23+
24+export const meta: Route.MetaFunction = () => [{ title: "Audit log · sudo" }, { name: "robots", content: "noindex, nofollow" }];
25+
26+export async function loader({ request, context }: Route.LoaderArgs) {
27+ const staff = requireStaff(context);
28+ const url = new URL(request.url);
29+ const by = parseBy(url.searchParams.get("by"));
30+ const action = parseAction(url.searchParams.get("action"));
31+ const before = parseBefore(url.searchParams.get("before"));
32+ // Enterprises' names, so a line about one reads as its name, not its id.
33+ const [result, accounts] = await Promise.all([
34+ settle(admin.audit({ by: by ?? undefined, action: action ?? undefined, before: before ?? undefined })),
35+ settle(admin.accounts()),
36+ ]);
37+ const names = Object.fromEntries(
38+ (accounts.ok ? accounts.value : []).filter((row) => row.account.kind === "enterprise").map((row) => [row.account.id, row.account.name]),
39+ );
40+ const actions = result.ok ? result.value : [];
41+ return {
42+ me: staff.email,
43+ by,
44+ action,
45+ before,
46+ actions,
47+ names,
48+ older: olderBefore(actions),
49+ error: result.ok ? null : result.error,
50+ };
51+}
52+
53+/** Where a change came from: Stripe's lines are about Stripe, not a person. */
54+function isStripe(entry: AdminAction) {
55+ return entry.action === "stripe" || entry.action === "webhook" || entry.by === "stripe";
56+}
57+
58+export default function Audit({ loaderData }: Route.ComponentProps) {
59+ const { me, by, action, before, actions, names, older, error } = loaderData;
60+ const nameMap = new Map(Object.entries(names));
61+ const filtered = by != null || action != null;
62+
63+ return (
64+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
65+ <PageHeader
66+ title="Audit log"
67+ description="Every change made in sudo, and what Stripe told billing, newest first, across every customer. Each names who made it."
68+ />
69+
70+ <form method="get" action="/audit" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
71+ <Input name="by" type="search" defaultValue={by ?? ""} placeholder="Staff email, such as you@g1t.sh" aria-label="Made by" className="font-mono text-xs sm:w-72" />
72+ <Select name="action" defaultValue={action ?? ""} aria-label="Kind of change" className="sm:w-60">
73+ <option value="">Every kind of change</option>
74+ {Object.entries(AUDIT_ACTIONS).map(([value, label]) => (
75+ <option key={value} value={value}>
76+ {label}
77+ </option>
78+ ))}
79+ </Select>
80+ <div className="flex items-center gap-2">
81+ <Button type="submit" variant="quiet" className="py-2">
82+ Apply
83+ </Button>
84+ <Link to={auditHref({ by: me })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
85+ Mine
86+ </Link>
87+ {filtered && (
88+ <Link to="/audit" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
89+ Clear
90+ </Link>
91+ )}
92+ </div>
93+ </form>
94+
95+ {before && (
96+ <p className="mt-4 text-xs text-faint">
97+ Changes before <When at={before} time />.{" "}
98+ <Link to={auditHref({ by, action })} className="text-merged hover:underline">
99+ Back to the newest
100+ </Link>
101+ </p>
102+ )}
103+
104+ {error ? (
105+ <div className="mt-4">
106+ <Notice tone="warn">Billing did not answer for the audit log: {error}</Notice>
107+ </div>
108+ ) : actions.length === 0 ? (
109+ <div className="mt-4">
110+ <EmptyState title={filtered || before ? "No changes match" : "No changes yet"}>
111+ {filtered ? "Try another staff email or kind of change, or clear the filters." : "Changes appear here as staff make them."}
112+ </EmptyState>
113+ </div>
114+ ) : (
115+ <ol className="mt-4 divide-y divide-line rounded-lg border border-line bg-surface">
116+ {actions.map((entry) => {
117+ const path = accountPath(entry.account);
118+ const stripe = isStripe(entry);
119+ return (
120+ <li key={entry.id} className="flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-start sm:gap-4 sm:px-5">
121+ <p className="shrink-0 text-xs whitespace-nowrap text-faint sm:w-44 sm:pt-0.5">
122+ <When at={entry.createdAt} time />
123+ </p>
124+ <div className="min-w-0 grow">
125+ <p className="flex flex-wrap items-center gap-x-2 text-sm">
126+ <span className={`font-medium ${stripe ? "text-info" : "text-merged"}`}>{actionLabel(entry.action)}</span>
127+ {path && (
128+ <Link to={path} className="inline-flex items-center gap-0.5 text-fg-soft hover:text-fg hover:underline hover:underline-offset-4">
129+ <span className={entry.account.startsWith("ws_") ? "font-mono text-xs" : ""}>{accountName(entry.account, nameMap)}</span>
130+ <ChevronRight size={12} className="text-faint" />
131+ </Link>
132+ )}
133+ </p>
134+ {entry.detail && <p className="mt-0.5 text-sm break-words text-muted">{entry.detail}</p>}
135+ </div>
136+ <p className="flex shrink-0 items-center gap-1 text-xs text-faint sm:w-56 sm:justify-end sm:pt-0.5">
137+ {stripe && entry.by === "stripe" ? (
138+ "Stripe"
139+ ) : (
140+ <Link to={auditHref({ by: entry.by, action })} title={`Every change by ${entry.by}`} className="inline-flex min-w-0 items-center gap-1 hover:text-fg">
141+ <UserRound size={11} className="shrink-0" />
142+ <span className="truncate font-mono">{entry.by.toLowerCase() === me.toLowerCase() ? "you" : entry.by}</span>
143+ </Link>
144+ )}
145+ </p>
146+ </li>
147+ );
148+ })}
149+ </ol>
150+ )}
151+
152+ {(older || actions.length > 0) && !error && (
153+ <nav aria-label="Pages" className="mt-4 flex items-center justify-between gap-3 text-sm">
154+ <span className="text-xs text-faint">
155+ {actions.length} change{actions.length === 1 ? "" : "s"}
156+ {older ? `, ${AUDIT_PAGE} a page` : ""}
157+ </span>
158+ {older && (
159+ <Link
160+ to={auditHref({ by, action, before: older })}
161+ className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-muted hover:border-line-strong hover:text-fg"
162+ >
163+ Older
164+ <ChevronRight size={14} />
165+ </Link>
166+ )}
167+ </nav>
168+ )}
169+ </main>
170+ );
171+}
+214−0
1+import { ExternalLink } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { InvoiceSummary } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/invoices";
7+import { InvoiceStatus } from "~/components/sales";
8+import { Avatar, Badge, Button, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui";
9+import { monthLong } from "~/lib/chart";
10+import { INVOICE_STATUSES, accountPath, invoiceTotals, parseInvoiceStatus, parseMonth, safeUrl } from "~/lib/ledgers";
11+import { usd } from "~/lib/money";
12+import { admin } from "~/lib/services.server";
13+import { settle } from "~/lib/settle";
14+import { requireStaff } from "~/lib/staff";
15+
16+export const meta: Route.MetaFunction = () => [{ title: "Invoices · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17+
18+/** The most billing returns in one list. */
19+const MOST = 200;
20+
21+export async function loader({ request, context }: Route.LoaderArgs) {
22+ requireStaff(context);
23+ const url = new URL(request.url);
24+ const status = parseInvoiceStatus(url.searchParams.get("status"));
25+ const month = parseMonth(url.searchParams.get("month"));
26+ const result = await settle(admin.allInvoices({ status: status ?? undefined, month: month ?? undefined }));
27+ const invoices = result.ok ? result.value : [];
28+ return {
29+ status,
30+ month,
31+ invoices,
32+ totals: invoiceTotals(invoices),
33+ capped: invoices.length >= MOST,
34+ error: result.ok ? null : result.error,
35+ };
36+}
37+
38+const STATUS_LABEL: Record<string, string> = { open: "Open", overdue: "Overdue", failed: "Failed", paid: "Paid", void: "Void" };
39+const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit", manual: "Sent by staff" };
40+
41+export default function Invoices({ loaderData }: Route.ComponentProps) {
42+ const { status, month, invoices, totals, capped, error } = loaderData;
43+ const filtered = status != null || month != null;
44+ const scope = [status ? STATUS_LABEL[status].toLowerCase() : null, month ? `in ${monthLong(month)}` : null].filter(Boolean).join(", ");
45+
46+ return (
47+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
48+ <PageHeader
49+ title="Invoices"
50+ description="Every invoice g1t has sent, newest first: workspaces' monthly and near-the-limit invoices, and enterprises' net-30 ones."
51+ />
52+
53+ {error ? (
54+ <div className="mt-6">
55+ <Notice tone="warn">Billing did not answer for invoices: {error}</Notice>
56+ </div>
57+ ) : (
58+ <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
59+ <Stat label={filtered ? `Invoices, ${scope}` : "Invoices"} value={`${totals.count}${capped ? "+" : ""}`} hint={capped ? `The newest ${MOST}` : "Void ones count for nothing"} />
60+ <Stat label="Amount" value={usd(totals.amountMicros)} hint="Everything not void" />
61+ <Stat label="Paid" value={usd(totals.paidMicros)} tone={totals.paidMicros > 0 ? "mint" : undefined} />
62+ <Stat
63+ label="Outstanding"
64+ value={usd(totals.outstandingMicros)}
65+ hint={`${totals.outstanding} open, overdue or failed`}
66+ tone={totals.outstanding > 0 ? "warn" : undefined}
67+ />
68+ </div>
69+ )}
70+
71+ <form method="get" action="/invoices" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
72+ <Select name="status" defaultValue={status ?? ""} aria-label="Status" className="sm:w-44">
73+ <option value="">Any status</option>
74+ {INVOICE_STATUSES.map((value) => (
75+ <option key={value} value={value}>
76+ {STATUS_LABEL[value]}
77+ </option>
78+ ))}
79+ </Select>
80+ <Input type="month" name="month" defaultValue={month ?? ""} aria-label="Month" className="sm:w-48" />
81+ <div className="flex items-center gap-2">
82+ <Button type="submit" variant="quiet" className="py-2">
83+ Apply
84+ </Button>
85+ {filtered && (
86+ <Link to="/invoices" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
87+ Clear
88+ </Link>
89+ )}
90+ </div>
91+ </form>
92+
93+ {!error &&
94+ (invoices.length === 0 ? (
95+ <div className="mt-4">
96+ <EmptyState title={filtered ? "No invoices match" : "No invoices yet"}>
97+ {filtered ? "Try another status or month, or clear the filters." : "Invoices appear here as months close and workspaces are charged near their limits."}
98+ </EmptyState>
99+ </div>
100+ ) : (
101+ <>
102+ {/* Phones: one card per invoice. */}
103+ <ul className="mt-4 space-y-2 md:hidden">
104+ {invoices.map((invoice) => (
105+ <li key={invoice.invoiceId} className="rounded-lg border border-line bg-surface p-4">
106+ <div className="flex items-start justify-between gap-3">
107+ <Customer invoice={invoice} />
108+ <span className="tabular shrink-0 text-sm font-medium">{usd(invoice.amountMicros)}</span>
109+ </div>
110+ <div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
111+ <InvoiceStatus status={invoice.status} />
112+ <span className="text-muted">{invoice.period}</span>
113+ <span className="text-faint">· {REASON[invoice.reason] ?? invoice.reason}</span>
114+ </div>
115+ <div className="mt-3 flex items-center justify-between gap-3 border-t border-line pt-3 text-xs text-faint">
116+ <Dates invoice={invoice} />
117+ <StripeLink url={invoice.hostedUrl} />
118+ </div>
119+ </li>
120+ ))}
121+ </ul>
122+
123+ {/* Wider screens: a table. */}
124+ <div className="mt-4 hidden overflow-x-auto rounded-lg border border-line md:block">
125+ <table className="w-full text-sm">
126+ <thead>
127+ <tr className="border-b border-line bg-surface text-left text-xs text-muted">
128+ <th className="px-4 py-2.5 font-medium">Customer</th>
129+ <th className="px-4 py-2.5 font-medium">For</th>
130+ <th className="px-4 py-2.5 text-right font-medium">Amount</th>
131+ <th className="px-4 py-2.5 font-medium">Status</th>
132+ <th className="px-4 py-2.5 font-medium">Sent · paid</th>
133+ <th className="px-4 py-2.5 font-medium">On Stripe</th>
134+ </tr>
135+ </thead>
136+ <tbody>
137+ {invoices.map((invoice) => (
138+ <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0 hover:bg-surface/60">
139+ <td className="px-4 py-3">
140+ <Customer invoice={invoice} />
141+ </td>
142+ <td className="px-4 py-3">
143+ <p className="text-fg-soft">{invoice.period}</p>
144+ <p className="text-xs text-faint">{REASON[invoice.reason] ?? invoice.reason}</p>
145+ </td>
146+ <td className="tabular px-4 py-3 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
147+ <td className="px-4 py-3">
148+ <InvoiceStatus status={invoice.status} />
149+ </td>
150+ <td className="px-4 py-3 text-xs whitespace-nowrap text-muted">
151+ <Dates invoice={invoice} />
152+ </td>
153+ <td className="px-4 py-3 text-xs">
154+ <StripeLink url={invoice.hostedUrl} />
155+ </td>
156+ </tr>
157+ ))}
158+ </tbody>
159+ </table>
160+ </div>
161+ {capped && <p className="mt-3 text-xs text-faint">Only the newest {MOST} are listed; filter by month or status to see older ones.</p>}
162+ </>
163+ ))}
164+ </main>
165+ );
166+}
167+
168+function Customer({ invoice }: { invoice: InvoiceSummary }) {
169+ const path = accountPath(invoice.account);
170+ const name = (
171+ <span className="flex min-w-0 items-center gap-2">
172+ <Avatar name={invoice.name} size={18} />
173+ <span className={`truncate ${invoice.kind === "workspace" ? "font-mono text-[0.8125rem]" : "font-medium"}`}>{invoice.name}</span>
174+ </span>
175+ );
176+ return (
177+ <div className="min-w-0">
178+ {path ? (
179+ <Link to={path} className="block hover:underline hover:underline-offset-4">
180+ {name}
181+ </Link>
182+ ) : (
183+ name
184+ )}
185+ <div className="mt-1.5">
186+ {invoice.kind === "enterprise" ? <Badge tone="lavender">Enterprise</Badge> : <Badge>Workspace</Badge>}
187+ </div>
188+ </div>
189+ );
190+}
191+
192+function Dates({ invoice }: { invoice: InvoiceSummary }) {
193+ return (
194+ <span>
195+ <When at={invoice.createdAt} />
196+ {invoice.paidAt ? (
197+ <span className="text-accent">
198+ {" · "}paid <When at={invoice.paidAt} />
199+ </span>
200+ ) : null}
201+ </span>
202+ );
203+}
204+
205+function StripeLink({ url }: { url: string | null }) {
206+ const safe = safeUrl(url);
207+ if (!safe) return <span className="text-faint">—</span>;
208+ return (
209+ <a href={safe} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline">
210+ Stripe
211+ <ExternalLink size={12} />
212+ </a>
213+ );
214+}
+1−1
117117 hint="Sent and not yet paid"
118118 />
119119 <AttentionCard
120− to={reachOutHref({})}
120+ to={reachOutHref({ due: true })}
121121 icon={<CalendarClock size={15} />}
122122 label="Follow-ups due"
123123 value={overview.followUpsDue}
+63−13
1−import { ChevronRight } from "lucide-react";
1+import { CalendarClock, ChevronRight } from "lucide-react";
22 import { Link } from "react-router";
33
44 import type { AdminOwner, Signal } from "@g1t/contracts";
66 import type { Route } from "./+types/reach-out";
77 import { Owners } from "~/components/billing";
88 import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
9−import { Avatar, EmptyState, Notice, PageHeader } from "~/components/ui";
9+import { Avatar, EmptyState, Notice, PageHeader, When } from "~/components/ui";
1010 import { usd } from "~/lib/money";
1111 import { identity, admin } from "~/lib/services.server";
1212 import { settle } from "~/lib/settle";
13−import { SIGNAL_KINDS, type Who, bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref } from "~/lib/signals";
13+import {
14+ SIGNAL_KINDS,
15+ type Who,
16+ bySignalUrgency,
17+ countByKind,
18+ filterSignals,
19+ followUpsDue,
20+ isFollowUpDue,
21+ isSignalKind,
22+ parseWho,
23+ reachOutHref,
24+ today,
25+} from "~/lib/signals";
1426 import { requireStaff } from "~/lib/staff";
1527
1628 export const meta: Route.MetaFunction = () => [{ title: "Reach out · sudo" }, { name: "robots", content: "noindex, nofollow" }];
2133 const rawKind = url.searchParams.get("kind");
2234 const kind = isSignalKind(rawKind) ? rawKind : null;
2335 const who = parseWho(url.searchParams.get("who"));
36+ const due = url.searchParams.get("due") === "1";
37+ const on = today();
2438
2539 // Owners and names come from identity's list: one call, newest 500.
2640 const [signals, workspaces] = await Promise.all([settle(admin.signals()), settle(identity.workspaces())]);
27− const all = signals.ok ? bySignalUrgency(signals.value) : [];
41+ const every = signals.ok ? bySignalUrgency(signals.value) : [];
42+ const dueAll = followUpsDue(every, on);
43+ // Follow-ups due: one line per workspace whose next step is due.
44+ const all = due ? dueAll : every;
2845 const people = new Map(
2946 (workspaces.ok ? workspaces.value : []).map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]),
3047 );
3653 }));
3754 return {
3855 me: staff.email,
56+ on,
3957 kind,
4058 who,
59+ due,
60+ dueCount: filterSignals(dueAll, { kind: null, who, me: staff.email }).length,
4161 rows,
42− total: all.length,
43− workspaces: new Set(all.map((signal) => signal.workspace)).size,
62+ total: every.length,
63+ workspaces: new Set(every.map((signal) => signal.workspace)).size,
4464 counts: countByKind(forWho),
4565 whoCounts: {
4666 all: all.length,
7999 }
80100
81101 export default function ReachOut({ loaderData }: Route.ComponentProps) {
82− const { me, kind, who, rows, total, workspaces, counts, whoCounts, error } = loaderData;
102+ const { me, on, kind, who, due, dueCount, rows, total, workspaces, counts, whoCounts, error } = loaderData;
83103 const forWho = Object.values(counts).reduce((sum, count) => sum + count, 0);
84104
85105 return (
109129 <div className="flex flex-wrap items-center gap-1.5">
110130 <span className="mr-1 w-10 text-xs text-faint">Whose</span>
111131 {WHO.map((option) => (
112− <Pill key={option.who} to={reachOutHref({ kind, who: option.who })} active={who === option.who} count={whoCounts[option.who]}>
132+ <Pill key={option.who} to={reachOutHref({ kind, who: option.who, due })} active={who === option.who} count={whoCounts[option.who]}>
113133 {option.label}
114134 </Pill>
115135 ))}
136+ <span className="mx-1 h-4 w-px bg-line" aria-hidden="true" />
137+ <Pill to={reachOutHref({ kind, who, due: !due })} active={due} count={dueCount}>
138+ <CalendarClock size={12} />
139+ Follow-ups due
140+ </Pill>
116141 </div>
117142 <div className="flex flex-wrap items-center gap-1.5">
118143 <span className="mr-1 w-10 text-xs text-faint">Why</span>
119− <Pill to={reachOutHref({ who })} active={kind == null} count={forWho}>
144+ <Pill to={reachOutHref({ who, due })} active={kind == null} count={forWho}>
120145 Any
121146 </Pill>
122147 {SIGNAL_KINDS.map((entry) => (
123− <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}>
148+ <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who, due })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}>
124149 {entry.label}
125150 </Pill>
126151 ))}
130155 {rows.length === 0 ? (
131156 <div className="mt-5">
132157 <EmptyState title={total === 0 ? "Nobody needs a word right now" : "None match"}>
133− {total === 0
158+ {due && rows.length === 0 && total > 0
159+ ? "No next steps are due. Set one on a workspace's Sales section and it shows up here on its day."
160+ : total === 0
134161 ? "Workspaces show up here when they stop, come close to their limit, are declined, grow, or pay for the first time."
135162 : who === "mine"
136163 ? "No signals for workspaces you own. Take one from Unassigned by setting yourself as its owner on its Sales section."
157184 <StageBadge stage={row.signal.stage} />
158185 <StaffName email={row.signal.owner} me={me} />
159186 </div>
187+ <NextStep signal={row.signal} on={on} />
160188 </Link>
161189 </li>
162190 ))}
170198 <th className="px-4 py-2.5 font-medium">Workspace</th>
171199 <th className="px-4 py-2.5 font-medium">Why</th>
172200 <th className="px-4 py-2.5 text-right font-medium">Figure</th>
173− <th className="px-4 py-2.5 font-medium">Stage</th>
201+ <th className="px-4 py-2.5 font-medium">Stage · next step</th>
174202 <th className="px-4 py-2.5 font-medium">Owner at g1t</th>
175203 <th className="w-8 px-2 py-2.5" />
176204 </tr>
188216 <p className="mt-1 max-w-md text-sm text-muted">{row.signal.detail}</p>
189217 </td>
190218 <td className="tabular px-4 py-3 text-right whitespace-nowrap text-fg-soft">{usd(row.signal.valueMicros)}</td>
191− <td className="px-4 py-3">
219+ <td className="max-w-56 px-4 py-3">
192220 <StageBadge stage={row.signal.stage} />
221+ <NextStep signal={row.signal} on={on} />
193222 </td>
194223 <td className="max-w-48 px-4 py-3 text-xs">
195224 <StaffName email={row.signal.owner} me={me} />
228257 </div>
229258 );
230259 }
260+
261+/** The workspace's next step, and its day: amber when it is due. */
262+function NextStep({ signal, on }: { signal: Signal; on: string }) {
263+ if (!signal.nextStep && !signal.nextAt) return null;
264+ const due = isFollowUpDue(signal, on);
265+ return (
266+ <p className={`mt-1.5 flex items-start gap-1.5 text-xs ${due ? "text-warn" : "text-muted"}`}>
267+ <CalendarClock size={12} className="mt-0.5 shrink-0" />
268+ <span className="min-w-0">
269+ {signal.nextStep ?? "Next step"}
270+ {signal.nextAt && (
271+ <span className={due ? "text-warn" : "text-faint"}>
272+ {" · "}
273+ {due ? (signal.nextAt.slice(0, 10) < on ? "overdue since " : "due ") : "on "}
274+ <When at={`${signal.nextAt.slice(0, 10)}T00:00:00Z`} />
275+ </span>
276+ )}
277+ </span>
278+ </p>
279+ );
280+}