From bb16281552957ba5d1ef009f3c32b0cee4a7fb6b Mon Sep 17 00:00:00 2001 From: isiborella <162569104+isiborella@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:25:34 +0000 Subject: [PATCH] feat(creator): add risk assessment, forum integration, tax export, and trust badges Closes: #816 Closes: #822 Closes: #828 Closes: #829 --- .../creator/CommunityForumPanel.tsx | 39 ++++++++++++ src/components/creator/CreatorTrustBadges.tsx | 22 +++++++ src/components/creator/TaxReportExport.tsx | 60 +++++++++++++++++++ .../invoice/RiskAssessmentPanel.tsx | 35 +++++++++++ src/lib/creatorForum.test.ts | 22 +++++++ src/lib/creatorForum.ts | 27 +++++++++ src/lib/creatorTaxReport.test.ts | 22 +++++++ src/lib/creatorTaxReport.ts | 43 +++++++++++++ src/lib/creatorVerification.test.ts | 25 ++++++++ src/lib/creatorVerification.ts | 33 ++++++++++ src/lib/invoiceRiskAssessment.test.ts | 22 +++++++ src/lib/invoiceRiskAssessment.ts | 43 +++++++++++++ 12 files changed, 393 insertions(+) create mode 100644 src/components/creator/CommunityForumPanel.tsx create mode 100644 src/components/creator/CreatorTrustBadges.tsx create mode 100644 src/components/creator/TaxReportExport.tsx create mode 100644 src/components/invoice/RiskAssessmentPanel.tsx create mode 100644 src/lib/creatorForum.test.ts create mode 100644 src/lib/creatorForum.ts create mode 100644 src/lib/creatorTaxReport.test.ts create mode 100644 src/lib/creatorTaxReport.ts create mode 100644 src/lib/creatorVerification.test.ts create mode 100644 src/lib/creatorVerification.ts create mode 100644 src/lib/invoiceRiskAssessment.test.ts create mode 100644 src/lib/invoiceRiskAssessment.ts diff --git a/src/components/creator/CommunityForumPanel.tsx b/src/components/creator/CommunityForumPanel.tsx new file mode 100644 index 0000000..24b7f80 --- /dev/null +++ b/src/components/creator/CommunityForumPanel.tsx @@ -0,0 +1,39 @@ +import { DEFAULT_FORUM_URL, newTopicUrl, sortTopicsByActivity, topicUrl, type ForumTopic } from "@/lib/creatorForum"; + +interface Props { + topics: ForumTopic[]; + baseUrl?: string; +} + +export default function CommunityForumPanel({ topics, baseUrl = DEFAULT_FORUM_URL }: Props) { + const config = { baseUrl }; + return ( + + + Creator community + + Start a discussion + + + {topics.length === 0 ? ( + No discussions yet. + ) : ( + + {sortTopicsByActivity(topics).map((t) => ( + + + {t.title} + + {t.replies} replies + + ))} + + )} + + ); +} diff --git a/src/components/creator/CreatorTrustBadges.tsx b/src/components/creator/CreatorTrustBadges.tsx new file mode 100644 index 0000000..9c78b4b --- /dev/null +++ b/src/components/creator/CreatorTrustBadges.tsx @@ -0,0 +1,22 @@ +import { getTrustBadges, getTrustTier, type CreatorProfile, type TrustTier } from "@/lib/creatorVerification"; + +const TIER_STYLES: Record = { + unverified: "bg-gray-100 text-gray-700", + verified: "bg-blue-100 text-blue-800", + trusted: "bg-green-100 text-green-800", +}; + +export default function CreatorTrustBadges({ profile }: { profile: CreatorProfile }) { + const tier = getTrustTier(profile); + const badges = getTrustBadges(profile); + return ( + + {tier} + {badges.map((b) => ( + + ✓ {b.label} + + ))} + + ); +} diff --git a/src/components/creator/TaxReportExport.tsx b/src/components/creator/TaxReportExport.tsx new file mode 100644 index 0000000..2c6d250 --- /dev/null +++ b/src/components/creator/TaxReportExport.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { buildTaxReportCsv, summarizeTaxYear, type TaxablePayment } from "@/lib/creatorTaxReport"; + +interface Props { + payments: TaxablePayment[]; +} + +export default function TaxReportExport({ payments }: Props) { + const years = Array.from(new Set(payments.map((p) => new Date(p.paidAt).getUTCFullYear()))).sort((a, b) => b - a); + const [year, setYear] = useState(years[0] ?? new Date().getUTCFullYear()); + const summary = summarizeTaxYear(payments, year); + + const handleExport = () => { + const blob = new Blob([buildTaxReportCsv(payments, year)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `tax-report-${year}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + Tax report + + setYear(Number(e.target.value))} + className="rounded border px-2 py-1 text-sm" + > + {(years.length ? years : [year]).map((y) => ( + + {y} + + ))} + + + Export CSV + + + + + Payments{summary.count} + Gross{summary.gross} + Fees{summary.fees} + Net{summary.net} + + + ); +} diff --git a/src/components/invoice/RiskAssessmentPanel.tsx b/src/components/invoice/RiskAssessmentPanel.tsx new file mode 100644 index 0000000..ebb01e1 --- /dev/null +++ b/src/components/invoice/RiskAssessmentPanel.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { assessInvoiceRisk, type RiskInput, type RiskLevel } from "@/lib/invoiceRiskAssessment"; + +const LEVEL_STYLES: Record = { + low: "bg-green-100 text-green-800", + medium: "bg-amber-100 text-amber-800", + high: "bg-red-100 text-red-800", +}; + +export default function RiskAssessmentPanel(props: RiskInput) { + const { score, level, factors } = assessInvoiceRisk(props); + return ( + + + Risk assessment + + {level} risk · {score}/100 + + + {factors.length > 0 ? ( + + {factors.map((f) => ( + + {f.label} + +{f.points} + + ))} + + ) : ( + No risk factors detected. + )} + + ); +} diff --git a/src/lib/creatorForum.test.ts b/src/lib/creatorForum.test.ts new file mode 100644 index 0000000..7b4e158 --- /dev/null +++ b/src/lib/creatorForum.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { newTopicUrl, sortTopicsByActivity, topicUrl } from "./creatorForum"; + +const config = { baseUrl: "https://forum.example.com/" }; + +describe("creatorForum", () => { + it("builds topic URLs without double slashes", () => { + expect(topicUrl(config, { id: 42, slug: "tips" })).toBe("https://forum.example.com/t/tips/42"); + }); + + it("builds an encoded new-topic URL", () => { + expect(newTopicUrl(config, "Help & tips")).toBe("https://forum.example.com/new-topic?title=Help+%26+tips&category=creators"); + }); + + it("sorts topics by most recent activity", () => { + const topics = [ + { id: 1, title: "a", slug: "a", replies: 0, lastActivity: "2025-01-01T00:00:00Z" }, + { id: 2, title: "b", slug: "b", replies: 3, lastActivity: "2025-06-01T00:00:00Z" }, + ]; + expect(sortTopicsByActivity(topics).map((t) => t.id)).toEqual([2, 1]); + }); +}); diff --git a/src/lib/creatorForum.ts b/src/lib/creatorForum.ts new file mode 100644 index 0000000..83874ea --- /dev/null +++ b/src/lib/creatorForum.ts @@ -0,0 +1,27 @@ +export interface ForumConfig { + /** Base URL of the community forum (e.g. a Discourse instance). */ + baseUrl: string; +} + +export interface ForumTopic { + id: number; + title: string; + slug: string; + replies: number; + lastActivity: string; +} + +export const DEFAULT_FORUM_URL = process.env.NEXT_PUBLIC_COMMUNITY_FORUM_URL ?? "https://forum.stellarsplit.app"; + +export function topicUrl(config: ForumConfig, topic: Pick): string { + return `${config.baseUrl.replace(/\/+$/, "")}/t/${encodeURIComponent(topic.slug)}/${topic.id}`; +} + +export function newTopicUrl(config: ForumConfig, title: string, category = "creators"): string { + const params = new URLSearchParams({ title, category }); + return `${config.baseUrl.replace(/\/+$/, "")}/new-topic?${params.toString()}`; +} + +export function sortTopicsByActivity(topics: ForumTopic[]): ForumTopic[] { + return [...topics].sort((a, b) => Date.parse(b.lastActivity) - Date.parse(a.lastActivity)); +} diff --git a/src/lib/creatorTaxReport.test.ts b/src/lib/creatorTaxReport.test.ts new file mode 100644 index 0000000..e9de0ff --- /dev/null +++ b/src/lib/creatorTaxReport.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { buildTaxReportCsv, summarizeTaxYear } from "./creatorTaxReport"; + +const payments = [ + { invoiceId: "1", title: "Dinner, split", paidAt: "2025-03-01T00:00:00Z", amount: 100, fee: 1, asset: "XLM" }, + { invoiceId: "2", title: "Rent", paidAt: "2025-12-31T23:00:00Z", amount: 500, fee: 5, asset: "USDC" }, + { invoiceId: "3", title: "Old", paidAt: "2024-06-01T00:00:00Z", amount: 999, fee: 9, asset: "XLM" }, +]; + +describe("creatorTaxReport", () => { + it("summarizes only the requested year", () => { + expect(summarizeTaxYear(payments, 2025)).toEqual({ year: 2025, gross: 600, fees: 6, net: 594, count: 2 }); + }); + + it("builds a CSV with escaped cells and a totals row", () => { + const lines = buildTaxReportCsv(payments, 2025).split("\n"); + expect(lines[0]).toBe("Invoice ID,Title,Paid At,Asset,Gross,Fee,Net"); + expect(lines[1]).toBe('1,"Dinner, split",2025-03-01T00:00:00Z,XLM,100,1,99'); + expect(lines).toHaveLength(4); + expect(lines[3]).toBe("TOTAL,,,,600,6,594"); + }); +}); diff --git a/src/lib/creatorTaxReport.ts b/src/lib/creatorTaxReport.ts new file mode 100644 index 0000000..86a19b3 --- /dev/null +++ b/src/lib/creatorTaxReport.ts @@ -0,0 +1,43 @@ +export interface TaxablePayment { + invoiceId: string; + title: string; + /** ISO date the payment settled. */ + paidAt: string; + amount: number; + fee: number; + asset: string; +} + +export interface TaxReportSummary { + year: number; + gross: number; + fees: number; + net: number; + count: number; +} + +export function filterByTaxYear(payments: TaxablePayment[], year: number): TaxablePayment[] { + return payments.filter((p) => new Date(p.paidAt).getUTCFullYear() === year); +} + +export function summarizeTaxYear(payments: TaxablePayment[], year: number): TaxReportSummary { + const rows = filterByTaxYear(payments, year); + const gross = rows.reduce((s, p) => s + p.amount, 0); + const fees = rows.reduce((s, p) => s + p.fee, 0); + return { year, gross, fees, net: gross - fees, count: rows.length }; +} + +function csvCell(value: string | number): string { + const s = String(value); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function buildTaxReportCsv(payments: TaxablePayment[], year: number): string { + const header = ["Invoice ID", "Title", "Paid At", "Asset", "Gross", "Fee", "Net"]; + const lines = filterByTaxYear(payments, year).map((p) => + [p.invoiceId, p.title, p.paidAt, p.asset, p.amount, p.fee, p.amount - p.fee].map(csvCell).join(",") + ); + const s = summarizeTaxYear(payments, year); + lines.push(["TOTAL", "", "", "", s.gross, s.fees, s.net].map(csvCell).join(",")); + return [header.join(","), ...lines].join("\n"); +} diff --git a/src/lib/creatorVerification.test.ts b/src/lib/creatorVerification.test.ts new file mode 100644 index 0000000..b9c2ef5 --- /dev/null +++ b/src/lib/creatorVerification.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { getTrustBadges, getTrustTier } from "./creatorVerification"; + +const base = { emailVerified: false, walletVerified: false, kycVerified: false, accountAgeDays: 0, completedInvoices: 0, disputeRate: 0 }; + +describe("creatorVerification", () => { + it("returns no badges and unverified tier for a new profile", () => { + expect(getTrustBadges(base)).toEqual([]); + expect(getTrustTier(base)).toBe("unverified"); + }); + + it("marks wallet-verified creators as verified", () => { + expect(getTrustTier({ ...base, walletVerified: true })).toBe("verified"); + }); + + it("marks fully verified reliable creators as trusted", () => { + const p = { emailVerified: true, walletVerified: true, kycVerified: true, accountAgeDays: 365, completedInvoices: 20, disputeRate: 0 }; + expect(getTrustBadges(p).map((b) => b.id)).toEqual(["email", "wallet", "kyc", "established", "reliable"]); + expect(getTrustTier(p)).toBe("trusted"); + }); + + it("withholds the reliable badge when dispute rate is high", () => { + expect(getTrustBadges({ ...base, completedInvoices: 20, disputeRate: 0.2 })).toEqual([]); + }); +}); diff --git a/src/lib/creatorVerification.ts b/src/lib/creatorVerification.ts new file mode 100644 index 0000000..2b53b6d --- /dev/null +++ b/src/lib/creatorVerification.ts @@ -0,0 +1,33 @@ +export interface CreatorProfile { + emailVerified: boolean; + walletVerified: boolean; + kycVerified: boolean; + accountAgeDays: number; + completedInvoices: number; + disputeRate: number; // 0-1 +} + +export type BadgeId = "email" | "wallet" | "kyc" | "established" | "reliable"; + +export interface TrustBadge { + id: BadgeId; + label: string; +} + +export type TrustTier = "unverified" | "verified" | "trusted"; + +export function getTrustBadges(p: CreatorProfile): TrustBadge[] { + const badges: TrustBadge[] = []; + if (p.emailVerified) badges.push({ id: "email", label: "Email verified" }); + if (p.walletVerified) badges.push({ id: "wallet", label: "Wallet verified" }); + if (p.kycVerified) badges.push({ id: "kyc", label: "Identity verified" }); + if (p.accountAgeDays >= 180) badges.push({ id: "established", label: "Established creator" }); + if (p.completedInvoices >= 10 && p.disputeRate < 0.05) badges.push({ id: "reliable", label: "Reliable payer history" }); + return badges; +} + +export function getTrustTier(p: CreatorProfile): TrustTier { + if (!p.walletVerified) return "unverified"; + const ids = getTrustBadges(p).map((b) => b.id); + return ids.includes("kyc") && ids.includes("reliable") ? "trusted" : "verified"; +} diff --git a/src/lib/invoiceRiskAssessment.test.ts b/src/lib/invoiceRiskAssessment.test.ts new file mode 100644 index 0000000..d518302 --- /dev/null +++ b/src/lib/invoiceRiskAssessment.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { assessInvoiceRisk } from "./invoiceRiskAssessment"; + +const base = { amount: 100, recipientCount: 2, creatorAccountAgeDays: 365, priorDisputes: 0, deadlineDays: 14 }; + +describe("assessInvoiceRisk", () => { + it("rates a typical invoice as low risk", () => { + expect(assessInvoiceRisk(base)).toEqual({ score: 0, level: "low", factors: [] }); + }); + + it("flags medium risk for elevated amount from a young account", () => { + const r = assessInvoiceRisk({ ...base, amount: 5_000, creatorAccountAgeDays: 10, priorDisputes: 1, isPrivate: true }); + expect(r.level).toBe("medium"); + expect(r.factors.map((f) => f.id)).toEqual(["medium-amount", "young-account", "disputes", "private"]); + }); + + it("flags high risk and caps score at 100", () => { + const r = assessInvoiceRisk({ amount: 50_000, recipientCount: 50, creatorAccountAgeDays: 1, priorDisputes: 5, deadlineDays: 0 }); + expect(r.level).toBe("high"); + expect(r.score).toBe(100); + }); +}); diff --git a/src/lib/invoiceRiskAssessment.ts b/src/lib/invoiceRiskAssessment.ts new file mode 100644 index 0000000..103337a --- /dev/null +++ b/src/lib/invoiceRiskAssessment.ts @@ -0,0 +1,43 @@ +export type RiskLevel = "low" | "medium" | "high"; + +export interface RiskInput { + /** Total invoice amount in whole units. */ + amount: number; + recipientCount: number; + /** Age of the creator account in days. */ + creatorAccountAgeDays: number; + /** Number of invoices previously disputed by this creator. */ + priorDisputes: number; + /** Days until the invoice deadline. */ + deadlineDays: number; + isPrivate?: boolean; +} + +export interface RiskFactor { + id: string; + label: string; + points: number; +} + +export interface RiskAssessment { + score: number; + level: RiskLevel; + factors: RiskFactor[]; +} + +export function assessInvoiceRisk(input: RiskInput): RiskAssessment { + const factors: RiskFactor[] = []; + if (input.amount >= 10_000) factors.push({ id: "large-amount", label: "Large invoice amount", points: 30 }); + else if (input.amount >= 1_000) factors.push({ id: "medium-amount", label: "Elevated invoice amount", points: 10 }); + if (input.creatorAccountAgeDays < 7) factors.push({ id: "new-account", label: "Creator account under 7 days old", points: 25 }); + else if (input.creatorAccountAgeDays < 30) factors.push({ id: "young-account", label: "Creator account under 30 days old", points: 10 }); + if (input.priorDisputes > 0) + factors.push({ id: "disputes", label: `${input.priorDisputes} prior dispute(s)`, points: Math.min(input.priorDisputes * 15, 30) }); + if (input.deadlineDays < 1) factors.push({ id: "rushed-deadline", label: "Deadline under 24 hours", points: 15 }); + if (input.recipientCount > 20) factors.push({ id: "many-recipients", label: "More than 20 recipients", points: 10 }); + if (input.isPrivate) factors.push({ id: "private", label: "Private invoice", points: 5 }); + + const score = Math.min(100, factors.reduce((sum, f) => sum + f.points, 0)); + const level: RiskLevel = score >= 60 ? "high" : score >= 30 ? "medium" : "low"; + return { score, level, factors }; +}
No discussions yet.
No risk factors detected.