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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/ANALYTICS_BENCHMARKING.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs/SDK_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
82 changes: 82 additions & 0 deletions src/app/api/embed/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>, invoice) => {
counts[invoice.status] = (counts[invoice.status] ?? 0) + 1;
return counts;
}, {});
const daily = new Map<string, number>();
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 });
}
}
46 changes: 46 additions & 0 deletions src/app/api/embed/token/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
26 changes: 26 additions & 0 deletions src/app/api/embed/usage/route.ts
Original file line number Diff line number Diff line change
@@ -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" } });
}
16 changes: 16 additions & 0 deletions src/app/api/embed/usageStore.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading