diff --git a/backend/alembic/versions/0010_refunds.py b/backend/alembic/versions/0010_refunds.py new file mode 100644 index 0000000..a427f86 --- /dev/null +++ b/backend/alembic/versions/0010_refunds.py @@ -0,0 +1,92 @@ +"""refunds for scam victims + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-10-07 18:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0010" +down_revision: str | Sequence[str] | None = "0009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "refunds", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("txn_id", sa.BigInteger(), nullable=False), + sa.Column("victim_id", sa.String(length=32), nullable=False), + sa.Column("wallet_id", sa.String(length=32), nullable=False), + sa.Column("case_id", sa.Integer(), nullable=True), + sa.Column("report_id", sa.Integer(), nullable=True), + sa.Column("amount_claimed", sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column("amount_refunded", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("status", sa.String(length=16), server_default="open", nullable=False), + sa.Column("outcome", sa.String(length=20), nullable=True), + sa.Column("filed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("sla_due_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("settled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("settled_by", sa.Integer(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "status IN ('open', 'paid', 'unrecoverable', 'declined')", + name=op.f("ck_refunds_status"), + ), + sa.CheckConstraint("amount_claimed > 0", name=op.f("ck_refunds_claimed_positive")), + sa.CheckConstraint( + "amount_refunded IS NULL" + " OR (amount_refunded >= 0 AND amount_refunded <= amount_claimed)", + name=op.f("ck_refunds_refunded_within_claim"), + ), + sa.CheckConstraint( + "(status = 'paid') = (amount_refunded > 0)", name=op.f("ck_refunds_paid_has_amount") + ), + sa.CheckConstraint( + "(status = 'open') = (settled_at IS NULL)", name=op.f("ck_refunds_settled_has_time") + ), + sa.ForeignKeyConstraint(["case_id"], ["cases.id"], name=op.f("fk_refunds_case_id_cases")), + sa.ForeignKeyConstraint( + ["report_id"], + ["customer_reports.id"], + name=op.f("fk_refunds_report_id_customer_reports"), + ), + sa.ForeignKeyConstraint( + ["settled_by"], ["users.id"], name=op.f("fk_refunds_settled_by_users") + ), + sa.ForeignKeyConstraint( + ["txn_id"], + ["transactions.txn_id"], + name=op.f("fk_refunds_txn_id_transactions"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_refunds")), + sa.UniqueConstraint("txn_id", name=op.f("uq_refunds_txn_id")), + ) + op.create_index(op.f("ix_refunds_case_id"), "refunds", ["case_id"], unique=False) + op.create_index(op.f("ix_refunds_victim_id"), "refunds", ["victim_id"], unique=False) + op.create_index("ix_refunds_wallet_status", "refunds", ["wallet_id", "status"], unique=False) + op.create_index("ix_refunds_status_sla", "refunds", ["status", "sla_due_at"], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_refunds_status_sla", table_name="refunds") + op.drop_index("ix_refunds_wallet_status", table_name="refunds") + op.drop_index(op.f("ix_refunds_victim_id"), table_name="refunds") + op.drop_index(op.f("ix_refunds_case_id"), table_name="refunds") + op.drop_table("refunds") diff --git a/backend/dump.rdb b/backend/dump.rdb new file mode 100644 index 0000000..861ce5b Binary files /dev/null and b/backend/dump.rdb differ diff --git a/backend/src/fraudlens/api/app.py b/backend/src/fraudlens/api/app.py index ec6933a..99e54d4 100644 --- a/backend/src/fraudlens/api/app.py +++ b/backend/src/fraudlens/api/app.py @@ -45,6 +45,7 @@ network, ops, public, + refunds, scoring, webhooks, ) @@ -220,6 +221,7 @@ async def unavailable(request: Request, exc: Exception) -> JSONResponse: alerts, cases, appeals, + refunds, network, customer, intel, diff --git a/backend/src/fraudlens/api/routes/cases.py b/backend/src/fraudlens/api/routes/cases.py index 795ded0..b61df4c 100644 --- a/backend/src/fraudlens/api/routes/cases.py +++ b/backend/src/fraudlens/api/routes/cases.py @@ -14,6 +14,7 @@ from ...intel.attribute import categorise_report from ...intel.taxonomy import load_taxonomy from ...platform import cases as workflow +from ...platform import refunds from ...platform.audit import WorkflowError, audit from ...platform.cases import AT_RISK_FRACTION, sla_state from ...platform.events import Identifier @@ -23,6 +24,7 @@ CustomerReport, Decision, FreezeRequest, + Refund, Transaction, User, ) @@ -39,7 +41,7 @@ Unfreeze, Verdict, ) -from ..views import alert_view, case_view, event_view, freeze_view, user_view +from ..views import alert_view, case_view, event_view, freeze_view, refund_view, user_view router = APIRouter(tags=["cases"]) @@ -330,11 +332,14 @@ def get_case(case_id: RowId, p: Plat, s: Db, ctx: Reviewer) -> dict: reports = s.scalars( select(CustomerReport).where(CustomerReport.case_id == case_id).order_by(CustomerReport.id) ).all() + claims = s.scalars(select(Refund).where(Refund.case_id == case_id).order_by(Refund.id)).all() people = [case.assigned_to, case.closed_by, *(e.actor_id for e in events)] people += [who for f in freezes for who in (f.requested_by, f.decided_by)] + people += [r.settled_by for r in claims] names = _names(s, people) + now = p.scorer.now() return { - **case_view(case, p.scorer.now(), names), + **case_view(case, now, names), "subject": p.graph.risk(case.subject_id), "alerts": [alert_view(t, d) for t, d in alerts], "timeline": [ @@ -359,6 +364,12 @@ def get_case(case_id: RowId, p: Plat, s: Db, ctx: Reviewer) -> dict: } for r in reports ], + "refunds": { + "claims": [refund_view(r, now, names) for r in claims], + # What is still in the wallet: the most a confirmed verdict can return. + "recoverable": refunds.balance(s, case.subject_id), + "wallet_frozen": case.subject_id in p.scorer.frozen, + }, } @@ -382,15 +393,15 @@ def escalate(case_id: RowId, body: Escalate, p: Plat, ctx: Reviewer) -> dict: @router.post("/cases/{case_id}/verdict") def verdict(case_id: RowId, body: Verdict, p: Plat, ctx: Reviewer) -> dict: - """The human decision: closes the case and releases or blocks the held money.""" + """The human decision: closes the case and releases or blocks the held money. + + Confirmed fraud on a frozen wallet also refunds the victims who reported it; on a + wallet not frozen yet it requests the freeze, and its approval pays them. + """ outcome = workflow.give_verdict( p.scorer, ctx, case_id, body.verdict, body.note, body.reason_code ) - return { - "case": case_view(outcome["case"], p.scorer.now()), - "released": outcome["released"], - "blocked": outcome["blocked"], - } + return {"case": case_view(outcome.pop("case"), p.scorer.now()), **outcome} # ---------------------------------------------------------------- freezes @@ -415,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") diff --git a/backend/src/fraudlens/api/routes/customer.py b/backend/src/fraudlens/api/routes/customer.py index 54ce0bf..e9df4f5 100644 --- a/backend/src/fraudlens/api/routes/customer.py +++ b/backend/src/fraudlens/api/routes/customer.py @@ -18,6 +18,7 @@ from ...platform.audit import WorkflowError from ..deps import Db, Plat, Platform, Service, TxnId from ..schemas import CustomerResponse, MessageCheck, PaymentVerify, RecipientCheck, ScamReport +from ..views import customer_refund_view router = APIRouter(prefix="/customer", tags=["customer"]) @@ -73,13 +74,28 @@ def respond(txn_id: TxnId, body: CustomerResponse, p: Plat, ctx: Service) -> dic @router.post("/reports", status_code=201) def report(body: ScamReport, p: Plat, ctx: Service) -> dict: - """'I think I was scammed.' Opens a case for an analyst; decides nothing by itself.""" + """'I think I was scammed.' Opens a case for an analyst; decides nothing by itself. + + Reporting a payment that went through also claims a refund for it: `refund` is + where that claim stands (GET .../transactions/{txn_id}/refund follows it). + """ _limit(p.report_limit, body.reporter_id) - made = workflow.report_scam( + return report_view(p, *workflow.report_scam( p.scorer, ctx, body.reporter_id, body.reported_wallet_id, body.txn_id, body.category, body.description, - ) # fmt: skip - return {"report_id": made.id, "case_id": made.case_id, "reference": made.reference} + )) # fmt: skip + + +def report_view(p: Platform, made, refund) -> dict: + claim = None + if refund is not None: + claim = customer_refund_view(refund, refund.wallet_id in p.scorer.frozen, p.scorer.now()) + return { + "report_id": made.id, + "case_id": made.case_id, + "reference": made.reference, + "refund": claim, + } @router.post("/message-check") diff --git a/backend/src/fraudlens/api/routes/demo.py b/backend/src/fraudlens/api/routes/demo.py index 22e099a..5c5b984 100644 --- a/backend/src/fraudlens/api/routes/demo.py +++ b/backend/src/fraudlens/api/routes/demo.py @@ -39,7 +39,8 @@ ) from ..views import result_view from .appeals import customer_appeal_view, find_appeal -from .customer import _limit, check_recipient, check_text, verify_payment +from .customer import _limit, check_recipient, check_text, report_view, verify_payment +from .refunds import customer_view, find_refund router = APIRouter(prefix="/demo", tags=["demo"]) @@ -208,11 +209,10 @@ def respond(body: DemoResponse, p: Plat, s: Db, ctx: Staff) -> dict: def report(body: ScamReport, p: Plat, ctx: Staff) -> dict: """The demo customer's 'I think I was scammed'. Limited per wallet, like the real one.""" _limit(p.report_limit, body.reporter_id) - made = workflow.report_scam( + return report_view(p, *workflow.report_scam( p.scorer, ctx, body.reporter_id, body.reported_wallet_id, body.txn_id, body.category, body.description, - ) # fmt: skip - return {"report_id": made.id, "case_id": made.case_id, "reference": made.reference} + )) # fmt: skip def _sender(s: Session, txn_id: int) -> str: @@ -239,6 +239,14 @@ def appeal_status( return customer_appeal_view(p, s, find_appeal(s, _sender(s, txn_id), txn_id)) +@router.get("/refund") +def refund_status( + txn_id: Annotated[int, Query(ge=0, lt=2**62)], p: Plat, s: Db, ctx: Staff +) -> dict: + """Where the demo customer's refund stands, as their app would show it.""" + return customer_view(p, find_refund(s, _sender(s, txn_id), txn_id)) + + @router.post("/recipient-check") def recipient_check(body: RecipientCheck, p: Plat, ctx: Staff) -> dict: """What the app would show before the customer types an amount.""" diff --git a/backend/src/fraudlens/api/routes/refunds.py b/backend/src/fraudlens/api/routes/refunds.py new file mode 100644 index 0000000..db8e76b --- /dev/null +++ b/backend/src/fraudlens/api/routes/refunds.py @@ -0,0 +1,92 @@ +"""Refunds for scam victims: the victim's app follows its claim, reviewers see the queue.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from fastapi import APIRouter, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ...platform import refunds as workflow +from ...platform.audit import WorkflowError +from ...platform.models import Refund, User +from ..deps import Db, Plat, Platform, Reviewer, RowId, Service, TxnId +from ..schemas import RefundDecline +from ..views import customer_refund_view, refund_view + +router = APIRouter(tags=["refunds"]) + +STATUSES = ("open", "paid", "unrecoverable", "declined") + + +def find_refund(s: Session, wallet_id: str, txn_id: int) -> Refund: + refund = s.scalar(select(Refund).where(Refund.txn_id == txn_id)) + # The same answer for "no claim" and "not yours". + if refund is None or refund.victim_id != wallet_id: + raise WorkflowError(404, "refund_not_found", "no refund claim for this payment") + return refund + + +def customer_view(p: Platform, refund: Refund) -> dict: + return customer_refund_view(refund, refund.wallet_id in p.scorer.frozen, p.scorer.now()) + + +# ------------------------------------------------------------- the customer's app + + +@router.get("/customer/transactions/{txn_id}/refund") +def refund_status( + txn_id: TxnId, + p: Plat, + s: Db, + ctx: Service, + wallet_id: Annotated[str, Query(pattern=r"^[A-Za-z0-9_-]{1,32}$")], +) -> dict: + """Where the victim's refund stands. The claim itself is made by reporting the payment.""" + return customer_view(p, find_refund(s, wallet_id, txn_id)) + + +# ------------------------------------------------------------------ the reviewers + + +@router.get("/refunds") +def list_refunds( + p: Plat, + s: Db, + ctx: Reviewer, + status: Literal["open", "paid", "unrecoverable", "declined"] | None = "open", + limit: Annotated[int, Query(ge=1, le=200)] = 50, +) -> dict: + """The queue: the nearest promise to the customer first.""" + where = [Refund.status == status] if status else [] + rows = s.scalars( + select(Refund) + .where(*where) + .order_by(Refund.sla_due_at.asc(), Refund.id.desc()) + .limit(limit) + ).all() + ids = {r.settled_by for r in rows if r.settled_by is not None} + names = ( + dict(s.execute(select(User.id, User.username).where(User.id.in_(ids))).all()) if ids else {} + ) + counts = dict(s.execute(select(Refund.status, func.count()).group_by(Refund.status)).all()) + paid = s.scalar( + select(func.coalesce(func.sum(Refund.amount_refunded), 0)).where(Refund.status == "paid") + ) + now = p.scorer.now() + return { + "now": now, + "counts": {k: counts.get(k, 0) for k in STATUSES}, + "refunded_total": float(paid), + "refunds": [ + refund_view(r, now, names) | {"wallet_frozen": r.wallet_id in p.scorer.frozen} + for r in rows + ], + } + + +@router.post("/refunds/{refund_id}/decline") +def decline(refund_id: RowId, body: RefundDecline, p: Plat, ctx: Reviewer) -> dict: + """Take one claim out of a case: the claimant is not a victim. No money moves.""" + return refund_view(workflow.decline(p.scorer, ctx, refund_id, body.note), p.scorer.now()) diff --git a/backend/src/fraudlens/api/schemas.py b/backend/src/fraudlens/api/schemas.py index 0db4419..2b02b2a 100644 --- a/backend/src/fraudlens/api/schemas.py +++ b/backend/src/fraudlens/api/schemas.py @@ -153,6 +153,10 @@ class AppealDecision(Body): note: Reason +class RefundDecline(Body): + note: Reason + + class MessageCheck(Body): """A message the customer received and wants checked. It is read, never stored.""" diff --git a/backend/src/fraudlens/api/views.py b/backend/src/fraudlens/api/views.py index eff99ae..57acdb6 100644 --- a/backend/src/fraudlens/api/views.py +++ b/backend/src/fraudlens/api/views.py @@ -7,7 +7,16 @@ from ..intel.attribute import categorise from ..intel.taxonomy import load_taxonomy from ..platform.cases import sla_state -from ..platform.models import Appeal, Case, CaseEvent, Decision, FreezeRequest, Transaction, User +from ..platform.models import ( + Appeal, + Case, + CaseEvent, + Decision, + FreezeRequest, + Refund, + Transaction, + User, +) from ..platform.pii import redact from ..platform.scoring import Result @@ -105,6 +114,47 @@ def appeal_view(a: Appeal, now: datetime, names: dict[int, str] | None = None) - } +def refund_view(r: Refund, now: datetime, names: dict[int, str] | None = None) -> dict: + names = names or {} + return { + "id": r.id, + "txn_id": r.txn_id, + "victim_id": r.victim_id, + "wallet_id": r.wallet_id, + "case_id": r.case_id, + "amount_claimed": r.amount_claimed, + "amount_refunded": r.amount_refunded, + "status": r.status, + "outcome": r.outcome, + "filed_at": r.filed_at, + "sla_due_at": r.sla_due_at, + "overdue": r.status == "open" and r.sla_due_at < now, + "settled_at": r.settled_at, + "settled_by": r.settled_by, + "settler": names.get(r.settled_by), + "note": redact(r.note) if r.note else None, + } + + +def customer_refund_view(r: Refund, frozen: bool, now: datetime) -> dict: + """What the victim's app shows: where the claim stands and what came back, never + who decided or what else is known about the receiving wallet.""" + return { + "id": r.id, + "txn_id": r.txn_id, + "status": r.status, + "outcome": r.outcome, + "amount_claimed": r.amount_claimed, + "amount_refunded": r.amount_refunded, + # The receiver can no longer cash out or send on what is left. + "protected": frozen or r.status == "paid", + "filed_at": r.filed_at, + "sla_due_at": r.sla_due_at, + "settled_at": r.settled_at, + "now": now, + } + + def alert_view(t: Transaction, d: Decision) -> dict: """One line of the alert queue.""" return { diff --git a/backend/src/fraudlens/intel/taxonomy.yaml b/backend/src/fraudlens/intel/taxonomy.yaml index 5980add..06d92a5 100644 --- a/backend/src/fraudlens/intel/taxonomy.yaml +++ b/backend/src/fraudlens/intel/taxonomy.yaml @@ -18,24 +18,25 @@ categories: number: 1 name: { en: Social engineering, bn: প্ররোচনামূলক প্রতারণা } summary: >- - Talking a customer into sending money or giving up a secret. The customer - presses the buttons, so nothing looks wrong to a system that only checks - who is signed in. + The fraudster talks the customer into sending money or sharing a secret + code. The customer presses the buttons themselves, so a system that only + checks who is signed in sees nothing wrong. bangladesh: >- - The usual opening is a phone call: "bKash/Nagad/upay office theke bolchi, - apnar account bondho hoye jabe", followed by a request for the OTP that - has just arrived, or for the PIN "to verify". Variants: a refund or - cashback that needs the customer's code first, a relative "in hospital" - writing from a new number, and long romance or friendship chats on - Facebook, IMO and WhatsApp that end with a gift stuck at customs. Callers - often learn the victim's number and last amount from an agent's register - or from someone standing nearby. + It usually starts with a phone call: "bKash/Nagad/upay office theke + bolchi, apnar account bondho hoye jabe" (I am calling from the + bKash/Nagad/upay office, your account will be closed). The caller then + asks for the OTP that has just arrived, or for the PIN "to verify". Other + versions: a refund or cashback that needs the customer's code first; a + relative "in hospital" writing from a new number; long romance or + friendship chats on Facebook, IMO and WhatsApp that end with a gift + "stuck at customs". Callers often learn the victim's number and last + payment amount from an agent's register, or from someone standing nearby. examples: [fake support call, OTP theft, PIN theft, romance scam, refund scam] detectors: [transaction, text] signals: - - a first payment to a wallet the sender has never paid, large against the sender's habits - - a receiver that many unrelated strangers started paying this week - - a message that asks for an OTP or PIN, or pushes the reader to act within minutes + - A first payment to a wallet the sender has never paid before, and the amount is large for this sender + - A receiver that many unrelated strangers started paying this week + - A message that asks for an OTP or PIN, or rushes the reader to act within minutes advice: en: >- upay never asks for your PIN or an OTP, by phone, SMS or chat. If someone @@ -50,21 +51,22 @@ categories: number: 2 name: { en: Fake identity and impersonation, bn: ভুয়া পরিচয় ও ছদ্মবেশ } summary: >- - Pretending to be someone the customer already trusts: the wallet company, - an agent, a bank, a government office, a merchant. + The fraudster pretends to be someone the customer already trusts: the + wallet company, an agent, a bank, a government office or a shop. bangladesh: >- - Fake "customer care" numbers and Facebook pages that copy a wallet's logo - and answer people who complain in public comments. Callers who say they - are the agent the customer just used, a bank officer, or an official of an - education board or a social-safety programme paying a stipend (উপবৃত্তি) or - an allowance (ভাতা). Fake merchant pages that take the name of a known - shop. The number is almost always an ordinary personal mobile number. + Fake "customer care" numbers and Facebook pages copy a wallet's logo. + They reply to people who complain in public comments. Some callers say + they are the agent the customer just used, or a bank officer. Others say + they are from an education board, or from a social-safety programme that + pays a stipend (উপবৃত্তি) or an allowance (ভাতা). Fake shop pages use the + name of a well-known shop. The number they use is almost always an + ordinary personal mobile number. examples: [fake customer care, fake agent, fake support page, fake merchant] detectors: [transaction, text, links] signals: - - a message that claims to come from the wallet, a bank or a government office - - a "helpline" that is a personal mobile number, or a page on a look-alike domain - - payments from many first-time senders to one personal wallet + - A message that says it comes from the wallet company, a bank or a government office + - A "helpline" that is a personal mobile number, or a web page on an address made to look like the real one + - Many people paying one personal wallet for the first time advice: en: >- This looks like someone pretending to be upay, an agent or an official. @@ -79,21 +81,23 @@ categories: number: 3 name: { en: Fake payment and transaction, bn: ভুয়া পেমেন্ট ও লেনদেন } summary: >- - Making it look as if money was sent when it was not, so the victim hands - over goods or "returns" money that never arrived. + The fraudster makes it look as if money was sent when it was not. The + victim then hands over goods, or "returns" money that never arrived. bangladesh: >- - "Bhai, vul kore apnar number e taka chole geche, ferot din": an SMS that - copies a cash-in notice, sent from a personal number, then a call asking - for the money back. Shopkeepers and F-commerce sellers are shown an edited - screenshot of a "successful" payment and asked to release the goods. - Fake refund and reversal notices work the same way. The notice never - comes from the wallet's own sender name and the balance does not change. + "Bhai, vul kore apnar number e taka chole geche, ferot din" (Brother, + money went to your number by mistake, please send it back). This comes as + an SMS that copies a cash-in notice, sent from a personal number, and then + a call asking for the money back. Shopkeepers and Facebook sellers are + shown an edited screenshot of a "successful" payment and asked to hand + over the goods. Fake refund and reversal notices work the same way. The + notice never comes from the wallet's own sender name, and the balance + does not change. examples: [fake screenshot, fake SMS, fake refund, transaction manipulation] detectors: [ledger, text] signals: - - a claimed transaction ID that is not in the ledger, or not to this wallet - - a claimed amount that differs from the recorded one - - a message asking to send back money "sent by mistake" + - A transaction ID that is not in our records, or that was not sent to this wallet + - An amount that is different from the one in our records + - A message asking to send back money "sent by mistake" advice: en: >- Check your own balance and transaction list before you return money or @@ -108,21 +112,22 @@ categories: number: 4 name: { en: Account and identity takeover, bn: অ্যাকাউন্ট ও পরিচয় দখল } summary: >- - Someone else gets into the customer's wallet and moves the money + Someone else gets into the customer's wallet and moves the money out themselves. bangladesh: >- - The OTP and PIN collected by a scam call are used to register the wallet - on the fraudster's handset, and the balance leaves within minutes, usually - to a mule wallet and then out at an agent in another district. Other - routes: a SIM replaced or re-registered with someone else's identity - papers, a stolen or borrowed phone with the PIN written down or guessed, - and remote-access apps the customer was told to install. + A scam call collects the OTP and PIN. The fraudster uses them to register + the wallet on their own phone. The balance is gone within minutes, + usually sent to a mule wallet (a wallet used to pass stolen money on) and + then cashed out at an agent in another district. Other ways in: a SIM + replaced or re-registered with someone else's ID papers; a stolen or + borrowed phone where the PIN was written down or guessed; and + remote-control apps the customer was told to install. examples: [OTP theft, PIN theft, SIM fraud, stolen phone, account takeover] detectors: [transaction, text] signals: - - a handset the wallet has used for minutes, not months - - a payment that empties the balance, from a district or network the wallet never uses - - a message that asks for a code, a SIM change or a new device registration + - A phone that has been on the wallet for minutes, not months + - A payment that empties the balance, from a district or mobile network the wallet never uses + - A message that asks for a code, a SIM change or registering a new phone advice: en: >- Someone may be trying to get into your wallet. Do not share any code, @@ -137,22 +142,22 @@ categories: number: 5 name: { en: Marketplace and merchant fraud, bn: মার্কেটপ্লেস ও মার্চেন্ট প্রতারণা } summary: >- - Fraud around buying and selling: goods that never arrive, sellers who are - not sellers, buyers who never paid. + Fraud in buying and selling: goods that never arrive, sellers who are not + real sellers, and buyers who never paid. bangladesh: >- - Facebook-page shops (F-commerce) and marketplace listings that ask for the - full price or a "booking" or "delivery charge" in advance to a personal - wallet, then block the buyer. Deep-discount pre-order schemes that collect - advance payments for months. Second-hand listings for phones and - motorbikes where the "seller" is in another district and wants a courier - fee first. Sellers are hit from the other side by fake payment proofs - (category 3). + Facebook page shops (F-commerce) and online listings ask for the full + price, or a "booking" or "delivery charge", in advance to a personal + wallet. Then they block the buyer. Big-discount pre-order schemes collect + advance payments for months. In second-hand listings for phones and + motorbikes, the "seller" is in another district and wants a courier fee + first. Sellers are also targeted the other way round, with fake payment + proofs (type 3). examples: [fake e-commerce, marketplace scam, advance payment, fake merchant] detectors: [text, transaction] signals: - - an advance payment asked to a personal wallet instead of a merchant account - - a price far below the market with a deadline - - one personal wallet receiving first-time payments from buyers across the country + - An advance payment asked to a personal wallet instead of a merchant account + - A price far below the market price, with a deadline + - One personal wallet getting first-time payments from buyers all over the country advice: en: >- Be careful with advance payments. Prefer cash on delivery or a merchant @@ -166,20 +171,21 @@ categories: number: 6 name: { en: Phishing and malicious technology, bn: ফিশিং ও ক্ষতিকর প্রযুক্তি } summary: >- - Links, apps and QR codes built to capture a wallet's credentials or to + Links, apps and QR codes made to steal a wallet's login details, or to take control of the phone. bangladesh: >- - Links sent by SMS, Messenger and WhatsApp to pages that copy a wallet's - login or a "KYC update" form. "Update" or "offer" apps sent as an .apk - file outside the Play Store. Callers who ask the customer to install - AnyDesk or TeamViewer "so the officer can fix the account". QR codes that - are said to *receive* money but open a payment to the fraudster. + Links sent by SMS, Messenger and WhatsApp lead to pages that copy a + wallet's login page or a "KYC update" (identity details) form. "Update" + or "offer" apps are sent as .apk files, outside the Play Store. Callers + ask the customer to install AnyDesk or TeamViewer "so the officer can fix + the account". Some QR codes are said to be for receiving money, but they + open a payment to the fraudster instead. examples: [phishing links, malicious APK, fake QR, remote access] detectors: [links, text] signals: - - a wallet's name on a domain that is not the wallet's - - a link shortener, a bare IP address or an .apk download - - a request to install a remote-access app or to scan a code "to receive" + - A wallet's name on a web address that does not belong to that wallet + - A shortened link, a link that is only numbers (an IP address), or an .apk download + - A request to install a remote-control app, or to scan a code "to receive" money advice: en: >- Do not open this link or install anything it offers. upay is only in the @@ -194,21 +200,21 @@ categories: number: 7 name: { en: Transaction and financial network fraud, bn: লেনদেন ও আর্থিক নেটওয়ার্ক প্রতারণা } summary: >- - What happens to stolen money afterwards: it is moved through wallets that - exist to receive it and is turned into cash before anyone can stop it. + What happens to stolen money next: it is passed through wallets that + exist only to receive it, then turned into cash before anyone can stop it. bangladesh: >- - Mule wallets opened with other people's identity documents, or rented from - students and day labourers for a small commission. Money from many victims - lands in one wallet, is forwarded through one or two more, and is cashed - out at agent points within minutes, often far from where the victims - live. The same pattern carries online betting and informal remittance - (hundi) money. + Mule wallets are opened with other people's ID documents, or rented from + students and day labourers for a small commission. Money from many + victims lands in one wallet. It is passed on through one or two more + wallets, then cashed out at agent points within minutes, often far from + where the victims live. Online betting money and informal remittance + (hundi) money move the same way. examples: [mule accounts, layered transfers, rapid cash-out, abnormal transactions] detectors: [transaction, text] signals: - - many unrelated senders, then a quick forward or cash-out of nearly everything received - - wallets that share a handset or pass money in a ring - - a message offering to rent a wallet or to "receive and forward" money for a commission + - Many unrelated senders, then almost everything received is quickly passed on or cashed out + - Wallets that share one phone, or pass money around in a circle + - A message offering to rent a wallet, or to "receive and forward" money for a commission advice: en: >- Never let anyone use your wallet to receive or pass on money, whatever @@ -222,22 +228,23 @@ categories: number: 8 name: { en: Scam campaigns, bn: সংঘবদ্ধ প্রতারণা প্রচারণা } summary: >- - One script sent to many people: a prize, a return, a job, a loan, a - donation. Each victim pays a small "fee" and the fees add up. + One script sent to many people: a prize, an investment return, a job, a + loan or a donation. Each victim pays a small "fee", and the fees add up. bangladesh: >- - "Apni 25 lakh taka lottery jitechen", with a registration or VAT fee to - pay first. Trading and MLM apps promising a daily return until - withdrawals stop. Part-time "like and subscribe" jobs on Telegram and - WhatsApp that pay a few taka, then ask for a deposit to unlock bigger - tasks. Instant-loan apps that charge a processing fee and never lend. - Stipend, allowance and remittance-bonus messages, fake cashback offers, - and charity appeals after floods and during Ramadan. + "Apni 25 lakh taka lottery jitechen" (You have won a 25 lakh taka + lottery), with a registration or VAT fee to pay first. Trading and MLM + apps promise a daily profit, until withdrawals stop. Part-time "like and + subscribe" jobs on Telegram and WhatsApp pay a few taka, then ask for a + deposit to unlock bigger tasks. Instant-loan apps charge a processing fee + and never give the loan. Also: stipend, allowance and remittance-bonus + messages, fake cashback offers, and charity appeals after floods and + during Ramadan. examples: [lottery, investment, job, loan, charity, fake cashback, fake remittance] detectors: [transaction, text] signals: - - small fee-sized payments from many strangers to one wallet - - a customer sending a series of rising amounts to the same new receiver - - a message promising money in return for a fee paid first + - Many strangers sending small, fee-sized payments to one wallet + - A customer sending bigger and bigger amounts to the same new receiver + - A message promising money if a fee is paid first advice: en: >- A prize, a job, a loan or an investment that asks you to pay first is a diff --git a/backend/src/fraudlens/platform/cases.py b/backend/src/fraudlens/platform/cases.py index 280639c..d021312 100644 --- a/backend/src/fraudlens/platform/cases.py +++ b/backend/src/fraudlens/platform/cases.py @@ -2,7 +2,8 @@ The scorer pauses a payment at most. What happens next is decided here, by people: an analyst releases or blocks held money with a verdict, and freezing a -wallet needs a second person to approve the first one's request. +wallet needs a second person to approve the first one's request. A confirmed +verdict on a frozen wallet also refunds the victims who reported it (refunds.py). """ from __future__ import annotations @@ -15,7 +16,7 @@ from sqlalchemy.orm import Session from . import blocklist as lists -from . import notify, tracking +from . import notify, refunds, tracking from .audit import Ctx, WorkflowError, audit from .models import ( Case, @@ -23,6 +24,7 @@ CustomerReport, Decision, FreezeRequest, + Refund, Transaction, User, Wallet, @@ -246,7 +248,8 @@ def give_verdict( "verdict", case_id=case.id, ) # fmt: skip scorer.load_blocklist(s) - return {"case": case, "released": released, "blocked": blocked} + settled = refunds.on_verdict(s, scorer, ctx, case) + return {"case": case, "released": released, "blocked": blocked, **settled} # ----------------------------------------------------------------- freezes @@ -316,6 +319,8 @@ def decide_freeze(scorer: Scorer, ctx: Ctx, request_id: int, approve: bool, note s.flush() if approve: scorer.frozen.add(wallet.wallet_id) + # The verdict may already be in: then this approval is what pays the victims. + refunds.settle(s, scorer, ctx, wallet.wallet_id) return request @@ -379,12 +384,16 @@ def report_scam( txn_id: int | None, category: str, description: str, -) -> CustomerReport: - """A customer says they were scammed. It opens a case; a person decides what it means.""" +) -> tuple[CustomerReport, Refund | None]: + """A customer says they were scammed. It opens a case; a person decides what it means. + + A report on a payment that went through also claims a refund for it (refunds.py). + """ with scorer.transaction() as s: _wallet(s, reporter_id) if s.get(Wallet, reported_wallet_id) is None: raise WorkflowError(422, "unknown_wallet", "the reported wallet does not exist") + txn = None if txn_id is not None: txn = s.get(Transaction, txn_id) if txn is None or (txn.sender_id, txn.receiver_id) != (reporter_id, reported_wallet_id): @@ -421,4 +430,5 @@ def report_scam( report_id=report.id, reporter_id=reporter_id, category=category, txn_id=txn_id, ) # fmt: skip audit(s, ctx, "customer.report", "wallet", reported_wallet_id, report_id=report.id) - return report + refund = refunds.open_claim(s, scorer, ctx, report, txn) + return report, refund diff --git a/backend/src/fraudlens/platform/load.py b/backend/src/fraudlens/platform/load.py index 29bf1f7..b0ebf11 100644 --- a/backend/src/fraudlens/platform/load.py +++ b/backend/src/fraudlens/platform/load.py @@ -33,7 +33,7 @@ SYSTEM = Ctx(None, "system", None) # Everything the platform writes at run time. Never users, never the audit log. RUNTIME_TABLES = ( - "appeals", "pending_decisions", "shadow_scores", "decisions", "case_events", + "refunds", "appeals", "pending_decisions", "shadow_scores", "decisions", "case_events", "freeze_requests", "customer_reports", "wallet_flags", "blocklist", "cases", "transactions", "past_cases", "wallets", "agents", ) # fmt: skip diff --git a/backend/src/fraudlens/platform/models.py b/backend/src/fraudlens/platform/models.py index 3046fcf..da9e446 100644 --- a/backend/src/fraudlens/platform/models.py +++ b/backend/src/fraudlens/platform/models.py @@ -43,6 +43,9 @@ CASE_SOURCE = ("alert", "customer_report", "manual") FREEZE_STATUS = ("pending", "approved", "rejected") APPEAL_STATUS = ("pending", "approved", "rejected") +# open: the case is still investigating; paid: upay returned money (maybe less than lost); +# unrecoverable: fraud confirmed, nothing left in the wallet; declined: no refund. +REFUND_STATUS = ("open", "paid", "unrecoverable", "declined") # How the customer says they know the person they are paying. APPEAL_RELATIONS = ( "family", @@ -382,6 +385,52 @@ class Appeal(Base): created_at: Mapped[datetime] = mapped_column(Timestamp, server_default=func.now()) +class Refund(Base): + """A scam victim's claim to get back a payment they sent to a mule wallet. + + Opened when the victim reports a completed payment. Settled by the case verdict: + once the wallet is confirmed fraud and frozen, what is still in it is returned to + the victims who claimed, shared in proportion to what each lost. upay's ledger + moves the money; this row is the instruction and the customer's receipt. + """ + + __tablename__ = "refunds" + __table_args__ = ( + CheckConstraint(_one_of("status", REFUND_STATUS), name="status"), + CheckConstraint("amount_claimed > 0", name="claimed_positive"), + CheckConstraint( + "amount_refunded IS NULL" + " OR (amount_refunded >= 0 AND amount_refunded <= amount_claimed)", + name="refunded_within_claim", + ), + CheckConstraint("(status = 'paid') = (amount_refunded > 0)", name="paid_has_amount"), + CheckConstraint("(status = 'open') = (settled_at IS NULL)", name="settled_has_time"), + Index("ix_refunds_wallet_status", "wallet_id", "status"), + Index("ix_refunds_status_sla", "status", "sla_due_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + # One claim per payment. + txn_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("transactions.txn_id", ondelete="CASCADE"), unique=True + ) + victim_id: Mapped[str] = mapped_column(String(32), index=True) # who sent it, and is paid back + wallet_id: Mapped[str] = mapped_column(String(32)) # where it went: the reported wallet + case_id: Mapped[int | None] = mapped_column(ForeignKey("cases.id"), index=True) + report_id: Mapped[int | None] = mapped_column(ForeignKey("customer_reports.id")) + amount_claimed: Mapped[float] = mapped_column(Money) + amount_refunded: Mapped[float | None] = mapped_column(Money) + status: Mapped[str] = mapped_column(String(16), server_default="open") + # Why it ended as it did: confirmed_fraud, not_confirmed, nothing_left, not_a_victim. + outcome: Mapped[str | None] = mapped_column(String(20)) + filed_at: Mapped[datetime] = mapped_column(Timestamp) # domain time, like opened_at + sla_due_at: Mapped[datetime] = mapped_column(Timestamp) + settled_at: Mapped[datetime | None] = mapped_column(Timestamp) + settled_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + note: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(Timestamp, server_default=func.now()) + + class PastCase(Base): """Confirmed fraud cases from before the platform went live (the training period).""" diff --git a/backend/src/fraudlens/platform/refunds.py b/backend/src/fraudlens/platform/refunds.py new file mode 100644 index 0000000..9272ef1 --- /dev/null +++ b/backend/src/fraudlens/platform/refunds.py @@ -0,0 +1,243 @@ +"""Refunds for scam victims: freeze the mule wallet, then return what is left in it. + +1. A customer reports a completed payment as a scam. Their report opens a refund + claim for that payment, joined to the case on the receiving wallet. +2. The wallet is frozen with the usual two-person freeze, so nothing more can be + cashed out or sent on. Until then, the case is the only thing protecting the money. +3. The case verdict settles every claim on it: + - `confirmed_fraud`: once the wallet is frozen, what is still in it goes back + to the claimants. If it holds less than they lost, each gets the same share of + their loss. A confirmed verdict on a wallet that is not frozen yet asks for the + freeze itself, and the claims are paid the moment a second person approves it. + - any other verdict: the claims are declined and no money moves. + +A reviewer can decline one claim on an open case (the claimant looks like part of +the scheme, not a victim of it). Nobody can pay one by hand: money only leaves a +wallet that two people froze and a person confirmed as fraud. + +FraudLens decides; upay's ledger moves the money. A paid claim is that instruction, +and what the victim's app shows as their receipt. +""" + +from __future__ import annotations + +from datetime import timedelta + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from .audit import Ctx, WorkflowError, audit +from .models import Case, CaseEvent, CustomerReport, FreezeRequest, Refund, Transaction +from .scoring import Scorer + +# The promise shown to the victim. Five days, as UK payment providers must refund +# authorised-push-payment scams; the clock is the platform's, like every deadline. +SLA = timedelta(days=5) +REFUNDABLE_TYPES = ("SEND_MONEY",) + + +def _event(s: Session, case_id: int | None, ctx: Ctx | None, kind: str, **data) -> None: + if case_id is not None: + s.add( + CaseEvent(case_id=case_id, actor_id=ctx.user_id if ctx else None, kind=kind, data=data) + ) + + +def allocate(claimed: list[float], available: float) -> list[float]: + """What each claim gets back from `available`: all of it when there is enough, + otherwise the same share of each loss, rounded down to the paisa so the total + never exceeds what is there.""" + cents = [round(amount * 100) for amount in claimed] + total, pot = sum(cents), max(int(available * 100 + 1e-6), 0) + if total <= pot: + return [c / 100 for c in cents] + return [c * pot // total / 100 for c in cents] + + +def balance(s: Session, wallet_id: str) -> float: + """What is still in the wallet, from the ledger. + + The balance upay last reported for one of the wallet's own payments, plus what + came in and minus what went out after it, minus refunds already paid from it. + A wallet that has never paid anything out is the sum of what it received. + """ + done = Transaction.status == "completed" + last = s.execute( + select( + Transaction.ts, + Transaction.txn_id, + Transaction.sender_balance_before, + Transaction.amount, + ) + .where( + Transaction.sender_id == wallet_id, + done, + Transaction.sender_balance_before.is_not(None), + ) + .order_by(Transaction.ts.desc(), Transaction.txn_id.desc()) + .limit(1) + ).first() + after, base, refunded_after = [], 0.0, [] + if last is not None: + ts, txn_id, before, amount = last + base = max(float(before) - float(amount), 0.0) + after = [or_(Transaction.ts > ts, (Transaction.ts == ts) & (Transaction.txn_id > txn_id))] + refunded_after = [Refund.settled_at > ts] + + def total(*where) -> float: + amount = func.coalesce(func.sum(Transaction.amount), 0) + return float(s.scalar(select(amount).where(done, *where, *after))) + + received = total(Transaction.receiver_id == wallet_id) + sent = total(Transaction.sender_id == wallet_id) + refunded = float( + s.scalar( + select(func.coalesce(func.sum(Refund.amount_refunded), 0)).where( + Refund.wallet_id == wallet_id, Refund.status == "paid", *refunded_after + ) + ) + ) + return round(max(base + received - sent - refunded, 0.0), 2) + + +def open_claim( + s: Session, scorer: Scorer, ctx: Ctx, report: CustomerReport, txn: Transaction | None +) -> Refund | None: + """The claim a scam report makes for its payment. Only money that actually left + the customer can be claimed: a payment that was held, cancelled or blocked never + reached the scammer. Reporting the same payment again returns the first claim.""" + if txn is None or txn.status != "completed" or txn.type not in REFUNDABLE_TYPES: + return None + existing = s.scalar(select(Refund).where(Refund.txn_id == txn.txn_id)) + if existing is not None: + return existing + now = scorer.now() + refund = Refund( + txn_id=txn.txn_id, + victim_id=txn.sender_id, + wallet_id=txn.receiver_id, + case_id=report.case_id, + report_id=report.id, + amount_claimed=txn.amount, + filed_at=now, + sla_due_at=now + SLA, + ) + s.add(refund) + s.flush() + _event( + s, report.case_id, None, "refund_claimed", + refund_id=refund.id, txn_id=txn.txn_id, amount=txn.amount, victim_id=txn.sender_id, + ) # fmt: skip + audit( + s, ctx, "refund.claim", "refund", refund.id, + txn_id=txn.txn_id, wallet_id=txn.receiver_id, amount=txn.amount, + ) # fmt: skip + return refund + + +def _close(s: Session, ctx: Ctx, refund: Refund, status: str, outcome: str, now, amount=0.0): + refund.status, refund.outcome, refund.amount_refunded = status, outcome, amount + refund.settled_at, refund.settled_by = now, ctx.user_id + _event( + s, refund.case_id, ctx, f"refund_{status}", + refund_id=refund.id, txn_id=refund.txn_id, amount=amount, outcome=outcome, + ) # fmt: skip + audit( + s, ctx, f"refund.{status}", "refund", refund.id, + txn_id=refund.txn_id, wallet_id=refund.wallet_id, victim_id=refund.victim_id, + claimed=refund.amount_claimed, refunded=amount, outcome=outcome, + ) # fmt: skip + + +def settle(s: Session, scorer: Scorer, ctx: Ctx, wallet_id: str) -> list[Refund]: + """Pay the open claims on a frozen wallet whose case confirmed fraud. Called + inside `scorer.transaction()` by the verdict and by the freeze approval, so + whichever of the two comes second pays. Returns the claims it settled.""" + if wallet_id not in scorer.frozen: + return [] + claims = s.scalars( + select(Refund) + .join(Case, Case.id == Refund.case_id) + .where( + Refund.wallet_id == wallet_id, + Refund.status == "open", + Case.verdict == "confirmed_fraud", + ) + .order_by(Refund.filed_at, Refund.id) + .with_for_update(of=Refund) + ).all() + if not claims: + return [] + now = scorer.now() + amounts = allocate([c.amount_claimed for c in claims], balance(s, wallet_id)) + for claim, amount in zip(claims, amounts, strict=True): + if amount > 0: + _close(s, ctx, claim, "paid", "confirmed_fraud", now, amount) + else: + _close(s, ctx, claim, "unrecoverable", "nothing_left", now) + s.flush() + return claims + + +def on_verdict(s: Session, scorer: Scorer, ctx: Ctx, case: Case) -> dict: + """What a closed case means for its claims. Returns what happened, for the response.""" + claims = s.scalars( + select(Refund).where(Refund.case_id == case.id, Refund.status == "open") + ).all() + out = { + "refunds_paid": [], + "refunds_waiting": [], + "refunds_declined": [], + "freeze_request": None, + } + if not claims: + return out + if case.verdict != "confirmed_fraud": + now = scorer.now() + for claim in claims: + _close(s, ctx, claim, "declined", "not_confirmed", now) + out["refunds_declined"] = [c.id for c in claims] + return out + settled = settle(s, scorer, ctx, case.subject_id) + if settled: + out["refunds_paid"] = [c.id for c in settled if c.status == "paid"] + return out + # Not frozen yet: ask for it now, so one approval both freezes and refunds. + out["refunds_waiting"] = [c.id for c in claims] + pending = s.scalar( + select(FreezeRequest).where( + FreezeRequest.wallet_id == case.subject_id, FreezeRequest.status == "pending" + ) + ) + if pending is None: + pending = FreezeRequest( + wallet_id=case.subject_id, + case_id=case.id, + reason=( + f"Confirmed fraud on case #{case.id}. Freeze so {len(claims)} reported " + "payment(s) can be refunded from what is left in the wallet." + ), + requested_by=ctx.user_id, + ) + s.add(pending) + s.flush() + _event(s, case.id, ctx, "freeze_requested", request_id=pending.id, for_refunds=True) + audit( + s, ctx, "freeze.request", "wallet", case.subject_id, + request_id=pending.id, case_id=case.id, for_refunds=True, + ) # fmt: skip + out["freeze_request"] = pending.id + return out + + +def decline(scorer: Scorer, ctx: Ctx, refund_id: int, note: str) -> Refund: + """A reviewer takes one claim out: the claimant is not a victim. No money moves.""" + with scorer.transaction() as s: + refund = s.scalar(select(Refund).where(Refund.id == refund_id).with_for_update()) + if refund is None: + raise WorkflowError(404, "refund_not_found", f"no refund claim {refund_id}") + if refund.status != "open": + raise WorkflowError(409, "already_settled", f"the claim is already {refund.status}") + _close(s, ctx, refund, "declined", "not_a_victim", scorer.now()) + refund.note = note + return refund diff --git a/backend/tests/test_platform.py b/backend/tests/test_platform.py index 6cec58e..10a5b0a 100644 --- a/backend/tests/test_platform.py +++ b/backend/tests/test_platform.py @@ -37,7 +37,7 @@ from fraudlens.mlops import drift, feedback, review from fraudlens.mlops import shadow as shadow_mode from fraudlens.models.train import FEEDBACK_COLUMNS -from fraudlens.platform import notify, replay, verify +from fraudlens.platform import notify, refunds, replay, verify from fraudlens.platform.db import make_engine, make_sessions, migrate from fraudlens.platform.events import event_adapter, moment, network_of from fraudlens.platform.load import load @@ -52,6 +52,7 @@ Delivery, FreezeRequest, PendingDecision, + Refund, ShadowScore, Transaction, TranslationReview, @@ -1122,6 +1123,157 @@ def test_a_customer_report_opens_a_case(client, tokens, p, replayed, spare): assert limited.status_code == 429 +def _mule_with_victims(p, n: int = 2) -> list[Transaction]: + """Completed payments from `n` different senders into one wallet nothing has happened to.""" + with p.sessions() as s: + busy = set(s.scalars(select(Case.subject_id))) | set(s.scalars(select(Refund.wallet_id))) + rows = s.scalars( + select(Transaction) + .where( + Transaction.type == "SEND_MONEY", + Transaction.status == "completed", + Transaction.receiver_type == "wallet", + ) + .order_by(Transaction.ts.desc()) + .limit(5_000) + ).all() + by_wallet: dict[str, dict[str, Transaction]] = {} + for txn in rows: + mule = txn.receiver_id + if mule in busy or mule in p.scorer.engine.flagged or mule in p.scorer.frozen: + continue + by_wallet.setdefault(mule, {}).setdefault(txn.sender_id, txn) + if len(by_wallet[mule]) == n: + return list(by_wallet[mule].values()) + pytest.skip(f"no wallet with {n} different senders") + + +def _report_payment(client, service, txn: Transaction) -> dict: + made = client.post( + "/v1/customer/reports", + headers=service, + json={ + "reporter_id": txn.sender_id, + "reported_wallet_id": txn.receiver_id, + "txn_id": txn.txn_id, + "category": "impersonation", + "description": "Someone called saying they were from upay and asked for this.", + }, + ) + assert made.status_code == 201, made.text + return made.json() + + +def test_a_confirmed_scam_freezes_the_wallet_and_refunds_the_victim(client, tokens, p, replayed): + service, analyst = tokens("upay-core"), tokens("analyst1") + victim_txn, other_txn = _mule_with_victims(p) + mule = victim_txn.receiver_id + with p.sessions() as s: + available = refunds.balance(s, mule) + + made = _report_payment(client, service, victim_txn) + claim = made["refund"] + assert (claim["status"], claim["amount_claimed"]) == ("open", victim_txn.amount) + assert claim["protected"] is False # not frozen yet + assert ( + _report_payment(client, service, victim_txn)["refund"]["id"] == claim["id"] + ) # one per payment + status = f"/v1/customer/transactions/{victim_txn.txn_id}/refund" + assert client.get(status, headers=service, params={"wallet_id": "W0"}).status_code == 404 + assert client.get(status, headers=analyst, params={"wallet_id": mule}).status_code == 403 + + # A second claimant on the same case looks like part of the scheme: a reviewer takes it out. + other = _report_payment(client, service, other_txn)["refund"] + case_id = made["case_id"] + assert _report_payment(client, service, other_txn)["case_id"] == case_id + decline = f"/v1/refunds/{other['id']}/decline" + assert client.post(decline, headers=service, json={"note": REASON}).status_code == 403 + out = client.post(decline, headers=analyst, json={"note": REASON}) + assert (out.json()["status"], out.json()["outcome"]) == ("declined", "not_a_victim") + assert client.post(decline, headers=analyst, json={"note": REASON}).status_code == 409 + + detail = client.get(f"/v1/cases/{case_id}", headers=analyst).json() + assert {c["id"] for c in detail["refunds"]["claims"]} == {claim["id"], other["id"]} + assert detail["refunds"]["recoverable"] == available + assert detail["refunds"]["wallet_frozen"] is False + + # Confirmed, but not frozen: the verdict asks for the freeze and pays nobody yet. + verdict = client.post( + f"/v1/cases/{case_id}/verdict", + headers=analyst, + json={"verdict": "confirmed_fraud", "note": REASON}, + ).json() + assert verdict["refunds_waiting"] == [claim["id"]] and verdict["refunds_paid"] == [] + request_id = verdict["freeze_request"] + assert ( + client.get(status, headers=service, params={"wallet_id": victim_txn.sender_id}).json()[ + "status" + ] + == "open" + ) + own = client.post( + f"/v1/freeze-requests/{request_id}/approve", + headers=analyst, + json={"note": REASON}, + ) + assert own.status_code == 403 # an analyst cannot approve, and never their own request + + # The second person's approval freezes the wallet and pays the victim from what is left. + approved = client.post( + f"/v1/freeze-requests/{request_id}/approve", + headers=tokens("supervisor1"), + json={"note": REASON}, + ) + assert approved.status_code == 200, approved.text + assert mule in p.scorer.frozen + paid = client.get(status, headers=service, params={"wallet_id": victim_txn.sender_id}).json() + expected = min(victim_txn.amount, available) + if expected > 0: + assert (paid["status"], paid["amount_refunded"]) == ("paid", expected) + else: + assert (paid["status"], paid["outcome"]) == ("unrecoverable", "nothing_left") + assert paid["protected"] is True and "settled_by" not in paid + + with p.sessions() as s: + assert refunds.balance(s, mule) == round(available - expected, 2) + actions = list( + s.scalars( + select(AuditLog.action).where( + AuditLog.object_type == "refund", AuditLog.object_id == str(claim["id"]) + ) + ) + ) + assert actions[0] == "refund.claim" and actions[-1] in ("refund.paid", "refund.unrecoverable") + # The mule cannot take out what is left. + body = send(p, mule, victim_txn.sender_id, amount=10.0) + result = client.post("/v1/score", headers=service, json=body).json() + assert result["status_reason"] == "wallet_frozen" + queue = client.get("/v1/refunds", headers=analyst, params={"status": paid["status"]}).json() + assert claim["id"] in {r["id"] for r in queue["refunds"]} + + +def test_a_scam_that_is_not_confirmed_refunds_nothing(client, tokens, p, replayed): + service, analyst = tokens("upay-core"), tokens("analyst2") + [txn] = _mule_with_victims(p, 1) + made = _report_payment(client, service, txn) + verdict = client.post( + f"/v1/cases/{made['case_id']}/verdict", + headers=analyst, + json={"verdict": "legitimate", "note": REASON}, + ).json() + assert verdict["refunds_declined"] == [made["refund"]["id"]] + assert verdict["freeze_request"] is None + status = client.get( + f"/v1/customer/transactions/{txn.txn_id}/refund", + headers=service, + params={"wallet_id": txn.sender_id}, + ).json() + assert (status["status"], status["outcome"], status["amount_refunded"]) == ( + "declined", "not_confirmed", 0.0, + ) # fmt: skip + assert txn.receiver_id not in p.scorer.frozen + + def test_recipient_check_reveals_nothing_about_ordinary_wallets(client, tokens, p, replayed, spare): service = tokens("upay-core") sender = spare() diff --git a/backend/tests/test_refunds.py b/backend/tests/test_refunds.py new file mode 100644 index 0000000..7739fe6 --- /dev/null +++ b/backend/tests/test_refunds.py @@ -0,0 +1,31 @@ +"""Sharing what is left in a frozen wallet among the victims who claimed it.""" + +from __future__ import annotations + +import pytest + +from fraudlens.platform.refunds import allocate + + +def test_every_victim_is_paid_in_full_when_the_money_is_still_there(): + assert allocate([1_000.0, 500.0], 3_000.0) == [1_000.0, 500.0] + assert allocate([1_000.0, 500.0], 1_500.0) == [1_000.0, 500.0] + + +def test_a_shortfall_is_shared_in_proportion_to_each_loss(): + assert allocate([1_000.0, 500.0], 600.0) == [400.0, 200.0] + + +@pytest.mark.parametrize( + "claimed, available", + [([333.33, 0.01, 99.99], 100.0), ([0.07, 0.07, 0.07], 0.2), ([12.5] * 7, 33.33)], +) +def test_never_more_than_is_in_the_wallet_and_never_more_than_was_lost(claimed, available): + paid = allocate(claimed, available) + assert round(sum(paid), 2) <= available + assert all(0 <= p <= c for p, c in zip(paid, claimed, strict=True)) + + +def test_an_empty_wallet_pays_nothing(): + assert allocate([10.0, 20.0], 0.0) == [0.0, 0.0] + assert allocate([10.0], -5.0) == [0.0] diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index fdd80b7..8970b57 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -66,6 +66,16 @@ Then follow the held payment into the console: 13. Back on the case, give the verdict **confirmed fraud** with a reason. The held payments are blocked and the wallet is flagged; from now on the hard rule stops any payment to it. +13a. **Getting a victim's money back.** On the phone, pick *Looks like a scam* + and press *send anyway*: the money goes. On the done screen press *Scammed? + Report it to claim your money back* and pick a reason. The phone shows the + refund tracker (reported → receiver frozen → scam confirmed → money back) and + reminds the customer that a refund never needs a PIN or a fee. Open the case: + the **Victim refunds** card shows the claim and how much is still in the + wallet. Give **confirmed fraud**: a freeze request is filed for you. As + supervisor1, approve it in **Freeze approvals**; the refund is paid in the + same step. Back on the phone, *Check my refund* shows the amount returned. + `/refunds` lists every claim. 14. **Mule rings** (`/rings`): open a ring. Wallets tied by shared handsets and transfers; taken-over victims are listed apart from members. **Propose freezing the ring** sends one request per member wallet to the same approval diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index cee6a33..4335435 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -91,12 +91,14 @@ served at `/docs` outside production. | | `POST /customer/message-check`, `POST /customer/payment-verify` ([FRAUD_TAXONOMY.md](FRAUD_TAXONOMY.md)) | service | | | `POST\|GET /customer/transactions/{txn_id}/appeal` (§6) | service | | Appeals | `GET /appeals`, `POST /appeals/{id}/approve\|reject` (§6) | analyst, supervisor | +| | `GET /customer/transactions/{txn_id}/refund` (§6) | service | +| Refunds | `GET /refunds`, `POST /refunds/{id}/decline` (§6) | analyst, supervisor | | Fraud types | `GET /intel/taxonomy` | analyst, supervisor, admin | | Operations | `GET /metrics/summary`, `GET /metrics/daily`, `GET /model`, `GET /model/report`, `GET /policy` | analyst, supervisor, admin | | After deployment (§12) | `GET /models`, `GET /model/shadow`, `GET /metrics/drift`, `GET /feedback` | analyst, supervisor, admin | | Audit | `GET /audit` | supervisor, admin | | | `POST /audit/reveals` (§7) | analyst, supervisor | -| Demo (§14), absent in production | `GET /demo/scenarios`, `GET /demo/payment-draft`, `GET /demo/payment-claims`, `GET /demo/appeal`, `POST /demo/pay\|respond\|report\|appeal\|recipient-check\|message-check\|payment-verify\|advance-clock` | analyst, supervisor, admin | +| Demo (§14), absent in production | `GET /demo/scenarios`, `GET /demo/payment-draft`, `GET /demo/payment-claims`, `GET /demo/appeal`, `GET /demo/refund`, `POST /demo/pay\|respond\|report\|appeal\|recipient-check\|message-check\|payment-verify\|advance-clock` | analyst, supervisor, admin | | | `GET /health`, `GET /ready` | none | Errors always have the same shape, with the request id that is also in the @@ -208,6 +210,30 @@ and each request still needs its own approval by a second person. otherwise; a rejected one adds nothing. The customer's view (`GET /customer/transactions/{txn_id}/appeal`) shows status and deadline only. Everything is audited. Migration `0004_appeals`. +- **Getting the money back.** A report on a payment that went through + (`POST /customer/reports` with its `txn_id`) also opens a refund claim for it, + returned as `refund`; one claim per payment, only for completed send-money + payments from the reporter to the reported wallet. The case on that wallet + settles every claim on it (`platform/refunds.py`): + 1. The wallet is frozen with the ordinary two-person freeze (§5), so nothing + more leaves it. Before that, a confirmed-fraud wallet's own payments are + already held by rule R02. + 2. A `confirmed_fraud` verdict on a frozen wallet pays the claims at once from + what is still in it (the balance upay last reported for the wallet, plus + what came in since, minus what went out and refunds already paid). If that + is less than the claims, each victim gets the same share of their loss. A + confirmed verdict on a wallet that is not frozen yet files the freeze + request itself, and the second person's approval pays the victims. + 3. Any other verdict declines the claims; no money moves. + A reviewer can take one claim out of an open case (`POST /refunds/{id}/decline`, + "not a victim"), so an accomplice cannot report a payment to draw the frozen + money back out. Nobody can pay a claim by hand. A paid claim is the + instruction to upay's ledger to move the money; FraudLens holds none. The + victim's view (`GET /customer/transactions/{txn_id}/refund`) shows status, + amounts, whether the receiver is frozen (`protected`) and the date promised: + five days on the platform clock, as UK payment providers must refund + authorised-push-payment scams. Everything is audited and on the case + timeline. Migration `0010_refunds`. ## 7. Security diff --git a/frontend/app/(console)/agents/page.tsx b/frontend/app/(console)/agents/page.tsx index cac5425..b31d482 100644 --- a/frontend/app/(console)/agents/page.tsx +++ b/frontend/app/(console)/agents/page.tsx @@ -10,11 +10,11 @@ export default function AgentsPage() { const agents = useApi("/v1/agents/risk?limit=100"); return ( <> - - + {(data) => data.length ? ( diff --git a/frontend/app/(console)/alerts/page.tsx b/frontend/app/(console)/alerts/page.tsx index 8685d62..efae5ab 100644 --- a/frontend/app/(console)/alerts/page.tsx +++ b/frontend/app/(console)/alerts/page.tsx @@ -108,7 +108,7 @@ export default function AlertsPage() { return ( <> - } /> - -
+ +
Tier diff --git a/frontend/app/(console)/appeals/page.tsx b/frontend/app/(console)/appeals/page.tsx index c6cc30a..9cc066d 100644 --- a/frontend/app/(console)/appeals/page.tsx +++ b/frontend/app/(console)/appeals/page.tsx @@ -46,13 +46,13 @@ export default function AppealsPage() { return ( <> - {outcome &&
{outcome}
} - +
{STATUSES.map((value) => ( setStatus(value)}> diff --git a/frontend/app/(console)/approvals/page.tsx b/frontend/app/(console)/approvals/page.tsx index 607453b..0dce908 100644 --- a/frontend/app/(console)/approvals/page.tsx +++ b/frontend/app/(console)/approvals/page.tsx @@ -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; @@ -62,9 +62,9 @@ export default function ApprovalsPage() { return ( <> - {outcome &&
{outcome}
} {!canApprove && ( @@ -101,7 +101,7 @@ export default function ApprovalsPage() { )} - +
{STATUSES.map((value) => setStatus(value)}>{words(value)})}
@@ -114,7 +114,16 @@ export default function ApprovalsPage() { #{request.id} - {request.reason} + + {request.reason} + {request.refunds && ( +

+ {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`} +

+ )} + {request.requester}
{when(request.created_at)}
@@ -129,7 +138,9 @@ export default function ApprovalsPage() { you asked for this ) : ( - + )} @@ -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"} diff --git a/frontend/app/(console)/audit/page.tsx b/frontend/app/(console)/audit/page.tsx index 82d166b..9e82ce4 100644 --- a/frontend/app/(console)/audit/page.tsx +++ b/frontend/app/(console)/audit/page.tsx @@ -64,11 +64,11 @@ export default function AuditPage() { return ( <> - - +
- + {(data) => data.length ? ( diff --git a/frontend/app/(console)/cases/[id]/page.tsx b/frontend/app/(console)/cases/[id]/page.tsx index b8208f5..cd55458 100644 --- a/frontend/app/(console)/cases/[id]/page.tsx +++ b/frontend/app/(console)/cases/[id]/page.tsx @@ -9,7 +9,7 @@ import { Async, Badge, Button, Card, CategoryBadges, Empty, ErrorNote, Facts, in import { FreezeButton, NetworkCard, RiskProfile } from "@/components/wallet"; import { api, useApi } from "@/lib/api"; import { taka, when, words } from "@/lib/format"; -import type { CaseDetail, CaseEvent, Me } from "@/lib/types"; +import type { CaseDetail, CaseEvent, CaseRefunds, Me, Refund } from "@/lib/types"; type Verdict = "confirmed_fraud" | "legitimate" | "inconclusive"; @@ -54,10 +54,15 @@ function eventText(event: CaseEvent): string { const blocked = (data.blocked as number[] | undefined)?.length ?? 0; return `Verdict: ${words(String(data.verdict))}${data.reason_code ? ` (${words(String(data.reason_code)).toLowerCase()})` : ""}${blocked ? ` · ${blocked} payment${blocked > 1 ? "s" : ""} blocked` : ""}${released ? ` · ${released} released` : ""}`; } - case "freeze_requested": return "Freeze requested"; + case "freeze_requested": return data.for_refunds ? "Freeze requested, so the victims can be refunded" : "Freeze requested"; case "freeze_approved": return "Freeze approved: the wallet is frozen"; case "freeze_rejected": return "Freeze rejected"; case "customer_report": return "A customer reported this wallet"; + case "refund_claimed": return `Refund claimed for payment #${data.txn_id} (${taka(Number(data.amount))})`; + case "refund_paid": return `Refunded ${taka(Number(data.amount))} for payment #${data.txn_id}`; + case "refund_unrecoverable": return `Nothing left to refund for payment #${data.txn_id}`; + case "refund_declined": + return `Refund declined for payment #${data.txn_id}: ${data.outcome === "not_a_victim" ? "not a victim" : "fraud not confirmed"}`; case "customer_response": return data.action === "cancel" ? `The customer cancelled payment #${data.txn_id}` : `The customer went ahead with payment #${data.txn_id}`; default: return words(event.kind); @@ -142,6 +147,95 @@ function Assign({ data, onDone }: { data: CaseDetail; onDone: () => void }) { ); } +function Step({ done, children }: { done: boolean; children: React.ReactNode }) { + return ( +
  • + + {children} +
  • + ); +} + +/** The victims who reported a payment into this wallet, and what a verdict will give back. */ +function Refunds({ data, reload }: { data: CaseDetail; reload: () => void }) { + const { canReview } = useSession(); + const [declining, setDeclining] = useState(null); + const { claims, recoverable, wallet_frozen: frozen }: CaseRefunds = data.refunds; + const open = claims.filter((claim) => claim.status === "open"); + const owed = open.reduce((sum, claim) => sum + claim.amount_claimed, 0); + const confirmed = data.verdict === "confirmed_fraud"; + const pendingFreeze = data.freeze_requests.some((request) => request.status === "pending"); + const cover = owed > 0 ? Math.min(1, recoverable / owed) : 1; + return ( + + {claims.length ? ( + <> +
      + {claims.length} reported payment{claims.length > 1 ? "s" : ""}{open.length ? `, ${taka(owed)} still owed` : ""} + {frozen ? "Wallet frozen: nothing more can be cashed out" : pendingFreeze ? "Freeze waiting for a second person" : "Freeze the wallet to protect the money"} + {confirmed ? "Fraud confirmed" : data.verdict ? `Closed as ${words(data.verdict).toLowerCase()}: no refund` : "Confirm the fraud with a verdict"} +
    + {open.length > 0 && ( +

    + {taka(recoverable)} is still in the wallet:{" "} + {cover >= 1 ? "enough to refund every open claim in full." : `each victim would get ${Math.floor(cover * 100)}% of what they lost.`} +

    + )} + {canReview && open.length > 0 && !frozen && !pendingFreeze && data.status !== "closed" && ( +
    + )} +
      + {claims.map((claim) => ( +
    • + + lost {taka(claim.amount_claimed)} + + payment #{claim.txn_id} + {claim.status === "paid" && ` · ${taka(claim.amount_refunded ?? 0)} refunded ${when(claim.settled_at)}`} + {claim.status === "open" && ` · promised by ${when(claim.sla_due_at)}`} + {claim.note && ` · ${claim.note}`} + + + + + {canReview && claim.status === "open" && } + +
    • + ))} +
    + + ) : ( + No victim has claimed a refund. A customer who reports a payment into this wallet claims one. + )} + {declining && ( + setDeclining(null)} + onSubmit={async (note) => { + await api(`/v1/refunds/${declining.id}/decline`, { note }); + reload(); + }} + /> + )} +
    + ); +} + +function verdictIntro(verdict: Verdict, data: CaseDetail): string { + const open = data.refunds.claims.filter((claim) => claim.status === "open").length; + if (!open) return VERDICTS[verdict].intro; + const victims = open > 1 ? `${open} victims are` : "1 victim is"; + if (verdict !== "confirmed_fraud") return `${VERDICTS[verdict].intro} No refund is paid: ${open > 1 ? `${open} refund claims are` : "the refund claim is"} declined.`; + return data.refunds.wallet_frozen + ? `${VERDICTS[verdict].intro} ${victims} refunded now from the ${taka(data.refunds.recoverable)} left in the frozen wallet.` + : `${VERDICTS[verdict].intro} A freeze is requested for you; ${victims} refunded as soon as a second person approves it.`; +} + function Detail({ data, reload }: { data: CaseDetail; reload: () => void }) { const { me, canReview, canApprove } = useSession(); const [dialog, setDialog] = useState(null); @@ -152,7 +246,7 @@ function Detail({ data, reload }: { data: CaseDetail; reload: () => void }) { return ( <> - Case #{data.id} {data.overdue && review overdue}} sub={<>About wallet · opened {when(data.opened_at)} · {data.source === "manual" ? "opened by a reviewer" : data.source === "customer_report" ? "opened by a customer report" : "opened by an alert"}} /> @@ -173,7 +267,7 @@ function Detail({ data, reload }: { data: CaseDetail; reload: () => void }) {
    - + void }) { )} + + {data.timeline.length ? : Nothing has happened yet.} {canReview && } @@ -253,7 +349,7 @@ function Detail({ data, reload }: { data: CaseDetail; reload: () => void }) { setDialog(null)} diff --git a/frontend/app/(console)/cases/page.tsx b/frontend/app/(console)/cases/page.tsx index 0408902..dd8d361 100644 --- a/frontend/app/(console)/cases/page.tsx +++ b/frontend/app/(console)/cases/page.tsx @@ -90,13 +90,13 @@ export default function CasesPage() { return ( <> - } />
    - +
    diff --git a/frontend/app/(console)/consortium/page.tsx b/frontend/app/(console)/consortium/page.tsx index 35dec96..7ef301f 100644 --- a/frontend/app/(console)/consortium/page.tsx +++ b/frontend/app/(console)/consortium/page.tsx @@ -30,14 +30,14 @@ export default function ConsortiumPage() { const [tab, setTab] = useState("feeds"); return ( <> - {(o) => (
    -
    +
    a + f.confirmed_listings, 0))} sub="as tokens, signed per provider" /> a + f.suspected_in_filter, 0))} sub="testable, not listable" /> diff --git a/frontend/app/(console)/fairness/page.tsx b/frontend/app/(console)/fairness/page.tsx index 96d2e58..2f559db 100644 --- a/frontend/app/(console)/fairness/page.tsx +++ b/frontend/app/(console)/fairness/page.tsx @@ -189,7 +189,7 @@ function Fairness({ fairness, version, mitigation }: { fairness: Insights["fairn const order = [...Object.keys(DIMENSION).filter((name) => name in groups), ...Object.keys(groups).filter((name) => !(name in DIMENSION))]; return ( <> -
    +
    ("/v1/model/report"); return ( <> - diff --git a/frontend/app/(console)/fraud-types/page.tsx b/frontend/app/(console)/fraud-types/page.tsx index 76ae378..e5328e6 100644 --- a/frontend/app/(console)/fraud-types/page.tsx +++ b/frontend/app/(console)/fraud-types/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; +import { Bullets, Contents, DocSection, H3, KeyPoints, P, Quote } from "@/components/doc"; import { Async, Badge, Button, Card, CategoryBadges, Empty, ErrorNote, Facts, inputClass, PageHeader, Stat, Table, Tabs, Td, type Tone } from "@/components/ui"; import { api, useApi } from "@/lib/api"; import { num, pct, taka, when, words } from "@/lib/format"; @@ -10,10 +11,10 @@ import type { CheckLevel, FraudCategory, FraudTaxonomy, MessageCheck, PaymentCla type Tab = "coverage" | "message" | "payment"; const DETECTOR: Record = { - transaction: { label: "Payment models", tone: "blue", what: "the transaction and recipient models, the rules and the payment graph" }, - text: { label: "Message classifier", tone: "violet", what: "the scam-message classifier, trained on English, Bangla and Banglish" }, - links: { label: "Link check", tone: "orange", what: "fixed checks on any link in a message; not a model" }, - ledger: { label: "Ledger check", tone: "green", what: "the payment is looked up in the recorded transactions" }, + transaction: { label: "Payment models", tone: "blue", what: "The models that score payments and receivers, the rules, and the map of who pays whom." }, + text: { label: "Message check", tone: "violet", what: "A model that reads messages in English, Bangla and Banglish and spots scam wording." }, + links: { label: "Link check", tone: "orange", what: "Fixed checks on any link in a message. This part is not a model." }, + ledger: { label: "Payment record check", tone: "green", what: "Looks the payment up in our own records of real transactions." }, }; const LEVEL: Record = { @@ -39,80 +40,103 @@ function rate(value: number | null | undefined, digits = 0): string { return value == null ? "–" : pct(value, digits); } +const anchor = (id: string) => `type-${id}`; + function Coverage({ data }: { data: FraudTaxonomy }) { const report = data.report; return ( - <> - {report ? ( -
    - - - - a + b, 0))} - sub={`synthetic messages from ${num(report.corpus.templates)} templates in ${num(report.corpus.families)} scripts`} - /> -
    - ) : ( - - )} +
    + -
    - {data.categories.map((category) => )} -
    + These results come from testing on made-up scam and safe messages.

    } + > + {report ? ( +
    + + + + a + b, 0))} + sub={`Made-up messages, built from ${num(report.corpus.templates)} templates in ${num(report.corpus.families)} scripts.`} + /> +
    + ) : ( + + )} +
    - {report && ( -
    - - - {Object.entries(report.test.languages).map(([lang, row]) => ( - - - - - - - ))} -
    {LANGUAGE[lang] ?? lang}{rate(row.recall, 1)}{rate(row.false_positive_rate, 2)}{rate(report.unseen.languages[lang]?.recall, 1)}
    -
    - - - {Object.entries(report.unseen.families ?? {}).map(([family, row]) => ( - - - - - - - ))} -
    {words(family)}{row.scam ? "scam" : "harmless"}{num(row.messages)}{rate(row.flagged, 0)}
    -
    + Each type below explains how it works, in plain words.

    }> + ({ id: anchor(category.id), label: category.name.en }))} /> +
    + {data.categories.map((category) => )}
    +
    + + {report && ( + +
    + + + {Object.entries(report.test.languages).map(([lang, row]) => ( + + + + + + + ))} +
    {LANGUAGE[lang] ?? lang}{rate(row.recall, 1)}{rate(row.false_positive_rate, 2)}{rate(report.unseen.languages[lang]?.recall, 1)}
    +
    + + + {Object.entries(report.unseen.families ?? {}).map(([family, row]) => ( + + + + + + + ))} +
    {words(family)}{row.scam ? "scam" : "safe"}{num(row.messages)}{rate(row.flagged, 0)}
    +
    +
    +
    )} - -
      - {(report?.limits ?? []).map((limit) =>
    • {limit}
    • )} -
    • The category on an alert is read from evidence already on it (the scenario, similar past cases, the mule score). It is a label for the reviewer and changes no decision.
    • -
    • Text a customer sends to be checked is scored and thrown away. Only the level and the category names are kept in the audit log.
    • -
    -
    - + + + +
    ); } @@ -121,39 +145,51 @@ function Category({ category, report }: { category: FraudCategory; report: Fraud const unseen = report?.unseen.categories[category.id]; const reads = category.detectors.includes("text"); return ( - {category.detectors.map((id) => ( - {DETECTOR[id]?.label ?? words(id)} - ))}} - > -

    {category.summary}

    -

    {category.examples.map((example) => {example})}

    +
    +
    +

    + {category.number}. + {category.name.en} +

    +

    {category.name.bn}

    +
    + +

    {category.summary}

    + +

    Common examples

    +

    {category.examples.map((example) => {example})}

    + +

    How it happens in Bangladesh

    +

    {category.bangladesh}

    -

    In Bangladesh

    -

    {category.bangladesh}

    +

    Warning signs FraudLens looks for

    + -

    What FraudLens looks for

    -
      - {category.signals.map((signal) =>
    • {signal}
    • )} +

      Which parts of FraudLens spot it

      +
        + {category.detectors.map((id) => ( +
      • + {DETECTOR[id]?.label ?? words(id)} + {DETECTOR[id] && {DETECTOR[id].what}} +
      • + ))}
      -

      What the customer is told

      -

      {category.advice.en}

      -

      {category.advice.bn}

      +

      What the customer is told

      + {reads && known && ( -
      +
      +

      How often the message check names this type correctly

      )} - +
    ); } @@ -163,7 +199,7 @@ function CheckResult({ found }: { found: MessageCheck }) {
    {LEVEL[found.level].label} - {found.risk == null ? "classifier not loaded: links only" : `scam score ${num(found.risk, 3)} · model ${found.model_version}`} + {found.risk == null ? "message check not loaded: only the links were checked" : `scam score ${num(found.risk, 3)} · model ${found.model_version}`}
    (); return (
    - +