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
32 changes: 31 additions & 1 deletion backend/src/fraudlens/api/routes/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,37 @@ def freeze_requests(
query = query.where(FreezeRequest.status == status)
rows = s.scalars(query).all()
names = _names(s, [who for r in rows for who in (r.requested_by, r.decided_by)])
return [freeze_view(r, names) for r in rows]
claims = _open_claims(s, {r.wallet_id for r in rows if r.status == "pending"})
return [freeze_view(r, names) | {"refunds": claims.get(r.wallet_id)} for r in rows]


def _open_claims(s: Session, wallets: set[str]) -> dict[str, dict]:
"""Victims waiting on each wallet's freeze: those whose fraud is already confirmed are
paid the moment the freeze is approved."""
if not wallets:
return {}
confirmed = Case.verdict == "confirmed_fraud"
rows = s.execute(
select(
Refund.wallet_id,
func.count(),
func.sum(Refund.amount_claimed),
func.count().filter(confirmed),
func.coalesce(func.sum(Refund.amount_claimed).filter(confirmed), 0),
)
.join(Case, Case.id == Refund.case_id)
.where(Refund.wallet_id.in_(wallets), Refund.status == "open")
.group_by(Refund.wallet_id)
).all()
return {
wallet: {
"open": n,
"claimed": float(claimed),
"paid_on_approval": n_confirmed,
"paid_on_approval_claimed": float(confirmed_claimed),
}
for wallet, n, claimed, n_confirmed, confirmed_claimed in rows
}


@router.post("/freeze-requests/{request_id}/approve")
Expand Down
25 changes: 20 additions & 5 deletions frontend/app/(console)/approvals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useMemo, useState } from "react";
import { Id, useSession } from "@/components/session";
import { Async, Badge, Button, Card, Chip, Empty, PageHeader, ReasonDialog, StatusBadge, Table, Td } from "@/components/ui";
import { api, ApiError, useApi } from "@/lib/api";
import { when, words } from "@/lib/format";
import { taka, when, words } from "@/lib/format";
import type { Freeze } from "@/lib/types";

