diff --git a/.env.example b/.env.example index cfa1b07a..de2924ea 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ NEXT_PUBLIC_STELLAR_NETWORK=testnet NEXT_PUBLIC_CONTRACT_ID=YOUR_CONTRACT_ID_HERE NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org API_KEY_SIGNING_SECRET=replace-with-a-long-random-server-secret +EMBED_ANALYTICS_SIGNING_SECRET=replace-with-a-different-long-random-server-secret CSRF_SECRET=replace-with-a-long-random-server-secret # Generate with: npx web-push generate-vapid-keys diff --git a/docs/ANALYTICS_BENCHMARKING.md b/docs/ANALYTICS_BENCHMARKING.md new file mode 100644 index 00000000..d76c7459 --- /dev/null +++ b/docs/ANALYTICS_BENCHMARKING.md @@ -0,0 +1,28 @@ +# Analytics benchmarking + +The analytics page includes local comparison rankings for invoice completion +rate and USDC raised per invoice. The initial percentile curves are explicitly +identified as an illustrative B2B reference model; they are not audited +industry distributions or live competitor telemetry. Replace the curves in +`src/lib/benchmarking.ts` only when a documented, comparable benchmark dataset +and its methodology are available. + +## Published industry context + +The panel links to a May 11, 2017 U.S. EXIM Bank article summarizing a 2016 +MarketInvoice study of more than 300,000 invoices across over 80 countries. The +summary reports that 60% of invoices were paid late, average lateness was six +days, and the SME average was 4.3 days. These figures are shown as historical +context only: the app does not currently record whether payment arrived before +or after the invoice due date, so they are not used to rank current accounts. + +Source: [U.S. EXIM Bank, Key Facts & Figures on Late Invoice Payments](https://grow.exim.gov/blog/key-facts-and-figures-on-late-invoice-payments). + +## Privacy and report behavior + +Benchmark calculations run in the browser from aggregate values already +available to the analytics page. Reports are hidden until there are at least +five invoices. Downloads contain only a timestamp, aggregate sample count, and +rounded aggregate metrics; invoice IDs, wallet addresses, and payer addresses +are never included or transmitted. This minimum sample size reduces small +sample disclosure risk but is not a formal anonymity guarantee. diff --git a/docs/SDK_INTEGRATION.md b/docs/SDK_INTEGRATION.md index 7912569c..d4428850 100644 --- a/docs/SDK_INTEGRATION.md +++ b/docs/SDK_INTEGRATION.md @@ -202,6 +202,15 @@ Track these metrics in analytics: - Creator fee tier distribution - Average funding time per creator +## Client Analytics Iframe + +1. Configure a unique server-only `EMBED_ANALYTICS_SIGNING_SECRET` in production. +2. Create a read-only API key under Settings → API Keys. +3. Open Settings → API Dashboard, select the key, enter the creator wallet and the client site's HTTPS origin, then choose the initial status/date filters and widget styling. +4. Generate and copy the iframe snippet into the client site. The signed analytics token is placed in the URL fragment, expires after 30 days, and is restricted to the configured origin. Generate a replacement snippet after expiration. + +The widget fetches read-only portfolio totals, invoice status counts, and daily funded totals from `/api/embed/analytics`. Supported filters are `status=all|Pending|Released|Refunded` and `range=all|30d|90d|365d`. Embed request counts are available in the API Dashboard; the current usage counter is process-local and should be backed by a shared persistent store for multi-instance production deployments. + --- **Status**: Issues #309 (frontend hook) ✅ COMPLETE diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx index e2ccd7e9..6876647d 100644 --- a/src/app/analytics/page.tsx +++ b/src/app/analytics/page.tsx @@ -11,6 +11,8 @@ import ChartBuilder, { type ChartBuilderDataPoint } from "@/components/analytics import AnalyticsPanel from "@/components/subscriptions/AnalyticsPanel"; import DataQualityPanel from "@/components/DataQualityPanel"; import { loadSubscriptions, type Subscription } from "@/lib/subscriptions"; +import AttributionAnalyticsPanel from "@/components/analytics/AttributionAnalyticsPanel"; +import BenchmarkAnalyticsPanel from "@/components/analytics/BenchmarkAnalyticsPanel"; const STROOPS = 10_000_000; diff --git a/src/app/api/embed/analytics/route.ts b/src/app/api/embed/analytics/route.ts new file mode 100644 index 00000000..dfe71d26 --- /dev/null +++ b/src/app/api/embed/analytics/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { splitClient } from "@/lib/stellar"; +import { verifyEmbedAnalyticsToken } from "@/lib/embedAnalyticsToken"; +import { recordEmbedUsage } from "@/app/api/embed/usageStore"; +import { getEmbedAnalyticsSigningSecret } from "@/lib/embedAnalyticsSecret"; + +export const dynamic = "force-dynamic"; + +function createdAt(invoice: { createdAt?: number; deadline: number }): number { + return invoice.createdAt ?? invoice.deadline - 7 * 24 * 60 * 60; +} + +export async function GET(request: NextRequest) { + const token = request.headers.get("x-embed-token"); + const claims = token ? verifyEmbedAnalyticsToken(token, getEmbedAnalyticsSigningSecret()) : null; + if (!claims) return NextResponse.json({ error: "Embed token is invalid or expired." }, { status: 401 }); + if (request.nextUrl.searchParams.get("creator") !== claims.creator) { + return NextResponse.json({ error: "Embed token does not grant access to this creator." }, { status: 403 }); + } + + const suppliedOrigin = request.headers.get("x-embed-parent-origin"); + if (claims.origin && suppliedOrigin !== claims.origin) { + return NextResponse.json({ error: "This embed token is not valid for this origin." }, { status: 403 }); + } + + const status = request.nextUrl.searchParams.get("status") ?? "all"; + const range = request.nextUrl.searchParams.get("range") ?? "all"; + if (!new Set(["all", "Pending", "Released", "Refunded"]).has(status)) { + return NextResponse.json({ error: "Unsupported status filter." }, { status: 400 }); + } + if (!new Set(["all", "30d", "90d", "365d"]).has(range)) { + return NextResponse.json({ error: "Unsupported date range filter." }, { status: 400 }); + } + + try { + const invoices = []; + let offset = 0; + while (offset < 10_000) { + const batch = await (splitClient as any).getInvoicesByCreator(claims.creator, offset, 100); + if (!Array.isArray(batch) || batch.length === 0) break; + invoices.push(...batch); + offset += batch.length; + if (batch.length < 100) break; + } + const earliest = range === "all" ? 0 : Date.now() / 1000 - Number.parseInt(range, 10) * 24 * 60 * 60; + const filtered = invoices.filter((invoice) => + (status === "all" || invoice.status === status) && createdAt(invoice) >= earliest, + ); + const raised = filtered.reduce((sum, invoice) => sum + Number(invoice.funded ?? 0n), 0); + const target = filtered.reduce((sum, invoice) => + sum + invoice.recipients.reduce((recipientSum: number, recipient: { amount: bigint }) => recipientSum + Number(recipient.amount), 0), + 0); + const statusCounts = filtered.reduce((counts: Record, invoice) => { + counts[invoice.status] = (counts[invoice.status] ?? 0) + 1; + return counts; + }, {}); + const daily = new Map(); + filtered.forEach((invoice) => { + const day = new Date(createdAt(invoice) * 1000).toISOString().slice(0, 10); + daily.set(day, (daily.get(day) ?? 0) + Number(invoice.funded ?? 0n)); + }); + const result = { + creator: claims.creator, + generatedAt: Date.now(), + filters: { status, range }, + summary: { + invoiceCount: filtered.length, + activeInvoices: filtered.filter((invoice) => invoice.status === "Pending").length, + paymentCount: filtered.reduce((sum, invoice) => sum + invoice.payments.length, 0), + fundedStroops: raised, + targetStroops: target, + completionRate: target > 0 ? Math.min(100, raised / target * 100) : 0, + }, + statuses: Object.entries(statusCounts).map(([name, value]) => ({ name, value })), + daily: Array.from(daily.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, fundedStroops]) => ({ date, fundedStroops })), + }; + recordEmbedUsage({ creator: claims.creator, tokenId: claims.tokenId, timestamp: Date.now() }); + return NextResponse.json(result, { headers: { "Cache-Control": "private, no-store" } }); + } catch { + return NextResponse.json({ error: "Unable to load creator analytics." }, { status: 502 }); + } +} \ No newline at end of file diff --git a/src/app/api/embed/token/route.ts b/src/app/api/embed/token/route.ts new file mode 100644 index 00000000..709a3214 --- /dev/null +++ b/src/app/api/embed/token/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { randomUUID } from "crypto"; +import { extractBearerToken } from "@/lib/apiKeys"; +import { verifySignedApiKey } from "@/lib/signedApiKeys"; +import { createEmbedAnalyticsToken } from "@/lib/embedAnalyticsToken"; +import { getEmbedAnalyticsSigningSecret } from "@/lib/embedAnalyticsSecret"; + +export const dynamic = "force-dynamic"; + +function normalizeOrigin(value: unknown): string | null | undefined { + if (value === undefined || value === null || value === "") return null; + if (typeof value !== "string") return undefined; + try { + const origin = new URL(value).origin; + const parsed = new URL(origin); + if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") return undefined; + return origin; + } catch { + return undefined; + } +} + +export async function POST(request: NextRequest) { + const apiKey = extractBearerToken(request.headers.get("authorization")); + const scope = apiKey ? verifySignedApiKey(apiKey) : null; + if (!scope) { + return NextResponse.json({ error: "A valid API key is required." }, { status: 401 }); + } + if (scope !== "read") return NextResponse.json({ error: "Embed analytics requires a read-only API key." }, { status: 403 }); + + const body = await request.json().catch(() => null); + const creator = body?.creator; + const origin = normalizeOrigin(body?.origin); + if (typeof creator !== "string" || !/^G[A-Z2-7]{55}$/.test(creator)) { + return NextResponse.json({ error: "A valid Stellar creator address is required." }, { status: 400 }); + } + if (!origin) { + return NextResponse.json({ error: "A trusted HTTPS client origin is required (localhost is allowed for development)." }, { status: 400 }); + } + + const { token, claims } = createEmbedAnalyticsToken( + { creator, origin, tokenId: randomUUID() }, + getEmbedAnalyticsSigningSecret(), + ); + return NextResponse.json({ token, expiresAt: claims.expiresAt }); +} \ No newline at end of file diff --git a/src/app/api/embed/usage/route.ts b/src/app/api/embed/usage/route.ts new file mode 100644 index 00000000..4e720b1c --- /dev/null +++ b/src/app/api/embed/usage/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { extractBearerToken } from "@/lib/apiKeys"; +import { verifySignedApiKey } from "@/lib/signedApiKeys"; +import { getEmbedUsage } from "@/app/api/embed/usageStore"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const apiKey = extractBearerToken(request.headers.get("authorization")); + const scope = apiKey ? verifySignedApiKey(apiKey) : null; + if (!scope) { + return NextResponse.json({ error: "A valid API key is required." }, { status: 401 }); + } + if (scope !== "read") return NextResponse.json({ error: "Embed usage requires a read-only API key." }, { status: 403 }); + const creator = request.nextUrl.searchParams.get("creator") ?? ""; + if (!/^G[A-Z2-7]{55}$/.test(creator)) { + return NextResponse.json({ error: "A valid Stellar creator address is required." }, { status: 400 }); + } + const events = getEmbedUsage(creator); + const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; + return NextResponse.json({ + total: events.length, + last24Hours: events.filter((event) => event.timestamp >= oneDayAgo).length, + lastUsedAt: events.length ? Math.max(...events.map((event) => event.timestamp)) : null, + }, { headers: { "Cache-Control": "no-store" } }); +} \ No newline at end of file diff --git a/src/app/api/embed/usageStore.ts b/src/app/api/embed/usageStore.ts new file mode 100644 index 00000000..e9b900dc --- /dev/null +++ b/src/app/api/embed/usageStore.ts @@ -0,0 +1,16 @@ +export interface EmbedUsageEvent { + creator: string; + tokenId: string; + timestamp: number; +} + +const usageEvents: EmbedUsageEvent[] = []; + +export function recordEmbedUsage(event: EmbedUsageEvent): void { + usageEvents.push(event); + if (usageEvents.length > 10_000) usageEvents.splice(0, usageEvents.length - 10_000); +} + +export function getEmbedUsage(creator: string): EmbedUsageEvent[] { + return usageEvents.filter((event) => event.creator === creator); +} \ No newline at end of file diff --git a/src/app/approvals/page.tsx b/src/app/approvals/page.tsx new file mode 100644 index 00000000..6f925104 --- /dev/null +++ b/src/app/approvals/page.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Check, CircleDollarSign, Clock3, Plus, X } from "lucide-react"; +import { getFreighterPublicKey } from "@/lib/freighter"; +import { splitClient } from "@/lib/stellar"; +import { apiFetch } from "@/lib/apiClient"; +import { parseAmount } from "@stellar-split/sdk"; +import { useInvoiceTags } from "@/hooks/useInvoiceTags"; +import { + completeApprovalRequest, + getApprovalPolicy, + getApprovalRequests, + notifyApprovalUsers, + recordApprovalDecision, + saveApprovalPolicy, + saveApprovalRequests, + summarizeApprovalAnalytics, + type ApprovalPolicy, + type InvoiceApprovalRequest, +} from "@/lib/invoiceApprovals"; + +const emptyPolicy: ApprovalPolicy = { enabled: false, minimumAmount: 1000, levels: [[""]] }; + +function shorten(address: string): string { + return address.length > 18 ? `${address.slice(0, 8)}...${address.slice(-6)}` : address; +} + +export default function ApprovalsPage() { + const searchParams = useSearchParams(); + const [wallet, setWallet] = useState(null); + const [policy, setPolicy] = useState(emptyPolicy); + const [requests, setRequests] = useState([]); + const [comment, setComment] = useState(""); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [saving, setSaving] = useState(false); + const [changeInvoiceId, setChangeInvoiceId] = useState(""); + const [changeAmount, setChangeAmount] = useState(""); + const [changeSummary, setChangeSummary] = useState(""); + const requestedId = searchParams.get("request"); + const { saveTags } = useInvoiceTags(); + + const refresh = useCallback(() => setRequests(getApprovalRequests()), []); + + useEffect(() => { + getFreighterPublicKey() + .then((address) => { + setWallet(address); + setPolicy(getApprovalPolicy(address) ?? emptyPolicy); + }) + .catch(() => setError("Connect your wallet to manage approval workflows.")); + refresh(); + window.addEventListener("invoice-approvals-updated", refresh); + return () => window.removeEventListener("invoice-approvals-updated", refresh); + }, [refresh]); + + const visibleRequests = useMemo( + () => requests.filter((request) => + request.owner === wallet || request.levels.some((level) => level.approvers.includes(wallet ?? "")), + ).sort((a, b) => b.updatedAt - a.updatedAt), + [requests, wallet], + ); + const selectedRequest = visibleRequests.find((request) => request.id === requestedId) ?? visibleRequests[0]; + const analytics = summarizeApprovalAnalytics(visibleRequests); + const mayDecide = Boolean( + wallet && selectedRequest?.status === "pending" && + selectedRequest.levels[selectedRequest.currentLevel]?.approvers.includes(wallet) && + !selectedRequest.levels[selectedRequest.currentLevel]?.decisions.some((item) => item.approver === wallet), + ); + + const updateLevel = (index: number, value: string) => { + setPolicy((current) => ({ + ...current, + levels: current.levels.map((level, levelIndex) => + levelIndex === index ? value.split("\n").map((address) => address.trim()) : level, + ), + })); + }; + + const savePolicy = () => { + if (!wallet) return; + const levels = policy.levels + .map((level) => [...new Set(level.map((address) => address.trim()).filter(Boolean))]) + .filter((level) => level.length > 0); + if (policy.enabled && levels.length === 0) { + setError("Add at least one approver before enabling this workflow."); + return; + } + if (levels.some((level) => level.includes(wallet))) { + setError("The invoice creator cannot approve their own request."); + return; + } + const next = { ...policy, minimumAmount: Math.max(0, Number(policy.minimumAmount) || 0), levels }; + saveApprovalPolicy(wallet, next); + setPolicy(next); + setError(""); + setNotice("Approval policy saved."); + }; + + const decide = (decision: "approved" | "rejected") => { + if (!wallet || !selectedRequest) return; + try { + const updated = recordApprovalDecision(selectedRequest, wallet, decision, comment); + saveApprovalRequests(requests.map((request) => request.id === updated.id ? updated : request)); + if (updated.status === "pending" && updated.currentLevel !== selectedRequest.currentLevel) { + notifyApprovalUsers( + updated, + updated.levels[updated.currentLevel]?.approvers ?? [], + `Approval requested for ${updated.title} (level ${updated.currentLevel + 1})`, + ); + } else if (updated.status === "approved" || updated.status === "rejected") { + notifyApprovalUsers(updated, [updated.owner], `Invoice approval ${updated.status}: ${updated.title}`); + } + setComment(""); + setError(""); + setNotice(`Request ${decision}.`); + } catch (decisionError) { + setError(decisionError instanceof Error ? decisionError.message : "Unable to record this decision."); + } + }; + + const finalizeCreation = async () => { + if (!wallet || !selectedRequest?.payload) return; + setSaving(true); + setError(""); + try { + if (selectedRequest.owner !== wallet) throw new Error("Only the request owner can create this invoice."); + const payload = selectedRequest.payload; + const recipients = payload.recipients.map((recipient) => ({ + address: recipient.address, + amount: parseAmount(recipient.amount), + })); + const result = payload.cloneSourceId + ? await (splitClient as any).cloneInvoice({ + creator: wallet, + sourceInvoiceId: payload.cloneSourceId, + recipients, + token: payload.token, + deadline: payload.deadline, + }) + : await splitClient.createInvoice({ + creator: wallet, + recipients, + token: payload.token, + deadline: payload.deadline, + ...(payload.recurring ? { recurring: payload.recurring, intervalDays: payload.intervalDays } : {}), + }); + + if (payload.splitMeta) { + await apiFetch(`/api/invoices/${result.invoiceId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", "x-wallet-public-key": wallet }, + body: JSON.stringify({ splitMeta: payload.splitMeta }), + }).catch(() => null); + } + if (payload.tags?.length) { + await saveTags(result.invoiceId, payload.tags).catch(() => null); + } + const completed = completeApprovalRequest(selectedRequest, wallet, result.invoiceId, result.txHash); + saveApprovalRequests(requests.map((request) => request.id === completed.id ? completed : request)); + notifyApprovalUsers(completed, [wallet], `Approved invoice created: ${completed.title}`); + setNotice(`Invoice #${result.invoiceId} created.`); + } catch (finalizeError) { + setError(finalizeError instanceof Error ? finalizeError.message : "Invoice creation failed."); + } finally { + setSaving(false); + } + }; + + const submitModification = () => { + if (!wallet || !changeInvoiceId.trim() || !changeSummary.trim()) { + setError("Enter an invoice ID and a summary of the requested change."); + return; + } + const amount = Number(changeAmount); + if (!Number.isFinite(amount) || amount < 0) { + setError("Enter a valid invoice amount for conditional routing."); + return; + } + const recipients = policy.levels + .map((level) => [...new Set(level.map((address) => address.trim()).filter(Boolean))]) + .filter((level) => level.length > 0); + if (!policy.enabled || amount < policy.minimumAmount || recipients.length === 0) { + setError("Modification requests must meet the enabled approval policy threshold."); + return; + } + const now = Date.now(); + const request: InvoiceApprovalRequest = { + id: `mod-${now}-${Math.random().toString(36).slice(2, 8)}`, + owner: wallet, + kind: "modification", + invoiceId: changeInvoiceId.trim(), + title: `Modification for invoice #${changeInvoiceId.trim()}`, + amount, + changeSummary: changeSummary.trim(), + status: "pending", + currentLevel: 0, + levels: recipients.map((approvers) => ({ approvers, decisions: [] })), + events: [{ id: `event-${now}`, actor: wallet, action: "submitted", timestamp: now }], + createdAt: now, + updatedAt: now, + }; + saveApprovalRequests([request, ...requests]); + notifyApprovalUsers(request, recipients[0], `Modification approval requested for invoice #${request.invoiceId}`); + setChangeInvoiceId(""); + setChangeAmount(""); + setChangeSummary(""); + setError(""); + setNotice("Modification request submitted for approval."); + }; + + return ( +
+
+
+

