Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
'use client';
export default function GlobalError({reset}:{error:Error&{digest?:string};reset:()=>void}){return <html><body><div className="error-screen"><span className="micro-label">system boundary failed</span><strong aria-hidden>500</strong><h1>The trace system could not start.</h1><p>No financial state was inferred from this UI failure. Check the local API and try again.</p><button onClick={reset}>Retry the desk</button></div></body></html>}
import './globals.css';
export default function GlobalError({reset}:{error:Error&{digest?:string};reset:()=>void}){return <html lang="en"><body><main><div className="error-screen"><span className="micro-label">system boundary failed</span><strong aria-hidden>500</strong><h1>The trace system could not start.</h1><p>No financial state was inferred from this UI failure. Check the local API and try again.</p><button onClick={reset}>Retry the desk</button></div></main></body></html>}
426 changes: 421 additions & 5 deletions apps/web/app/globals.css

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { Metadata } from 'next';
import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google';
import type { ReactNode } from 'react';
import { Sidebar } from '@/components/sidebar';
import './globals.css';

const sans = IBM_Plex_Sans({ subsets: ['latin'], weight: ['400', '500', '600'], variable: '--font-plex-sans' });
const mono = IBM_Plex_Mono({ subsets: ['latin'], weight: ['400', '500'], variable: '--font-plex-mono' });

export const metadata: Metadata = { title: { default: 'fintech-lab', template: '%s · fintech-lab' }, description: 'A local payment engineering laboratory for tracing money and state.' };

