From fde5cb4ee979b447cee51f705532a4763640118e Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Sat, 8 Aug 2026 12:19:01 +0530
Subject: [PATCH 1/8] feat: add outstandings UI
---
scripts/outstandings-copy.test.mjs | 17 +
src/AllClientsScreen.tsx | 295 ++++++
src/AxalScreen.tsx | 214 +++++
src/DocumentsScreen.tsx | 299 ++++++
src/DscScreen.tsx | 268 ++++++
src/ErrorBoundary.tsx | 47 +
src/GstScreen.tsx | 76 ++
src/MirrorProofScreen.tsx | 945 +++++++++++++++++++
src/OutstandingsScreen.tsx | 601 +++++++++++-
src/main.tsx | 1399 ++++------------------------
src/outstandings-copy.ts | 11 +-
src/styles.css | 693 +++++++++++++-
12 files changed, 3610 insertions(+), 1255 deletions(-)
create mode 100644 src/AllClientsScreen.tsx
create mode 100644 src/AxalScreen.tsx
create mode 100644 src/DocumentsScreen.tsx
create mode 100644 src/DscScreen.tsx
create mode 100644 src/ErrorBoundary.tsx
create mode 100644 src/GstScreen.tsx
create mode 100644 src/MirrorProofScreen.tsx
diff --git a/scripts/outstandings-copy.test.mjs b/scripts/outstandings-copy.test.mjs
index 41c7c3d..7519620 100644
--- a/scripts/outstandings-copy.test.mjs
+++ b/scripts/outstandings-copy.test.mjs
@@ -92,3 +92,20 @@ test("unaged receivables disclose the ageing scope without inventing an On Accou
assert.match(disclosure, /cannot prove the full unallocated balance/i);
assert.equal(outstandingsAgeingDisclosure(false), null);
});
+
+test("a path that can prove the unallocated balance says so instead of disclaiming it", () => {
+ // The voucher scan derives bills from vouchers and genuinely cannot
+ // establish the unallocated remainder, so its disclaimer is honest. The
+ // native bills path recovers that figure exactly from the party ledgers, so
+ // repeating "Bridge does not show an On Account amount" there would be false
+ // while a screen right above it displays exactly that amount.
+ const known = outstandingsAgeingDisclosure(true, true);
+ assert.match(known, /shown as Unallocated above/i);
+ assert.doesNotMatch(known, /cannot prove/i);
+ assert.doesNotMatch(known, /does not show/i);
+
+ // Absent knowledge must keep the original disclaimer, never silently claim a
+ // figure it does not have.
+ assert.match(outstandingsAgeingDisclosure(true, false), /cannot prove the full unallocated balance/i);
+ assert.equal(outstandingsAgeingDisclosure(false, true), null);
+});
diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx
new file mode 100644
index 0000000..f0e4653
--- /dev/null
+++ b/src/AllClientsScreen.tsx
@@ -0,0 +1,295 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import React from "react";
+import { ChevronRight, RefreshCw } from "lucide-react";
+import { invoke } from "@tauri-apps/api/core";
+import { outstandingsPartialState } from "./outstandings-copy";
+
+type CompanyRef = { name: string; guid: string };
+
+type Props = {
+ config: { host: string; port: number };
+ companies: CompanyRef[];
+ onOpenCompany: (company: CompanyRef) => void;
+ /// Returns to the single-company view. The two screens are the same
+ /// question at two altitudes, so the switch has to work both ways.
+ onBack?: () => void;
+};
+
+type Report = {
+ receivable_total: string;
+ payable_total: string;
+ ageing: { days_0_30: string; days_31_60: string; days_61_90: string; days_90_plus: string };
+ open_receivable_bill_count: number;
+ top_parties: Array<{ party: string; oldest_bill_age_days: number | null }>;
+};
+
+type LoadResult =
+ | { state: "complete"; report: Report; unallocated_total?: string }
+ | { state: "partial"; reason_code: string };
+
+type Entry = { company: string; result: LoadResult };
+
+function amountOf(value: string | undefined) {
+ const parsed = Number.parseFloat(value ?? "0");
+ return Number.isFinite(parsed) ? Math.abs(parsed) : 0;
+}
+
+function formatMoney(value: string) {
+ const negative = value.startsWith("-");
+ const unsigned = negative ? value.slice(1) : value;
+ const [whole, fraction] = unsigned.split(".");
+ const tail = whole.slice(-3);
+ const head = whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ",");
+ const grouped = head ? `${head},${tail}` : tail;
+ return `${negative ? "−" : ""}₹${grouped}${fraction ? `.${fraction.padEnd(2, "0")}` : ""}`;
+}
+
+/// Compact form for a wide table: a crore figure at full precision makes every
+/// column unreadable, and at this altitude the reader is comparing clients, not
+/// reconciling paise. The exact figure is one click away on the client's own
+/// screen, and in the export.
+function formatCompact(value: string | undefined) {
+ const amount = amountOf(value);
+ if (amount === 0) return "—";
+ if (amount >= 10_000_000) return `₹${(amount / 10_000_000).toFixed(2)} cr`;
+ if (amount >= 100_000) return `₹${(amount / 100_000).toFixed(2)} L`;
+ return `₹${Math.round(amount).toLocaleString("en-IN")}`;
+}
+
+type SortKey = "client" | "receivable" | "overdue" | "unallocated" | "oldest";
+
+/// Severity tiers reuse the ageing ramp used on the single-client screen, so a
+/// chip means the same thing in both places.
+function ageTier(days: number | null) {
+ if (days === null) return 0;
+ if (days <= 30) return 1;
+ if (days <= 60) return 2;
+ if (days <= 90) return 3;
+ return 4;
+}
+
+export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: Props) {
+ const [sort, setSort] = React.useState<{ key: SortKey; desc: boolean }>({ key: "overdue", desc: true });
+ const [entries, setEntries] = React.useState(null);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const requestVersion = React.useRef(0);
+
+ const load = React.useCallback(async () => {
+ if (companies.length === 0) return;
+ const version = requestVersion.current + 1;
+ requestVersion.current = version;
+ setLoading(true);
+ setError(null);
+ try {
+ const next = await invoke("fetch_tally_outstandings_all_companies", {
+ request: {
+ config,
+ companies: companies.map((company) => ({
+ company: company.name,
+ expected_company_guid: company.guid,
+ })),
+ currency_assertion: "INR",
+ },
+ });
+ if (requestVersion.current !== version) return;
+ setEntries(next);
+ } catch (cause) {
+ if (requestVersion.current !== version) return;
+ setEntries(null);
+ setError(
+ cause && typeof cause === "object" && "message" in cause && typeof cause.message === "string"
+ ? cause.message
+ : "The local Tally read did not complete.",
+ );
+ } finally {
+ if (requestVersion.current === version) setLoading(false);
+ }
+ }, [config.host, config.port, companies.map((company) => company.guid).join("|")]);
+
+ const rows = React.useMemo(() => {
+ if (!entries) return [];
+ return entries
+ .map((entry) => {
+ const complete = entry.result.state === "complete" ? entry.result : null;
+ const oldest = complete
+ ? complete.report.top_parties.reduce(
+ (worst, party) =>
+ party.oldest_bill_age_days === null
+ ? worst
+ : Math.max(worst ?? 0, party.oldest_bill_age_days),
+ null,
+ )
+ : null;
+ return {
+ company: entry.company,
+ complete,
+ reasonCode: entry.result.state === "partial" ? entry.result.reason_code : null,
+ receivable: complete ? amountOf(complete.report.receivable_total) : 0,
+ overdue: complete ? amountOf(complete.report.ageing.days_90_plus) : 0,
+ unallocated: complete ? amountOf(complete.unallocated_total) : 0,
+ // How much of this book's exposure Tally cannot age. It is the
+ // single best signal of whether the other numbers can be trusted,
+ // and it varies enormously between books.
+ unallocatedShare: complete && complete.unallocated_total !== undefined
+ ? Math.round(
+ amountOf(complete.unallocated_total)
+ / Math.max(1, amountOf(complete.unallocated_total) + amountOf(complete.report.receivable_total))
+ * 100,
+ )
+ : null,
+ oldest,
+ };
+ })
+ .sort((left, right) => {
+ const direction = sort.desc ? -1 : 1;
+ if (sort.key === "client") return left.company.localeCompare(right.company) * direction;
+ if (sort.key === "oldest") {
+ // A book with no aged bill has no "oldest" -- it must not sort as
+ // zero and look the least urgent thing on the screen.
+ const l = left.oldest ?? -1;
+ const r = right.oldest ?? -1;
+ return (l - r) * direction;
+ }
+ return (left[sort.key] - right[sort.key]) * direction;
+ });
+ }, [entries, sort]);
+
+ const totals = React.useMemo(() => rows.reduce(
+ (sum, row) => ({
+ receivable: sum.receivable + row.receivable,
+ overdue: sum.overdue + row.overdue,
+ unallocated: sum.unallocated + row.unallocated,
+ }),
+ { receivable: 0, overdue: 0, unallocated: 0 },
+ ), [rows]);
+
+ const readable = rows.filter((row) => row.complete).length;
+ const largestExposure = Math.max(...rows.map((row) => row.receivable + row.unallocated), 0);
+
+ return (
+
+
+
+
All clients
+
+ {entries
+ ? `${readable} of ${rows.length} ${rows.length === 1 ? "book" : "books"} read`
+ : `${companies.length} ${companies.length === 1 ? "book" : "books"} open in Tally`}
+
+
+
+ {onBack && (
+
+ Back to one client
+
+ )}
+ void load()} disabled={loading || companies.length === 0}>
+
+ {loading ? "Reading each book…" : entries ? "Refresh" : "Read all clients"}
+
+
+
+
+ {error && Read failed {error}
}
+
+ {companies.length === 0 && (
+
+ No verified companies yet
+ Open your client books in Tally and choose them under Manage Tally. Bridge reads each one in turn.
+
+ )}
+
+ {!entries && !loading && companies.length > 0 && (
+
+ Ready
+ Bridge reads each book in turn, one request at a time. Roughly a third of a second per company.
+
+ )}
+
+ {entries && rows.length > 0 && (
+ <>
+
+
Receivable {formatCompact(String(totals.receivable))}
+
Overdue 90+ {formatCompact(String(totals.overdue))}
+
Unallocated {formatCompact(String(totals.unallocated))}
+
+
+
+
+ {([
+ ["client", "Client"],
+ ["receivable", "Receivable"],
+ ["overdue", "Overdue 90+"],
+ ["unallocated", "Unallocated"],
+ ["oldest", "Oldest"],
+ ] as Array<[SortKey, string]>).map(([key, label]) => (
+ setSort((current) =>
+ current.key === key
+ ? { key, desc: !current.desc }
+ // Money and age default to worst-first; a name defaults
+ // to A-Z, because "descending name" is never what is
+ // wanted on first click.
+ : { key, desc: key !== "client" })}
+ >
+ {label}
+
+ ))}
+
+ {rows.map((row) => {
+ const partial = row.reasonCode ? outstandingsPartialState(row.reasonCode) : null;
+ return (
+
{
+ const match = companies.find((company) => company.name === row.company);
+ if (match) onOpenCompany(match);
+ }}
+ >
+ {/* Magnitude behind the row: which client is biggest is
+ readable without comparing five columns of digits. */}
+ 0 ? Math.max(1, (row.receivable + row.unallocated) / largestExposure * 100) : 0}%` }}
+ aria-hidden="true"
+ />
+
+ {row.company}
+ {partial
+ ? {partial.title}
+ : row.unallocatedShare !== null && (
+ {row.unallocatedShare}% carries no bill reference
+ )}
+
+ {/* Every money column in the SAME unit. Mixing full precision
+ and compact across columns made them uncomparable. */}
+ {row.complete ? formatCompact(String(row.receivable)) : "—"}
+ 0 ? "is-overdue" : undefined}>
+ {row.complete ? formatCompact(String(row.overdue)) : "—"}
+
+ {row.complete ? formatCompact(String(row.unallocated)) : "—"}
+
+ {row.oldest === null
+ ? none
+ : {row.oldest}d }
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/AxalScreen.tsx b/src/AxalScreen.tsx
new file mode 100644
index 0000000..5eb1a20
--- /dev/null
+++ b/src/AxalScreen.tsx
@@ -0,0 +1,214 @@
+import React from "react";
+import { Cloud, RefreshCw } from "lucide-react";
+import { invoke } from "@tauri-apps/api/core";
+
+type AxalIntegration = "tally" | "documents" | "dsc";
+
+type AxalValidationResponse = {
+ valid: boolean;
+ status?: string | null;
+ last_synced?: string | null;
+ error?: string | null;
+};
+
+type AxalSessionResponse = {
+ credentialSessionId: string;
+ validation: AxalValidationResponse;
+};
+
+type AxalConnectionStatus = {
+ connected: boolean;
+ status: string;
+ last_synced_at?: string | null;
+ workspace: {
+ id: string;
+ name: string;
+ billing_plan: string;
+ storage_used: number;
+ storage_limit: number;
+ };
+};
+
+function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return "0 B";
+ }
+
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ const value = bytes / 1024 ** index;
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
+}
+
+type Props = {
+ busy: boolean;
+ setBusy: (busy: boolean) => void;
+ // Owned by App() and shared with the DSC and Documents views -- read
+ // here, never duplicated locally. This view still *writes* them because
+ // validating credentials and checking connection status only ever
+ // happens from here.
+ axalConnection: AxalConnectionStatus | null;
+ axalSession: { id: string; integration: AxalIntegration } | null;
+ setAxalSession: (session: { id: string; integration: AxalIntegration } | null) => void;
+ setAxalConnection: (connection: AxalConnectionStatus | null) => void;
+};
+
+// Owns: the AXAL backend view (view === "axal"), its credential form state
+// (base URL, integration, API ID/key), the validation/status result state,
+// and the validate/check-status handlers.
+//
+// Deliberately does NOT own: `axalConnection` or `axalSession`. Those are
+// AXAL workspace-session state shared with the DSC and Documents views (both
+// already extracted, both receive it as props from App()), so they stay in
+// App() and are passed down here rather than duplicated. `busy` is likewise
+// a cross-view flag owned by App().
+export function AxalScreen({ busy, setBusy, axalConnection, axalSession, setAxalSession, setAxalConnection }: Props) {
+ const [axalBaseUrl, setAxalBaseUrl] = React.useState("https://complyeaze.com");
+ const [axalIntegration, setAxalIntegration] = React.useState("dsc");
+ const [axalApiId, setAxalApiId] = React.useState("");
+ const [axalApiKey, setAxalApiKey] = React.useState("");
+ const [axalValidation, setAxalValidation] = React.useState(null);
+ const [axalError, setAxalError] = React.useState(null);
+ const [axalAction, setAxalAction] = React.useState<"validate" | "status" | null>(null);
+
+ function axalCredentials() {
+ return {
+ api_key: axalApiKey,
+ api_id: axalApiId,
+ integration: axalIntegration,
+ base_url: axalBaseUrl,
+ };
+ }
+
+ function invalidateAxalSession() {
+ const sessionId = axalSession?.id;
+ setAxalSession(null);
+ setAxalConnection(null);
+ if (sessionId) {
+ void invoke("revoke_axal_credential_session", {
+ credentialSessionId: sessionId,
+ }).catch(() => undefined);
+ }
+ }
+
+ async function validateAxal() {
+ setBusy(true);
+ setAxalAction("validate");
+ setAxalError(null);
+ try {
+ const result = await invoke("validate_axal_credentials", {
+ credentials: axalCredentials(),
+ });
+ setAxalValidation(result.validation);
+ setAxalSession({ id: result.credentialSessionId, integration: axalIntegration });
+ setAxalConnection(null);
+ } catch (error) {
+ setAxalError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setAxalApiKey("");
+ setBusy(false);
+ setAxalAction(null);
+ }
+ }
+
+ async function checkAxalStatus() {
+ if (!axalSession) {
+ setAxalError("Validate AXAL credentials before checking connection status.");
+ return;
+ }
+ setBusy(true);
+ setAxalAction("status");
+ setAxalError(null);
+ try {
+ const result = await invoke("check_axal_connection_status", {
+ credentialSessionId: axalSession.id,
+ });
+ setAxalConnection(result);
+ } catch (error) {
+ setAxalError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setBusy(false);
+ setAxalAction(null);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+ {axalError && {axalError}
}
+
+
+
+ Credential validation
+ {axalAction === "validate" ? (
+
+
+ Validating credentials
+ Checking the API key against AXAL.
+
+ ) : (
+
+
Status {axalValidation ? (axalValidation.valid ? "Valid" : "Invalid") : "Not checked"}
+
Server state {axalValidation?.status || "-"}
+
Last synced {axalValidation?.last_synced || "-"}
+
Error {axalValidation?.error || "-"}
+
+ )}
+
+
+
+ Workspace status
+ {axalAction === "status" ? (
+
+
+ Checking workspace
+ Fetching integration status and workspace metadata.
+
+ ) : (
+
+
Connection {axalConnection ? (axalConnection.connected ? "Connected" : "Disconnected") : "Not checked"}
+
Status {axalConnection?.status || "-"}
+
Workspace {axalConnection?.workspace.name || "-"}
+
Plan {axalConnection?.workspace.billing_plan || "-"}
+
Storage {axalConnection ? `${formatBytes(axalConnection.workspace.storage_used)} / ${formatBytes(axalConnection.workspace.storage_limit)}` : "-"}
+
Last synced {axalConnection?.last_synced_at || "-"}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/DocumentsScreen.tsx b/src/DocumentsScreen.tsx
new file mode 100644
index 0000000..6f18dbf
--- /dev/null
+++ b/src/DocumentsScreen.tsx
@@ -0,0 +1,299 @@
+import React from "react";
+import { FileText, FolderOpen, RefreshCw, UploadCloud } from "lucide-react";
+import { invoke } from "@tauri-apps/api/core";
+
+const TABLE_PREVIEW_LIMIT = 100;
+
+type DocumentFile = {
+ scanId: string;
+ relativePath: string;
+ size: number;
+ mtime: number;
+ extension?: string | null;
+ mimeType: string;
+ hash?: string | null;
+ contentHash?: string | null;
+ serverFileKey?: string | null;
+ multipartInfo?: {
+ uploadId: string;
+ parts: {
+ partNumber: number;
+ etag: string;
+ size: number;
+ bytesRead: number;
+ }[];
+ } | null;
+};
+
+type ScanDocumentsResponse = {
+ scanSessionId: string;
+ files: DocumentFile[];
+ totalSize: number;
+ skipped: { path: string; reason: string }[];
+};
+
+type SyncDocumentsResponse = {
+ success: boolean;
+ uploadedFiles: DocumentFile[];
+ failedFiles: { relativePath: string; error: string }[];
+ duplicateCount: number;
+ batchIds: string[];
+};
+
+type SelectedDocumentPath = {
+ selectionId: string;
+ displayName: string;
+};
+
+type AxalIntegration = "tally" | "documents" | "dsc";
+
+function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return "0 B";
+ }
+
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ const value = bytes / 1024 ** index;
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
+}
+
+function formatPreviewCount(total: number, label = "loaded"): string {
+ return `Showing ${Math.min(total, TABLE_PREVIEW_LIMIT)} of ${total} returned ${label}; source completeness not established`;
+}
+
+type Props = {
+ busy: boolean;
+ setBusy: (busy: boolean) => void;
+ // Owned by App() and shared with the AXAL and DSC views -- read here,
+ // never duplicated locally.
+ axalConnection: { workspace: { id: string; name: string } } | null;
+ axalSession: { id: string; integration: AxalIntegration } | null;
+};
+
+// Owns: the Documents view (view === "documents"), its selected-path/scan/
+// sync state, and the choose/scan/sync/clear handlers.
+//
+// Deliberately does NOT own: `axalConnection` or `axalSession`. Those are
+// AXAL workspace-session state shared with the AXAL and DSC views, so they
+// stay in App() and are passed down read-only rather than duplicated here.
+// `busy` is likewise a cross-view flag owned by App().
+export function DocumentsScreen({ busy, setBusy, axalConnection, axalSession }: Props) {
+ const [documentPaths, setDocumentPaths] = React.useState([]);
+ const [documentScan, setDocumentScan] = React.useState(null);
+ const [documentSync, setDocumentSync] = React.useState(null);
+ const [documentError, setDocumentError] = React.useState(null);
+ const [documentAction, setDocumentAction] = React.useState<"scan" | "sync" | null>(null);
+
+ async function scanDocuments() {
+ setBusy(true);
+ setDocumentAction("scan");
+ setDocumentError(null);
+ setDocumentSync(null);
+ try {
+ const result = await invoke("scan_document_paths", {
+ request: {
+ selection_ids: documentPaths.map((path) => path.selectionId),
+ use_hash: true,
+ exclude_hidden_files: true,
+ exclude_zero_byte_files: true,
+ },
+ });
+ setDocumentScan(result);
+ } catch (error) {
+ setDocumentError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setBusy(false);
+ setDocumentAction(null);
+ }
+ }
+
+ async function chooseDocumentFiles() {
+ setDocumentError(null);
+ try {
+ const paths = await invoke("select_document_files");
+ if (paths.length > 0) {
+ setDocumentPaths((current) => [...current, ...paths]);
+ setDocumentScan(null);
+ setDocumentSync(null);
+ }
+ } catch (error) {
+ setDocumentError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ async function chooseDocumentFolder() {
+ setDocumentError(null);
+ try {
+ const paths = await invoke("select_document_folder");
+ if (paths.length > 0) {
+ setDocumentPaths((current) => [...current, ...paths]);
+ setDocumentScan(null);
+ setDocumentSync(null);
+ }
+ } catch (error) {
+ setDocumentError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ function clearDocuments() {
+ void invoke("revoke_document_authorizations", {
+ selectionIds: documentPaths.map((path) => path.selectionId),
+ scanSessionId: documentScan?.scanSessionId ?? null,
+ }).catch(() => undefined);
+ setDocumentPaths([]);
+ setDocumentScan(null);
+ setDocumentSync(null);
+ }
+
+ async function syncDocuments() {
+ if (!documentScan?.files.length || !axalConnection || axalSession?.integration !== "documents") {
+ setDocumentError("Scan files and check AXAL workspace status before syncing documents.");
+ return;
+ }
+
+ setBusy(true);
+ setDocumentAction("sync");
+ setDocumentError(null);
+ try {
+ const result = await invoke("sync_documents_to_axal", {
+ request: {
+ credentialSessionId: axalSession.id,
+ workspaceExternalId: axalConnection.workspace.id,
+ scanSessionId: documentScan.scanSessionId,
+ files: documentScan.files,
+ maxFilesPerBatch: 20,
+ },
+ });
+ setDocumentSync(result);
+ } catch (error) {
+ setDocumentError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setBusy(false);
+ setDocumentAction(null);
+ }
+ }
+
+ return (
+ <>
+
+
+
+ Choose Files
+
+
+
+ Choose Folder
+
+
+ Clear
+
+
+
+ {documentAction === "scan" ? "Scanning..." : "Scan"}
+
+
+
+ {documentAction === "sync" ? "Syncing..." : "Sync Documents"}
+
+
+
+ {documentError && {documentError}
}
+
+
+
+
Selected paths
+ {documentPaths.length} selected
+
+ {documentPaths.length === 0 ? (
+
+
+ No paths selected
+ Choose files or a folder before scanning.
+
+ ) : (
+
+ {documentPaths.map((path) => (
+
{path.displayName}
+ ))}
+
+ )}
+
+
+
+
+ Scan summary
+ {documentAction === "scan" ? (
+
+
+ Scanning documents
+ Hashing files and preparing document metadata.
+
+ ) : (
+
+
Files {documentScan?.files.length ?? 0}
+
Total size {formatBytes(documentScan?.totalSize ?? 0)}
+
Skipped {documentScan?.skipped.length ?? 0}
+
Workspace {axalConnection?.workspace.name || "Check AXAL status first"}
+
+ )}
+
+
+
+ Sync summary
+ {documentAction === "sync" ? (
+
+
+ Uploading documents
+ Requesting upload URLs, sending files, and confirming the batch.
+
+ ) : (
+
+
Status {documentSync ? (documentSync.success ? "Complete" : "Partial") : "Not synced"}
+
Uploaded {documentSync?.uploadedFiles.length ?? 0}
+
Failed {documentSync?.failedFiles.length ?? 0}
+
Duplicates {documentSync?.duplicateCount ?? 0}
+
+ )}
+
+
+
+
+
+
Files
+ {formatPreviewCount(documentScan?.files.length ?? 0, "ready")}
+
+ {!documentScan?.files.length ? (
+
+
+ No files scanned
+ Enter one or more file/folder paths, then scan.
+
+ ) : (
+
+
+
+
+ Path
+ Type
+ Size
+ Hash
+
+
+
+ {documentScan.files.slice(0, TABLE_PREVIEW_LIMIT).map((file) => (
+
+ {file.relativePath}
+ {file.mimeType}
+ {formatBytes(file.size)}
+ {file.contentHash ? `${file.contentHash.slice(0, 12)}...` : "-"}
+
+ ))}
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/DscScreen.tsx b/src/DscScreen.tsx
new file mode 100644
index 0000000..23572ad
--- /dev/null
+++ b/src/DscScreen.tsx
@@ -0,0 +1,268 @@
+import React from "react";
+import { Cloud, KeyRound, RefreshCw } from "lucide-react";
+import { invoke } from "@tauri-apps/api/core";
+
+type DscCertificate = {
+ label: string;
+ common_name?: string | null;
+ organization?: string | null;
+ issuer_name?: string | null;
+ serial_number?: string | null;
+ valid_from?: string | null;
+ valid_to?: string | null;
+ fingerprint?: string | null;
+ parse_error?: string | null;
+};
+
+type DscAttempt = {
+ token_type: string;
+ library_path: string;
+ library_exists: boolean;
+ loaded: boolean;
+ initialized: boolean;
+ slot_count: number;
+ login_success: boolean;
+ certificate_count?: number | null;
+ certificates: DscCertificate[];
+ error?: string | null;
+};
+
+type DscProbeReport = {
+ platform: string;
+ arch: string;
+ force_load: boolean;
+ detect_only: boolean;
+ attempts: DscAttempt[];
+};
+
+type DscSyncResponse = {
+ success: boolean;
+ message: string;
+ results?: {
+ created: number;
+ updated: number;
+ skipped: number;
+ errors: string[];
+ } | null;
+};
+
+type AxalIntegration = "tally" | "documents" | "dsc";
+
+const DSC_METADATA_RETENTION_MS = 5 * 60 * 1000;
+
+type Props = {
+ busy: boolean;
+ setBusy: (busy: boolean) => void;
+ // Owned by App() and shared with the AXAL view -- read here, never
+ // duplicated locally.
+ axalConnection: { workspace: { id: string } } | null;
+ axalSession: { id: string; integration: AxalIntegration } | null;
+};
+
+// Owns: the DSC token view (view === "dsc"), its token-PIN/report/sync
+// state, and the detect/extract/sync handlers.
+//
+// Deliberately does NOT own: `axalConnection` or `axalSession`. Those are
+// AXAL workspace-session state shared with the AXAL and Documents views, so
+// they stay in App() and are passed down read-only rather than duplicated
+// here. `busy` is likewise a cross-view flag owned by App().
+export function DscScreen({ busy, setBusy, axalConnection, axalSession }: Props) {
+ // In App(), this state persisted across view changes, so a dedicated
+ // `if (view !== "dsc") clearDscSensitiveState()` effect cleared it the
+ // instant the operator navigated away. Now that this state lives inside
+ // DscScreen, which is only mounted while view === "dsc", React unmounting
+ // this component on navigation away already discards it -- that effect is
+ // therefore redundant here and was intentionally not carried over. The
+ // 5-minute idle-timeout effect below still applies while this view stays
+ // mounted and active.
+ const [dscReport, setDscReport] = React.useState(null);
+ const [dscDetectReport, setDscDetectReport] = React.useState(null);
+ const [dscPin, setDscPin] = React.useState("");
+ const [dscError, setDscError] = React.useState(null);
+ const [dscAction, setDscAction] = React.useState<"detect" | "extract" | null>(null);
+ const [dscSync, setDscSync] = React.useState(null);
+ const [dscSyncing, setDscSyncing] = React.useState(false);
+ const dscRequestVersion = React.useRef(0);
+
+ const clearDscSensitiveState = React.useCallback(() => {
+ dscRequestVersion.current += 1;
+ setDscReport(null);
+ setDscDetectReport(null);
+ setDscPin("");
+ setDscSync(null);
+ }, []);
+
+ React.useEffect(() => {
+ if (!dscReport && !dscDetectReport && !dscPin && !dscSync) return;
+ const expiry = window.setTimeout(clearDscSensitiveState, DSC_METADATA_RETENTION_MS);
+ return () => window.clearTimeout(expiry);
+ }, [clearDscSensitiveState, dscDetectReport, dscPin, dscReport, dscSync]);
+
+ async function runDsc(detectOnly: boolean) {
+ const pin = dscPin;
+ if (!detectOnly && !pin) {
+ setDscError("Enter the DSC token PIN before extracting certificates.");
+ return;
+ }
+
+ const requestVersion = ++dscRequestVersion.current;
+ setBusy(true);
+ setDscAction(detectOnly ? "detect" : "extract");
+ setDscError(null);
+ setDscReport(null);
+ setDscDetectReport(null);
+ setDscSync(null);
+ if (!detectOnly) {
+ setDscPin("");
+ }
+ try {
+ const result = detectOnly
+ ? await invoke("detect_dsc_token")
+ : await invoke("extract_dsc_certificates", { pins: [pin] });
+ if (requestVersion === dscRequestVersion.current) {
+ if (detectOnly) {
+ setDscDetectReport(result);
+ } else {
+ setDscReport(result);
+ }
+ }
+ } catch (error) {
+ if (requestVersion === dscRequestVersion.current) {
+ setDscError(error instanceof Error ? error.message : String(error));
+ }
+ } finally {
+ setBusy(false);
+ setDscAction(null);
+ }
+ }
+
+ const successfulDscAttempt = dscReport?.attempts.find(
+ (attempt) => attempt.login_success && attempt.certificates.length > 0,
+ );
+ const detectedDscAttempt = dscDetectReport?.attempts.find(
+ (attempt) => attempt.loaded && attempt.initialized && attempt.slot_count > 0 && !attempt.error,
+ );
+ const primaryCertificate =
+ successfulDscAttempt?.certificates.find((certificate) => certificate.common_name) ??
+ successfulDscAttempt?.certificates[0];
+
+ async function syncDscCertificate() {
+ if (!primaryCertificate || !successfulDscAttempt || !axalConnection || axalSession?.integration !== "dsc") {
+ setDscError("Extract a certificate and check AXAL workspace status before syncing.");
+ return;
+ }
+
+ setDscSyncing(true);
+ setDscError(null);
+ try {
+ const holderName =
+ primaryCertificate.common_name || primaryCertificate.organization || primaryCertificate.label;
+ const result = await invoke("sync_dsc_certificates_to_axal", {
+ request: {
+ credentialSessionId: axalSession.id,
+ workspaceExternalId: axalConnection.workspace.id,
+ certificates: [
+ {
+ holderName,
+ provider: primaryCertificate.issuer_name || "Unknown",
+ serialNumber: primaryCertificate.serial_number || "",
+ tokenType: successfulDscAttempt.token_type,
+ class: "Unknown",
+ purpose: "Digital Signature",
+ issueDate: primaryCertificate.valid_from || "",
+ expirationDate: primaryCertificate.valid_to || "",
+ clientName: holderName,
+ metadata: {
+ organization: primaryCertificate.organization,
+ issuer: primaryCertificate.issuer_name,
+ fingerprint: primaryCertificate.fingerprint,
+ tokenType: successfulDscAttempt.token_type,
+ },
+ },
+ ],
+ },
+ });
+ setDscSync(result);
+ } catch (error) {
+ setDscError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setDscSyncing(false);
+ }
+ }
+
+ return (
+ <>
+
+
+ {dscError && {dscError}
}
+
+
+
+ Certificate summary
+ {dscAction ? (
+
+
+ {dscAction === "detect" ? "Detecting token" : "Reading certificate"}
+ This can take a few seconds while the token library initializes.
+
+ ) : primaryCertificate ? (
+
+
Client {primaryCertificate.common_name || primaryCertificate.organization || primaryCertificate.label}
+
Expiry {primaryCertificate.valid_to || "Unknown"}
+
Serial {primaryCertificate.serial_number || "Unknown"}
+
Provider {successfulDscAttempt?.token_type ?? "Unknown"}
+
Certificates {successfulDscAttempt?.certificate_count ?? successfulDscAttempt?.certificates.length ?? 0}
+
AXAL sync {dscSync?.message || "Not synced"}
+
+ ) : detectedDscAttempt ? (
+
+
+ Token detected
+ {detectedDscAttempt.token_type} token is available. Extract certificates to show holder details.
+
+ ) : (
+
+
+ No certificate loaded
+ Detect the token or extract certificates to show DSC holder details.
+
+ )}
+ {primaryCertificate && (
+
+
+
+ {dscSyncing ? "Syncing..." : "Sync Certificate"}
+
+
+ )}
+ {(dscReport || dscDetectReport) && (
+
+
+ Clear certificate details
+
+ Certificate and token details clear automatically after five minutes.
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx
new file mode 100644
index 0000000..b5c35ad
--- /dev/null
+++ b/src/ErrorBoundary.tsx
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import React from "react";
+
+type Props = {
+ children: React.ReactNode;
+ // Shown in the fallback panel so an operator can tell which screen broke.
+ label?: string;
+};
+
+type State = {
+ error: Error | null;
+};
+
+/// Contains a render failure to the single panel that produced it. Before
+/// this existed, one broken screen (e.g. a Rules-of-Hooks violation) unmounted
+/// the entire React tree and left the app a blank white window with no
+/// message -- the sidebar and every other screen went with it. Each mounted
+/// screen gets its own instance, keyed on the active view, so switching away
+/// from a broken screen and back retries the render instead of staying stuck.
+export class ErrorBoundary extends React.Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
+ console.error(`ErrorBoundary caught a render failure${this.props.label ? ` in ${this.props.label}` : ""}:`, error, info.componentStack);
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ {this.props.label ? `${this.props.label} hit a problem` : "This screen hit a problem"}
+ {this.state.error.message || "An unexpected error stopped this screen from rendering."}
+ window.location.reload()}>Reload
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/GstScreen.tsx b/src/GstScreen.tsx
new file mode 100644
index 0000000..c70bab9
--- /dev/null
+++ b/src/GstScreen.tsx
@@ -0,0 +1,76 @@
+import { FileText } from "lucide-react";
+
+type GstReturnDraft = {
+ company: string;
+ financial_year: string;
+ gstr1: {
+ b2b_invoice_count: number;
+ b2c_invoice_count: number;
+ credit_debit_note_count: number;
+ hsn_summary_count: number;
+ };
+ gstr3b: {
+ outward_taxable_value: string;
+ integrated_tax: string;
+ central_tax: string;
+ state_tax: string;
+ cess: string;
+ };
+ missing_fields: string[];
+};
+
+type Props = {
+ draft: GstReturnDraft | null;
+};
+
+// Owns: the read-only GST return readiness view (view === "gst"), which
+// renders the most recently prepared draft.
+//
+// Deliberately does NOT own: `gstCompany`, `gstFinancialYear`,
+// `dashboardError`, or the `prepareDraft` handler. The "Check GST
+// Availability" trigger (company/financial-year inputs and button) lives on
+// the dashboard view, not inside this one -- the dashboard reads and writes
+// that state directly, so it stays in App() and is out of scope for this
+// extraction. `draft` remains owned by App() and is passed down read-only.
+export function GstScreen({ draft }: Props) {
+ const gstDraftComplete = draft !== null && draft.missing_fields.length === 0;
+
+ return (
+ !draft || !gstDraftComplete ? (
+
+ GST calculation unavailable
+
+
+ No verified GST draft
+
+ {draft
+ ? draft.missing_fields.join(" ")
+ : "Use GST preparation on the dashboard to check availability. Zero values are not assumed."}
+
+
+
+ ) : (
+
+
+ GSTR-1 draft
+
+
B2B invoices {draft.gstr1.b2b_invoice_count}
+
B2C invoices {draft.gstr1.b2c_invoice_count}
+
Credit/debit notes {draft.gstr1.credit_debit_note_count}
+
HSN summaries {draft.gstr1.hsn_summary_count}
+
+
+
+ GSTR-3B draft
+
+
Taxable value {draft.gstr3b.outward_taxable_value}
+
IGST {draft.gstr3b.integrated_tax}
+
CGST {draft.gstr3b.central_tax}
+
SGST {draft.gstr3b.state_tax}
+
+
+
+
+ )
+ );
+}
diff --git a/src/MirrorProofScreen.tsx b/src/MirrorProofScreen.tsx
new file mode 100644
index 0000000..35d594c
--- /dev/null
+++ b/src/MirrorProofScreen.tsx
@@ -0,0 +1,945 @@
+import React from "react";
+import { CircleHelp, Database, FileText, Play, RefreshCw } from "lucide-react";
+import { classifyTallyError } from "./tally-error-copy";
+
+// Owns: the presentational markup of the Accounting mirror and proof view
+// (view === "mirror") -- the saved-company picker, the truth-state hero,
+// the requested-period controls, the Gap Map, the local mirror explorer,
+// the hash-linked proof ledger and its redacted-preview export, the
+// synthetic write-fixture safety gate, and the Tally runtime/queue panel.
+//
+// Deliberately does NOT own any of the state or handlers behind that
+// markup. Every one of them turned out to be needed outside this view too,
+// so they all stay in App() and are passed down read-only (plus the
+// handful of setters/callbacks this view triggers):
+//
+// - `syncEvidence`, `snapshotJob`, and `recentSnapshotRuns` feed the
+// "Verified baseline" / "Latest attempt" operator-summary strip, which
+// App() also renders while `view === "dashboard"` -- not just here.
+// - App() runs a background effect that keeps polling an active
+// snapshot run's status (and refreshes sync evidence when it finishes)
+// regardless of which view is on screen, so a running read is not lost
+// by navigating away. That effect needs `snapshotJob`,
+// `refreshSyncEvidence`, and `refreshRecentSnapshots` to live in App().
+// - `snapshotJob`/`snapshotActive`/`snapshotStartOutcomeUnknown` also
+// drive `savedCompanySelectionLocked` in App(), which gates company
+// selection everywhere (including the Connect Tally view) because
+// changing the selected company while a snapshot could still be
+// mutating that scope is unsafe. That guard's invariant is unchanged
+// by this extraction: the state it reads still lives in App().
+// - `proofPreview`, `mirrorExplorer`, and the write-fixture attestations
+// are cleared by App()'s `clearSelectedCompanyScope`, which runs from
+// several other places (probing, bootstrapping a company, switching
+// the saved company) that are not guaranteed to happen while this view
+// is unmounted -- so that state has to stay where the clearing code
+// already lives.
+// - `runtimeSessions`/`refreshRuntime` are shared with the endpoint
+// probe, discovery, and bootstrap handlers on other views, and with a
+// separate App()-level poll keyed on `tallyAction`/`snapshotActive`.
+//
+// Because of that, this file receives its data and every handler as props
+// instead of owning local state -- a deliberately "thin" extraction per the
+// task's own fallback: presentational markup only, state and behaviour left
+// exactly where they were.
+
+type TallyConfig = {
+ host: string;
+ port: number;
+};
+
+type ConnectionStatus = {
+ reachable: boolean;
+ compatible: boolean;
+ server_text: string;
+ product: "TallyPrime" | "Tally ERP 9" | "Unknown";
+ error?: string;
+};
+
+type TallyCompany = {
+ name: string;
+ guid?: string;
+ guid_observed?: boolean;
+ mirror_company_id?: string;
+ correlation_key?: string;
+};
+
+type TallyCommandErrorEnvelope = {
+ code: string;
+ category: string;
+ message: string;
+ retry: "safe" | "after_change" | "not_recommended";
+ local_state_changed: boolean;
+ tally_state_may_have_changed: boolean;
+ remediation: string;
+};
+
+type OperatorError = string | TallyCommandErrorEnvelope;
+
+type CapabilityEvidence = {
+ state: "supported" | "unsupported" | "unknown" | "not_configured";
+ confidence: "documented" | "observed" | "inferred" | "unknown";
+ safe_reason_code?: string;
+};
+
+type CapabilityProfile = {
+ profile_version: number;
+ product: string;
+ release?: string;
+ mode?: string;
+ transports: Record;
+ features: Record;
+ packs: Record;
+};
+
+type TallyProofSummary = {
+ integrity_state: "entry_hash_valid";
+ run_id: string;
+ selection_token: string;
+ proof_sha256: string;
+ pack_id: string;
+ outcome: "completed" | "failed" | "cancelled" | "outcome_unknown";
+ verification_state: "verified" | "partial" | "unverified";
+ started_at_unix_ms: number;
+ completed_at_unix_ms?: number;
+ accepted_records: number;
+ rejected_records: number;
+ provenance_unavailable_records: number;
+ gap_codes: string[];
+ warning_codes: string[];
+};
+
+type TallySyncEvidence = {
+ latest_proofs: TallyProofSummary[];
+ latest_reconciliation_mismatches: Array<{
+ reason_code: string;
+ record_aliases: string[];
+ }>;
+ incremental: {
+ execution_enabled: boolean;
+ establishment_receipts: number;
+ active_checkpoint_heads: number;
+ state: string;
+ };
+ core_accounting_freshness: {
+ state: "fresh" | "stale" | "never_verified";
+ verified_at_unix_ms?: number;
+ checkpoint_present: boolean;
+ proof_present: boolean;
+ };
+};
+
+type RedactedProofPreview = {
+ json: string;
+ payload_sha256: string;
+};
+
+type MirrorExplorerPage = {
+ offset: number;
+ limit: number;
+ total_records: number;
+ records: Array<{
+ local_alias: string;
+ object_type: string;
+ identity_confidence: string;
+ last_batch_state: string;
+ tombstoned: boolean;
+ }>;
+};
+
+type SnapshotPhase = "prepare" | "capability_check" | "company_identity_check" | "plan_windows" | "extract" | "normalize" | "validate" | "stage" | "reconcile" | "commit_pending" | "emit_proof" | "completed" | "partial" | "failed" | "cancelled";
+
+type SnapshotJobStatus = {
+ run_id: string;
+ mirror_company_id: string | null;
+ pack_id: string | null;
+ requested_from_yyyymmdd: string | null;
+ requested_to_yyyymmdd: string | null;
+ phase: SnapshotPhase;
+ active_window_id: string | null;
+ completed_windows: number;
+ total_windows: number;
+ verification: "verified" | "partial" | "unverified" | null;
+ proof_id: string | null;
+ proof_sha256: string | null;
+ gap_codes: string[];
+ warning_codes: string[];
+ failure_code: string | null;
+ requires_resume: boolean;
+ resume_available: boolean;
+};
+
+type TallyRuntimeSnapshot = {
+ session_id: string;
+ canonical_endpoint: string;
+ issued_requests: number;
+ active_requests: number;
+ active_request_ids: string[];
+ consecutive_failures: number;
+ circuit_state: "closed" | "open" | "half_open";
+ circuit_retry_after_unix_ms?: number;
+ last_success_unix_ms?: number;
+ last_failure_unix_ms?: number;
+ cached_capability_observed_at_unix_ms?: number;
+};
+
+type TallyAction = "probe" | "discover" | "bootstrap" | "save" | "fixture_enroll" | "fixture_revoke" | "evidence" | "explorer" | "start" | "resume" | "cancel";
+
+const PACK_LABELS: Record = {
+ core_accounting: "Core accounting",
+ india_tax: "India tax",
+ bills_and_payments: "Bills and payments",
+ inventory: "Inventory",
+};
+
+const CAPABILITY_REASON_LABELS: Record = {
+ xml_export_probe_failed: "The safe XML export probe did not complete.",
+ tally_status_not_recognized: "The endpoint response was not recognized as a compatible Tally status.",
+ release_not_observed: "The Tally release was not observed, so this transport was not tested.",
+ configuration_not_observed: "Bridge did not inspect this optional transport's configuration.",
+ company_identity_invalid: "The company result contained an invalid or unsafe identity field.",
+ company_identity_ambiguous: "Two or more returned companies shared the same normalized GUID.",
+ direct_company_report_untrusted: "Tally returned a direct company report without the normal success wrapper. Its names remain unverified until separately checked.",
+ standard_ledger_identity_profile_observed: "A strict, scoped standard ledger collection observed one local company identity. It does not establish completeness, sync eligibility, or write support.",
+ scoped_standard_identity_observed: "A strict, scoped local company identity was observed. Responder authenticity and accounting completeness remain unestablished.",
+ practical_limit_not_measured: "No live workload has established a practical response limit for this endpoint.",
+ selected_read_probe_not_run: "This selected read was not run by the connection probe.",
+ selected_ledger_read_empty_observed: "The exact selected ledger profile returned a valid empty response; source emptiness is not claimed.",
+ selected_ledger_read_non_empty_observed: "The exact selected ledger profile returned validated identified rows, which were discarded.",
+ selected_voucher_window_empty_observed: "The exact request-bound voucher window returned a valid empty response; source completeness is not claimed.",
+ selected_voucher_window_non_empty_observed: "The exact request-bound voucher window returned validated identified rows, which were discarded.",
+ qualification_prerequisite_failed: "Voucher qualification was skipped because the ledger prerequisite did not pass.",
+ selected_voucher_date_outside_window: "A returned voucher fell outside the exact reviewed date window.",
+ selected_read_identity_unavailable: "The selected response did not prove stable unique row identity.",
+ selected_read_schema_rejected: "The selected response did not match the exact reviewed schema and structure.",
+ selected_read_transport_or_validation_failed: "The selected read failed transport, decoding, or strict validation and remains unknown.",
+ write_probe_not_run: "No write probe was run. Bridge never infers write support from read access.",
+ verified_snapshot_not_run: "No profile-scoped capability run has established this pack's declared contract.",
+};
+
+function formatIdentifier(value: string): string {
+ const words = value.replace(/_/g, " ");
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+function formatRuntimeTime(value?: number): string {
+ if (value === undefined || !Number.isFinite(value)) {
+ return "Not observed";
+ }
+ return new Date(value).toLocaleString();
+}
+
+function formatDuration(startedAt: number, completedAt?: number): string {
+ if (!Number.isFinite(startedAt) || completedAt === undefined || completedAt < startedAt) return "Duration unavailable";
+ const seconds = Math.round((completedAt - startedAt) / 1000);
+ return `Duration ${seconds}s`;
+}
+
+function formatTallyDate(value?: string): string {
+ if (!value || value.length !== 8) {
+ return value || "-";
+ }
+
+ return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
+}
+
+function formatCapabilityState(state: CapabilityEvidence["state"]): string {
+ switch (state) {
+ case "supported":
+ return "Supported";
+ case "unsupported":
+ return "Unsupported";
+ case "not_configured":
+ return "Not configured";
+ default:
+ return "Unknown";
+ }
+}
+
+function formatConfidence(confidence: CapabilityEvidence["confidence"]): string {
+ switch (confidence) {
+ case "documented":
+ return "Documented evidence";
+ case "observed":
+ return "Observed by this probe";
+ case "inferred":
+ return "Inferred, not directly observed";
+ default:
+ return "Evidence confidence unknown";
+ }
+}
+
+function formatCapabilityReason(reason?: string): string {
+ if (!reason) {
+ return "No reason code was returned.";
+ }
+
+ return CAPABILITY_REASON_LABELS[reason] || `Reason: ${formatIdentifier(reason)}.`;
+}
+
+function CapabilityBadge({ evidence }: { evidence?: CapabilityEvidence }) {
+ if (!evidence) {
+ return Not observed ;
+ }
+
+ return (
+
+ {formatCapabilityState(evidence.state)}
+
+ );
+}
+
+function CapabilityRows({
+ capabilities,
+ labels,
+}: {
+ capabilities?: Record;
+ labels: Record;
+}) {
+ const keys = Array.from(new Set([...Object.keys(labels), ...Object.keys(capabilities || {})]));
+
+ return (
+
+ {keys.map((key) => {
+ const evidence = capabilities?.[key];
+ return (
+
+
+ {labels[key] || formatIdentifier(key)}
+
+ {evidence
+ ? `${formatConfidence(evidence.confidence)}. ${formatCapabilityReason(evidence.safe_reason_code)}`
+ : "This endpoint has not been probed in the current configuration."}
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function TallyErrorNotice({ message }: { message: OperatorError }) {
+ const guidance = classifyTallyError(typeof message === "string" ? { message } : message);
+ const displayMessage = typeof message === "string" ? message : message.message;
+ return (
+
+ {guidance.category}
+ {guidance.action}
+
+ {typeof message === "string" ? "Details" : "Technical details"}
+ {typeof message !== "string" && (
+ <>
+ Code {message.code} · Retry {formatIdentifier(message.retry)} · Local state {message.local_state_changed ? "changed" : "unchanged"} · Tally state {message.tally_state_may_have_changed ? "may have changed" : "unchanged by this read-only action"}
+ Next step: {message.remediation}
+ >
+ )}
+ {displayMessage}
+
+
+ );
+}
+
+function CopyTokenButton({ value, label }: { value: string; label: string }) {
+ const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle");
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopyState("copied");
+ window.setTimeout(() => setCopyState("idle"), 1500);
+ } catch {
+ setCopyState("failed");
+ }
+ }
+ return (
+
+ void copy()} aria-label={`Copy ${label}`}>
+ {copyState === "copied" ? "Copied" : "Copy"}
+
+
+ {copyState === "failed" ? `Copy failed; select the ${label} text manually.` : copyState === "copied" ? `${label} copied.` : ""}
+
+ {copyState === "failed" && (
+ event.currentTarget.select()}
+ />
+ )}
+
+ );
+}
+
+type GapGuidance = {
+ title: string;
+ action: string;
+ retry: "after_change" | "not_useful";
+};
+
+const GAP_GUIDANCE: Record = {
+ source_cut_atomicity_unavailable: {
+ title: "Atomic source cut is unavailable",
+ action: "No operator action can close this gap in the current Tally profile. The run may still be useful, but it must remain Partial.",
+ retry: "not_useful",
+ },
+ period_report_profile_unobserved: {
+ title: "Ledger-balance profile is not validated",
+ action: "Validate the exact release, mode, report configuration, scenario, optional-voucher behavior, and receipt/delivery-note tracking effects with a synthetic company before enabling this custom cross-view.",
+ retry: "not_useful",
+ },
+ voucher_header_entry_total_unavailable: {
+ title: "Voucher header totals are unavailable",
+ action: "Do not infer header totals from balanced entries. Extend the capability pack and validate the source fields first.",
+ retry: "not_useful",
+ },
+ voucher_entry_applicability_unavailable: {
+ title: "Voucher applicability is incomplete",
+ action: "Classify the voucher type and its book-effect semantics before treating missing entries as an error.",
+ retry: "not_useful",
+ },
+ record_provenance_unavailable: {
+ title: "Raw-record provenance is unavailable",
+ action: "Use a connector path that binds each canonical record to a source-fragment hash, then run a new evidence read.",
+ retry: "after_change",
+ },
+ report_tie_out_unavailable: {
+ title: "Ledger-balance cross-view did not complete",
+ action: "Check that Tally is responsive and the custom read-only report is supported, then run a new evidence read.",
+ retry: "after_change",
+ },
+ capability_profile_changed_during_run: {
+ title: "Capability profile changed during the run",
+ action: "Stabilize the Tally release, mode, loaded company, and endpoint configuration before retrying.",
+ retry: "after_change",
+ },
+ source_changed_during_run: {
+ title: "Source data changed during the run",
+ action: "Run again during a controlled quiet period. A stable reread still does not prove atomic isolation.",
+ retry: "after_change",
+ },
+ minimum_window_response_too_large: {
+ title: "One Tally day exceeds the bounded response limit",
+ action: "Bridge cannot split below one calendar day. Reduce that day's source density or use a future qualified collection filter before starting a new run; retrying unchanged will fail again.",
+ retry: "after_change",
+ },
+ adaptive_window_limit_reached: {
+ title: "Adaptive window safety limit reached",
+ action: "Start a new run for a shorter requested period. Bridge stopped before growing the durable split graph beyond its reviewed bound.",
+ retry: "after_change",
+ },
+};
+
+function guidanceForGap(code: string): GapGuidance {
+ return GAP_GUIDANCE[code] ?? {
+ title: formatIdentifier(code),
+ action: "Inspect the local Proof of Sync and support artifact. Do not retry unchanged until this gap's cause is understood.",
+ retry: "not_useful",
+ };
+}
+
+function GapMap({ codes, available }: { codes: string[]; available: boolean }) {
+ const uniqueCodes = Array.from(new Set(codes)).sort();
+ return (
+
+ {!available ? (
+
+ No inspected attempt; Gap Map unavailable
+ Load evidence or inspect a durable run before interpreting gaps.
+
+ ) : uniqueCodes.length === 0 ? (
+
+ No declared gaps in this attempt
+ This does not establish accuracy unless the attempt is explicitly Verified.
+
+ ) : uniqueCodes.map((code) => {
+ const guidance = guidanceForGap(code);
+ return (
+
+
+ {guidance.title}
+ {code}
+
+ {guidance.action}
+
+ {guidance.retry === "after_change" ? "Retry only after the stated change" : "Retrying unchanged is not useful"}
+
+
+ );
+ })}
+
+ );
+}
+
+type Props = {
+ // Cross-view Tally connection/session state -- also read by the
+ // dashboard, the company-context bar, and the Connect Tally view.
+ config: TallyConfig;
+ status: ConnectionStatus | null;
+ passport: CapabilityProfile | null;
+ tallyAction: TallyAction | null;
+ selectedCompanyRecord: TallyCompany | undefined;
+ selectedCompanyLive: boolean;
+ // The "Choose/change a saved company" panel, rendered by App() and
+ // passed down as a slot. `scripts/tally-setup-safety.test.mjs` asserts
+ // (against the full src/main.tsx text) on this panel's copy and on the
+ // exact `onClick`/`disabled` expressions of its buttons, so this markup
+ // -- and `savedCompanyList`/`savedCompanySelectionLocked`/
+ // `selectSavedCompany` behind it -- must stay in main.tsx. See the file
+ // header.
+ savedCompanyPicker: React.ReactNode;
+ companyError: OperatorError | null;
+ // The entire "Synthetic write fixture (advanced)" panel,
+ // rendered by App() and passed down as a slot rather than owned here.
+ // `scripts/tally-setup-safety.test.mjs` asserts (against the full
+ // src/main.tsx text) that its heading and its enroll/revoke handlers'
+ // exact `onClick` expressions are present in that file, so this markup
+ // -- and the write-fixture state and handlers behind it -- must stay in
+ // main.tsx rather than move here. See the file header.
+ fixtureControls: React.ReactNode;
+
+ // Requested accounting period. `startCoreSnapshot` (owned by App(), see
+ // below) reads it too, so it is not duplicated as view-local state.
+ voucherFrom: string;
+ setVoucherFrom: (value: string) => void;
+ voucherTo: string;
+ setVoucherTo: (value: string) => void;
+
+ // Sync evidence, Core Accounting snapshot runs, the mirror explorer, and
+ // the redacted proof preview -- all owned by App() (see the file header).
+ syncEvidence: TallySyncEvidence | null;
+ syncEvidenceError: OperatorError | null;
+ refreshSyncEvidence: (announce?: boolean) => Promise;
+ latestProof: TallyProofSummary | undefined;
+ mirrorTruthState: string;
+
+ snapshotJob: SnapshotJobStatus | null;
+ setSnapshotJob: (job: SnapshotJobStatus) => void;
+ snapshotSelectionVersion: React.MutableRefObject;
+ snapshotActive: boolean;
+ snapshotError: OperatorError | null;
+ snapshotStartOutcomeUnknown: boolean;
+ setSnapshotStartOutcomeUnknown: (value: boolean) => void;
+ startCoreSnapshot: () => Promise;
+ cancelCoreSnapshot: () => Promise;
+ resumeCoreSnapshot: (runId: string) => Promise;
+ selectedRecentSnapshotRuns: SnapshotJobStatus[];
+ refreshRecentSnapshots: () => Promise;
+ inspectedJob: SnapshotJobStatus | null;
+ activeGapCodes: string[];
+ activeWarningCodes: string[];
+
+ mirrorExplorer: MirrorExplorerPage | null;
+ mirrorExplorerError: OperatorError | null;
+ loadMirrorExplorerPage: (offset: number) => Promise;
+
+ proofPreview: RedactedProofPreview | null;
+ proofPreviewSelection: { proofId: string; runId: string } | null;
+ previewRedactedProof: (proof: TallyProofSummary) => Promise;
+
+ // Shared per-endpoint runtime/queue evidence -- also updated by the
+ // probe/discover/bootstrap handlers on other views and by a separate
+ // App()-level poll keyed on `tallyAction`/`snapshotActive`.
+ runtimeSessions: TallyRuntimeSnapshot[];
+ runtimeError: OperatorError | null;
+ refreshRuntime: () => Promise;
+ cancelTallyRequest: (requestId: string) => Promise;
+};
+
+export function MirrorProofScreen({
+ config,
+ status,
+ passport,
+ tallyAction,
+ selectedCompanyRecord,
+ selectedCompanyLive,
+ savedCompanyPicker,
+ companyError,
+ fixtureControls,
+ voucherFrom,
+ setVoucherFrom,
+ voucherTo,
+ setVoucherTo,
+ syncEvidence,
+ syncEvidenceError,
+ refreshSyncEvidence,
+ latestProof,
+ mirrorTruthState,
+ snapshotJob,
+ setSnapshotJob,
+ snapshotSelectionVersion,
+ snapshotActive,
+ snapshotError,
+ snapshotStartOutcomeUnknown,
+ setSnapshotStartOutcomeUnknown,
+ startCoreSnapshot,
+ cancelCoreSnapshot,
+ resumeCoreSnapshot,
+ selectedRecentSnapshotRuns,
+ refreshRecentSnapshots,
+ inspectedJob,
+ activeGapCodes,
+ activeWarningCodes,
+ mirrorExplorer,
+ mirrorExplorerError,
+ loadMirrorExplorerPage,
+ proofPreview,
+ proofPreviewSelection,
+ previewRedactedProof,
+ runtimeSessions,
+ runtimeError,
+ refreshRuntime,
+ cancelTallyRequest,
+}: Props) {
+ return (
+ <>
+ {savedCompanyPicker}
+
+
+
Truth state
+
{latestProof ? `${formatIdentifier(latestProof.outcome)} · ${formatIdentifier(latestProof.verification_state)} ${formatIdentifier(latestProof.pack_id)} attempt` : "No durable Core Accounting run receipt yet"}
+
+ {latestProof
+ ? `Within this run's declared Core Accounting scope, Bridge persisted ${latestProof.accepted_records} provenance-backed accepted canonical rows, ${latestProof.provenance_unavailable_records} canonical rows with an explicit provenance-unavailable gap, and ${latestProof.rejected_records} rejected rows. These are not Tally source-total counts. ${latestProof.gap_codes.length} declared gap(s) and ${latestProof.warning_codes.length} warning(s).`
+ : "Endpoint reachability and fetched preview rows do not establish a Verified accounting state."}
+
+
+
+
+ {formatIdentifier(mirrorTruthState)}
+
+
+ From setVoucherFrom(event.target.value)} />
+ To setVoucherTo(event.target.value)} />
+
+ {snapshotJob?.requested_from_yyyymmdd && snapshotJob.requested_to_yyyymmdd && (
+
+ Selected run period: {formatTallyDate(snapshotJob.requested_from_yyyymmdd)} to {formatTallyDate(snapshotJob.requested_to_yyyymmdd)}
+
+ )}
+
void refreshSyncEvidence(true)} disabled={!selectedCompanyRecord?.mirror_company_id || tallyAction !== null}>
+ {tallyAction === "evidence" ? "Refreshing..." : "Refresh evidence"}
+
+
void startCoreSnapshot()} disabled={!selectedCompanyRecord?.mirror_company_id || !selectedCompanyLive || snapshotActive || snapshotStartOutcomeUnknown || tallyAction !== null}>
+ {tallyAction === "start" ? "Starting..." : "Run read-only Core Accounting evidence read"}
+
+ {snapshotJob?.resume_available && (
+
void resumeCoreSnapshot(snapshotJob.run_id)} disabled={tallyAction !== null}>
+ {tallyAction === "resume" ? "Resuming..." : "Resume interrupted run"}
+
+ )}
+ {snapshotActive && (
+
void cancelCoreSnapshot()} disabled={tallyAction !== null}>{tallyAction === "cancel" ? "Cancelling..." : "Cancel active run"}
+ )}
+
+
+ Reads Bridge's declared Core Accounting v3 scope for this period. It is not a native Trial Balance, a complete-books guarantee, or an atomic Tally snapshot.
+
+ {syncEvidenceError && }
+ {snapshotError && }
+ {companyError && }
+ {snapshotStartOutcomeUnknown && (
+
+ A previous start outcome is unknown. Inspect the refreshed durable runs before allowing another start.
+ setSnapshotStartOutcomeUnknown(false)}>I reviewed the runs; allow a new start
+
+ )}
+
+ {snapshotJob && (
+
+ Run {snapshotJob.run_id}
+ Phase: {formatIdentifier(snapshotJob.phase)}
+ Completed executable windows: {snapshotJob.completed_windows}/{snapshotJob.total_windows}
+ {snapshotJob.verification ? `Result: ${formatIdentifier(snapshotJob.verification)}` : "No verification claim yet"}
+ {snapshotJob.failure_code && Failure: {formatIdentifier(snapshotJob.failure_code)} }
+ {snapshotJob.requires_resume && (
+ {snapshotJob.resume_available ? "Worker detached: explicit resume required" : "Detached legacy state: inspect only"}
+ )}
+
+ )}
+
+ {selectedRecentSnapshotRuns.length > 0 && (
+
+
+
+
Recent durable Core Accounting runs
+
Recovery status comes from hash-checked encrypted state, including runs discovered after an app restart.
+
+
void refreshRecentSnapshots()}>
+ Refresh runs
+
+
+
+
+ Showing up to 10 of {selectedRecentSnapshotRuns.length} loaded runs for {selectedCompanyRecord?.name}
+ Run Pack Phase Executable windows Worker Action
+
+ {selectedRecentSnapshotRuns.slice(0, 10).map((run) => (
+
+ {run.run_id}
+ {formatIdentifier(run.pack_id ?? "unknown")}
+ {formatIdentifier(run.phase)}
+ {run.completed_windows}/{run.total_windows}
+ {run.resume_available ? "Resume available" : run.requires_resume ? "Inspect only" : run.phase === "completed" || run.phase === "partial" || run.phase === "failed" || run.phase === "cancelled" ? "Terminal" : "Active"}
+ { snapshotSelectionVersion.current += 1; setSnapshotJob(run); setSnapshotStartOutcomeUnknown(false); }}>Inspect
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ Endpoint evidence
+ {status ? (status.compatible ? "Compatible status observed" : status.reachable ? "Reachable; compatibility unknown" : "Not reachable") : "Not checked"}
+ {status ? `${config.host}:${config.port}` : "Run Check Tally Endpoint to collect a current probe."}
+
+
+ Company pin
+ {selectedCompanyRecord?.mirror_company_id ? "Observed GUID persisted" : "Not established"}
+ {selectedCompanyRecord?.guid || selectedCompanyRecord?.guid_observed ? "GUID value is stored locally and hidden in this view." : "Select and probe a GUID-bearing company."}
+
+
+ Last verified
+ {formatRuntimeTime(syncEvidence?.core_accounting_freshness.verified_at_unix_ms)}
+ {syncEvidence ? formatIdentifier(syncEvidence.core_accounting_freshness.state) : "Evidence not loaded"}
+
+
+ Local verified checkpoint
+ {syncEvidence?.core_accounting_freshness.checkpoint_present ? "Bridge receipt committed" : "None"}
+ {syncEvidence?.core_accounting_freshness.proof_present ? "Bridge committed this local receipt atomically; it is not a Tally source watermark or source-isolation guarantee." : "Partial and failed runs never advance freshness."}
+
+
+ Incremental execution
+ {syncEvidence?.incremental.execution_enabled ? "Enabled" : "Incremental disabled; use a new full planned read"}
+
+ {syncEvidence
+ ? `${formatIdentifier(syncEvidence.incremental.state)} · ${syncEvidence.incremental.establishment_receipts} receipt(s), ${syncEvidence.incremental.active_checkpoint_heads} head(s)`
+ : "No exact-scope incremental evidence loaded. A full planned read does not imply source completeness or atomicity."}
+
+
+
+
+ {fixtureControls}
+
+
+
+
+
Gap Map
+
Declared limits for the inspected attempt, with remediation and retry guidance. An empty map is not a Verified claim.
+
+
{activeGapCodes.length} gap{activeGapCodes.length === 1 ? "" : "s"}
+
+
+ {inspectedJob && Gap Map scope: inspected run {inspectedJob.run_id}. This does not replace the separate latest-attempt summary.
}
+ {activeWarningCodes.length > 0 && (
+
+
Warnings
+
{activeWarningCodes.map((code) => {code} — {formatIdentifier(code)} )}
+
+ )}
+
+
+
+
+
+
Local mirror explorer
+
Paged, privacy-preserving metadata for the selected company and Core Accounting pack. Names, amounts, source IDs, and payloads are not returned to this view.
+
Totals describe rows currently held in Bridge's local mirror for the selected pack/run state. They are not Tally source counts and may reflect a Partial attempt. Aliases are page-local and may shift after later runs.
+
+
void loadMirrorExplorerPage(0)} disabled={!selectedCompanyRecord?.mirror_company_id || tallyAction !== null}>
+ {tallyAction === "explorer" ? "Loading..." : "Load mirror page"}
+
+
+ {mirrorExplorerError && }
+ {!mirrorExplorer ? (
+ Mirror page not loaded This local read does not contact Tally and remains available for persisted company pins.
+ ) : mirrorExplorer.records.length === 0 ? (
+ No local mirror rows in this selected pack scope The local query completed for this company and pack. This says nothing about records outside that scope.
+ ) : (
+ <>
+
+
+ Showing {mirrorExplorer.offset + 1}-{Math.min(mirrorExplorer.offset + mirrorExplorer.records.length, mirrorExplorer.total_records)} of {mirrorExplorer.total_records} local records. Absence on this page is not absence from the mirror.
+ Local alias Object Identity confidence Last batch Lifecycle
+ {mirrorExplorer.records.map((record) => (
+
+ {record.local_alias}
+ {formatIdentifier(record.object_type)}
+ {formatIdentifier(record.identity_confidence)}
+ {formatIdentifier(record.last_batch_state)}
+ {record.tombstoned ? "Tombstoned" : "Present in local mirror"}
+
+ ))}
+
+
+
+ void loadMirrorExplorerPage(Math.max(0, mirrorExplorer.offset - mirrorExplorer.limit))}>Previous page
+ Page {Math.floor(mirrorExplorer.offset / mirrorExplorer.limit) + 1}
+ = mirrorExplorer.total_records || tallyAction !== null} onClick={() => void loadMirrorExplorerPage(mirrorExplorer.offset + mirrorExplorer.limit)}>Next page
+
+ >
+ )}
+
+
+
+
+
+
Hash-linked local proof ledger
+
Append-only under Bridge's local controls. Hash checks detect inconsistency; this is not a signature, a tamper-proof audit log, or proof that the responder was genuine Tally.
+
+
Latest {syncEvidence?.latest_proofs.length ?? 0} loaded · 20-row API limit
+
+ {!latestProof ? (
+
+ No proof entries for this company
+ A production Core Accounting attempt will append its outcome, gaps, returned-row counts, and local proof hash here.
+
+ ) : (
+
+
+ Loaded Proof of Sync attempt summaries; accepted/rejected values are returned run-scope rows, not source-completeness counts; older history may not be loaded
+ Completed Run Pack Result Accepted / rejected returned rows Proof hash Gaps Warnings Support export
+
+ {syncEvidence?.latest_proofs.map((proof) => (
+
+ {formatRuntimeTime(proof.completed_at_unix_ms)}{formatDuration(proof.started_at_unix_ms, proof.completed_at_unix_ms)}
+ {proof.run_id}
+ {formatIdentifier(proof.pack_id)}
+ {formatIdentifier(proof.outcome)} · {formatIdentifier(proof.verification_state)} · Local hash check: {proof.integrity_state === "entry_hash_valid" ? "passed" : formatIdentifier(proof.integrity_state)}
+ {proof.accepted_records} / {proof.rejected_records}
+ {proof.proof_sha256.slice(0, 12)}...
+ {proof.gap_codes.length ? proof.gap_codes.map(formatIdentifier).join(", ") : "None declared"}
+ {proof.warning_codes.length ? proof.warning_codes.map(formatIdentifier).join(", ") : "None declared"}
+ void previewRedactedProof(proof)}>{proofPreviewSelection?.proofId === proof.selection_token ? "Loading/selected" : "Preview"}
+
+ ))}
+
+
+
+ )}
+ {proofPreview && (
+
+
+
+
Exact redacted support artifact for run {proofPreviewSelection?.runId ?? "unknown"}
+
Review these exact local-only bytes before saving. This is a checksum-backed local consistency record, not a signature or proof that the responder was genuine Tally.
+
+
+ Save reviewed JSON
+
+
+ Payload checksum: {proofPreview.payload_sha256}
+ {proofPreview.json}
+
+ )}
+ {!!syncEvidence?.latest_reconciliation_mismatches.length && (
+
+ Local reconciliation drill-down
+ Session-local aliases identify repeated affected records without exposing Tally IDs or book contents. They are deliberately excluded from the public support export.
+
+ {syncEvidence.latest_reconciliation_mismatches.map((mismatch) => (
+
+ {formatIdentifier(mismatch.reason_code)} : {mismatch.record_aliases.join(", ") || "No record alias available"}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
Pack readiness
+
Supported means the declared pack contract was observed for this exact profile; it does not mean complete books or a Verified run.
+
+
+
+
+
+
+ What “Verified” will require
+
+ Every requested scope and window completes.
+ Tally application status and payload validation pass.
+ The company identity matches the pinned source.
+ A product-supported atomic source cut or equally strong isolation mechanism is evidenced.
+ Declared reconciliation checks pass.
+
+
+ Until those results are reported, Bridge will not present previews, counts, or absence of errors as accounting accuracy.
+
+ {passport?.mode?.toLowerCase().includes("education") && (
+ The currently observed Education profile does not provide atomic source-cut evidence, so current Core Accounting runs remain Partial.
+ )}
+
+
+
+
+
+
+
Tally runtime
+
+ Per-endpoint queue and health evidence. A closed circuit means requests are allowed; it is not proof that a pack is complete.
+
+
+
void refreshRuntime()}>
+ Refresh
+
+
+ {runtimeError && }
+ {runtimeSessions.length === 0 ? (
+
+ No endpoint session yet
+ Run a Tally endpoint check to create one shared runtime session.
+
+ ) : (
+
+ {runtimeSessions.map((session) => (
+
+
+
+ {session.canonical_endpoint}
+ {formatIdentifier(session.circuit_state)} circuit · {session.active_requests} active · {session.issued_requests} issued
+
+
+ {formatIdentifier(session.circuit_state)}
+
+
+
+
Consecutive failures {session.consecutive_failures}
+
Last success {formatRuntimeTime(session.last_success_unix_ms)}
+
Last failure {formatRuntimeTime(session.last_failure_unix_ms)}
+
Capability observed {formatRuntimeTime(session.cached_capability_observed_at_unix_ms)}
+
+ {session.circuit_retry_after_unix_ms && (
+ Retry after {formatRuntimeTime(session.circuit_retry_after_unix_ms)}.
+ )}
+ {session.active_request_ids.length > 0 && (
+
+ {session.active_request_ids.map((requestId) => (
+
+ {requestId}
+
+ void cancelTallyRequest(requestId)}>Cancel request
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ >
+ );
+}
diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx
index 007ecd5..209b163 100644
--- a/src/OutstandingsScreen.tsx
+++ b/src/OutstandingsScreen.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { RefreshCw } from "lucide-react";
+import { Building2, ChevronRight, Download, RefreshCw } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { isNonRetryableOutstandingsBoundary, outstandingsAgeingDisclosure, outstandingsPartialState } from "./outstandings-copy";
import { canStartOutstandingsRead } from "./outstandings-currency";
@@ -8,6 +8,10 @@ type Props = {
config: { host: string; port: number };
company?: { name: string; guid: string };
onChangeSetup: () => void;
+ /// Switches to the cross-client view. Present only when more than one book
+ /// is open, because a scope switch with one option is noise.
+ onViewAllClients?: () => void;
+ openBookCount?: number;
};
type Report = {
@@ -40,19 +44,56 @@ type Report = {
source_bytes: number;
};
+type TopParty = Report["top_parties"][number];
+type PartySortKey = "party" | "outstanding" | "age";
+type PartySort = { key: PartySortKey; direction: "asc" | "desc" };
+
+type OpenBill = {
+ party: string;
+ reference: string;
+ bill_date: string;
+ due_date: string;
+ amount: string;
+ age_days: number;
+ kind: "receivable" | "payable";
+};
+
type LoadResult =
- | { state: "complete"; report: Report; currency_assertion: string; synced_at_unix_ms: number }
+ | {
+ state: "complete";
+ report: Report;
+ currency_assertion: string;
+ synced_at_unix_ms: number;
+ // Absent when the read path cannot establish it. Absent is not zero and
+ // must never render as zero.
+ unallocated_total?: string;
+ unallocated_by_party?: Array<{ party: string; amount: string }>;
+ // Absent on the voucher-scan path, which reads no per-bill detail.
+ // Absent must never render as "no open bills" -- it means the
+ // party row cannot be expanded at all.
+ open_bills?: Array;
+ }
| { state: "partial"; reason_code: string; synced_at_unix_ms: number };
type InrCompleteResult = Extract & { currency_assertion: "INR" };
-export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
+export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllClients, openBookCount = 1 }: Props) {
const [result, setResult] = React.useState(null);
const [error, setError] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [inrAssertedCompanyGuid, setInrAssertedCompanyGuid] = React.useState(null);
+ const [view, setView] = React.useState<"ageing" | "unallocated">("ageing");
+ const [exportNotice, setExportNotice] = React.useState<{ message: string; path?: string } | null>(null);
+ const [expandedParty, setExpandedParty] = React.useState(null);
+ const [partySort, setPartySort] = React.useState(null);
+ const [currencyCheck, setCurrencyCheck] = React.useState<"idle" | "checking" | "inr" | "undetermined">("idle");
const [, refreshClock] = React.useReducer((value) => value + 1, 0);
const requestVersion = React.useRef(0);
+ // Settles the initial tab once per loaded report, keyed on the report's own
+ // sync timestamp -- not on every render, and never after the operator has
+ // clicked a tab, since this effect only fires again when a NEW report
+ // arrives.
+ const defaultedViewForSyncedAt = React.useRef(null);
React.useEffect(() => {
const timer = window.setInterval(refreshClock, 30_000);
@@ -65,6 +106,7 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
setError(null);
setLoading(false);
setInrAssertedCompanyGuid(null);
+ setExpandedParty(null);
}, [config.host, config.port, company?.guid, company?.name]);
const readPermitted = canStartOutstandingsRead(company, inrAssertedCompanyGuid);
@@ -103,6 +145,59 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
// The screen is mounted only when the operator opens Outstandings.
}, [load, readPermitted]);
+ // Establish the base currency from Tally rather than making the operator
+ // assert it. The INR requirement stays -- putting a rupee symbol in front of
+ // a foreign balance misstates money -- but it is a fact Tally holds, and
+ // asking for it on every company was a step the product can answer itself.
+ // Where Tally cannot settle it (several currencies defined, or a non-Indian
+ // one) the manual confirmation below is still shown.
+ React.useEffect(() => {
+ if (!company || inrAssertedCompanyGuid === company.guid) return;
+ let cancelled = false;
+ setCurrencyCheck("checking");
+ void invoke<{ is_inr: boolean; mailing_name: string; currency_count: number }>(
+ "detect_tally_base_currency",
+ { request: { config, company: company.name, expected_company_guid: company.guid } },
+ )
+ .then((currency) => {
+ if (cancelled) return;
+ if (currency.is_inr) setInrAssertedCompanyGuid(company.guid);
+ setCurrencyCheck(currency.is_inr ? "inr" : "undetermined");
+ })
+ .catch(() => {
+ if (!cancelled) setCurrencyCheck("undetermined");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [config.host, config.port, company?.guid, company?.name]);
+
+ // Must sit above the early returns below: a hook placed after them runs on
+ // some renders and not others, which React rejects outright with "Rendered
+ // more hooks than during the previous render" -- and the whole screen blanks.
+ const inrCompleteResult = isInrCompleteResult(result) ? result : null;
+ // null when open_bills is absent entirely (voucher-scan path) -- distinct
+ // from a party simply having no rows in a present-but-partial array.
+ const openBillsByParty = React.useMemo(
+ () => billsByParty(inrCompleteResult?.open_bills),
+ [inrCompleteResult],
+ );
+
+ // On a book where most balances carry no bill reference, the ageing panel
+ // can describe a rounding error against total exposure. Default to
+ // whichever breakdown describes more money, but only once per report -- a
+ // later click on the other tab must never be overridden by this effect
+ // re-running on an unrelated render.
+ React.useEffect(() => {
+ if (!inrCompleteResult) return;
+ if (defaultedViewForSyncedAt.current === inrCompleteResult.synced_at_unix_ms) return;
+ defaultedViewForSyncedAt.current = inrCompleteResult.synced_at_unix_ms;
+ const unallocatedTotal = inrCompleteResult.unallocated_total;
+ if (unallocatedTotal === undefined) return;
+ const exposure = amountOf(inrCompleteResult.report.receivable_total) + amountOf(inrCompleteResult.report.payable_total);
+ setView(amountOf(unallocatedTotal) > exposure ? "unallocated" : "ageing");
+ }, [inrCompleteResult]);
+
if (!company) {
return (
@@ -114,18 +209,56 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
}
if (!readPermitted) {
+ if (currencyCheck === "checking" || currencyCheck === "idle") {
+ return (
+
+ Opening {company.name}
+ Reading the company’s base currency from Tally.
+
+ );
+ }
return (
- Confirm the company base currency
- Bridge cannot read a company’s base currency from the sealed Unit A export. Outstandings are available only after you explicitly confirm that the selected company uses INR.
+ Confirm the base currency
+ Tally did not settle this company’s base currency — it defines more than one currency, or one that is not the Indian rupee. Bridge shows totals in rupees, so confirm before continuing.
setInrAssertedCompanyGuid(company.guid)}>This company uses INR
);
}
- const completeResult = isInrCompleteResult(result) ? result : null;
+ const completeResult = inrCompleteResult;
const report = completeResult?.report ?? null;
- const ageingDisclosure = report && outstandingsAgeingDisclosure(report.has_unaged_receivable);
+ const composition = report
+ ? exposureComposition(report, completeResult?.unallocated_total)
+ : null;
+ const unallocatedParties = completeResult?.unallocated_by_party ?? [];
+ const largestUnallocated = Math.max(...unallocatedParties.map((entry) => amountOf(entry.amount)), 0);
+ const largestExposure = report
+ ? Math.max(...report.top_parties.map((party) => amountOf(party.outstanding_total)), 0)
+ : 0;
+ // Default order (no click yet) is exactly what Tally/Bridge already
+ // returned -- largest exposure first -- so an unsorted column never
+ // reshuffles rows the operator hasn't asked to sort.
+ const sortedTopParties = report && partySort
+ ? [...report.top_parties].sort(comparePartiesBy(partySort))
+ : report?.top_parties ?? [];
+ function togglePartySort(key: PartySortKey) {
+ setPartySort((current) => (current?.key === key
+ ? { key, direction: current.direction === "asc" ? "desc" : "asc" }
+ // Amount and age read naturally biggest/oldest first; a name reads
+ // naturally A-first. Each column's first click picks that direction.
+ : { key, direction: key === "party" ? "asc" : "desc" }));
+ }
+ function partyAriaSort(key: PartySortKey): React.AriaAttributes["aria-sort"] {
+ if (partySort?.key !== key) return "none";
+ return partySort.direction === "asc" ? "ascending" : "descending";
+ }
+ // Redundant once the unallocated total is known: that case now has its own
+ // "Unallocated" figure in the totals row and its own "Unallocated by
+ // party" tab, so this paragraph only earns its place when the total is
+ // NOT known and those two are absent.
+ const ageingDisclosure = report && completeResult?.unallocated_total === undefined
+ && outstandingsAgeingDisclosure(report.has_unaged_receivable);
const unsupportedCurrencyAssertion = result?.state === "complete" && !completeResult;
return (
@@ -140,25 +273,63 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
? `Checked ${relativeTime(result.synced_at_unix_ms)}`
: "No Tally data was read"
: "Not read in this session"}
- {report ? ` · ${report.source_voucher_count.toLocaleString("en-IN")} vouchers verified` : ""}
+ {report ? ` · ${readProvenance(report)}` : ""}
+ {onViewAllClients && openBookCount > 1 && (
+
+
+ Compare clients
+
+ )}
+ {completeResult && (
+ {
+ try {
+ const path = await exportCsv(completeResult);
+ setExportNotice({ message: fileNameOf(path), path });
+ } catch (cause) {
+ setExportNotice({ message: operatorMessage(cause) });
+ }
+ }}
+ >
+
+ Export
+
+ )}
Manage Tally
{!outstandingsUnavailable && (
- {loading ? "Reading verified segments…" : result ? "Refresh" : "Load outstandings"}
+ {loading ? "Reading…" : result ? "Refresh" : "Load outstandings"}
)}
+ {exportNotice && (
+
+
+ {exportNotice.path ? <>Saved {exportNotice.message} to Downloads> : exportNotice.message}
+
+
+ {exportNotice.path && (
+ void invoke("reveal_exported_file", { path: exportNotice.path })}>
+ {revealLabel()}
+
+ )}
+ setExportNotice(null)} aria-label="Dismiss">Dismiss
+
+
+ )}
{error && Read failed {error}
}
{loading && !report && (
- Reading verified segments
- Bridge is checking each voucher segment twice. Totals stay withheld until complete coverage is proven.
+ Reading from Tally
+ Every read is taken twice and compared. Totals stay withheld unless both copies agree.
)}
{!loading && result?.state === "partial" && (
@@ -179,24 +350,86 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
Receivable {formatMoney(completeResult.report.receivable_total, completeResult.currency_assertion)}
Payable {formatMoney(completeResult.report.payable_total, completeResult.currency_assertion)}
+ {completeResult.unallocated_total !== undefined && (
+
+ Unallocatedno bill reference
+ {formatMoney(completeResult.unallocated_total, completeResult.currency_assertion)}
+
+ )}
-
-
Receivable ageing (bill references only) as of {formatDate(completeResult.report.as_of_yyyymmdd)}
- {[
- ["0–30", completeResult.report.ageing.days_0_30],
- ["31–60", completeResult.report.ageing.days_31_60],
- ["61–90", completeResult.report.ageing.days_61_90],
- ["90+", completeResult.report.ageing.days_90_plus],
- ].map(([label, amount]) => (
-
{label} days {formatMoney(amount, completeResult.currency_assertion)}
+ {/* Part-to-whole. The three figures above are routinely orders of
+ magnitude apart -- on a bulk book the unallocated share is >99% --
+ and equal-sized tiles hide exactly that. */}
+ {composition && composition.length > 1 && (
+
+
+ {composition.map((slice) => (
+
+ ))}
+
+
+ {composition.map((slice) => (
+
+
+ {slice.label} {slice.share}
+
+ ))}
+
+
+ )}
+
+
+
+ {unallocatedParties.length > 0 ? (
+
+ setView("ageing")}>Ageing
+ setView("unallocated")}>Unallocated by party
+
+ ) :
Receivable ageing }
+
+ {view === "ageing"
+ ? `bill references only · as of ${formatDate(completeResult.report.as_of_yyyymmdd)}`
+ : `no bill reference · ${unallocatedParties.length} ${unallocatedParties.length === 1 ? "party" : "parties"}`}
+
+
+ {view === "ageing" && ageingRows(completeResult.report).map((row) => (
+
+ {row.label}
+
+
+
+ {formatMoney(row.amount, completeResult.currency_assertion)}
+ {row.count === 0 ? "—" : `${row.count} ${row.count === 1 ? "bill" : "bills"}`}
+
))}
+ {view === "unallocated" && unallocatedParties.map((entry) => {
+ const percent = largestUnallocated > 0
+ ? Math.max(1, (amountOf(entry.amount) / largestUnallocated) * 100)
+ : 0;
+ return (
+
+ {entry.party}
+
+
+
+ {formatMoney(entry.amount, completeResult.currency_assertion)}
+
+ );
+ })}
- {ageingDisclosure && (
-
- {ageingDisclosure}
-
- )}
+ {ageingDisclosure &&
{ageingDisclosure}
}
@@ -205,22 +438,86 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
{completeResult.report.top_parties.length ? (
-
-
Party
-
Outstanding
-
Oldest bill reference
+
+ togglePartySort("party")}>Party
+ togglePartySort("outstanding")}>Outstanding
+ togglePartySort("age")}>Oldest bill reference
- {completeResult.report.top_parties.map((party) => {
+ {sortedTopParties.map((party) => {
const hasReceivable = party.receivable !== "0";
const hasPayable = party.payable !== "0";
const kind = hasReceivable && hasPayable
? "Receivable + payable"
: hasReceivable ? "Receivable" : "Payable";
- return (
-
-
{party.party} {kind}
+ const share = largestExposure > 0
+ ? Math.max(1, (amountOf(party.outstanding_total) / largestExposure) * 100)
+ : 0;
+ const expandable = openBillsByParty !== null;
+ const isExpanded = expandable && expandedParty === party.party;
+ const panelId = `party-bills-${slugify(party.party)}`;
+ const rowCells = (
+ <>
+ {/* Rank is readable from the row itself rather than from
+ comparing ten right-aligned numbers. */}
+
+
+ {expandable && (
+
+ )}
+
{party.party} {kind}
+
{formatMoney(party.outstanding_total, completeResult.currency_assertion)}
-
{party.oldest_bill_age_days === null ? "No bill reference" : `${party.oldest_bill_age_days} days`}
+
+ {party.oldest_bill_age_days === null
+ ? no bill reference
+ : {party.oldest_bill_age_days}d }
+
+ >
+ );
+ return (
+
+ {expandable ? (
+
setExpandedParty(isExpanded ? null : party.party)}
+ >
+ {rowCells}
+
+ ) : (
+
+ {rowCells}
+
+ )}
+ {isExpanded && (
+
+
+ {
+ try {
+ const path = await exportPartyStatement(completeResult, party.party);
+ setExportNotice({ message: fileNameOf(path), path });
+ } catch (cause) {
+ setExportNotice({ message: operatorMessage(cause) });
+ }
+ }}
+ >
+
+ Statement
+
+
+ {renderPartyBills(openBillsByParty?.get(party.party), completeResult.currency_assertion)}
+
+ )}
);
})}
@@ -231,13 +528,247 @@ export function OutstandingsScreen({ config, company, onChangeSetup }: Props) {
) : !loading && !result && !error ? (
Ready for a read-only scan
- Bridge will pin the company, read bounded voucher segments twice, and show numbers only when both copies agree.
+ Bridge pins the company by GUID, reads each report twice, and shows numbers only when both copies agree.
) : null}
);
}
+/// Builds one party's statement as an `.xlsx` workbook via the Rust command
+/// and writes it to Downloads. Sends the `open_bills`/`unallocated_by_party`
+/// rows this screen already holds from `fetch_tally_outstandings` -- Bridge
+/// never reads Tally a second time to produce a statement.
+async function exportPartyStatement(result: InrCompleteResult, party: string) {
+ return invoke
("export_party_statement", {
+ request: {
+ company: result.report.company_name,
+ as_of_yyyymmdd: result.report.as_of_yyyymmdd,
+ party,
+ open_bills: result.open_bills ?? [],
+ unallocated_by_party: result.unallocated_by_party ?? [],
+ },
+ });
+}
+
+/// Builds the report as CSV.
+///
+/// Amounts are written as raw decimal strings, never the rupee-formatted
+/// display value: `₹4,69,474.80` lands in a spreadsheet as text and silently
+/// breaks every downstream SUM. The disclosure rows are part of the export
+/// because a figure that needs a caveat on screen needs it in the file too --
+/// the file is what gets forwarded.
+async function exportCsv(result: InrCompleteResult) {
+ const csv = reportToCsv(result.report, result.unallocated_total, result.unallocated_by_party);
+ const slug = result.report.company_name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
+ // A BOM so Excel reads UTF-8 party names instead of mojibake -- Indian
+ // ledger names routinely carry non-ASCII characters.
+ return invoke("save_report_download", {
+ fileName: `outstandings-${slug}-${result.report.as_of_yyyymmdd}.csv`,
+ contents: `\ufeff${csv}`,
+ });
+}
+
+function reportToCsv(
+ report: Report,
+ unallocatedTotal: string | undefined,
+ unallocatedByParty: Array<{ party: string; amount: string }> | undefined,
+) {
+ const cell = (value: string | number) => {
+ const text = String(value);
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+ };
+ const row = (...values: Array) => values.map(cell).join(",");
+
+ const lines = [
+ row("Bridge — aged outstandings"),
+ row("Company", report.company_name),
+ row("As of", formatDate(report.as_of_yyyymmdd)),
+ row("Currency", "INR"),
+ "",
+ row("Measure", "Amount"),
+ row("Receivable", report.receivable_total),
+ row("Payable", report.payable_total),
+ ...(unallocatedTotal === undefined ? [] : [row("Unallocated (no bill reference)", unallocatedTotal)]),
+ "",
+ row("Receivable ageing (bill references only)", "Amount", "Bills"),
+ row("0-30 days", report.ageing.days_0_30, report.ageing_bill_counts.days_0_30),
+ row("31-60 days", report.ageing.days_31_60, report.ageing_bill_counts.days_31_60),
+ row("61-90 days", report.ageing.days_61_90, report.ageing_bill_counts.days_61_90),
+ row("90+ days", report.ageing.days_90_plus, report.ageing_bill_counts.days_90_plus),
+ "",
+ row("Party", "Receivable", "Payable", "Outstanding", "Oldest bill (days)"),
+ ...report.top_parties.map((party) => row(
+ party.party,
+ party.receivable,
+ party.payable,
+ party.outstanding_total,
+ party.oldest_bill_age_days === null ? "no bill reference" : party.oldest_bill_age_days,
+ )),
+ ];
+
+ if (unallocatedByParty && unallocatedByParty.length > 0) {
+ lines.push("", row("Unallocated by party", "Amount"));
+ for (const entry of unallocatedByParty) lines.push(row(entry.party, entry.amount));
+ }
+
+ if (report.has_unaged_receivable) {
+ lines.push("", row("Note", "Receivable includes entries with no bill reference. Tally gives them no bill and no age, so they are excluded from the ageing buckets."));
+ }
+ return lines.join("\n");
+}
+
+function fileNameOf(path: string) {
+ const parts = path.split(/[\\/]/);
+ return parts[parts.length - 1] || path;
+}
+
+/// "Show in Finder" on macOS, "Show in Explorer" on Windows -- naming the
+/// user's own file manager rather than a generic "Open folder".
+function revealLabel() {
+ const platform = navigator.userAgent;
+ if (platform.includes("Mac")) return "Show in Finder";
+ if (platform.includes("Win")) return "Show in Explorer";
+ return "Open folder";
+}
+
+/// Bills with no reference carry `oldest_bill_age_days: null`. They sort
+/// after every aged party regardless of direction -- "no bill reference" is
+/// not younger or older than an actual age, so a direction flip must not
+/// move it to the top.
+function comparePartiesBy(sort: PartySort) {
+ return (a: TopParty, b: TopParty) => {
+ let cmp: number;
+ if (sort.key === "party") {
+ cmp = a.party.localeCompare(b.party);
+ } else if (sort.key === "outstanding") {
+ cmp = amountOf(a.outstanding_total) - amountOf(b.outstanding_total);
+ } else {
+ if (a.oldest_bill_age_days === null && b.oldest_bill_age_days === null) return 0;
+ if (a.oldest_bill_age_days === null) return 1;
+ if (b.oldest_bill_age_days === null) return -1;
+ cmp = a.oldest_bill_age_days - b.oldest_bill_age_days;
+ }
+ return sort.direction === "asc" ? cmp : -cmp;
+ };
+}
+
+function amountOf(value: string) {
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) ? Math.abs(parsed) : 0;
+}
+
+/// Ageing rows carry their own bar width. Widths are relative to the LARGEST
+/// bucket, not to the total: on a book where one bucket holds everything, a
+/// total-relative scale renders the other three as invisible slivers and the
+/// distribution reads as a single block.
+function ageingRows(report: Report) {
+ const rows = [
+ { label: "0–30", amount: report.ageing.days_0_30, count: report.ageing_bill_counts.days_0_30, tier: 1 },
+ { label: "31–60", amount: report.ageing.days_31_60, count: report.ageing_bill_counts.days_31_60, tier: 2 },
+ { label: "61–90", amount: report.ageing.days_61_90, count: report.ageing_bill_counts.days_61_90, tier: 3 },
+ { label: "90+", amount: report.ageing.days_90_plus, count: report.ageing_bill_counts.days_90_plus, tier: 4 },
+ ];
+ const largest = Math.max(...rows.map((row) => amountOf(row.amount)), 0);
+ return rows.map((row) => ({
+ ...row,
+ percent: largest > 0 ? (amountOf(row.amount) / largest) * 100 : 0,
+ }));
+}
+
+/// Groups open bills by exact party name for the drill-down. Returns null
+/// when `open_bills` is absent entirely (the voucher-scan path never sends
+/// it) -- that null is what tells a row it must not be expandable at all,
+/// distinct from a present array that simply has no rows for this party.
+function billsByParty(openBills: Array | undefined): Map> | null {
+ if (openBills === undefined) return null;
+ const map = new Map>();
+ for (const bill of openBills) {
+ const list = map.get(bill.party);
+ if (list) list.push(bill);
+ else map.set(bill.party, [bill]);
+ }
+ return map;
+}
+
+function slugify(value: string) {
+ return value.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase();
+}
+
+/// Bill rows for one party's drill-down. A party can have exposure entirely
+/// from unallocated entries -- common, and on some books most parties -- so
+/// an empty (but present) list gets a one-line explanation rather than a
+/// blank area.
+function renderPartyBills(bills: Array | undefined, currencyAssertion: "INR") {
+ if (!bills || bills.length === 0) {
+ return No bill references — this party's balance is unallocated.
;
+ }
+ return (
+
+
+ Reference
+ Bill date
+ Amount
+ Age
+
+ {bills.map((bill, index) => {
+ // A bill and due date that differ mean this party has a credit
+ // period -- the reason Tally's ageing can outrun a naive
+ // bill-date calculation, and worth surfacing rather than hiding.
+ const hasCreditPeriod = bill.due_date !== bill.bill_date;
+ return (
+
+ {bill.reference || "—"}
+
+ {formatDate(bill.bill_date)}
+ {hasCreditPeriod && due {formatDate(bill.due_date)} }
+
+ {formatMoney(bill.amount, currencyAssertion)}
+ {bill.age_days}d
+
+ );
+ })}
+
+ );
+}
+
+function ageTier(days: number) {
+ if (days <= 30) return 1;
+ if (days <= 60) return 2;
+ if (days <= 90) return 3;
+ return 4;
+}
+
+function exposureComposition(report: Report, unallocatedTotal: string | undefined) {
+ const slices = [
+ { label: "Receivable", value: amountOf(report.receivable_total), tone: "receivable" },
+ { label: "Payable", value: amountOf(report.payable_total), tone: "payable" },
+ ...(unallocatedTotal === undefined
+ ? []
+ : [{ label: "Unallocated", value: amountOf(unallocatedTotal), tone: "unallocated" }]),
+ ].filter((slice) => slice.value > 0);
+ const total = slices.reduce((sum, slice) => sum + slice.value, 0);
+ if (total <= 0) return null;
+ return slices.map((slice) => {
+ const percent = (slice.value / total) * 100;
+ // Sub-1% slices would otherwise round to "0%" while still being drawn.
+ const share = percent < 1 ? "<1%" : `${Math.round(percent)}%`;
+ return { ...slice, share };
+ });
+}
+
+// Describes what was actually read. The native bills path reads no vouchers at
+// all, so reporting a voucher count there would be a false provenance claim —
+// and "0 vouchers verified" reads as a failure rather than as a different,
+// cheaper read.
+function readProvenance(report: Report) {
+ if (report.source_voucher_count > 0) {
+ return `${report.source_voucher_count.toLocaleString("en-IN")} vouchers verified`;
+ }
+ const bills = report.open_receivable_bill_count.toLocaleString("en-IN");
+ return `${bills} open ${report.open_receivable_bill_count === 1 ? "bill" : "bills"} read from Tally`;
+}
+
function formatMoney(value: string, currencyAssertion: "INR") {
const negative = value.startsWith("-");
const unsigned = negative ? value.slice(1) : value;
diff --git a/src/main.tsx b/src/main.tsx
index 51f586e..f7ad691 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import { Activity, Building2, Cable, Check, CircleHelp, Cloud, Database, FileText, FolderOpen, KeyRound, Play, RefreshCw, ShieldCheck, UploadCloud } from "lucide-react";
+import { Activity, Building2, Cable, Check, Cloud, Database, FileText, FolderOpen, KeyRound, Play, ShieldCheck } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import {
applyProbeCompanySelectionTransition,
@@ -14,6 +14,13 @@ import {
import { classifyTallyError } from "./tally-error-copy";
import { TallyReadinessFlow } from "./TallyReadinessFlow";
import { OutstandingsScreen } from "./OutstandingsScreen";
+import { AllClientsScreen } from "./AllClientsScreen";
+import { GstScreen } from "./GstScreen";
+import { DscScreen } from "./DscScreen";
+import { DocumentsScreen } from "./DocumentsScreen";
+import { AxalScreen } from "./AxalScreen";
+import { MirrorProofScreen } from "./MirrorProofScreen";
+import { ErrorBoundary } from "./ErrorBoundary";
import "./styles.css";
type TallyConfig = {
@@ -218,7 +225,7 @@ type TallyRuntimeSnapshot = {
cached_capability_observed_at_unix_ms?: number;
};
-type GstReturnDraft = {
+export type GstReturnDraft = {
company: string;
financial_year: string;
gstr1: {
@@ -237,53 +244,8 @@ type GstReturnDraft = {
missing_fields: string[];
};
-type DscCertificate = {
- label: string;
- common_name?: string | null;
- organization?: string | null;
- issuer_name?: string | null;
- serial_number?: string | null;
- valid_from?: string | null;
- valid_to?: string | null;
- fingerprint?: string | null;
- parse_error?: string | null;
-};
-
-type DscAttempt = {
- token_type: string;
- library_path: string;
- library_exists: boolean;
- loaded: boolean;
- initialized: boolean;
- slot_count: number;
- login_success: boolean;
- certificate_count?: number | null;
- certificates: DscCertificate[];
- error?: string | null;
-};
-
-type DscProbeReport = {
- platform: string;
- arch: string;
- force_load: boolean;
- detect_only: boolean;
- attempts: DscAttempt[];
-};
-
type AxalIntegration = "tally" | "documents" | "dsc";
-type AxalValidationResponse = {
- valid: boolean;
- status?: string | null;
- last_synced?: string | null;
- error?: string | null;
-};
-
-type AxalSessionResponse = {
- credentialSessionId: string;
- validation: AxalValidationResponse;
-};
-
type AxalConnectionStatus = {
connected: boolean;
status: string;
@@ -297,59 +259,7 @@ type AxalConnectionStatus = {
};
};
-type DscSyncResponse = {
- success: boolean;
- message: string;
- results?: {
- created: number;
- updated: number;
- skipped: number;
- errors: string[];
- } | null;
-};
-
-type DocumentFile = {
- scanId: string;
- relativePath: string;
- size: number;
- mtime: number;
- extension?: string | null;
- mimeType: string;
- hash?: string | null;
- contentHash?: string | null;
- serverFileKey?: string | null;
- multipartInfo?: {
- uploadId: string;
- parts: {
- partNumber: number;
- etag: string;
- size: number;
- bytesRead: number;
- }[];
- } | null;
-};
-
-type ScanDocumentsResponse = {
- scanSessionId: string;
- files: DocumentFile[];
- totalSize: number;
- skipped: { path: string; reason: string }[];
-};
-
-type SyncDocumentsResponse = {
- success: boolean;
- uploadedFiles: DocumentFile[];
- failedFiles: { relativePath: string; error: string }[];
- duplicateCount: number;
- batchIds: string[];
-};
-
-type SelectedDocumentPath = {
- selectionId: string;
- displayName: string;
-};
-
-type View = "dashboard" | "outstandings" | "companies" | "gst" | "mirror" | "dsc" | "documents" | "axal";
+type View = "dashboard" | "clients" | "outstandings" | "companies" | "gst" | "mirror" | "dsc" | "documents" | "axal";
type TallyAction = "probe" | "discover" | "bootstrap" | "save" | "fixture_enroll" | "fixture_revoke" | "evidence" | "explorer" | "start" | "resume" | "cancel";
const TABLE_PREVIEW_LIMIT = 100;
@@ -357,6 +267,7 @@ const MIRROR_PAGE_LIMIT = 25;
const VIEW_TITLES: Record = {
dashboard: "Tally evidence dashboard",
+ clients: "All clients",
outstandings: "Aged outstandings",
companies: "Connect Tally",
gst: "GST return readiness",
@@ -462,106 +373,6 @@ function CapabilityRows({
);
}
-type GapGuidance = {
- title: string;
- action: string;
- retry: "after_change" | "not_useful";
-};
-
-const GAP_GUIDANCE: Record = {
- source_cut_atomicity_unavailable: {
- title: "Atomic source cut is unavailable",
- action: "No operator action can close this gap in the current Tally profile. The run may still be useful, but it must remain Partial.",
- retry: "not_useful",
- },
- period_report_profile_unobserved: {
- title: "Ledger-balance profile is not validated",
- action: "Validate the exact release, mode, report configuration, scenario, optional-voucher behavior, and receipt/delivery-note tracking effects with a synthetic company before enabling this custom cross-view.",
- retry: "not_useful",
- },
- voucher_header_entry_total_unavailable: {
- title: "Voucher header totals are unavailable",
- action: "Do not infer header totals from balanced entries. Extend the capability pack and validate the source fields first.",
- retry: "not_useful",
- },
- voucher_entry_applicability_unavailable: {
- title: "Voucher applicability is incomplete",
- action: "Classify the voucher type and its book-effect semantics before treating missing entries as an error.",
- retry: "not_useful",
- },
- record_provenance_unavailable: {
- title: "Raw-record provenance is unavailable",
- action: "Use a connector path that binds each canonical record to a source-fragment hash, then run a new evidence read.",
- retry: "after_change",
- },
- report_tie_out_unavailable: {
- title: "Ledger-balance cross-view did not complete",
- action: "Check that Tally is responsive and the custom read-only report is supported, then run a new evidence read.",
- retry: "after_change",
- },
- capability_profile_changed_during_run: {
- title: "Capability profile changed during the run",
- action: "Stabilize the Tally release, mode, loaded company, and endpoint configuration before retrying.",
- retry: "after_change",
- },
- source_changed_during_run: {
- title: "Source data changed during the run",
- action: "Run again during a controlled quiet period. A stable reread still does not prove atomic isolation.",
- retry: "after_change",
- },
- minimum_window_response_too_large: {
- title: "One Tally day exceeds the bounded response limit",
- action: "Bridge cannot split below one calendar day. Reduce that day's source density or use a future qualified collection filter before starting a new run; retrying unchanged will fail again.",
- retry: "after_change",
- },
- adaptive_window_limit_reached: {
- title: "Adaptive window safety limit reached",
- action: "Start a new run for a shorter requested period. Bridge stopped before growing the durable split graph beyond its reviewed bound.",
- retry: "after_change",
- },
-};
-
-function guidanceForGap(code: string): GapGuidance {
- return GAP_GUIDANCE[code] ?? {
- title: formatIdentifier(code),
- action: "Inspect the local Proof of Sync and support artifact. Do not retry unchanged until this gap's cause is understood.",
- retry: "not_useful",
- };
-}
-
-function GapMap({ codes, available }: { codes: string[]; available: boolean }) {
- const uniqueCodes = Array.from(new Set(codes)).sort();
- return (
-
- {!available ? (
-
- No inspected attempt; Gap Map unavailable
- Load evidence or inspect a durable run before interpreting gaps.
-
- ) : uniqueCodes.length === 0 ? (
-
- No declared gaps in this attempt
- This does not establish accuracy unless the attempt is explicitly Verified.
-
- ) : uniqueCodes.map((code) => {
- const guidance = guidanceForGap(code);
- return (
-
-
- {guidance.title}
- {code}
-
- {guidance.action}
-
- {guidance.retry === "after_change" ? "Retry only after the stated change" : "Retrying unchanged is not useful"}
-
-
- );
- })}
-
- );
-}
-
function TallyErrorNotice({ message }: { message: OperatorError }) {
const guidance = classifyTallyError(typeof message === "string" ? { message } : message);
const displayMessage = typeof message === "string" ? message : message.message;
@@ -583,40 +394,6 @@ function TallyErrorNotice({ message }: { message: OperatorError }) {
);
}
-function CopyTokenButton({ value, label }: { value: string; label: string }) {
- const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle");
- async function copy() {
- try {
- await navigator.clipboard.writeText(value);
- setCopyState("copied");
- window.setTimeout(() => setCopyState("idle"), 1500);
- } catch {
- setCopyState("failed");
- }
- }
- return (
-
- void copy()} aria-label={`Copy ${label}`}>
- {copyState === "copied" ? "Copied" : "Copy"}
-
-
- {copyState === "failed" ? `Copy failed; select the ${label} text manually.` : copyState === "copied" ? `${label} copied.` : ""}
-
- {copyState === "failed" && (
- event.currentTarget.select()}
- />
- )}
-
- );
-}
-
-const DSC_METADATA_RETENTION_MS = 5 * 60 * 1000;
-
function App() {
const currentFinancialYear = React.useMemo(() => getCurrentFinancialYear(), []);
const [config, setConfig] = React.useState({ host: "localhost", port: 9000 });
@@ -634,6 +411,11 @@ function App() {
const [untrustedDiscoveryError, setUntrustedDiscoveryError] = React.useState(null);
const [selectedCompany, setSelectedCompany] = React.useState("");
const [liveCompanyKeys, setLiveCompanyKeys] = React.useState([]);
+ // Every company Tally reports as open. Kept separate from
+ // `untrustedDiscoveredCompanies`, which is deliberately cleared once a
+ // company verifies -- clearing that list is what made the other open books
+ // disappear from the UI the moment one was chosen.
+ const [openCompanyNames, setOpenCompanyNames] = React.useState([]);
const [persistedCompanyProfileTotal, setPersistedCompanyProfileTotal] = React.useState(0);
const [persistedCompanyProfilesLoaded, setPersistedCompanyProfilesLoaded] = React.useState(0);
const [persistedCompanyProfilesTruncated, setPersistedCompanyProfilesTruncated] = React.useState(false);
@@ -659,54 +441,18 @@ function App() {
const [gstCompany, setGstCompany] = React.useState("");
const [gstFinancialYear, setGstFinancialYear] = React.useState(currentFinancialYear.label);
const [draft, setDraft] = React.useState(null);
- const [dscReport, setDscReport] = React.useState(null);
- const [dscDetectReport, setDscDetectReport] = React.useState(null);
- const [dscPin, setDscPin] = React.useState("");
- const [dscError, setDscError] = React.useState(null);
- const [dscAction, setDscAction] = React.useState<"detect" | "extract" | null>(null);
- const [dscSync, setDscSync] = React.useState(null);
- const [dscSyncing, setDscSyncing] = React.useState(false);
- const [axalBaseUrl, setAxalBaseUrl] = React.useState("https://complyeaze.com");
- const [axalIntegration, setAxalIntegration] = React.useState("dsc");
- const [axalApiId, setAxalApiId] = React.useState("");
- const [axalApiKey, setAxalApiKey] = React.useState("");
+ // Owned by App() and shared with the DSC, Documents, and AXAL views --
+ // AxalScreen both reads and writes these two (see its Props comment).
const [axalSession, setAxalSession] = React.useState<{ id: string; integration: AxalIntegration } | null>(null);
- const [axalValidation, setAxalValidation] = React.useState(null);
const [axalConnection, setAxalConnection] = React.useState(null);
- const [axalError, setAxalError] = React.useState(null);
- const [axalAction, setAxalAction] = React.useState<"validate" | "status" | null>(null);
- const [documentPaths, setDocumentPaths] = React.useState([]);
- const [documentScan, setDocumentScan] = React.useState(null);
- const [documentSync, setDocumentSync] = React.useState(null);
- const [documentError, setDocumentError] = React.useState(null);
- const [documentAction, setDocumentAction] = React.useState<"scan" | "sync" | null>(null);
const [view, setView] = React.useState("dashboard");
const [busy, setBusy] = React.useState(false);
const [tallyAction, setTallyAction] = React.useState(null);
const tallyResultsVersion = React.useRef(0);
const proofPreviewRequestVersion = React.useRef(0);
const snapshotSelectionVersion = React.useRef(0);
- const dscRequestVersion = React.useRef(0);
const mainContentRef = React.useRef(null);
- const clearDscSensitiveState = React.useCallback(() => {
- dscRequestVersion.current += 1;
- setDscReport(null);
- setDscDetectReport(null);
- setDscPin("");
- setDscSync(null);
- }, []);
-
- React.useEffect(() => {
- if (!dscReport && !dscDetectReport && !dscPin && !dscSync) return;
- const expiry = window.setTimeout(clearDscSensitiveState, DSC_METADATA_RETENTION_MS);
- return () => window.clearTimeout(expiry);
- }, [clearDscSensitiveState, dscDetectReport, dscPin, dscReport, dscSync]);
-
- React.useEffect(() => {
- if (view !== "dsc") clearDscSensitiveState();
- }, [clearDscSensitiveState, view]);
-
const refreshRuntime = React.useCallback(async () => {
try {
const snapshots = await invoke("tally_runtime_snapshots");
@@ -739,10 +485,24 @@ function App() {
}
}, []);
+ // Both of these are backed by the encrypted mirror, and touching the mirror
+ // resolves its key from the OS keychain -- which prompts. Running them on
+ // mount meant every launch prompted before the operator had done anything,
+ // and it defeated the lazy mirror initialisation in the Rust layer entirely.
+ // Fetch each only once a view that actually needs it is open.
React.useEffect(() => {
+ // Mirror only. The app opens on the dashboard, so including it here would
+ // have kept the boot-time keychain prompt exactly as it was. The dashboard's
+ // "latest attempt" line derives from `snapshotJob`, which a user action
+ // sets -- it does not need the recent-runs list to render.
+ if (view !== "mirror") return;
void refreshRecentSnapshots();
+ }, [view, refreshRecentSnapshots]);
+
+ React.useEffect(() => {
+ if (view !== "companies" && view !== "outstandings" && view !== "clients") return;
void refreshPersistedCompanyProfiles();
- }, [refreshRecentSnapshots, refreshPersistedCompanyProfiles]);
+ }, [view, refreshPersistedCompanyProfiles]);
React.useEffect(() => {
mainContentRef.current?.focus();
@@ -945,6 +705,8 @@ function App() {
const discovered = await invoke("fetch_tally_companies", { config });
if (discoveryResultsVersion === tallyResultsVersion.current) {
setUntrustedDiscoveredCompanies(discovered);
+ setOpenCompanyNames(discovered.map((candidate) => candidate.name));
+ setOpenCompanyNames(discovered.map((candidate) => candidate.name));
}
} catch (error) {
if (discoveryResultsVersion === tallyResultsVersion.current) {
@@ -981,6 +743,7 @@ function App() {
const discovered = await invoke("fetch_tally_companies", { config });
if (resultsVersion === tallyResultsVersion.current) {
setUntrustedDiscoveredCompanies(discovered);
+ setOpenCompanyNames(discovered.map((candidate) => candidate.name));
}
} catch (error) {
if (resultsVersion === tallyResultsVersion.current) {
@@ -1021,7 +784,13 @@ function App() {
setSelectedReadScope(result.selected_read_scope ?? null);
setPassportSnapshotId(result.passport_snapshot_id ?? null);
setCompanies((current) => mergeTallyCompanies(liveCompanies, current));
- setLiveCompanyKeys(nextLiveCompanyKeys);
+ // MERGE, never replace. This probe is scoped to one company via
+ // SVCURRENTCOMPANY, so it returns exactly one row by design --
+ // replacing the live set with it made Bridge forget every other
+ // book open in Tally the moment a company was chosen, which is
+ // what reduced the all-clients read to "1 of 1 book".
+ setLiveCompanyKeys((current) =>
+ Array.from(new Set([...current, ...nextLiveCompanyKeys])));
},
},
);
@@ -1406,251 +1175,17 @@ function App() {
}
}
- async function runDsc(detectOnly: boolean) {
- const pin = dscPin;
- if (!detectOnly && !pin) {
- setDscError("Enter the DSC token PIN before extracting certificates.");
- return;
- }
-
- const requestVersion = ++dscRequestVersion.current;
- setBusy(true);
- setDscAction(detectOnly ? "detect" : "extract");
- setDscError(null);
- setDscReport(null);
- setDscDetectReport(null);
- setDscSync(null);
- if (!detectOnly) {
- setDscPin("");
- }
- try {
- const result = detectOnly
- ? await invoke("detect_dsc_token")
- : await invoke("extract_dsc_certificates", { pins: [pin] });
- if (requestVersion === dscRequestVersion.current) {
- if (detectOnly) {
- setDscDetectReport(result);
- } else {
- setDscReport(result);
- }
- }
- } catch (error) {
- if (requestVersion === dscRequestVersion.current) {
- setDscError(error instanceof Error ? error.message : String(error));
- }
- } finally {
- setBusy(false);
- setDscAction(null);
- }
- }
-
- function axalCredentials() {
- return {
- api_key: axalApiKey,
- api_id: axalApiId,
- integration: axalIntegration,
- base_url: axalBaseUrl,
- };
- }
-
- function invalidateAxalSession() {
- const sessionId = axalSession?.id;
- setAxalSession(null);
- setAxalConnection(null);
- if (sessionId) {
- void invoke("revoke_axal_credential_session", {
- credentialSessionId: sessionId,
- }).catch(() => undefined);
- }
- }
-
- async function validateAxal() {
- setBusy(true);
- setAxalAction("validate");
- setAxalError(null);
- try {
- const result = await invoke("validate_axal_credentials", {
- credentials: axalCredentials(),
- });
- setAxalValidation(result.validation);
- setAxalSession({ id: result.credentialSessionId, integration: axalIntegration });
- setAxalConnection(null);
- } catch (error) {
- setAxalError(error instanceof Error ? error.message : String(error));
- } finally {
- setAxalApiKey("");
- setBusy(false);
- setAxalAction(null);
- }
- }
-
- async function checkAxalStatus() {
- if (!axalSession) {
- setAxalError("Validate AXAL credentials before checking connection status.");
- return;
- }
- setBusy(true);
- setAxalAction("status");
- setAxalError(null);
- try {
- const result = await invoke("check_axal_connection_status", {
- credentialSessionId: axalSession.id,
- });
- setAxalConnection(result);
- } catch (error) {
- setAxalError(error instanceof Error ? error.message : String(error));
- } finally {
- setBusy(false);
- setAxalAction(null);
- }
- }
-
- async function syncDscCertificate() {
- if (!primaryCertificate || !successfulDscAttempt || !axalConnection || axalSession?.integration !== "dsc") {
- setDscError("Extract a certificate and check AXAL workspace status before syncing.");
- return;
- }
-
- setDscSyncing(true);
- setDscError(null);
- try {
- const holderName =
- primaryCertificate.common_name || primaryCertificate.organization || primaryCertificate.label;
- const result = await invoke("sync_dsc_certificates_to_axal", {
- request: {
- credentialSessionId: axalSession.id,
- workspaceExternalId: axalConnection.workspace.id,
- certificates: [
- {
- holderName,
- provider: primaryCertificate.issuer_name || "Unknown",
- serialNumber: primaryCertificate.serial_number || "",
- tokenType: successfulDscAttempt.token_type,
- class: "Unknown",
- purpose: "Digital Signature",
- issueDate: primaryCertificate.valid_from || "",
- expirationDate: primaryCertificate.valid_to || "",
- clientName: holderName,
- metadata: {
- organization: primaryCertificate.organization,
- issuer: primaryCertificate.issuer_name,
- fingerprint: primaryCertificate.fingerprint,
- tokenType: successfulDscAttempt.token_type,
- },
- },
- ],
- },
- });
- setDscSync(result);
- } catch (error) {
- setDscError(error instanceof Error ? error.message : String(error));
- } finally {
- setDscSyncing(false);
- }
- }
-
- async function scanDocuments() {
- setBusy(true);
- setDocumentAction("scan");
- setDocumentError(null);
- setDocumentSync(null);
- try {
- const result = await invoke("scan_document_paths", {
- request: {
- selection_ids: documentPaths.map((path) => path.selectionId),
- use_hash: true,
- exclude_hidden_files: true,
- exclude_zero_byte_files: true,
- },
- });
- setDocumentScan(result);
- } catch (error) {
- setDocumentError(error instanceof Error ? error.message : String(error));
- } finally {
- setBusy(false);
- setDocumentAction(null);
- }
- }
-
- async function chooseDocumentFiles() {
- setDocumentError(null);
- try {
- const paths = await invoke("select_document_files");
- if (paths.length > 0) {
- setDocumentPaths((current) => [...current, ...paths]);
- setDocumentScan(null);
- setDocumentSync(null);
- }
- } catch (error) {
- setDocumentError(error instanceof Error ? error.message : String(error));
- }
- }
-
- async function chooseDocumentFolder() {
- setDocumentError(null);
- try {
- const paths = await invoke("select_document_folder");
- if (paths.length > 0) {
- setDocumentPaths((current) => [...current, ...paths]);
- setDocumentScan(null);
- setDocumentSync(null);
- }
- } catch (error) {
- setDocumentError(error instanceof Error ? error.message : String(error));
- }
- }
-
- function clearDocuments() {
- void invoke("revoke_document_authorizations", {
- selectionIds: documentPaths.map((path) => path.selectionId),
- scanSessionId: documentScan?.scanSessionId ?? null,
- }).catch(() => undefined);
- setDocumentPaths([]);
- setDocumentScan(null);
- setDocumentSync(null);
- }
-
- async function syncDocuments() {
- if (!documentScan?.files.length || !axalConnection || axalSession?.integration !== "documents") {
- setDocumentError("Scan files and check AXAL workspace status before syncing documents.");
- return;
- }
-
- setBusy(true);
- setDocumentAction("sync");
- setDocumentError(null);
- try {
- const result = await invoke("sync_documents_to_axal", {
- request: {
- credentialSessionId: axalSession.id,
- workspaceExternalId: axalConnection.workspace.id,
- scanSessionId: documentScan.scanSessionId,
- files: documentScan.files,
- maxFilesPerBatch: 20,
- },
- });
- setDocumentSync(result);
- } catch (error) {
- setDocumentError(error instanceof Error ? error.message : String(error));
- } finally {
- setBusy(false);
- setDocumentAction(null);
- }
- }
-
- const successfulDscAttempt = dscReport?.attempts.find(
- (attempt) => attempt.login_success && attempt.certificates.length > 0,
- );
- const detectedDscAttempt = dscDetectReport?.attempts.find(
- (attempt) => attempt.loaded && attempt.initialized && attempt.slot_count > 0 && !attempt.error,
- );
- const primaryCertificate =
- successfulDscAttempt?.certificates.find((certificate) => certificate.common_name) ??
- successfulDscAttempt?.certificates[0];
const gstDraftComplete = draft !== null && draft.missing_fields.length === 0;
const selectedCompanyRecord = companies.find((company) => tallyCompanyKey(company) === selectedCompany);
const selectedCompanyLive = !!selectedCompanyRecord && liveCompanyKeys.includes(tallyCompanyKey(selectedCompanyRecord));
const currentProbeCompanyList = currentProbeCompanies(companies, liveCompanyKeys);
+ // Open in Tally but not already offered as a verified choice. Compared by
+ // name because a discovered candidate has no GUID until it is verified --
+ // establishing that GUID is exactly what choosing it does.
+ const verifiedCompanyNames = new Set(currentProbeCompanyList.map((company) => company.name));
+ const otherOpenCompanies = openCompanyNames
+ .filter((name) => !verifiedCompanyNames.has(name))
+ .map((name) => ({ name }));
const savedCompanyList = companies.filter((company) => Boolean(company.mirror_company_id));
const setupConnectionComplete = Boolean(status?.reachable && passport);
const selectedCompanyReady = tallyReadinessState({
@@ -1757,8 +1292,8 @@ function App() {
Dashboard
setView(selectedCompanyReady ? "outstandings" : "companies")}
>
Tally
@@ -1784,10 +1319,18 @@ function App() {
- {view !== "companies" &&
{view === "outstandings" ? "Receivables and payables" : "Tally Truth Layer"}
}
+ {view !== "companies" && (
+
+ {view === "outstandings"
+ ? "Receivables and payables"
+ : view === "clients"
+ ? "Every book open in Tally"
+ : "Tally Truth Layer"}
+
+ )}
{VIEW_TITLES[view]}
- {!["outstandings", "companies"].includes(view) && (
+ {!["outstandings", "companies", "clients"].includes(view) && (
{tallyAction === "probe" ? "Checking endpoint..." : "Check Tally Endpoint"}
@@ -1795,7 +1338,7 @@ function App() {
)}
- {!["outstandings", "companies"].includes(view) && (
+ {!["outstandings", "companies", "clients"].includes(view) && (
Selected company
@@ -1849,6 +1392,7 @@ function App() {
)}
{view === "dashboard" && (
+
<>
@@ -1984,17 +1528,46 @@ function App() {
DSC: token detection and certificate extraction
>
+
+ )}
+
+ {view === "clients" && (
+
+ company.guid)
+ .map((company) => ({ name: company.name, guid: company.guid as string }))}
+ onOpenCompany={(company) => {
+ setSelectedCompany(tallyCompanyKey({ name: company.name, guid: company.guid }));
+ setView("outstandings");
+ }}
+ onBack={() => setView("outstandings")}
+ />
+
)}
{view === "outstandings" && (
+
setView("companies")}
+ onViewAllClients={() => setView("clients")}
+ openBookCount={currentProbeCompanyList.filter((entry) => entry.guid).length}
/>
+
)}
{view === "companies" && (
+
<>
)}
+ {/* Switching to another client's book must not require noticing
+ that "Check Tally again" repopulates a hidden list.
+ Bridge's company report carries no HEADER/STATUS, so the
+ probe can never mark it trusted; the verified picker above
+ therefore only ever lists companies already SAVED, and
+ every other open book was unreachable once one was saved.
+ These stay a visually separate, clearly-unverified group --
+ choosing one runs the scoped bootstrap that verifies it. */}
+ {currentProbeCompanyList.length > 0 && otherOpenCompanies.length > 0 && (
+
+
Other companies open in Tally
+
Bridge verifies a company’s identity when you choose it.
+
+ {otherOpenCompanies.slice(0, TABLE_PREVIEW_LIMIT).map((company, index) => (
+ void bootstrapDirectCompany(company.name)}
+ disabled={snapshotActive || tallyAction !== null}
+ >
+
+ {company.name}
+ {tallyAction === "bootstrap" ? "Checking…" : "Switch to this company"}
+
+ ))}
+
+
+ )}
{selectedCompany && !selectedCompanyLive ?
Open this company in Tally, then check Tally again.
: null}
{selectedCompanyLive && !selectedCompanyReady && (
@@ -2088,50 +1690,25 @@ function App() {
)}
>
+
)}
{view === "gst" && (
- !draft || !gstDraftComplete ? (
-
- GST calculation unavailable
-
-
- No verified GST draft
-
- {draft
- ? draft.missing_fields.join(" ")
- : "Use GST preparation on the dashboard to check availability. Zero values are not assumed."}
-
-
-
- ) : (
-
-
- GSTR-1 draft
-
-
B2B invoices {draft.gstr1.b2b_invoice_count}
-
B2C invoices {draft.gstr1.b2c_invoice_count}
-
Credit/debit notes {draft.gstr1.credit_debit_note_count}
-
HSN summaries {draft.gstr1.hsn_summary_count}
-
-
-
- GSTR-3B draft
-
-
Taxable value {draft.gstr3b.outward_taxable_value}
-
IGST {draft.gstr3b.integrated_tax}
-
CGST {draft.gstr3b.central_tax}
-
SGST {draft.gstr3b.state_tax}
-
-
-
-
- )
+
+
+
)}
{view === "mirror" && (
- <>
- {savedCompanyList.length > 0 && (
+
+ 0 && (
{selectedCompanyRecord?.mirror_company_id ? "Saved company" : "Choose a saved company"}
Review local Mirror & Proof evidence without contacting Tally.
@@ -2153,135 +1730,8 @@ function App() {
)}
)}
-
-
-
Truth state
-
{latestProof ? `${formatIdentifier(latestProof.outcome)} · ${formatIdentifier(latestProof.verification_state)} ${formatIdentifier(latestProof.pack_id)} attempt` : "No durable Core Accounting run receipt yet"}
-
- {latestProof
- ? `Within this run's declared Core Accounting scope, Bridge persisted ${latestProof.accepted_records} provenance-backed accepted canonical rows, ${latestProof.provenance_unavailable_records} canonical rows with an explicit provenance-unavailable gap, and ${latestProof.rejected_records} rejected rows. These are not Tally source-total counts. ${latestProof.gap_codes.length} declared gap(s) and ${latestProof.warning_codes.length} warning(s).`
- : "Endpoint reachability and fetched preview rows do not establish a Verified accounting state."}
-
-
-
-
- {formatIdentifier(mirrorTruthState)}
-
-
- From setVoucherFrom(event.target.value)} />
- To setVoucherTo(event.target.value)} />
-
- {snapshotJob?.requested_from_yyyymmdd && snapshotJob.requested_to_yyyymmdd && (
-
- Selected run period: {formatTallyDate(snapshotJob.requested_from_yyyymmdd)} to {formatTallyDate(snapshotJob.requested_to_yyyymmdd)}
-
- )}
-
void refreshSyncEvidence(true)} disabled={!selectedCompanyRecord?.mirror_company_id || tallyAction !== null}>
- {tallyAction === "evidence" ? "Refreshing..." : "Refresh evidence"}
-
-
void startCoreSnapshot()} disabled={!selectedCompanyRecord?.mirror_company_id || !selectedCompanyLive || snapshotActive || snapshotStartOutcomeUnknown || tallyAction !== null}>
- {tallyAction === "start" ? "Starting..." : "Run read-only Core Accounting evidence read"}
-
- {snapshotJob?.resume_available && (
-
void resumeCoreSnapshot(snapshotJob.run_id)} disabled={tallyAction !== null}>
- {tallyAction === "resume" ? "Resuming..." : "Resume interrupted run"}
-
- )}
- {snapshotActive && (
-
void cancelCoreSnapshot()} disabled={tallyAction !== null}>{tallyAction === "cancel" ? "Cancelling..." : "Cancel active run"}
- )}
-
-
- Reads Bridge's declared Core Accounting v3 scope for this period. It is not a native Trial Balance, a complete-books guarantee, or an atomic Tally snapshot.
-
- {syncEvidenceError && }
- {snapshotError && }
- {companyError && }
- {snapshotStartOutcomeUnknown && (
-
- A previous start outcome is unknown. Inspect the refreshed durable runs before allowing another start.
- setSnapshotStartOutcomeUnknown(false)}>I reviewed the runs; allow a new start
-
- )}
-
- {snapshotJob && (
-
- Run {snapshotJob.run_id}
- Phase: {formatIdentifier(snapshotJob.phase)}
- Completed executable windows: {snapshotJob.completed_windows}/{snapshotJob.total_windows}
- {snapshotJob.verification ? `Result: ${formatIdentifier(snapshotJob.verification)}` : "No verification claim yet"}
- {snapshotJob.failure_code && Failure: {formatIdentifier(snapshotJob.failure_code)} }
- {snapshotJob.requires_resume && (
- {snapshotJob.resume_available ? "Worker detached: explicit resume required" : "Detached legacy state: inspect only"}
- )}
-
- )}
-
- {selectedRecentSnapshotRuns.length > 0 && (
-
-
-
-
Recent durable Core Accounting runs
-
Recovery status comes from hash-checked encrypted state, including runs discovered after an app restart.
-
-
void refreshRecentSnapshots()}>
- Refresh runs
-
-
-
-
- Showing up to 10 of {selectedRecentSnapshotRuns.length} loaded runs for {selectedCompanyRecord?.name}
- Run Pack Phase Executable windows Worker Action
-
- {selectedRecentSnapshotRuns.slice(0, 10).map((run) => (
-
- {run.run_id}
- {formatIdentifier(run.pack_id ?? "unknown")}
- {formatIdentifier(run.phase)}
- {run.completed_windows}/{run.total_windows}
- {run.resume_available ? "Resume available" : run.requires_resume ? "Inspect only" : run.phase === "completed" || run.phase === "partial" || run.phase === "failed" || run.phase === "cancelled" ? "Terminal" : "Active"}
- { snapshotSelectionVersion.current += 1; setSnapshotJob(run); setSnapshotStartOutcomeUnknown(false); }}>Inspect
-
- ))}
-
-
-
-
- )}
-
-
-
- Endpoint evidence
- {status ? (status.compatible ? "Compatible status observed" : status.reachable ? "Reachable; compatibility unknown" : "Not reachable") : "Not checked"}
- {status ? `${config.host}:${config.port}` : "Run Check Tally Endpoint to collect a current probe."}
-
-
- Company pin
- {selectedCompanyRecord?.mirror_company_id ? "Observed GUID persisted" : "Not established"}
- {selectedCompanyRecord?.guid || selectedCompanyRecord?.guid_observed ? "GUID value is stored locally and hidden in this view." : "Select and probe a GUID-bearing company."}
-
-
- Last verified
- {formatRuntimeTime(syncEvidence?.core_accounting_freshness.verified_at_unix_ms)}
- {syncEvidence ? formatIdentifier(syncEvidence.core_accounting_freshness.state) : "Evidence not loaded"}
-
-
- Local verified checkpoint
- {syncEvidence?.core_accounting_freshness.checkpoint_present ? "Bridge receipt committed" : "None"}
- {syncEvidence?.core_accounting_freshness.proof_present ? "Bridge committed this local receipt atomically; it is not a Tally source watermark or source-isolation guarantee." : "Partial and failed runs never advance freshness."}
-
-
- Incremental execution
- {syncEvidence?.incremental.execution_enabled ? "Enabled" : "Incremental disabled; use a new full planned read"}
-
- {syncEvidence
- ? `${formatIdentifier(syncEvidence.incremental.state)} · ${syncEvidence.incremental.establishment_receipts} receipt(s), ${syncEvidence.incremental.active_checkpoint_heads} head(s)`
- : "No exact-scope incremental evidence loaded. A full planned read does not imply source completeness or atomicity."}
-
-
-
-
- {selectedCompanyRecord?.mirror_company_id && (
+ companyError={companyError}
+ fixtureControls={selectedCompanyRecord?.mirror_company_id && (
Synthetic write fixture (advanced)
@@ -2324,502 +1774,67 @@ function App() {
)}
)}
-
-
-
-
-
Gap Map
-
Declared limits for the inspected attempt, with remediation and retry guidance. An empty map is not a Verified claim.
-
-
{activeGapCodes.length} gap{activeGapCodes.length === 1 ? "" : "s"}
-
-
- {inspectedJob && Gap Map scope: inspected run {inspectedJob.run_id}. This does not replace the separate latest-attempt summary.
}
- {activeWarningCodes.length > 0 && (
-
-
Warnings
-
{activeWarningCodes.map((code) => {code} — {formatIdentifier(code)} )}
-
- )}
-
-
-
-
-
-
Local mirror explorer
-
Paged, privacy-preserving metadata for the selected company and Core Accounting pack. Names, amounts, source IDs, and payloads are not returned to this view.
-
Totals describe rows currently held in Bridge's local mirror for the selected pack/run state. They are not Tally source counts and may reflect a Partial attempt. Aliases are page-local and may shift after later runs.
-
-
void loadMirrorExplorerPage(0)} disabled={!selectedCompanyRecord?.mirror_company_id || tallyAction !== null}>
- {tallyAction === "explorer" ? "Loading..." : "Load mirror page"}
-
-
- {mirrorExplorerError && }
- {!mirrorExplorer ? (
- Mirror page not loaded This local read does not contact Tally and remains available for persisted company pins.
- ) : mirrorExplorer.records.length === 0 ? (
- No local mirror rows in this selected pack scope The local query completed for this company and pack. This says nothing about records outside that scope.
- ) : (
- <>
-
-
- Showing {mirrorExplorer.offset + 1}-{Math.min(mirrorExplorer.offset + mirrorExplorer.records.length, mirrorExplorer.total_records)} of {mirrorExplorer.total_records} local records. Absence on this page is not absence from the mirror.
- Local alias Object Identity confidence Last batch Lifecycle
- {mirrorExplorer.records.map((record) => (
-
- {record.local_alias}
- {formatIdentifier(record.object_type)}
- {formatIdentifier(record.identity_confidence)}
- {formatIdentifier(record.last_batch_state)}
- {record.tombstoned ? "Tombstoned" : "Present in local mirror"}
-
- ))}
-
-
-
- void loadMirrorExplorerPage(Math.max(0, mirrorExplorer.offset - mirrorExplorer.limit))}>Previous page
- Page {Math.floor(mirrorExplorer.offset / mirrorExplorer.limit) + 1}
- = mirrorExplorer.total_records || tallyAction !== null} onClick={() => void loadMirrorExplorerPage(mirrorExplorer.offset + mirrorExplorer.limit)}>Next page
-
- >
- )}
-
-
-
-
-
-
Hash-linked local proof ledger
-
Append-only under Bridge's local controls. Hash checks detect inconsistency; this is not a signature, a tamper-proof audit log, or proof that the responder was genuine Tally.
-
-
Latest {syncEvidence?.latest_proofs.length ?? 0} loaded · 20-row API limit
-
- {!latestProof ? (
-
- No proof entries for this company
- A production Core Accounting attempt will append its outcome, gaps, returned-row counts, and local proof hash here.
-
- ) : (
-
-
- Loaded Proof of Sync attempt summaries; accepted/rejected values are returned run-scope rows, not source-completeness counts; older history may not be loaded
- Completed Run Pack Result Accepted / rejected returned rows Proof hash Gaps Warnings Support export
-
- {syncEvidence?.latest_proofs.map((proof) => (
-
- {formatRuntimeTime(proof.completed_at_unix_ms)}{formatDuration(proof.started_at_unix_ms, proof.completed_at_unix_ms)}
- {proof.run_id}
- {formatIdentifier(proof.pack_id)}
- {formatIdentifier(proof.outcome)} · {formatIdentifier(proof.verification_state)} · Local hash check: {proof.integrity_state === "entry_hash_valid" ? "passed" : formatIdentifier(proof.integrity_state)}
- {proof.accepted_records} / {proof.rejected_records}
- {proof.proof_sha256.slice(0, 12)}...
- {proof.gap_codes.length ? proof.gap_codes.map(formatIdentifier).join(", ") : "None declared"}
- {proof.warning_codes.length ? proof.warning_codes.map(formatIdentifier).join(", ") : "None declared"}
- void previewRedactedProof(proof)}>{proofPreviewSelection?.proofId === proof.selection_token ? "Loading/selected" : "Preview"}
-
- ))}
-
-
-
- )}
- {proofPreview && (
-
-
-
-
Exact redacted support artifact for run {proofPreviewSelection?.runId ?? "unknown"}
-
Review these exact local-only bytes before saving. This is a checksum-backed local consistency record, not a signature or proof that the responder was genuine Tally.
-
-
- Save reviewed JSON
-
-
- Payload checksum: {proofPreview.payload_sha256}
- {proofPreview.json}
-
- )}
- {!!syncEvidence?.latest_reconciliation_mismatches.length && (
-
- Local reconciliation drill-down
- Session-local aliases identify repeated affected records without exposing Tally IDs or book contents. They are deliberately excluded from the public support export.
-
- {syncEvidence.latest_reconciliation_mismatches.map((mismatch) => (
-
- {formatIdentifier(mismatch.reason_code)} : {mismatch.record_aliases.join(", ") || "No record alias available"}
-
- ))}
-
-
- )}
-
-
-
-
-
-
-
Pack readiness
-
Supported means the declared pack contract was observed for this exact profile; it does not mean complete books or a Verified run.
-
-
-
-
-
-
- What “Verified” will require
-
- Every requested scope and window completes.
- Tally application status and payload validation pass.
- The company identity matches the pinned source.
- A product-supported atomic source cut or equally strong isolation mechanism is evidenced.
- Declared reconciliation checks pass.
-
-
- Until those results are reported, Bridge will not present previews, counts, or absence of errors as accounting accuracy.
-
- {passport?.mode?.toLowerCase().includes("education") && (
- The currently observed Education profile does not provide atomic source-cut evidence, so current Core Accounting runs remain Partial.
- )}
-
-
-
-
-
-
-
Tally runtime
-
- Per-endpoint queue and health evidence. A closed circuit means requests are allowed; it is not proof that a pack is complete.
-
-
-
void refreshRuntime()}>
- Refresh
-
-
- {runtimeError && }
- {runtimeSessions.length === 0 ? (
-
- No endpoint session yet
- Run a Tally endpoint check to create one shared runtime session.
-
- ) : (
-
- {runtimeSessions.map((session) => (
-
-
-
- {session.canonical_endpoint}
- {formatIdentifier(session.circuit_state)} circuit · {session.active_requests} active · {session.issued_requests} issued
-
-
- {formatIdentifier(session.circuit_state)}
-
-
-
-
Consecutive failures {session.consecutive_failures}
-
Last success {formatRuntimeTime(session.last_success_unix_ms)}
-
Last failure {formatRuntimeTime(session.last_failure_unix_ms)}
-
Capability observed {formatRuntimeTime(session.cached_capability_observed_at_unix_ms)}
-
- {session.circuit_retry_after_unix_ms && (
- Retry after {formatRuntimeTime(session.circuit_retry_after_unix_ms)}.
- )}
- {session.active_request_ids.length > 0 && (
-
- {session.active_request_ids.map((requestId) => (
-
- {requestId}
-
- void cancelTallyRequest(requestId)}>Cancel request
-
- ))}
-
- )}
-
- ))}
-
- )}
-
- >
+ voucherFrom={voucherFrom}
+ setVoucherFrom={setVoucherFrom}
+ voucherTo={voucherTo}
+ setVoucherTo={setVoucherTo}
+ syncEvidence={syncEvidence}
+ syncEvidenceError={syncEvidenceError}
+ refreshSyncEvidence={refreshSyncEvidence}
+ latestProof={latestProof}
+ mirrorTruthState={mirrorTruthState}
+ snapshotJob={snapshotJob}
+ setSnapshotJob={setSnapshotJob}
+ snapshotSelectionVersion={snapshotSelectionVersion}
+ snapshotActive={snapshotActive}
+ snapshotError={snapshotError}
+ snapshotStartOutcomeUnknown={snapshotStartOutcomeUnknown}
+ setSnapshotStartOutcomeUnknown={setSnapshotStartOutcomeUnknown}
+ startCoreSnapshot={startCoreSnapshot}
+ cancelCoreSnapshot={cancelCoreSnapshot}
+ resumeCoreSnapshot={resumeCoreSnapshot}
+ selectedRecentSnapshotRuns={selectedRecentSnapshotRuns}
+ refreshRecentSnapshots={refreshRecentSnapshots}
+ inspectedJob={inspectedJob}
+ activeGapCodes={activeGapCodes}
+ activeWarningCodes={activeWarningCodes}
+ mirrorExplorer={mirrorExplorer}
+ mirrorExplorerError={mirrorExplorerError}
+ loadMirrorExplorerPage={loadMirrorExplorerPage}
+ proofPreview={proofPreview}
+ proofPreviewSelection={proofPreviewSelection}
+ previewRedactedProof={previewRedactedProof}
+ runtimeSessions={runtimeSessions}
+ runtimeError={runtimeError}
+ refreshRuntime={refreshRuntime}
+ cancelTallyRequest={cancelTallyRequest}
+ />
+
)}
{view === "dsc" && (
- <>
-
-
- {dscError &&
{dscError}
}
-
-
-
- Certificate summary
- {dscAction ? (
-
-
- {dscAction === "detect" ? "Detecting token" : "Reading certificate"}
- This can take a few seconds while the token library initializes.
-
- ) : primaryCertificate ? (
-
-
Client {primaryCertificate.common_name || primaryCertificate.organization || primaryCertificate.label}
-
Expiry {primaryCertificate.valid_to || "Unknown"}
-
Serial {primaryCertificate.serial_number || "Unknown"}
-
Provider {successfulDscAttempt?.token_type ?? "Unknown"}
-
Certificates {successfulDscAttempt?.certificate_count ?? successfulDscAttempt?.certificates.length ?? 0}
-
AXAL sync {dscSync?.message || "Not synced"}
-
- ) : detectedDscAttempt ? (
-
-
- Token detected
- {detectedDscAttempt.token_type} token is available. Extract certificates to show holder details.
-
- ) : (
-
-
- No certificate loaded
- Detect the token or extract certificates to show DSC holder details.
-
- )}
- {primaryCertificate && (
-
-
-
- {dscSyncing ? "Syncing..." : "Sync Certificate"}
-
-
- )}
- {(dscReport || dscDetectReport) && (
-
-
- Clear certificate details
-
- Certificate and token details clear automatically after five minutes.
-
- )}
-
-
- >
+
+
+
)}
{view === "documents" && (
- <>
-
-
-
- Choose Files
-
-
-
- Choose Folder
-
-
- Clear
-
-
-
- {documentAction === "scan" ? "Scanning..." : "Scan"}
-
-
-
- {documentAction === "sync" ? "Syncing..." : "Sync Documents"}
-
-
-
- {documentError &&
{documentError}
}
-
-
-
-
Selected paths
- {documentPaths.length} selected
-
- {documentPaths.length === 0 ? (
-
-
- No paths selected
- Choose files or a folder before scanning.
-
- ) : (
-
- {documentPaths.map((path) => (
-
{path.displayName}
- ))}
-
- )}
-
-
-
-
- Scan summary
- {documentAction === "scan" ? (
-
-
- Scanning documents
- Hashing files and preparing document metadata.
-
- ) : (
-
-
Files {documentScan?.files.length ?? 0}
-
Total size {formatBytes(documentScan?.totalSize ?? 0)}
-
Skipped {documentScan?.skipped.length ?? 0}
-
Workspace {axalConnection?.workspace.name || "Check AXAL status first"}
-
- )}
-
-
-
- Sync summary
- {documentAction === "sync" ? (
-
-
- Uploading documents
- Requesting upload URLs, sending files, and confirming the batch.
-
- ) : (
-
-
Status {documentSync ? (documentSync.success ? "Complete" : "Partial") : "Not synced"}
-
Uploaded {documentSync?.uploadedFiles.length ?? 0}
-
Failed {documentSync?.failedFiles.length ?? 0}
-
Duplicates {documentSync?.duplicateCount ?? 0}
-
- )}
-
-
-
-
-
-
Files
- {formatPreviewCount(documentScan?.files.length ?? 0, "ready")}
-
- {!documentScan?.files.length ? (
-
-
- No files scanned
- Enter one or more file/folder paths, then scan.
-
- ) : (
-
-
-
-
- Path
- Type
- Size
- Hash
-
-
-
- {documentScan.files.slice(0, TABLE_PREVIEW_LIMIT).map((file) => (
-
- {file.relativePath}
- {file.mimeType}
- {formatBytes(file.size)}
- {file.contentHash ? `${file.contentHash.slice(0, 12)}...` : "-"}
-
- ))}
-
-
-
- )}
-
- >
+
+
+
)}
{view === "axal" && (
- <>
-
-
-
-
- {axalError &&
{axalError}
}
-
-
-
- Credential validation
- {axalAction === "validate" ? (
-
-
- Validating credentials
- Checking the API key against AXAL.
-
- ) : (
-
-
Status {axalValidation ? (axalValidation.valid ? "Valid" : "Invalid") : "Not checked"}
-
Server state {axalValidation?.status || "-"}
-
Last synced {axalValidation?.last_synced || "-"}
-
Error {axalValidation?.error || "-"}
-
- )}
-
-
-
- Workspace status
- {axalAction === "status" ? (
-
-
- Checking workspace
- Fetching integration status and workspace metadata.
-
- ) : (
-
-
Connection {axalConnection ? (axalConnection.connected ? "Connected" : "Disconnected") : "Not checked"}
-
Status {axalConnection?.status || "-"}
-
Workspace {axalConnection?.workspace.name || "-"}
-
Plan {axalConnection?.workspace.billing_plan || "-"}
-
Storage {axalConnection ? `${formatBytes(axalConnection.workspace.storage_used)} / ${formatBytes(axalConnection.workspace.storage_limit)}` : "-"}
-
Last synced {axalConnection?.last_synced_at || "-"}
-
- )}
-
-
- >
+
+
+
)}
@@ -2881,46 +1896,10 @@ function formatRuntimeTime(value?: number): string {
return new Date(value).toLocaleString();
}
-function formatDuration(startedAt: number, completedAt?: number): string {
- if (!Number.isFinite(startedAt) || completedAt === undefined || completedAt < startedAt) return "Duration unavailable";
- const seconds = Math.round((completedAt - startedAt) / 1000);
- return `Duration ${seconds}s`;
-}
-
function toTallyDate(value: string): string {
return value.replace(/-/g, "");
}
-function formatTallyDate(value?: string): string {
- if (!value || value.length !== 8) {
- return value || "-";
- }
-
- return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
-}
-
-function formatBytes(bytes: number): string {
- if (!Number.isFinite(bytes) || bytes <= 0) {
- return "0 B";
- }
-
- const units = ["B", "KB", "MB", "GB", "TB"];
- const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
- const value = bytes / 1024 ** index;
- return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
-}
-
-function formatCapabilityEvidence(evidence?: CapabilityEvidence): string {
- if (!evidence) return "Unknown; qualification evidence unavailable";
- const reason = evidence.safe_reason_code
- ? CAPABILITY_REASON_LABELS[evidence.safe_reason_code] ?? formatIdentifier(evidence.safe_reason_code)
- : "No reason supplied";
- return `${formatIdentifier(evidence.state)} / ${formatIdentifier(evidence.confidence)} — ${reason}`;
-}
-
-function formatPreviewCount(total: number, label = "loaded"): string {
- return `Showing ${Math.min(total, TABLE_PREVIEW_LIMIT)} of ${total} returned ${label}; source completeness not established`;
-}
function mergeTallyCompanies(preferred: TallyCompany[], existing: TallyCompany[]): TallyCompany[] {
const merged = new Map();
diff --git a/src/outstandings-copy.ts b/src/outstandings-copy.ts
index 5f04967..ac3d063 100644
--- a/src/outstandings-copy.ts
+++ b/src/outstandings-copy.ts
@@ -81,7 +81,16 @@ export function isNonRetryableOutstandingsBoundary(value: string) {
return !outstandingsPartialState(value).retryable;
}
-export function outstandingsAgeingDisclosure(hasUnagedReceivable: boolean) {
+export function outstandingsAgeingDisclosure(
+ hasUnagedReceivable: boolean,
+ unallocatedTotalKnown = false,
+) {
if (!hasUnagedReceivable) return null;
+ if (unallocatedTotalKnown) {
+ // The native bills path recovers the unallocated balance exactly from the
+ // party ledgers, so the honest disclosure is now "shown separately" rather
+ // than "cannot be proven".
+ return "Receivable includes entries with no bill reference. Tally gives them no bill and no age, so they are excluded from these buckets and shown as Unallocated above.";
+ }
return "Receivable includes On Account entries that are excluded from these buckets. Tally gives them no bill reference or age. Bridge does not show an On Account amount because this voucher read cannot prove the full unallocated balance.";
}
diff --git a/src/styles.css b/src/styles.css
index 2b91e87..94c7491 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1078,6 +1078,33 @@ h2 {
font-weight: 700;
}
+.error-boundary-panel {
+ border-color: #f0b8ad;
+ background: #fff5f2;
+}
+
+.error-boundary-panel h2 {
+ margin: 0 0 6px;
+ color: #9b2c1f;
+ font-size: 16px;
+}
+
+.error-boundary-panel p {
+ margin: 0 0 14px;
+ color: #71433b;
+ overflow-wrap: anywhere;
+}
+
+.error-boundary-panel button {
+ background: #1f6a4a;
+ color: white;
+ border: 0;
+ border-radius: 14px;
+ padding: 8px 16px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
.setup-company {
display: grid;
gap: 18px;
@@ -1415,10 +1442,20 @@ summary:focus-visible,
.outstandings-totals {
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
border-bottom: 1px solid #e6ebe7;
}
+.outstandings-totals em {
+ display: block;
+ margin-top: 3px;
+ font-size: 11px;
+ font-style: normal;
+ letter-spacing: 0.02em;
+ color: #8a9c92;
+ cursor: help;
+}
+
.outstandings-totals div {
padding: 28px 30px;
}
@@ -1437,23 +1474,88 @@ summary:focus-visible,
.outstandings-totals strong {
display: block;
- margin-top: 8px;
- font-size: 36px;
+ margin-top: 10px;
+ font-size: 32px;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ color: #16241c;
font-variant-numeric: tabular-nums;
}
.outstandings-ageing {
- display: grid;
- grid-template-columns: 1.25fr repeat(4, minmax(120px, 1fr));
+ padding: 24px 30px 26px;
border-bottom: 1px solid #e6ebe7;
}
-.outstandings-ageing > div {
- padding: 22px 20px;
+.ageing-heading {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.ageing-heading h3 {
+ margin: 0;
+ font-size: 15px;
+}
+
+.ageing-heading span {
+ color: #7d9086;
+ font-size: 12px;
+}
+
+/* Bucket | bar | amount | count. The bar is the point: three empty buckets and
+ one full one is a fact a reader should get in one glance, not by comparing
+ four numbers. */
+.ageing-row {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr) 150px 64px;
+ align-items: center;
+ gap: 14px;
+ padding: 5px 0;
+}
+
+.ageing-bucket {
+ font-size: 12px;
+ color: #61756a;
+ font-variant-numeric: tabular-nums;
+}
+
+.ageing-track {
+ position: relative;
+ height: 22px;
+ border-radius: 5px;
+ background: #f1f5f2;
+ overflow: hidden;
}
-.outstandings-ageing > div + div {
- border-left: 1px solid #eef2ef;
+.ageing-fill {
+ display: block;
+ height: 100%;
+ border-radius: 0 4px 4px 0;
+ transition: width 260ms cubic-bezier(0.2, 0, 0, 1);
+}
+
+/* Sequential ramp, light -> dark with rising chroma. Monotonic lightness is the
+ requirement for a ramp; every bar is direct-labelled, which discharges the
+ low contrast of the lightest steps. */
+.ageing-fill.tier-1 { background: #f4e6c8; }
+.ageing-fill.tier-2 { background: #e8c47e; }
+.ageing-fill.tier-3 { background: #d19a35; }
+.ageing-fill.tier-4 { background: #a3690f; }
+
+.ageing-row strong {
+ font-size: 15px;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: #16241c;
+}
+
+.ageing-count {
+ font-size: 12px;
+ color: #8a9c92;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
}
.outstandings-ageing strong {
@@ -1510,6 +1612,49 @@ summary:focus-visible,
text-align: right;
}
+.outstandings-column-headers {
+ display: grid;
+ grid-template-columns: minmax(240px, 1fr) 180px 110px;
+ align-items: center;
+ gap: 18px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #eef2ef;
+}
+
+.outstandings-column-headers button {
+ border: 0;
+ background: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ color: #7d9086;
+ text-align: right;
+ cursor: pointer;
+}
+
+.outstandings-column-headers button:first-child {
+ text-align: left;
+}
+
+.outstandings-column-headers button:hover {
+ color: #16241c;
+}
+
+.outstandings-column-headers button[aria-sort="ascending"],
+.outstandings-column-headers button[aria-sort="descending"] {
+ color: #1f6a4a;
+}
+
+.outstandings-column-headers button[aria-sort="ascending"]::after {
+ content: " \2191";
+}
+
+.outstandings-column-headers button[aria-sort="descending"]::after {
+ content: " \2193";
+}
+
.outstandings-party {
min-height: 62px;
border-top: 1px solid #eef2ef;
@@ -1728,3 +1873,533 @@ summary:focus-visible,
transition-duration: 0.01ms !important;
}
}
+
+/* ---- Exposure composition strip -------------------------------------------
+ The three headline figures are routinely orders of magnitude apart. Equal
+ tiles imply equal weight, so the proportion gets its own mark. */
+
+.exposure-share {
+ padding: 18px 30px 20px;
+ border-bottom: 1px solid #e6ebe7;
+}
+
+.exposure-share-track {
+ display: flex;
+ gap: 2px; /* surface gap between adjacent fills */
+ height: 10px;
+ margin-bottom: 12px;
+}
+
+.exposure-share-slice {
+ min-width: 3px; /* a real but tiny share must stay visible */
+ border-radius: 2px;
+}
+
+.exposure-share-slice.is-receivable { background: #1f6a4a; }
+.exposure-share-slice.is-payable { background: #7fae97; }
+.exposure-share-slice.is-unallocated { background: #c9d5ce; }
+
+.exposure-share-key {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 18px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.exposure-share-key li {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12px;
+ color: #61756a;
+}
+
+.exposure-share-key i {
+ width: 9px;
+ height: 9px;
+ border-radius: 2px;
+}
+
+.exposure-share-key i.is-receivable { background: #1f6a4a; }
+.exposure-share-key i.is-payable { background: #7fae97; }
+.exposure-share-key i.is-unallocated { background: #c9d5ce; }
+
+.exposure-share-key b {
+ color: #16241c;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ---- Party rows ----------------------------------------------------------- */
+
+.outstandings-party {
+ position: relative;
+ isolation: isolate;
+}
+
+/* Magnitude sits behind the row rather than in its own column, so rank is
+ legible without spending horizontal space or asking the reader to compare
+ ten right-aligned numbers. */
+.party-magnitude {
+ position: absolute;
+ left: -10px;
+ top: 6px;
+ bottom: 6px;
+ z-index: -1;
+ border-radius: 5px;
+ background: linear-gradient(90deg, rgba(31, 106, 74, 0.09), rgba(31, 106, 74, 0.02));
+}
+
+.party-age {
+ text-align: right;
+}
+
+.age-chip {
+ display: inline-block;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-style: normal;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+/* Same ramp as the ageing bars, so a chip and a bar mean the same thing. */
+.age-chip.tier-1 { background: #f4e6c8; color: #5c4711; }
+.age-chip.tier-2 { background: #e8c47e; color: #533f0c; }
+.age-chip.tier-3 { background: #d19a35; color: #402f06; }
+.age-chip.tier-4 { background: #a3690f; color: #fff; }
+.age-chip.is-none { background: #eef2ef; color: #7d9086; }
+
+/* Long numbers must never clip. A rupee crore figure is wider than a lakh one,
+ and a fixed 3-column grid silently truncates the largest -- which is exactly
+ the number that matters most. */
+.outstandings-totals strong {
+ overflow-wrap: anywhere;
+ font-size: clamp(20px, 2.1vw, 30px);
+}
+
+.outstandings-export-notice {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 30px;
+ border-bottom: 1px solid #e6ebe7;
+ background: #f2f8f4;
+ color: #1f4a36;
+ font-size: 13px;
+}
+
+.outstandings-export-notice button {
+ border: 0;
+ background: none;
+ color: #1f6a4a;
+ font-size: 12px;
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+.view-switch {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ border-radius: 8px;
+ background: #eef2ef;
+}
+
+.view-switch button {
+ border: 0;
+ border-radius: 6px;
+ padding: 6px 11px;
+ background: none;
+ color: #61756a;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.view-switch button.is-on {
+ background: #fff;
+ color: #16241c;
+ font-weight: 600;
+ box-shadow: 0 1px 2px rgba(22, 36, 28, 0.1);
+}
+
+/* No count column here -- unallocated exposure has no bills by definition,
+ so every row would otherwise print a dead em-dash. */
+.ageing-row.is-party {
+ grid-template-columns: minmax(0, 150px) minmax(0, 1fr) 150px;
+}
+
+.ageing-party {
+ font-size: 13px;
+ color: #27352e;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Unallocated is not part of the ageing severity ramp — it has no age at all,
+ so it must not borrow a colour that encodes one. */
+.ageing-fill.is-unallocated {
+ background: #b6c6bd;
+}
+
+.company-more {
+ margin-top: 18px;
+ padding-top: 18px;
+ border-top: 1px solid #eef2ef;
+}
+
+.company-more h3 {
+ margin: 0 0 4px;
+ font-size: 14px;
+}
+
+.company-more > p {
+ margin: 0 0 12px;
+ color: #61756a;
+ font-size: 13px;
+}
+
+.export-notice-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+}
+
+/* ---- Party bills drill-down ------------------------------------------------
+ Each party row expands in place to its open bills. Reusing the row grid
+ and the same age-chip ramp keeps the drill-down reading as part of the
+ same table rather than a nested card. */
+
+button.outstandings-party {
+ display: grid;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-top: 1px solid #eef2ef;
+ background: none;
+ font: inherit;
+ color: inherit;
+ text-align: inherit;
+ cursor: pointer;
+ appearance: none;
+}
+
+.outstandings-party.is-expandable:hover {
+ background: #f7faf7;
+}
+
+.party-name-cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.party-caret {
+ flex-shrink: 0;
+ color: #7d9086;
+ transition: transform 160ms ease;
+}
+
+.party-caret.is-open {
+ transform: rotate(90deg);
+}
+
+.outstandings-party-bills {
+ padding: 4px 0 16px 30px;
+ border-top: 1px solid #eef2ef;
+ background: #f9fbfa;
+}
+
+.party-bills-empty {
+ margin: 0;
+ padding: 12px 0 0;
+ color: #61756a;
+ font-size: 13px;
+}
+
+.party-bills-table {
+ padding-top: 6px;
+}
+
+.party-bills-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 4px;
+}
+
+.party-statement-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: none;
+ background: none;
+ padding: 0;
+ color: #3f6b52;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.party-statement-action:hover {
+ text-decoration: underline;
+}
+
+.party-bills-heading,
+.party-bill-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 210px 150px 64px;
+ align-items: center;
+ gap: 14px;
+}
+
+.party-bills-heading {
+ padding: 6px 0;
+ color: #8a9c92;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.party-bills-heading span:nth-child(n + 3),
+.party-bill-row strong,
+.party-bill-row .age-chip {
+ justify-self: end;
+}
+
+.party-bill-row {
+ padding: 7px 0;
+ border-top: 1px solid #eef2ef;
+}
+
+.party-bill-reference,
+.party-bill-dates {
+ font-variant-numeric: tabular-nums;
+}
+
+.party-bill-reference {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #27352e;
+ font-size: 13px;
+}
+
+.party-bill-dates {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ color: #27352e;
+ font-size: 13px;
+}
+
+/* Bill date and due date only diverge when the party carries a credit
+ period -- the reason Tally's ageing can outrun a naive bill-date
+ calculation. Surfaced, not hidden. */
+.party-bill-due {
+ font-style: normal;
+ color: #7d9086;
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.party-bill-row strong {
+ font-size: 13px;
+ color: #16241c;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 860px) {
+ .party-bills-heading,
+ .party-bill-row {
+ grid-template-columns: minmax(0, 1fr) 150px 120px 52px;
+ }
+}
+
+@media (max-width: 520px) {
+ .outstandings-party-bills {
+ padding-left: 20px;
+ }
+
+ .party-bills-heading,
+ .party-bill-row {
+ grid-template-columns: 1fr auto;
+ row-gap: 4px;
+ }
+
+ .party-bills-heading span:nth-child(2),
+ .party-bill-row .party-bill-dates {
+ grid-column: 1 / -1;
+ }
+}
+
+/* ---- All clients (cross-company) ----------------------------------------- */
+
+.clients-screen {
+ overflow: hidden;
+ border: 1px solid #dce4de;
+ border-radius: 14px;
+ background: #fff;
+}
+
+.clients-totals {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ border-bottom: 1px solid #e6ebe7;
+}
+
+.clients-totals div {
+ padding: 20px 30px;
+}
+
+.clients-totals div + div {
+ border-left: 1px solid #e6ebe7;
+}
+
+.clients-totals span {
+ display: block;
+ color: #61756a;
+ font-size: 13px;
+}
+
+.clients-totals strong {
+ display: block;
+ margin-top: 8px;
+ font-size: 26px;
+ letter-spacing: -0.02em;
+ color: #16241c;
+ font-variant-numeric: tabular-nums;
+}
+
+.clients-table {
+ padding: 8px 30px 26px;
+}
+
+.clients-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 150px 120px 120px 70px;
+ align-items: center;
+ gap: 14px;
+ width: 100%;
+ min-height: 56px;
+ padding: 0;
+ border: 0;
+ border-top: 1px solid #eef2ef;
+ background: none;
+ text-align: right;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+.clients-row.is-head {
+ border-top: 0;
+ padding-bottom: 10px;
+ color: #61756a;
+ font-size: 12px;
+ cursor: default;
+}
+
+.clients-row:not(.is-head):hover {
+ background: #f7faf8;
+}
+
+.clients-row > span {
+ font-variant-numeric: tabular-nums;
+}
+
+.clients-name,
+.clients-row.is-head > span:first-child {
+ text-align: left;
+}
+
+.clients-name strong {
+ display: block;
+ font-size: 14px;
+}
+
+.clients-name em {
+ display: block;
+ margin-top: 3px;
+ font-size: 12px;
+ font-style: normal;
+ color: #8a9c92;
+}
+
+/* Overdue is the one number on this screen a partner acts on. */
+.clients-row .is-overdue {
+ color: #8a5a0c;
+ font-weight: 600;
+}
+
+/* ---- All clients: magnitude, sorting, chips ------------------------------- */
+
+.clients-row {
+ position: relative;
+ isolation: isolate;
+ grid-template-columns: minmax(0, 1fr) 120px 120px 120px 96px;
+}
+
+.clients-magnitude {
+ position: absolute;
+ left: -10px;
+ top: 6px;
+ bottom: 6px;
+ z-index: -1;
+ border-radius: 5px;
+ background: linear-gradient(90deg, rgba(31, 106, 74, 0.09), rgba(31, 106, 74, 0.02));
+}
+
+.clients-row.is-head button {
+ border: 0;
+ background: none;
+ padding: 0;
+ font: inherit;
+ color: #7d9086;
+ font-size: 12px;
+ text-align: right;
+ cursor: pointer;
+}
+
+.clients-row.is-head button:first-child {
+ text-align: left;
+}
+
+.clients-row.is-head button:hover {
+ color: #16241c;
+}
+
+.clients-row.is-head button.is-sorted {
+ color: #1f6a4a;
+ font-weight: 600;
+}
+
+.clients-row.is-head button.is-sorted::after {
+ content: "";
+ margin-left: 5px;
+}
+
+.clients-row.is-head button[aria-sort="descending"]::after { content: " ↓"; }
+.clients-row.is-head button[aria-sort="ascending"]::after { content: " ↑"; }
+
+/* Age chip plus a chevron, so the row reads as somewhere to go rather than a
+ dead cell. */
+.clients-age {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.clients-age svg {
+ color: #a8bab0;
+ flex-shrink: 0;
+}
+
+.clients-row:hover .clients-age svg {
+ color: #1f6a4a;
+}
From e25bef567ad6bb453538d1270b72bbb97677d7bd Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Sat, 15 Aug 2026 17:00:12 +0530
Subject: [PATCH 2/8] fix(outstandings): distinguish future-due bills in
drill-down
A null statement age is now rendered as not due; zero continues to mean due today. This preserves the native unaged distinction for operators.
---
src/OutstandingsScreen.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx
index 209b163..c338e7e 100644
--- a/src/OutstandingsScreen.tsx
+++ b/src/OutstandingsScreen.tsx
@@ -54,7 +54,7 @@ type OpenBill = {
bill_date: string;
due_date: string;
amount: string;
- age_days: number;
+ age_days: number | null;
kind: "receivable" | "payable";
};
@@ -724,7 +724,9 @@ function renderPartyBills(bills: Array | undefined, currencyAssertion:
{hasCreditPeriod && due {formatDate(bill.due_date)} }
{formatMoney(bill.amount, currencyAssertion)}
- {bill.age_days}d
+ {bill.age_days === null
+ ? not due
+ : {bill.age_days}d }
);
})}
From 81a477bb95e9b668bdeb50b8849bf688f2f39fbd Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Mon, 17 Aug 2026 17:51:29 +0530
Subject: [PATCH 3/8] fix(outstandings): neutralize CSV text formulas
Represent CSV text and numeric cells as distinct types. Prefix text values beginning with =, +, -, @, tab, or carriage return with an apostrophe before applying CSV quoting, while leaving raw amount and count cells numeric for downstream spreadsheet calculations.
The measured =BVL Zeta Formula party is covered alongside every active prefix. Mutation proof: removing = from the active-prefix set made the focused test fail with exit 1, returning the unneutralized party name; after restoration all 3 CSV tests and the 42-test frontend build passed with exit 0.
Signed-off-by: Tapish Khandelwal
---
scripts/outstandings-csv.test.mjs | 28 ++++++++++++++++
src/OutstandingsScreen.tsx | 53 +++++++++++++++----------------
src/outstandings-csv.ts | 28 ++++++++++++++++
3 files changed, 82 insertions(+), 27 deletions(-)
create mode 100644 scripts/outstandings-csv.test.mjs
create mode 100644 src/outstandings-csv.ts
diff --git a/scripts/outstandings-csv.test.mjs b/scripts/outstandings-csv.test.mjs
new file mode 100644
index 0000000..acbddc1
--- /dev/null
+++ b/scripts/outstandings-csv.test.mjs
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { csvNumericCell, csvRow, csvTextCell } from "../src/outstandings-csv.ts";
+
+test("neutralizes every active spreadsheet prefix in text cells", () => {
+ for (const [value, expected] of [
+ ["=BVL Zeta Formula", "'=BVL Zeta Formula"],
+ ["+SUM(A1:A2)", "'+SUM(A1:A2)"],
+ ["-1+2", "'-1+2"],
+ ["@SUM(A1:A2)", "'@SUM(A1:A2)"],
+ ["\t=SUM(A1:A2)", "'\t=SUM(A1:A2)"],
+ ["\r=SUM(A1:A2)", "\"'\r=SUM(A1:A2)\""],
+ ]) {
+ assert.equal(csvRow(csvTextCell(value)), expected);
+ }
+});
+
+test("preserves numeric amount and count cells", () => {
+ assert.equal(
+ csvRow(csvNumericCell("-11111.00"), csvNumericCell(5)),
+ "-11111.00,5",
+ );
+});
+
+test("quotes neutralized text with CSV syntax after prefixing", () => {
+ assert.equal(csvRow(csvTextCell('=HYPERLINK("https://example.invalid","x")')), '"\'=HYPERLINK(""https://example.invalid"",""x"")"');
+});
diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx
index c338e7e..990274d 100644
--- a/src/OutstandingsScreen.tsx
+++ b/src/OutstandingsScreen.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { Building2, ChevronRight, Download, RefreshCw } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { isNonRetryableOutstandingsBoundary, outstandingsAgeingDisclosure, outstandingsPartialState } from "./outstandings-copy";
+import { csvNumericCell, csvRow, csvTextCell, type CsvCell } from "./outstandings-csv";
import { canStartOutstandingsRead } from "./outstandings-currency";
type Props = {
@@ -574,46 +575,44 @@ function reportToCsv(
unallocatedTotal: string | undefined,
unallocatedByParty: Array<{ party: string; amount: string }> | undefined,
) {
- const cell = (value: string | number) => {
- const text = String(value);
- return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
- };
- const row = (...values: Array) => values.map(cell).join(",");
+ const text = csvTextCell;
+ const number = csvNumericCell;
+ const row = (...values: Array) => csvRow(...values);
const lines = [
- row("Bridge — aged outstandings"),
- row("Company", report.company_name),
- row("As of", formatDate(report.as_of_yyyymmdd)),
- row("Currency", "INR"),
+ row(text("Bridge — aged outstandings")),
+ row(text("Company"), text(report.company_name)),
+ row(text("As of"), text(formatDate(report.as_of_yyyymmdd))),
+ row(text("Currency"), text("INR")),
"",
- row("Measure", "Amount"),
- row("Receivable", report.receivable_total),
- row("Payable", report.payable_total),
- ...(unallocatedTotal === undefined ? [] : [row("Unallocated (no bill reference)", unallocatedTotal)]),
+ row(text("Measure"), text("Amount")),
+ row(text("Receivable"), number(report.receivable_total)),
+ row(text("Payable"), number(report.payable_total)),
+ ...(unallocatedTotal === undefined ? [] : [row(text("Unallocated (no bill reference)"), number(unallocatedTotal))]),
"",
- row("Receivable ageing (bill references only)", "Amount", "Bills"),
- row("0-30 days", report.ageing.days_0_30, report.ageing_bill_counts.days_0_30),
- row("31-60 days", report.ageing.days_31_60, report.ageing_bill_counts.days_31_60),
- row("61-90 days", report.ageing.days_61_90, report.ageing_bill_counts.days_61_90),
- row("90+ days", report.ageing.days_90_plus, report.ageing_bill_counts.days_90_plus),
+ row(text("Receivable ageing (bill references only)"), text("Amount"), text("Bills")),
+ row(text("0-30 days"), number(report.ageing.days_0_30), number(report.ageing_bill_counts.days_0_30)),
+ row(text("31-60 days"), number(report.ageing.days_31_60), number(report.ageing_bill_counts.days_31_60)),
+ row(text("61-90 days"), number(report.ageing.days_61_90), number(report.ageing_bill_counts.days_61_90)),
+ row(text("90+ days"), number(report.ageing.days_90_plus), number(report.ageing_bill_counts.days_90_plus)),
"",
- row("Party", "Receivable", "Payable", "Outstanding", "Oldest bill (days)"),
+ row(text("Party"), text("Receivable"), text("Payable"), text("Outstanding"), text("Oldest bill (days)")),
...report.top_parties.map((party) => row(
- party.party,
- party.receivable,
- party.payable,
- party.outstanding_total,
- party.oldest_bill_age_days === null ? "no bill reference" : party.oldest_bill_age_days,
+ text(party.party),
+ number(party.receivable),
+ number(party.payable),
+ number(party.outstanding_total),
+ party.oldest_bill_age_days === null ? text("no bill reference") : number(party.oldest_bill_age_days),
)),
];
if (unallocatedByParty && unallocatedByParty.length > 0) {
- lines.push("", row("Unallocated by party", "Amount"));
- for (const entry of unallocatedByParty) lines.push(row(entry.party, entry.amount));
+ lines.push("", row(text("Unallocated by party"), text("Amount")));
+ for (const entry of unallocatedByParty) lines.push(row(text(entry.party), number(entry.amount)));
}
if (report.has_unaged_receivable) {
- lines.push("", row("Note", "Receivable includes entries with no bill reference. Tally gives them no bill and no age, so they are excluded from the ageing buckets."));
+ lines.push("", row(text("Note"), text("Receivable includes entries with no bill reference. Tally gives them no bill and no age, so they are excluded from the ageing buckets.")));
}
return lines.join("\n");
}
diff --git a/src/outstandings-csv.ts b/src/outstandings-csv.ts
new file mode 100644
index 0000000..c8d07d1
--- /dev/null
+++ b/src/outstandings-csv.ts
@@ -0,0 +1,28 @@
+export type CsvCell =
+ | { kind: "text"; value: string }
+ | { kind: "number"; value: string | number };
+
+const ACTIVE_SPREADSHEET_PREFIX = /^[=+\-@\t\r]/;
+
+export function csvTextCell(value: string | number): CsvCell {
+ return { kind: "text", value: String(value) };
+}
+
+export function csvNumericCell(value: string | number): CsvCell {
+ return { kind: "number", value };
+}
+
+export function csvRow(...values: Array) {
+ return values.map(serializeCsvCell).join(",");
+}
+
+function serializeCsvCell(cell: CsvCell) {
+ const raw = String(cell.value);
+ // A leading apostrophe makes spreadsheet applications treat an untrusted
+ // label as text. Numeric cells are deliberately distinct: prefixing a
+ // negative amount would silently break downstream SUM operations.
+ const text = cell.kind === "text" && ACTIVE_SPREADSHEET_PREFIX.test(raw)
+ ? `'${raw}`
+ : raw;
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+}
From bb83b07e04b0f64aae147e599b9b4d9aa6bd3412 Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Mon, 17 Aug 2026 18:22:04 +0530
Subject: [PATCH 4/8] chore(tally): reseal compatibility surface for F1X
Signed-off-by: Tapish Khandelwal
---
.../compatibility/compatibility-surface.json | 26 +++++++++++--------
1 file changed, 15 insertions(+), 11 deletions(-)
diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json
index 1ced11e..e128ab4 100644
--- a/docs/tally/compatibility/compatibility-surface.json
+++ b/docs/tally/compatibility/compatibility-surface.json
@@ -63,7 +63,7 @@
},
{
"path": "docs/tally/compatibility/synthetic-write-canary-fixture.md",
- "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4"
+ "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f"
},
{
"path": "docs/tally/support-matrix.md",
@@ -79,7 +79,7 @@
},
{
"path": "scripts/outstandings-copy.test.mjs",
- "sha256": "77a8d992e00e02a1da22d19d6701a567e89e87124265c8e2b2b6394f93d5fe8b"
+ "sha256": "58ca3cb255a8e54dc3a4590146b908b800fd4186d474eb05c3e1a46ae6c005a0"
},
{
"path": "scripts/tally-company-selection.test.mjs",
@@ -143,7 +143,7 @@
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs",
- "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e"
+ "sha256": "c5c61049fdbedf31cfafb43d349961e736eb7c6a361ec08e410f2f94099b1200"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs",
@@ -175,7 +175,7 @@
},
{
"path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs",
- "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f"
+ "sha256": "65da6e2a0cf543c46e1834d82f836b2966f9f574952b991259179cf743f907d6"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs",
@@ -219,7 +219,7 @@
},
{
"path": "src-tauri/src/commands.rs",
- "sha256": "9ec65e68c073cc113510a5cfb11b7b973e0f8a5cc50e8ceb98954a3304e99ab4"
+ "sha256": "4b576096d19f4d06956983d12b562020889225cd938d44f99d17e852df2a8153"
},
{
"path": "src-tauri/src/db/encrypted.rs",
@@ -339,7 +339,7 @@
},
{
"path": "src-tauri/src/tally/runtime.rs",
- "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55"
+ "sha256": "ee50064831d9ff9028acc21369fe8237cd3f2aa9d856b8d22d6d9df81d26ab8e"
},
{
"path": "src-tauri/src/tally/serial_queue.rs",
@@ -359,7 +359,7 @@
},
{
"path": "src/OutstandingsScreen.tsx",
- "sha256": "d51921b342436ef431b0e771702b1331ec16d1b346693f9adc1f5918995e5426"
+ "sha256": "5aa3e6250b1f39163e2fd87b4c1997d25bfe03e96efd603c3ec21de0c0a96a05"
},
{
"path": "src/TallyReadinessFlow.tsx",
@@ -367,15 +367,19 @@
},
{
"path": "src/main.tsx",
- "sha256": "ead26567bb7f256bd7f92f9988d4a655884801246f6f38f4979125f0e2069bbc"
+ "sha256": "afdcba045263ee790264c171bb9a3e5286f565b6c6aa27036a33c7f4c1382ba1"
},
{
"path": "src/outstandings-copy.ts",
- "sha256": "948a310fa1d3c94d1fc46a940be11cb6ab27bb5d166e7ed966291ddb3d4d3f65"
+ "sha256": "0e78dd97d79c0166fe17a4e933733c369a7e4be3046ddbe3e38122ad5e234216"
+ },
+ {
+ "path": "src/outstandings-csv.ts",
+ "sha256": "3c836edf03c6d71e26e726e4b31f2c4ae9c98acb703629b2e24b578b33145796"
},
{
"path": "src/styles.css",
- "sha256": "0dd2517e24e126118cee071058946ab0301055ebdbd7985641448523ee468504"
+ "sha256": "33bd1f956b6b1ece2924fa8741fec9cb46a4b3b912c421b435b587c2e6293d6d"
},
{
"path": "src/tally-company-selection.ts",
@@ -430,5 +434,5 @@
"sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db"
}
],
- "manifest_sha256": "731155b112eacc5e8374d12a92cf0494c63a5a3de9879e6bea62950698f8c192"
+ "manifest_sha256": ""
}
From 654c665e6e1d08af0962d067786e1f1c58abf3db Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Tue, 18 Aug 2026 05:45:26 +0530
Subject: [PATCH 5/8] chore(tally): reseal F4X PR4 compatibility surface
Carry the PR4 UI surface over the F4X BILLREF presentation disclosure. Claims, evidence, and trusted-evidence keys remain byte-identical to the preserved PR4 head.
Compatibility gate: exit 0, unknown_claims=11, evidenced_claims=0. Claims/evidence/trusted identity checks: exit 0.
---
docs/tally/compatibility/compatibility-matrix.json | 2 +-
docs/tally/compatibility/compatibility-surface.json | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json
index 72eb644..8f197b6 100644
--- a/docs/tally/compatibility/compatibility-matrix.json
+++ b/docs/tally/compatibility/compatibility-matrix.json
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e",
- "compatibility_surface_sha256": "731155b112eacc5e8374d12a92cf0494c63a5a3de9879e6bea62950698f8c192",
+ "compatibility_surface_sha256": "4cfe12547b1799cde5bceb3a4ecce11e48c82d1bdacf4d7d0a19d25ed053666f",
"claims": [
{
"claim_id": "erp9-6-6-3-windows-education-xml-one-company",
diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json
index e128ab4..7f46b1b 100644
--- a/docs/tally/compatibility/compatibility-surface.json
+++ b/docs/tally/compatibility/compatibility-surface.json
@@ -339,7 +339,7 @@
},
{
"path": "src-tauri/src/tally/runtime.rs",
- "sha256": "ee50064831d9ff9028acc21369fe8237cd3f2aa9d856b8d22d6d9df81d26ab8e"
+ "sha256": "8244a73689ef2458c0d35c3a690fd926ae46dfa51b3d6901897e0f67f88abdbb"
},
{
"path": "src-tauri/src/tally/serial_queue.rs",
@@ -434,5 +434,5 @@
"sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db"
}
],
- "manifest_sha256": ""
+ "manifest_sha256": "4cfe12547b1799cde5bceb3a4ecce11e48c82d1bdacf4d7d0a19d25ed053666f"
}
From 0f8e52907cc68a81e507a883b0c7a42d9c045a50 Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Tue, 18 Aug 2026 11:31:32 +0530
Subject: [PATCH 6/8] fix(ui): disclose outstandings ageing basis
Render the serialized due-date or bill-date anchor next to ageing buckets and provide operator-readable reasons for native cross-check and per-company sweep failures.Red before fix: the focused Node test failed because the anchor label export did not exist. Mutation proof: forcing every path to say bill date failed the due-date assertion (14 passed, 1 failed; exit 1).Verified: outstandings copy suite 15/15, Impeccable deterministic detector clean, and pnpm build 44/44 plus TypeScript and Vite, exit 0. Node 26.3.0 emitted the repository's expected unsupported-engine warning against >=22.12 <25.
---
scripts/outstandings-copy.test.mjs | 14 +++++++++++++-
src/OutstandingsScreen.tsx | 5 +++--
src/outstandings-copy.ts | 18 ++++++++++++++++++
3 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/scripts/outstandings-copy.test.mjs b/scripts/outstandings-copy.test.mjs
index 7519620..d4479c0 100644
--- a/scripts/outstandings-copy.test.mjs
+++ b/scripts/outstandings-copy.test.mjs
@@ -4,7 +4,19 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
-import { isNonRetryableOutstandingsBoundary, outstandingsAgeingDisclosure, outstandingsPartialReason, outstandingsPartialState } from "../src/outstandings-copy.ts";
+import { isNonRetryableOutstandingsBoundary, outstandingsAgeingAnchorLabel, outstandingsAgeingDisclosure, outstandingsPartialReason, outstandingsPartialState } from "../src/outstandings-copy.ts";
+
+test("the backend ageing anchor is disclosed in the bucket label", () => {
+ assert.equal(outstandingsAgeingAnchorLabel("due_date"), "aged from due date");
+ assert.equal(outstandingsAgeingAnchorLabel("bill_date"), "aged from bill date");
+});
+
+test("new native and sweep boundaries have operator-readable reasons", () => {
+ assert.match(outstandingsPartialReason("native_overdue_crosscheck_mismatch"), /overdue-day cross-check/i);
+ assert.match(outstandingsPartialReason("company_currency_probe_failed"), /base currency/i);
+ assert.match(outstandingsPartialReason("company_base_currency_not_inr"), /not INR/i);
+ assert.match(outstandingsPartialReason("company_outstandings_read_failed"), /company read failed/i);
+});
test("uncalibrated sizing says the voucher read was not sent", () => {
const message = outstandingsPartialReason("outstandings_segment_sizing_uncalibrated");
diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx
index 990274d..d761dd4 100644
--- a/src/OutstandingsScreen.tsx
+++ b/src/OutstandingsScreen.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Building2, ChevronRight, Download, RefreshCw } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
-import { isNonRetryableOutstandingsBoundary, outstandingsAgeingDisclosure, outstandingsPartialState } from "./outstandings-copy";
+import { isNonRetryableOutstandingsBoundary, outstandingsAgeingAnchorLabel, outstandingsAgeingDisclosure, outstandingsPartialState, type OutstandingsAgeingAnchor } from "./outstandings-copy";
import { csvNumericCell, csvRow, csvTextCell, type CsvCell } from "./outstandings-csv";
import { canStartOutstandingsRead } from "./outstandings-currency";
@@ -64,6 +64,7 @@ type LoadResult =
state: "complete";
report: Report;
currency_assertion: string;
+ ageing_anchor: OutstandingsAgeingAnchor;
synced_at_unix_ms: number;
// Absent when the read path cannot establish it. Absent is not zero and
// must never render as zero.
@@ -398,7 +399,7 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl
) : Receivable ageing }
{view === "ageing"
- ? `bill references only · as of ${formatDate(completeResult.report.as_of_yyyymmdd)}`
+ ? `bill references only · ${outstandingsAgeingAnchorLabel(completeResult.ageing_anchor)} · as of ${formatDate(completeResult.report.as_of_yyyymmdd)}`
: `no bill reference · ${unallocatedParties.length} ${unallocatedParties.length === 1 ? "party" : "parties"}`}
diff --git a/src/outstandings-copy.ts b/src/outstandings-copy.ts
index ac3d063..167f05b 100644
--- a/src/outstandings-copy.ts
+++ b/src/outstandings-copy.ts
@@ -1,6 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
+export type OutstandingsAgeingAnchor = "due_date" | "bill_date";
+
+export function outstandingsAgeingAnchorLabel(anchor: OutstandingsAgeingAnchor) {
+ return anchor === "due_date" ? "aged from due date" : "aged from bill date";
+}
+
export function outstandingsPartialReason(value: string) {
+ if (value === "native_overdue_crosscheck_mismatch") {
+ return "Tally's overdue-day cross-check disagreed with the bill due dates, so Bridge withheld the totals";
+ }
+ if (value === "company_currency_probe_failed") {
+ return "Bridge could not verify this company's base currency";
+ }
+ if (value === "company_base_currency_not_inr") {
+ return "this company's verified base currency is not INR";
+ }
+ if (value === "company_outstandings_read_failed") {
+ return "this company read failed while the remaining companies continued";
+ }
if (value === "tally_segment_latency_trending_restart_recommended") {
return "comparable segments kept slowing toward the safety deadline; Tally may need a restart before another sync";
}
From 7419a81d321823154411d8decaceb8da74cda3d8 Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Tue, 18 Aug 2026 11:53:00 +0530
Subject: [PATCH 7/8] chore(tally): reseal F5X PR4 compatibility surface
Bind the explicit ageing-anchor copy and repaired upstream sources at the outstandings UI level. Claims, evidence, and trusted keys are unchanged. Compatibility gate: exit 0, unknown_claims=11, evidenced_claims=0.
---
.../compatibility/compatibility-matrix.json | 2 +-
.../compatibility/compatibility-surface.json | 18 +++++++++---------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json
index 8f197b6..a2faa13 100644
--- a/docs/tally/compatibility/compatibility-matrix.json
+++ b/docs/tally/compatibility/compatibility-matrix.json
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e",
- "compatibility_surface_sha256": "4cfe12547b1799cde5bceb3a4ecce11e48c82d1bdacf4d7d0a19d25ed053666f",
+ "compatibility_surface_sha256": "3864b97ae56f5642abc050e1b536e4d2860ded8fd7b63ec16a84a83b2100fb16",
"claims": [
{
"claim_id": "erp9-6-6-3-windows-education-xml-one-company",
diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json
index 7f46b1b..2880ecc 100644
--- a/docs/tally/compatibility/compatibility-surface.json
+++ b/docs/tally/compatibility/compatibility-surface.json
@@ -63,7 +63,7 @@
},
{
"path": "docs/tally/compatibility/synthetic-write-canary-fixture.md",
- "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f"
+ "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4"
},
{
"path": "docs/tally/support-matrix.md",
@@ -79,7 +79,7 @@
},
{
"path": "scripts/outstandings-copy.test.mjs",
- "sha256": "58ca3cb255a8e54dc3a4590146b908b800fd4186d474eb05c3e1a46ae6c005a0"
+ "sha256": "8c9adb100a45704ca8ba72203a397defca3e8b01a297e96043c543cc052b980a"
},
{
"path": "scripts/tally-company-selection.test.mjs",
@@ -143,7 +143,7 @@
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs",
- "sha256": "c5c61049fdbedf31cfafb43d349961e736eb7c6a361ec08e410f2f94099b1200"
+ "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs",
@@ -175,7 +175,7 @@
},
{
"path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs",
- "sha256": "65da6e2a0cf543c46e1834d82f836b2966f9f574952b991259179cf743f907d6"
+ "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs",
@@ -219,7 +219,7 @@
},
{
"path": "src-tauri/src/commands.rs",
- "sha256": "4b576096d19f4d06956983d12b562020889225cd938d44f99d17e852df2a8153"
+ "sha256": "9ec65e68c073cc113510a5cfb11b7b973e0f8a5cc50e8ceb98954a3304e99ab4"
},
{
"path": "src-tauri/src/db/encrypted.rs",
@@ -339,7 +339,7 @@
},
{
"path": "src-tauri/src/tally/runtime.rs",
- "sha256": "8244a73689ef2458c0d35c3a690fd926ae46dfa51b3d6901897e0f67f88abdbb"
+ "sha256": "71e8dc50d72abed5460c17055773c417b35dd8747307c4252a01f8e8ed07ff90"
},
{
"path": "src-tauri/src/tally/serial_queue.rs",
@@ -359,7 +359,7 @@
},
{
"path": "src/OutstandingsScreen.tsx",
- "sha256": "5aa3e6250b1f39163e2fd87b4c1997d25bfe03e96efd603c3ec21de0c0a96a05"
+ "sha256": "be442a765fbf8f3440abb6c4673e5baefec8f628743c314693f9d5ebed276dab"
},
{
"path": "src/TallyReadinessFlow.tsx",
@@ -371,7 +371,7 @@
},
{
"path": "src/outstandings-copy.ts",
- "sha256": "0e78dd97d79c0166fe17a4e933733c369a7e4be3046ddbe3e38122ad5e234216"
+ "sha256": "a2bcf820f694443ac122e91aa98fb354b6e2d5c6be3e1e9503b368a3a0a4f696"
},
{
"path": "src/outstandings-csv.ts",
@@ -434,5 +434,5 @@
"sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db"
}
],
- "manifest_sha256": "4cfe12547b1799cde5bceb3a4ecce11e48c82d1bdacf4d7d0a19d25ed053666f"
+ "manifest_sha256": "3864b97ae56f5642abc050e1b536e4d2860ded8fd7b63ec16a84a83b2100fb16"
}
From ce2a7abe729a120dd2fd5f0a90fe1bded531b819 Mon Sep 17 00:00:00 2001
From: Tapish Khandelwal
Date: Tue, 18 Aug 2026 12:10:53 +0530
Subject: [PATCH 8/8] chore(tally): reseal F5X PR4 after clippy repair
---
docs/tally/compatibility/compatibility-matrix.json | 2 +-
docs/tally/compatibility/compatibility-surface.json | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json
index a2faa13..a21f13a 100644
--- a/docs/tally/compatibility/compatibility-matrix.json
+++ b/docs/tally/compatibility/compatibility-matrix.json
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e",
- "compatibility_surface_sha256": "3864b97ae56f5642abc050e1b536e4d2860ded8fd7b63ec16a84a83b2100fb16",
+ "compatibility_surface_sha256": "54d5e83c80569da050230117deff05fb9fa6dcd0c33ed0c6cc50fbbb5e1c1e09",
"claims": [
{
"claim_id": "erp9-6-6-3-windows-education-xml-one-company",
diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json
index 2880ecc..7392b58 100644
--- a/docs/tally/compatibility/compatibility-surface.json
+++ b/docs/tally/compatibility/compatibility-surface.json
@@ -339,7 +339,7 @@
},
{
"path": "src-tauri/src/tally/runtime.rs",
- "sha256": "71e8dc50d72abed5460c17055773c417b35dd8747307c4252a01f8e8ed07ff90"
+ "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55"
},
{
"path": "src-tauri/src/tally/serial_queue.rs",
@@ -434,5 +434,5 @@
"sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db"
}
],
- "manifest_sha256": "3864b97ae56f5642abc050e1b536e4d2860ded8fd7b63ec16a84a83b2100fb16"
+ "manifest_sha256": "54d5e83c80569da050230117deff05fb9fa6dcd0c33ed0c6cc50fbbb5e1c1e09"
}