Invoice governance

+

Approvals

+
+

{wallet ? shorten(wallet) : "Wallet not connected"}

+
+ + {error &&

{error}

} + {notice &&

{notice}

} + +
+ {[ + ["Requests", analytics.total], + ["Pending", analytics.pending], + ["Approved", analytics.approved], + ["Rejected", analytics.rejected], + ["Avg. decision", `${analytics.averageDecisionHours.toFixed(1)}h`], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+

Approval rate: {analytics.approvalRate.toFixed(0)}%

+ +
+
+
+

Requests

+ {visibleRequests.length} total +
+ {visibleRequests.length === 0 ? ( +

No requests yet.

+ ) : ( + + )} + + {selectedRequest && ( +
+
+
+

{selectedRequest.kind} request

+

{selectedRequest.title}

+
+
+

Amount {selectedRequest.amount.toLocaleString(undefined, { maximumFractionDigits: 2 })}

+ {selectedRequest.changeSummary &&

{selectedRequest.changeSummary}

} +
    + {selectedRequest.levels.map((level, index) => ( +
  1. +

    + Level {index + 1}{selectedRequest.currentLevel === index && selectedRequest.status === "pending" ? " · current" : ""} +

    + {level.approvers.map((approver) => { + const decision = level.decisions.find((item) => item.approver === approver); + return ( +

    + {shorten(approver)}{decision ? ` · ${decision.decision}` : " · awaiting"} + {decision?.comment ? ` · “${decision.comment}”` : ""} +

    + ); + })} +
  2. + ))} +
+
+

History

+
    + {selectedRequest.events.map((event) => ( +
  1. +
  2. + ))} +
+
+ {mayDecide && ( +
+ +