export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return <html lang="en"><body><div className="app-shell"><Sidebar /><main>{children}</main></div></body></html>;
return <html lang="en" className={`${sans.variable} ${mono.variable}`}><body><a className="skip-link" href="#main">Skip to content</a><div className="app-shell"><Sidebar /><main id="main" tabIndex={-1}>{children}</main></div></body></html>;
}
4 changes: 2 additions & 2 deletions apps/web/app/ledger/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { api, formatMoney, formatTime } from '@/lib/api';
import { DataTable, EvidencePane, PageHeading, Status } from '@/components/console';
import { DataTable, EvidencePane, PageHeading } from '@/components/console';
interface Entry { transactionId:string; businessType:string; businessId:string; description:string; currency:string; postedAt:string; accountCode:string; accountType:string; debit:string; credit:string }
export const dynamic='force-dynamic';
export default async function LedgerPage(){const entries=await api<Entry[]>('/ledger/entries');return <><PageHeading eyebrow="double-entry source of truth" title="Ledger">Posted journals are immutable. Every transaction must balance within one currency.</PageHeading><EvidencePane label="journal register" title="Latest entries"><DataTable empty="No ledger entries yet. A confirmed capture will post the first balanced journal." columns={[{key:'business',label:'business event'},{key:'account',label:'account'},{key:'type',label:'type'},{key:'debit',label:'debit',align:'right'},{key:'credit',label:'credit',align:'right'},{key:'posted',label:'posted'}]} rows={entries.map((e,i)=>({id:`${e.transactionId}-${i}`,business:<><Status value={e.businessType}/><small className="cell-note">{e.description}</small></>,account:e.accountCode.replaceAll('_',' ').toLowerCase(),type:e.accountType.toLowerCase(),debit:Number(e.debit)?formatMoney(e.debit,e.currency):'—',credit:Number(e.credit)?formatMoney(e.credit,e.currency):'—',posted:formatTime(e.postedAt)}))}/></EvidencePane></>}
export default async function LedgerPage(){const entries=await api<Entry[]>('/ledger/entries');return <><PageHeading eyebrow="double-entry source of truth" title="Ledger">Posted journals are immutable. Every transaction must balance within one currency.</PageHeading><EvidencePane label="journal register" title="Latest entries"><DataTable empty="No ledger entries yet. A confirmed capture will post the first balanced journal." columns={[{key:'business',label:'business event'},{key:'account',label:'account'},{key:'type',label:'type'},{key:'debit',label:'debit',align:'right'},{key:'credit',label:'credit',align:'right'},{key:'posted',label:'posted'}]} rows={entries.map((e,i)=>({id:`${e.transactionId}-${i}`,business:<>{e.businessType.replaceAll('_',' ').toLowerCase()}<small className="cell-note">{e.description}</small></>,account:e.accountCode.replaceAll('_',' ').toLowerCase(),type:e.accountType.toLowerCase(),debit:Number(e.debit)?formatMoney(e.debit,e.currency):'—',credit:Number(e.credit)?formatMoney(e.credit,e.currency):'—',posted:formatTime(e.postedAt)}))}/></EvidencePane></>}
2 changes: 1 addition & 1 deletion apps/web/app/loading.tsx
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default function Loading(){return <div aria-label="Loading transaction trace" role="status"><div className="page-heading skeleton-heading"><div><i/><i/></div></div><div className="balance-strip skeleton-strip">{[0,1,2].map((item)=><div key={item}><i/><i/><i/></div>)}</div><div className="evidence-pane skeleton-pane"><header><div><i/><i/></div></header><div className="skeleton-rails">{[0,1,2,3,4].map((item)=><i key={item}/>)}</div></div></div>}
export default function Loading(){return <div className="skeleton" aria-label="Loading records" role="status"><div className="page-heading"><i/><i/><i/></div><div className="evidence-pane"><header><i/></header><div className="skeleton-rows">{[0,1,2,3,4,5].map((item)=><i key={item}/>)}</div></div></div>}
2 changes: 1 addition & 1 deletion apps/web/app/payments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default async function PaymentDetailPage({
rows={detail.attempts.map((attempt) => ({
id: attempt.id,
kind: attempt.kind.toLowerCase(),
status: <Status value={attempt.status} />,
status: <Status value={attempt.status} of="attempt" />,
amount: formatMoney(attempt.amount, attempt.currency),
provider: attempt.providerTransactionId ?? "—",
}))}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/payouts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { api,formatMoney,formatTime } from '@/lib/api';import { DataTable,EvidencePane,PageHeading,Status } from '@/components/console';
interface Row{id:string;status:string;amount:string;currency:string;providerReference?:string;failureCode?:string;createdAt:string;completedAt?:string}
export const dynamic='force-dynamic';export default async function Page(){const rows=await api<Row[]>('/payouts');return <><PageHeading eyebrow="available balance consumption" title="Payouts">Requests reserve merchant available funds under a PostgreSQL advisory lock before provider processing begins.</PageHeading><EvidencePane label="payout register" title="Merchant disbursements"><DataTable empty="No payout requested. Settled available funds can be reserved here." columns={[{key:'id',label:'payout'},{key:'status',label:'state'},{key:'amount',label:'amount',align:'right'},{key:'provider',label:'provider reference'},{key:'created',label:'requested'}]} rows={rows.map(r=>({id:r.id.slice(0,8)+'…',status:<Status value={r.status}/>,amount:formatMoney(r.amount,r.currency),provider:r.providerReference??r.failureCode??'—',created:formatTime(r.createdAt)}))}/></EvidencePane></>}
export const dynamic='force-dynamic';export default async function Page(){const rows=await api<Row[]>('/payouts');return <><PageHeading eyebrow="available balance consumption" title="Payouts">Requests reserve merchant available funds under a PostgreSQL advisory lock before provider processing begins.</PageHeading><EvidencePane label="payout register" title="Merchant disbursements"><DataTable empty="No payout requested. Settled available funds can be reserved here." columns={[{key:'id',label:'payout'},{key:'status',label:'state'},{key:'amount',label:'amount',align:'right'},{key:'provider',label:'provider reference'},{key:'created',label:'requested'}]} rows={rows.map(r=>({id:r.id.slice(0,8)+'…',status:<Status value={r.status} of="payout"/>,amount:formatMoney(r.amount,r.currency),provider:r.providerReference??r.failureCode??'—',created:formatTime(r.createdAt)}))}/></EvidencePane></>}
2 changes: 1 addition & 1 deletion apps/web/app/reconciliation/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { api,formatTime } from '@/lib/api';import { DataTable,EvidencePane,PageHeading,PaymentLink,Status } from '@/components/console';
interface Row{id:string;paymentId?:string;providerTransactionId?:string;issueType:string;severity:string;status:string;createdAt:string}
export const dynamic='force-dynamic';export default async function Page(){const rows=await api<Row[]>('/reconciliation/issues');return <><PageHeading eyebrow="internal ↔ provider comparison" title="Reconciliation">Differences become reviewable issues. The engine never silently repairs operational state or financial history.</PageHeading><EvidencePane label="exception queue" title="Open and resolved mismatches"><DataTable empty="No mismatches detected. Reconciliation runs will record disagreements here." columns={[{key:'severity',label:'severity'},{key:'issue',label:'issue type'},{key:'payment',label:'payment'},{key:'provider',label:'provider transaction'},{key:'status',label:'resolution'},{key:'detected',label:'detected'}]} rows={rows.map(r=>({id:r.id,severity:<Status value={r.severity}/>,issue:r.issueType.replaceAll('_',' ').toLowerCase(),payment:r.paymentId?<PaymentLink id={r.paymentId}/>:'—',provider:r.providerTransactionId?.slice(0,16)??'—',status:<Status value={r.status}/>,detected:formatTime(r.createdAt)}))}/></EvidencePane></>}
export const dynamic='force-dynamic';export default async function Page(){const rows=await api<Row[]>('/reconciliation/issues');return <><PageHeading eyebrow="internal ↔ provider comparison" title="Reconciliation">Differences become reviewable issues. The engine never silently repairs operational state or financial history.</PageHeading><EvidencePane label="exception queue" title="Open and resolved mismatches"><DataTable empty="No mismatches detected. Reconciliation runs will record disagreements here." columns={[{key:'severity',label:'severity'},{key:'issue',label:'issue type'},{key:'payment',label:'payment'},{key:'provider',label:'provider transaction'},{key:'status',label:'resolution'},{key:'detected',label:'detected'}]} rows={rows.map(r=>({id:r.id,severity:<Status value={r.severity} of="severity"/>,issue:r.issueType.replaceAll('_',' ').toLowerCase(),payment:r.paymentId?<PaymentLink id={r.paymentId}/>:'—',provider:r.providerTransactionId?.slice(0,16)??'—',status:<Status value={r.status}/>,detected:formatTime(r.createdAt)}))}/></EvidencePane></>}
2 changes: 1 addition & 1 deletion apps/web/app/settlements/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { api,formatMoney,formatTime } from '@/lib/api';import { DataTable,EvidencePane,PageHeading,Status } from '@/components/console';
interface Row{id:string;status:string;currency:string;grossAmount:string;feeAmount:string;netAmount:string;availableOn:string;completedAt?:string}
export const dynamic='force-dynamic';export default async function Page(){const rows=await api<Row[]>('/settlements');return <><PageHeading eyebrow="T+N availability boundary" title="Settlements">Capture creates pending money. Settlement converts the provider receivable and releases merchant availability.</PageHeading><EvidencePane label="settlement batches" title="Availability register"><DataTable empty="No settlement batch yet. Eligible captured payments will be grouped after their delay." columns={[{key:'id',label:'batch'},{key:'status',label:'state'},{key:'gross',label:'gross',align:'right'},{key:'fee',label:'fee',align:'right'},{key:'net',label:'merchant net',align:'right'},{key:'available',label:'available on'}]} rows={rows.map(r=>({id:r.id.slice(0,8)+'…',status:<Status value={r.status}/>,gross:formatMoney(r.grossAmount,r.currency),fee:formatMoney(r.feeAmount,r.currency),net:formatMoney(r.netAmount,r.currency),available:formatTime(r.availableOn)}))}/></EvidencePane></>}
export const dynamic='force-dynamic';export default async function Page(){const rows=await api<Row[]>('/settlements');return <><PageHeading eyebrow="T+N availability boundary" title="Settlements">Capture creates pending money. Settlement converts the provider receivable and releases merchant availability.</PageHeading><EvidencePane label="settlement batches" title="Availability register"><DataTable empty="No settlement batch yet. Eligible captured payments will be grouped after their delay." columns={[{key:'id',label:'batch'},{key:'status',label:'state'},{key:'gross',label:'gross',align:'right'},{key:'fee',label:'fee',align:'right'},{key:'net',label:'merchant net',align:'right'},{key:'available',label:'available on'}]} rows={rows.map(r=>({id:r.id.slice(0,8)+'…',status:<Status value={r.status} of="settlement"/>,gross:formatMoney(r.grossAmount,r.currency),fee:formatMoney(r.feeAmount,r.currency),net:formatMoney(r.netAmount,r.currency),available:formatTime(r.availableOn)}))}/></EvidencePane></>}
11 changes: 6 additions & 5 deletions apps/web/components/console.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import { formatMoney, formatTime } from '@/lib/api';
import { describeStatus, glyphs, type StatusOf } from '@/lib/status';

export function Wordmark() {
return <span className="wordmark"><span aria-hidden className="wordmark-mark">FL</span><span>fintech-lab<small>payment trace system</small></span></span>;
Expand All @@ -10,9 +11,9 @@ export function EvidencePane({ label, title, action, children, className = '' }:
return <section className={`evidence-pane ${className}`}><header><div><span className="micro-label">{label}</span><h2>{title}</h2></div>{action}</header><div className="pane-body">{children}</div></section>;
}

export function Status({ value }: { value: string }) {
const kind = /FAILED|DEAD|DECLINED|LOST/.test(value) ? 'failed' : /PENDING|PROCESSING|REQUIRES|DISPUTED|OPEN/.test(value) ? 'held' : /SUCCEEDED|CAPTURED|AUTHORIZED|PROCESSED|POSTED|RESOLVED|REFUNDED/.test(value) ? 'verified' : 'neutral';
return <span className={`status status-${kind}`}><span aria-hidden />{value.replaceAll('_', ' ').toLowerCase()}</span>;
export function Status({ value, of }: { value: string; of?: StatusOf }) {
const { tone, label } = describeStatus(value, of);
return <span className={`status tone-${tone}`} title={value}><span aria-hidden>{glyphs[tone]}</span>{label}</span>;
}

export interface Balance { currency: string; pending: string; available: string }
Expand All @@ -21,7 +22,7 @@ export function BalanceStrip({ balances }: { balances: Balance[] }) {
return <div className="balance-strip" aria-label="Merchant balances">
<div className="balance-primary"><span className="micro-label">available / {balance.currency}</span><strong>{formatMoney(balance.available, balance.currency)}</strong><small>eligible for payout</small></div>
<div><span className="micro-label">pending / {balance.currency}</span><strong>{formatMoney(balance.pending, balance.currency)}</strong><small>awaiting settlement</small></div>
<div><span className="micro-label">rail state</span><strong className="text-value">Operational</strong><small><i className="live-dot" aria-hidden /> inbox + outbox active</small></div>
<div><span className="micro-label">rail state</span><strong className="text-value">Operational</strong><small>inbox + outbox active</small></div>
</div>;
}

Expand All @@ -45,7 +46,7 @@ export function ConnectionState() {

export function DataTable({ columns, rows, empty }: { columns: Array<{ key: string; label: string; align?: 'right' }>; rows: Array<Record<string, ReactNode>>; empty: string }) {
if (!rows.length) return <p className="inline-empty">{empty}</p>;
return <div className="table-scroll"><table><thead><tr>{columns.map((column) => <th key={column.key} className={column.align === 'right' ? 'align-right' : ''}>{column.label}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={String(row.id ?? index)}>{columns.map((column) => <td key={column.key} className={column.align === 'right' ? 'align-right numeric' : ''}>{row[column.key]}</td>)}</tr>)}</tbody></table></div>;
return <div className="table-scroll"><table><thead><tr>{columns.map((column) => <th key={column.key} className={column.align === 'right' ? 'align-right' : ''}>{column.label}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={typeof row.id === 'string' ? row.id : index}>{columns.map((column) => <td key={column.key} className={column.align === 'right' ? 'align-right numeric' : ''}>{row[column.key]}</td>)}</tr>)}</tbody></table></div>;
}

export function PageHeading({ eyebrow, title, children, action }: { eyebrow: string; title: string; children: ReactNode; action?: ReactNode }) {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/components/nav-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

export function NavLink({ href, label }: { href: string; label: string }) {
const pathname = usePathname();
const current = href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(`${href}/`);
return <Link href={href} aria-current={current ? 'page' : undefined}>{label}</Link>;
}
3 changes: 2 additions & 1 deletion apps/web/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { Wordmark } from './console';
import { NavLink } from './nav-link';

const groups = [
{ label: 'flow', items: [['Overview', '/'], ['Payments', '/payments'], ['Ledger', '/ledger']] },
Expand All @@ -8,5 +9,5 @@ const groups = [
];

export function Sidebar() {
return <aside className="sidebar"><Link href="/" className="brand-link"><Wordmark /></Link><nav>{groups.map((group) => <div className="nav-group" key={group.label}><span>{group.label}</span>{group.items.map(([label, href]) => <Link key={href} href={href}>{label}</Link>)}</div>)}</nav><footer><span><i className="live-dot" aria-hidden /> External PSP lab</span><small>Stripe-shaped · no real money</small></footer></aside>;
return <aside className="sidebar"><Link href="/" className="brand-link"><Wordmark /></Link><nav aria-label="Main">{groups.map((group) => <div className="nav-group" key={group.label}><span>{group.label}</span>{group.items.map(([label, href]) => <NavLink key={href} href={href} label={label} />)}</div>)}</nav><footer><span>External PSP lab</span><span>Stripe-shaped · no real money</span></footer></aside>;
}
24 changes: 24 additions & 0 deletions apps/web/lib/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { describeStatus } from './status.ts';

test('captured, settled and paid out never share a tone or label', () => {
const captured = describeStatus('CAPTURED');
const settled = describeStatus('SUCCEEDED', 'settlement');
const paid = describeStatus('SUCCEEDED', 'payout');
assert.deepEqual([captured, settled, paid], [
{ tone: 'captured', label: 'captured' },
{ tone: 'settled', label: 'settled' },
{ tone: 'paid', label: 'paid out' },
]);
});

test('in-flight provider outcomes stay unknown, never success or failure', () => {
assert.deepEqual(describeStatus('PROCESSING', 'attempt'), { tone: 'pending', label: 'processing · outcome unknown' });
for (const value of ['PENDING', 'CAPTURE_PENDING', 'REQUIRES_AUTHORIZATION']) assert.equal(describeStatus(value).tone, 'pending');
});

test('generic success is not a money state, and unknown values stay neutral', () => {
assert.equal(describeStatus('SUCCEEDED', 'attempt').tone, 'done');
assert.equal(describeStatus('SOMETHING_NEW').tone, 'neutral');
});
Loading
Loading