flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

Commit

sudo: Invoices, Audit log, and follow-ups due

Invoices lists every invoice g1t has sent, workspaces' and enterprises', with totals, filters by status and month, and links to each customer and to Stripe. Audit log shows every change made in sudo and by Stripe, by person and kind, paged. Reach out shows each workspace's next step and filters to follow-ups due; the Overview card links there.

syntaqxcommitted Parentd3febabBrowse files
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+}