const STATUSES = ["pending", "approved", "rejected"] as const;
Expand Down Expand Up @@ -64,7 +64,7 @@ export default function ApprovalsPage() {
<>
<PageHeader tour="approvals-header"
title="Freeze approvals"
sub="Freezing a wallet takes two people: a reviewer asks, and a supervisor who is not that reviewer decides. Nothing here is automatic."
sub="Freezing a wallet takes two people: a reviewer asks, and a supervisor who is not that reviewer decides. Once the fraud is confirmed, approving the freeze is also what refunds the victims."
/>
{outcome && <div role="status" className="mb-3 rounded-xl border border-good/30 bg-good/10 px-3 py-2 text-sm text-good">{outcome}</div>}
{!canApprove && (
Expand Down Expand Up @@ -114,7 +114,16 @@ export default function ApprovalsPage() {
<Td>#{request.id}</Td>
<Td><Id value={request.wallet_id} caseId={request.case_id} /></Td>
<Td><StatusBadge status={request.status} /></Td>
<Td className="max-w-md whitespace-normal text-fg-2">{request.reason}</Td>
<Td className="max-w-md whitespace-normal text-fg-2">
{request.reason}
{request.refunds && (
<p className={`mt-1.5 text-xs font-medium ${request.refunds.paid_on_approval ? "text-good" : "text-warn"}`}>
{request.refunds.paid_on_approval
? `Approving pays back ${request.refunds.paid_on_approval} victim${request.refunds.paid_on_approval > 1 ? "s" : ""}, ${taka(request.refunds.paid_on_approval_claimed)} claimed`
: `${request.refunds.open} refund claim${request.refunds.open > 1 ? "s" : ""} waiting for a verdict`}
</p>
)}
</Td>
<Td className="whitespace-nowrap text-fg-2">
{request.requester}
<div className="text-xs text-fg-3">{when(request.created_at)}</div>
Expand All @@ -129,7 +138,9 @@ export default function ApprovalsPage() {
<Badge title="The two-person rule: whoever asked cannot approve">you asked for this</Badge>
) : (
<span className="flex gap-2">
<Button small variant="danger" onClick={() => setDialog({ title: `Freeze wallet (request #${request.id})`, requests: [request], approve: true })}>Approve</Button>
<Button small variant="danger" onClick={() => setDialog({ title: `Freeze wallet (request #${request.id})`, requests: [request], approve: true })}>
{request.refunds?.paid_on_approval ? "Approve and refund" : "Approve"}
</Button>
<Button small onClick={() => setDialog({ title: `Reject request #${request.id}`, requests: [request], approve: false })}>Reject</Button>
</span>
)}
Expand All @@ -149,7 +160,11 @@ export default function ApprovalsPage() {
title={dialog.title}
label="Decision note"
intro={dialog.approve
? "An approved freeze stops the wallet from sending or receiving until a supervisor lifts it."
? `An approved freeze stops the wallet from sending or receiving until a supervisor lifts it.${
dialog.requests.some((request) => request.refunds?.paid_on_approval)
? " The fraud is already confirmed, so the victims who reported it are refunded from what is left in the wallet in the same step."
: ""
}`
: "The wallet stays as it is. The requester sees your note."}
confirm={dialog.approve ? "Approve freeze" : "Reject"}
variant={dialog.approve ? "danger" : "primary"}
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/(console)/phone/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const T = {
newPayment: { bn: "নতুন লেনদেন", en: "New payment" },
reportForRefund: { bn: "প্রতারিত হয়েছেন? অভিযোগ করে টাকা ফেরত চান", en: "Scammed? Report it to claim your money back" },
refundTitle: { bn: "টাকা ফেরতের অবস্থা", en: "Your refund" },
refundBy: { bn: "যত দেরিতেই হোক, জানাব", en: "We'll tell you by" },
refundBy: { bn: "উত্তর পাবেন সর্বশেষ", en: "We'll tell you by" },
refundNoFee: {
bn: "টাকা ফেরত পেতে কাউকে পিন, ওটিপি বা কোনো ফি দিতে হয় না। কেউ চাইলে সে প্রতারক।",
en: "You never give a PIN, OTP or fee to get a refund. Anyone who asks is a scammer.",
Expand Down
6 changes: 6 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ export const metadata: Metadata = {
title: { default: "FraudLens", template: "%s · FraudLens" },
description: "Real-time fraud decisions, investigation and model oversight for mobile money.",
robots: { index: false, follow: false },
// Added to an iPhone's home screen, the console opens full screen like an app.
appleWebApp: { capable: true, title: "FraudLens", statusBarStyle: "black-translucent" },
};

export const viewport: Viewport = {
themeColor: "#1b1b1b",
colorScheme: "dark",
width: "device-width",
initialScale: 1,
// Draw under the notch and the home indicator; the bars pad themselves with the safe areas.
viewportFit: "cover",
};

export default async function RootLayout({ children }: { children: React.ReactNode }) {
Expand Down
160 changes: 160 additions & 0 deletions frontend/components/mobile-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import Link from "next/link";
import { type ReactNode, useEffect, useRef } from "react";

import { words } from "@/lib/format";
import { current, isActive, NAV, type NavItem, slug, tabsFor } from "@/lib/nav";
import type { Me } from "@/lib/types";

import { TourButton } from "./tour/tour";
import { cx } from "./ui";

export function Glyph({ d, className }: { d: string; className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d={d} />
</svg>
);
}

const MORE = "M5 12h.01M12 12h.01M19 12h.01";

/** The phone's title bar: the page's name in the middle, as an iOS navigation bar has it. */
export function TopBar({ pathname }: { pathname: string }) {
const page = current(pathname);
return (
<header className="sticky top-0 z-30 border-b border-line bg-base/75 pt-[env(safe-area-inset-top)] backdrop-blur-2xl backdrop-saturate-150 lg:hidden">
<div className="relative flex h-11 items-center px-4">
<span className="flex items-center gap-1.5 text-[0.8125rem] font-bold tracking-tight text-fg">
<span aria-hidden="true" className="size-2 rounded-full bg-accent" />
FraudLens
</span>
<span className="pointer-events-none absolute inset-x-24 truncate text-center text-[1.0625rem] font-semibold text-fg">{page?.short ?? ""}</span>
<TourButton compact className="ml-auto" />
</div>
</header>
);
}

/** The bottom tab bar: the role's four most-used pages and More. Stays under the tour's overlay (z-100). */
export function TabBar({ me, pathname, moreOpen, onMore }: { me: Me; pathname: string; moreOpen: boolean; onMore: () => void }) {
const tabs = tabsFor(me.role);
const onTab = tabs.some((tab) => isActive(pathname, tab.href));
const item = "flex min-h-[3.125rem] flex-1 flex-col items-center justify-center gap-0.5 text-[0.625rem] font-medium tracking-wide active:scale-95";
return (
<nav
aria-label="Tabs"
data-tour="bottom-nav"
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-base/80 pb-[env(safe-area-inset-bottom)] backdrop-blur-2xl backdrop-saturate-150 lg:hidden"
>
<div className="mx-auto flex max-w-xl px-1">
{tabs.map((tab) => {
const on = isActive(pathname, tab.href);
return (
<Link
key={tab.href}
href={tab.href}
data-tour={`tab-${tab.href === "/" ? "home" : slug(tab.href)}`}
aria-current={on ? "page" : undefined}
className={cx(item, on ? "text-accent" : "text-fg-3")}
>
<Glyph d={tab.icon} className={cx("size-6", on && "[&_path]:fill-accent/20")} />
{tab.short}
</Link>
);
})}
<button
type="button"
data-tour="tab-more"
aria-haspopup="dialog"
aria-expanded={moreOpen}
onClick={onMore}
className={cx(item, !onTab || moreOpen ? "text-accent" : "text-fg-3")}
>
<Glyph d={MORE} className="size-6 [stroke-width:3.2]" />
More
</button>
</div>
</nav>
);
}

function Row({ item, on, onPick }: { item: NavItem; on: boolean; onPick: () => void }) {
return (
<li>
<Link
href={item.href}
onClick={onPick}
aria-current={on ? "page" : undefined}
className={cx("flex min-h-11 items-center gap-3 px-3 py-2 text-[0.9375rem] active:bg-white/8", on ? "text-accent" : "text-fg")}
>
<span className={cx("grid size-7 shrink-0 place-items-center rounded-lg", on ? "bg-accent text-accent-ink" : "bg-accent/15 text-accent")}>
<Glyph d={item.icon} className="size-[1.125rem]" />
</span>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<Glyph d="M9.5 6l6 6-6 6" className="size-4 text-fg-4" />
</Link>
</li>
);
}

/** Every page, the account and sign-out, as an iOS sheet of inset grouped lists. */
export function MoreSheet({
me, pathname, serving, onSignOut, onClose,
}: {
me: Me; pathname: string; serving: ReactNode; onSignOut: () => void; onClose: () => void;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (dialog && !dialog.open) dialog.showModal();
}, []);
const initials = me.display_name.split(/\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase();
return (
<dialog
ref={ref}
aria-label="More"
onClose={onClose}
onClick={(event) => event.target === ref.current && onClose()}
className="sheet lg:hidden"
>
<div className="flex max-h-[85dvh] flex-col">
<div aria-hidden="true" className="mx-auto mt-2 h-1.5 w-9 shrink-0 rounded-full bg-white/25" />
<div className="flex items-center justify-between px-5 pt-3 pb-2">
<h2 className="text-[1.375rem] font-bold tracking-tight text-fg">More</h2>
<button type="button" onClick={onClose} className="min-h-11 px-1 text-[1.0625rem] font-semibold text-accent">Done</button>
</div>
<div className="overflow-y-auto overscroll-contain px-4 pb-5">
<div className="flex items-center gap-3 rounded-xl bg-white/5 px-3 py-3">
<span aria-hidden="true" className="grid size-11 shrink-0 place-items-center rounded-full bg-accent/20 text-[0.9375rem] font-semibold text-accent">{initials}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-fg">{me.display_name}</span>
<span className="text-[0.8125rem] text-fg-3">{words(me.role)}</span>
</span>
</div>
<div className="mt-3 [&>div]:mx-0">{serving}</div>
{NAV.map((group) => {
const items = group.items.filter((entry) => entry.roles.includes(me.role));
if (!items.length) return null;
return (
<section key={group.heading} className="mt-4">
<h3 className="px-3 pb-1.5 text-[0.8125rem] text-fg-3 uppercase">{group.heading}</h3>
<ul className="divide-y divide-line overflow-hidden rounded-xl bg-white/5 [&>li]:pl-0">
{items.map((item) => <Row key={item.href} item={item} on={isActive(pathname, item.href)} onPick={onClose} />)}
</ul>
</section>
);
})}
<button
type="button"
onClick={onSignOut}
className="mt-5 min-h-11 w-full rounded-xl bg-white/5 text-[0.9375rem] font-medium text-bad active:bg-white/8"
>
Sign out
</button>
</div>
</div>
</dialog>
);
}
Loading
Loading