diff --git a/src/__tests__/CreatorAccountabilityScore.test.tsx b/src/__tests__/CreatorAccountabilityScore.test.tsx new file mode 100644 index 0000000..2b9ebdd --- /dev/null +++ b/src/__tests__/CreatorAccountabilityScore.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import CreatorAccountabilityScore from "@/components/creator/CreatorAccountabilityScore"; + +describe("CreatorAccountabilityScore", () => { + it("renders the score, grade and breakdown", () => { + render( + , + ); + expect(screen.getByLabelText("Grade A")).toBeInTheDocument(); + expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100"); + expect(screen.getByText("50 / 50")).toBeInTheDocument(); + }); + + it("explains when there is not enough history", () => { + render( + , + ); + expect(screen.getByText(/Not enough invoice history/)).toBeInTheDocument(); + expect(screen.queryByRole("meter")).toBeNull(); + }); +}); diff --git a/src/__tests__/CreatorCertification.test.tsx b/src/__tests__/CreatorCertification.test.tsx new file mode 100644 index 0000000..eaf80f3 --- /dev/null +++ b/src/__tests__/CreatorCertification.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import CreatorCertification from "@/components/creator/CreatorCertification"; + +const base = { + walletVerified: false, + kycVerified: false, + accountAgeDays: 0, + completedInvoices: 0, + disputeRate: 0, + onTimeRate: 0, +}; + +describe("CreatorCertification", () => { + it("shows the uncertified state and bronze requirements", () => { + render(); + expect(screen.getByText("Not certified")).toBeInTheDocument(); + expect(screen.getByText("Requirements for bronze certification")).toBeInTheDocument(); + expect(screen.getByText(/Verify your wallet/)).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "33"); + }); + + it("shows the top tier when gold is reached", () => { + render( + , + ); + expect(screen.getByText("gold")).toBeInTheDocument(); + expect(screen.getByText("Top certification reached")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100"); + }); +}); diff --git a/src/__tests__/PortfolioOptimization.test.tsx b/src/__tests__/PortfolioOptimization.test.tsx new file mode 100644 index 0000000..fcdc35a --- /dev/null +++ b/src/__tests__/PortfolioOptimization.test.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import PortfolioOptimization from "@/components/recommendations/PortfolioOptimization"; + +const DAY = 86_400_000; +const NOW = 100 * DAY; + +describe("PortfolioOptimization", () => { + it("renders recommendations for an at-risk portfolio", () => { + render( + , + ); + expect(screen.getByRole("list", { name: "Portfolio optimization recommendations" })).toBeInTheDocument(); + expect(screen.getByText(/1 open invoice past due/)).toBeInTheDocument(); + }); + + it("shows a healthy state when there is nothing to recommend", () => { + render(); + expect(screen.getByText(/looks healthy/)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/SocialProofWidget.test.tsx b/src/__tests__/SocialProofWidget.test.tsx new file mode 100644 index 0000000..b40aa2c --- /dev/null +++ b/src/__tests__/SocialProofWidget.test.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import SocialProofWidget from "@/components/pay/SocialProofWidget"; + +const MIN = 60_000; +const NOW = 1_000_000_000; +const ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +describe("SocialProofWidget", () => { + it("shows recent payments with masked payer addresses", () => { + render( + , + ); + expect(screen.getByText("1 person paid in the last 24 hours")).toBeInTheDocument(); + expect(screen.getByText("5m ago")).toBeInTheDocument(); + expect(screen.getByText(/paid 25 XLM/)).toBeInTheDocument(); + expect(screen.queryByText(ADDR)).toBeNull(); + }); + + it("renders nothing when there are no payments", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/creator/CreatorAccountabilityScore.tsx b/src/components/creator/CreatorAccountabilityScore.tsx new file mode 100644 index 0000000..fa234ec --- /dev/null +++ b/src/components/creator/CreatorAccountabilityScore.tsx @@ -0,0 +1,52 @@ +import { computeAccountabilityScore, type AccountabilityInput } from "@/lib/creatorAccountability"; + +const ROWS = [ + { key: "fulfillment", label: "Fulfilment", max: 50 }, + { key: "disputes", label: "Disputes", max: 30 }, + { key: "responsiveness", label: "Responsiveness", max: 20 }, +] as const; + +export default function CreatorAccountabilityScore({ stats }: { stats: AccountabilityInput }) { + const { score, grade, breakdown } = computeAccountabilityScore(stats); + + if (score === null) { + return ( +
+

Accountability score

+

Not enough invoice history to rate this creator yet.

+
+ ); + } + + return ( +
+
+

Accountability score

+ + {grade} + +
+
+
+
+

{score} / 100

+
+ {ROWS.map((r) => ( +
+
{r.label}
+
+ {breakdown[r.key]} / {r.max} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/creator/CreatorCertification.tsx b/src/components/creator/CreatorCertification.tsx new file mode 100644 index 0000000..1bf777f --- /dev/null +++ b/src/components/creator/CreatorCertification.tsx @@ -0,0 +1,45 @@ +import { evaluateCertification, type CertificationInput, type CertificationLevel } from "@/lib/creatorCertification"; + +const LEVEL_STYLES: Record = { + none: "bg-gray-100 text-gray-700", + bronze: "bg-orange-100 text-orange-800", + silver: "bg-slate-200 text-slate-800", + gold: "bg-yellow-100 text-yellow-800", +}; + +export default function CreatorCertification({ profile }: { profile: CertificationInput }) { + const { level, next, requirements, progress } = evaluateCertification(profile); + const percent = Math.round(progress * 100); + + return ( +
+
+

Certification

+ + {level === "none" ? "Not certified" : level} + +
+

+ {next ? `Requirements for ${next} certification` : "Top certification reached"} +

+
+
+
+
    + {requirements.map((r) => ( +
  • + {r.label} + {r.met ? " (met)" : " (not met)"} +
  • + ))} +
+
+ ); +} diff --git a/src/components/pay/SocialProofWidget.tsx b/src/components/pay/SocialProofWidget.tsx new file mode 100644 index 0000000..18ffa1c --- /dev/null +++ b/src/components/pay/SocialProofWidget.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useMemo } from "react"; +import { buildSocialProof, summarizeSocialProof, type RecentPayment } from "@/lib/socialProof"; + +interface Props { + payments: RecentPayment[]; + limit?: number; + /** Overridable for deterministic rendering. */ + now?: number; +} + +export default function SocialProofWidget({ payments, limit = 5, now }: Props) { + const { items, summary } = useMemo(() => { + const at = now ?? Date.now(); + return { items: buildSocialProof(payments, at, limit), summary: summarizeSocialProof(payments, at) }; + }, [payments, limit, now]); + + if (items.length === 0) return null; + + return ( +
+ {summary.count > 0 && ( +

+ {summary.uniquePayers} {summary.uniquePayers === 1 ? "person" : "people"} paid in the last 24 hours +

+ )} +
    + {items.map((i) => ( +
  • + + {i.payerLabel} paid {i.amountLabel} + + {i.timeAgo} +
  • + ))} +
+
+ ); +} diff --git a/src/components/recommendations/PortfolioOptimization.tsx b/src/components/recommendations/PortfolioOptimization.tsx new file mode 100644 index 0000000..14eca63 --- /dev/null +++ b/src/components/recommendations/PortfolioOptimization.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useMemo } from "react"; +import { optimizePortfolio, type PortfolioInvoice, type TipSeverity } from "@/lib/portfolioOptimization"; + +interface Props { + invoices: PortfolioInvoice[]; + /** Overridable for deterministic rendering. */ + now?: number; +} + +const SEVERITY_STYLES: Record = { + critical: "border-red-200 bg-red-50 text-red-900", + warning: "border-yellow-200 bg-yellow-50 text-yellow-900", + info: "border-blue-200 bg-blue-50 text-blue-900", +}; + +export default function PortfolioOptimization({ invoices, now }: Props) { + const tips = useMemo(() => optimizePortfolio(invoices, now), [invoices, now]); + + if (tips.length === 0) { + return

Your invoice portfolio looks healthy — no recommendations right now.

; + } + + return ( +
    + {tips.map((t) => ( +
  • + {t.severity} + {t.message} +
  • + ))} +
+ ); +} diff --git a/src/lib/creatorAccountability.test.ts b/src/lib/creatorAccountability.test.ts new file mode 100644 index 0000000..a125fe6 --- /dev/null +++ b/src/lib/creatorAccountability.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { computeAccountabilityScore, gradeForScore, type AccountabilityInput } from "./creatorAccountability"; + +const perfect: AccountabilityInput = { + totalInvoices: 20, + fulfilledInvoices: 20, + cancelledInvoices: 0, + disputes: 0, + disputesLost: 0, + avgResponseHours: 2, +}; + +describe("computeAccountabilityScore", () => { + it("is unrated below the minimum history", () => { + const r = computeAccountabilityScore({ ...perfect, totalInvoices: 2, fulfilledInvoices: 2 }); + expect(r.score).toBeNull(); + expect(r.grade).toBe("unrated"); + }); + + it("gives a perfect creator 100 / A", () => { + const r = computeAccountabilityScore(perfect); + expect(r.score).toBe(100); + expect(r.grade).toBe("A"); + expect(r.breakdown).toEqual({ fulfillment: 50, disputes: 30, responsiveness: 20 }); + }); + + it("penalises cancellations against fulfilment", () => { + // (10 - 4 * 0.5) / 20 = 0.4 -> 20 fulfilment points + const r = computeAccountabilityScore({ ...perfect, fulfilledInvoices: 10, cancelledInvoices: 4 }); + expect(r.breakdown.fulfillment).toBe(20); + }); + + it("counts lost disputes double", () => { + // (2 + 2) / 20 = 0.2 -> 24 dispute points + const r = computeAccountabilityScore({ ...perfect, disputes: 2, disputesLost: 2 }); + expect(r.breakdown.disputes).toBe(24); + }); + + it("scores responsiveness linearly between 4h and 72h", () => { + expect(computeAccountabilityScore({ ...perfect, avgResponseHours: 4 }).breakdown.responsiveness).toBe(20); + expect(computeAccountabilityScore({ ...perfect, avgResponseHours: 38 }).breakdown.responsiveness).toBe(10); + expect(computeAccountabilityScore({ ...perfect, avgResponseHours: 500 }).breakdown.responsiveness).toBe(0); + }); + + it("never leaves the 0-100 range for out-of-range input", () => { + const r = computeAccountabilityScore({ + totalInvoices: 5, + fulfilledInvoices: 50, + cancelledInvoices: 0, + disputes: 99, + disputesLost: 99, + avgResponseHours: -10, + }); + expect(r.score).toBeGreaterThanOrEqual(0); + expect(r.score).toBeLessThanOrEqual(100); + }); +}); + +describe("gradeForScore", () => { + it("maps score boundaries to grades", () => { + expect(gradeForScore(90)).toBe("A"); + expect(gradeForScore(89)).toBe("B"); + expect(gradeForScore(75)).toBe("B"); + expect(gradeForScore(60)).toBe("C"); + expect(gradeForScore(40)).toBe("D"); + expect(gradeForScore(39)).toBe("F"); + }); +}); diff --git a/src/lib/creatorAccountability.ts b/src/lib/creatorAccountability.ts new file mode 100644 index 0000000..a414a39 --- /dev/null +++ b/src/lib/creatorAccountability.ts @@ -0,0 +1,59 @@ +export interface AccountabilityInput { + totalInvoices: number; + fulfilledInvoices: number; + cancelledInvoices: number; + disputes: number; + disputesLost: number; + /** Average time the creator takes to respond to payer messages, in hours. */ + avgResponseHours: number; +} + +export type AccountabilityGrade = "A" | "B" | "C" | "D" | "F" | "unrated"; + +export interface AccountabilityResult { + /** 0-100, or null when there is not enough history to rate the creator. */ + score: number | null; + grade: AccountabilityGrade; + breakdown: { fulfillment: number; disputes: number; responsiveness: number }; +} + +/** Minimum invoices before a creator is rated. */ +export const MIN_INVOICES_FOR_SCORE = 3; + +const FAST_RESPONSE_HOURS = 4; +const SLOW_RESPONSE_HOURS = 72; + +const clamp01 = (n: number) => Math.min(1, Math.max(0, Number.isFinite(n) ? n : 0)); + +export function gradeForScore(score: number): Exclude { + if (score >= 90) return "A"; + if (score >= 75) return "B"; + if (score >= 60) return "C"; + if (score >= 40) return "D"; + return "F"; +} + +/** + * Weights: fulfilment 50, disputes 30 (a lost dispute counts double), responsiveness 20. + * Cancelled invoices count against fulfilment. + */ +export function computeAccountabilityScore(input: AccountabilityInput): AccountabilityResult { + const total = input.totalInvoices; + if (total < MIN_INVOICES_FOR_SCORE) { + return { score: null, grade: "unrated", breakdown: { fulfillment: 0, disputes: 0, responsiveness: 0 } }; + } + + const fulfilledShare = clamp01((input.fulfilledInvoices - input.cancelledInvoices * 0.5) / total); + const disputePenalty = clamp01((input.disputes + input.disputesLost) / total); + const responseShare = clamp01( + (SLOW_RESPONSE_HOURS - input.avgResponseHours) / (SLOW_RESPONSE_HOURS - FAST_RESPONSE_HOURS), + ); + + const breakdown = { + fulfillment: Math.round(fulfilledShare * 50), + disputes: Math.round((1 - disputePenalty) * 30), + responsiveness: Math.round(responseShare * 20), + }; + const score = breakdown.fulfillment + breakdown.disputes + breakdown.responsiveness; + return { score, grade: gradeForScore(score), breakdown }; +} diff --git a/src/lib/creatorCertification.test.ts b/src/lib/creatorCertification.test.ts new file mode 100644 index 0000000..ebfd6d0 --- /dev/null +++ b/src/lib/creatorCertification.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { evaluateCertification, type CertificationInput } from "./creatorCertification"; + +const base: CertificationInput = { + walletVerified: false, + kycVerified: false, + accountAgeDays: 0, + completedInvoices: 0, + disputeRate: 0, + onTimeRate: 0, +}; + +describe("evaluateCertification", () => { + it("is uncertified for a new creator and targets bronze", () => { + const r = evaluateCertification(base); + expect(r.level).toBe("none"); + expect(r.next).toBe("bronze"); + expect(r.progress).toBeCloseTo(1 / 3); + }); + + it("awards bronze and targets silver", () => { + const r = evaluateCertification({ ...base, walletVerified: true, completedInvoices: 3 }); + expect(r.level).toBe("bronze"); + expect(r.next).toBe("silver"); + expect(r.requirements.map((q) => q.id)).toEqual(["kyc", "invoices", "age", "disputes"]); + expect(r.progress).toBeCloseTo(1 / 4); + }); + + it("awards silver and targets gold", () => { + const r = evaluateCertification({ + ...base, + walletVerified: true, + kycVerified: true, + completedInvoices: 20, + accountAgeDays: 100, + }); + expect(r.level).toBe("silver"); + expect(r.next).toBe("gold"); + }); + + it("awards gold with no next tier", () => { + const r = evaluateCertification({ + walletVerified: true, + kycVerified: true, + accountAgeDays: 400, + completedInvoices: 60, + disputeRate: 0.01, + onTimeRate: 0.95, + }); + expect(r.level).toBe("gold"); + expect(r.next).toBeNull(); + expect(r.progress).toBe(1); + expect(r.requirements.every((q) => q.met)).toBe(true); + }); + + it("does not skip a tier whose requirements are unmet", () => { + // Meets every gold requirement but never verified a wallet. + const r = evaluateCertification({ + walletVerified: false, + kycVerified: true, + accountAgeDays: 400, + completedInvoices: 60, + disputeRate: 0.01, + onTimeRate: 0.95, + }); + expect(r.level).toBe("none"); + expect(r.next).toBe("bronze"); + }); + + it("blocks a tier when the dispute rate is too high", () => { + const r = evaluateCertification({ ...base, walletVerified: true, completedInvoices: 10, disputeRate: 0.25 }); + expect(r.level).toBe("none"); + }); +}); diff --git a/src/lib/creatorCertification.ts b/src/lib/creatorCertification.ts new file mode 100644 index 0000000..8e4c9d7 --- /dev/null +++ b/src/lib/creatorCertification.ts @@ -0,0 +1,70 @@ +export interface CertificationInput { + walletVerified: boolean; + kycVerified: boolean; + accountAgeDays: number; + completedInvoices: number; + disputeRate: number; // 0-1 + onTimeRate: number; // 0-1 share of invoices fulfilled on time +} + +export type CertificationTier = "bronze" | "silver" | "gold"; +export type CertificationLevel = "none" | CertificationTier; + +export interface CertificationRequirement { + id: string; + label: string; + met: boolean; +} + +export interface CertificationResult { + level: CertificationLevel; + next: CertificationTier | null; + /** Requirements for the next tier (or for the top tier once it is reached). */ + requirements: CertificationRequirement[]; + /** Share of `requirements` that are met, 0-1. */ + progress: number; +} + +const TIERS: { tier: CertificationTier; requirements: (i: CertificationInput) => CertificationRequirement[] }[] = [ + { + tier: "bronze", + requirements: (i) => [ + { id: "wallet", label: "Verify your wallet", met: i.walletVerified }, + { id: "invoices", label: "Complete 3 invoices", met: i.completedInvoices >= 3 }, + { id: "disputes", label: "Keep your dispute rate under 20%", met: i.disputeRate < 0.2 }, + ], + }, + { + tier: "silver", + requirements: (i) => [ + { id: "kyc", label: "Verify your identity", met: i.kycVerified }, + { id: "invoices", label: "Complete 15 invoices", met: i.completedInvoices >= 15 }, + { id: "age", label: "Have an account at least 90 days old", met: i.accountAgeDays >= 90 }, + { id: "disputes", label: "Keep your dispute rate under 10%", met: i.disputeRate < 0.1 }, + ], + }, + { + tier: "gold", + requirements: (i) => [ + { id: "invoices", label: "Complete 50 invoices", met: i.completedInvoices >= 50 }, + { id: "age", label: "Have an account at least 1 year old", met: i.accountAgeDays >= 365 }, + { id: "disputes", label: "Keep your dispute rate under 5%", met: i.disputeRate < 0.05 }, + { id: "on-time", label: "Fulfil at least 90% of invoices on time", met: i.onTimeRate >= 0.9 }, + ], + }, +]; + +/** A tier is only awarded once every lower tier's requirements are also met. */ +export function evaluateCertification(input: CertificationInput): CertificationResult { + let level: CertificationLevel = "none"; + for (const { tier, requirements } of TIERS) { + const reqs = requirements(input); + if (reqs.every((r) => r.met)) { + level = tier; + continue; + } + return { level, next: tier, requirements: reqs, progress: reqs.filter((r) => r.met).length / reqs.length }; + } + const top = TIERS[TIERS.length - 1].requirements(input); + return { level, next: null, requirements: top, progress: 1 }; +} diff --git a/src/lib/portfolioOptimization.test.ts b/src/lib/portfolioOptimization.test.ts new file mode 100644 index 0000000..8df0db8 --- /dev/null +++ b/src/lib/portfolioOptimization.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { optimizePortfolio, type PortfolioInvoice } from "./portfolioOptimization"; + +const DAY = 86_400_000; +const NOW = 100 * DAY; + +const inv = (over: Partial & { id: string }): PortfolioInvoice => ({ + title: over.id, + amount: 100, + status: "open", + recipients: ["GA"], + createdAt: NOW - DAY, + ...over, +}); + +const ids = (tips: ReturnType) => tips.map((t) => t.id); + +describe("optimizePortfolio", () => { + it("returns nothing for an empty portfolio", () => { + expect(optimizePortfolio([], NOW)).toEqual([]); + }); + + it("flags overdue and soon-due open invoices, most urgent first", () => { + const tips = optimizePortfolio( + [ + inv({ id: "a", recipients: ["GA"], dueAt: NOW - DAY }), + inv({ id: "b", recipients: ["GB"], dueAt: NOW + 2 * DAY }), + inv({ id: "c", recipients: ["GC"], dueAt: NOW + 10 * DAY }), + ], + NOW, + ); + expect(ids(tips)).toEqual(["overdue", "expiring"]); + expect(tips[0].invoiceIds).toEqual(["a"]); + expect(tips[1].invoiceIds).toEqual(["b"]); + }); + + it("ignores due dates on invoices that are not open", () => { + const tips = optimizePortfolio([inv({ id: "a", status: "paid", dueAt: NOW - DAY })], NOW); + expect(tips).toEqual([]); + }); + + it("flags stale open invoices that have no deadline", () => { + const tips = optimizePortfolio([inv({ id: "old", createdAt: NOW - 45 * DAY }), inv({ id: "new" })], NOW); + expect(ids(tips)).toEqual(["stale"]); + expect(tips[0].invoiceIds).toEqual(["old"]); + }); + + it("flags recipient concentration above 50% of active value", () => { + const tips = optimizePortfolio( + [ + inv({ id: "a", recipients: ["GA"] }), + inv({ id: "b", recipients: ["GA"] }), + inv({ id: "c", recipients: ["GB"], amount: 50 }), + ], + NOW, + ); + expect(ids(tips)).toEqual(["concentration"]); + expect(tips[0].message).toContain("80%"); + expect(tips[0].invoiceIds).toEqual(["a", "b"]); + }); + + it("does not flag concentration for balanced recipients or tiny portfolios", () => { + expect( + ids(optimizePortfolio([inv({ id: "a", recipients: ["GA"] }), inv({ id: "b", recipients: ["GB"] }), inv({ id: "c", recipients: ["GC"] })], NOW)), + ).toEqual([]); + expect(ids(optimizePortfolio([inv({ id: "a" }), inv({ id: "b" })], NOW))).toEqual([]); + }); + + it("suggests batching three or more small open invoices", () => { + const tips = optimizePortfolio( + [ + inv({ id: "a", amount: 2, recipients: ["GA"] }), + inv({ id: "b", amount: 3, recipients: ["GB"] }), + inv({ id: "c", amount: 4, recipients: ["GC"] }), + ], + NOW, + ); + expect(ids(tips)).toEqual(["small-invoices"]); + }); + + it("flags a high cancellation rate", () => { + const list = [ + inv({ id: "a", status: "cancelled" }), + inv({ id: "b", status: "cancelled" }), + inv({ id: "c", status: "paid" }), + inv({ id: "d", status: "paid" }), + inv({ id: "e", status: "paid" }), + ]; + const tips = optimizePortfolio(list, NOW); + expect(ids(tips)).toEqual(["cancellations"]); + expect(tips[0].message).toContain("40%"); + }); +}); diff --git a/src/lib/portfolioOptimization.ts b/src/lib/portfolioOptimization.ts new file mode 100644 index 0000000..602ab3e --- /dev/null +++ b/src/lib/portfolioOptimization.ts @@ -0,0 +1,124 @@ +export type PortfolioInvoiceStatus = "open" | "funded" | "paid" | "expired" | "cancelled"; + +export interface PortfolioInvoice { + id: string; + title: string; + amount: number; + status: PortfolioInvoiceStatus; + recipients: string[]; + createdAt: number; // ms since epoch + dueAt?: number; // ms since epoch +} + +export type TipKind = "overdue" | "expiring" | "stale" | "concentration" | "small-invoices" | "cancellations"; +export type TipSeverity = "critical" | "warning" | "info"; + +export interface OptimizationTip { + id: TipKind; + severity: TipSeverity; + message: string; + invoiceIds: string[]; +} + +const DAY_MS = 86_400_000; +const EXPIRING_WITHIN_DAYS = 3; +const STALE_AFTER_DAYS = 30; +const CONCENTRATION_SHARE = 0.5; +const CONCENTRATION_MIN_INVOICES = 3; +const SMALL_AMOUNT = 10; +const SMALL_MIN_COUNT = 3; +const CANCEL_RATE = 0.3; +const CANCEL_MIN_INVOICES = 5; + +const SEVERITY_ORDER: Record = { critical: 0, warning: 1, info: 2 }; + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** Suggests ways to tidy up and de-risk an invoice portfolio, most urgent first. */ +export function optimizePortfolio(invoices: PortfolioInvoice[], now: number = Date.now()): OptimizationTip[] { + const tips: OptimizationTip[] = []; + const open = invoices.filter((i) => i.status === "open"); + + const overdue = open.filter((i) => i.dueAt !== undefined && i.dueAt < now); + if (overdue.length) { + tips.push({ + id: "overdue", + severity: "critical", + message: `${plural(overdue.length, "open invoice")} past due — extend the deadline or cancel.`, + invoiceIds: overdue.map((i) => i.id), + }); + } + + const expiring = open.filter( + (i) => i.dueAt !== undefined && i.dueAt >= now && i.dueAt - now <= EXPIRING_WITHIN_DAYS * DAY_MS, + ); + if (expiring.length) { + tips.push({ + id: "expiring", + severity: "warning", + message: `${plural(expiring.length, "invoice")} due within ${EXPIRING_WITHIN_DAYS} days — send a reminder.`, + invoiceIds: expiring.map((i) => i.id), + }); + } + + const stale = open.filter((i) => i.dueAt === undefined && now - i.createdAt > STALE_AFTER_DAYS * DAY_MS); + if (stale.length) { + tips.push({ + id: "stale", + severity: "warning", + message: `${plural(stale.length, "open invoice")} older than ${STALE_AFTER_DAYS} days with no deadline — set one or close it.`, + invoiceIds: stale.map((i) => i.id), + }); + } + + const active = invoices.filter((i) => i.status === "open" || i.status === "funded"); + if (active.length >= CONCENTRATION_MIN_INVOICES) { + const totals = new Map(); + let grand = 0; + for (const inv of active) { + if (!inv.recipients.length) continue; + const share = inv.amount / inv.recipients.length; + grand += inv.amount; + for (const r of inv.recipients) totals.set(r, (totals.get(r) ?? 0) + share); + } + let topRecipient: string | undefined; + let topTotal = 0; + totals.forEach((total, r) => { + if (total > topTotal) { + topRecipient = r; + topTotal = total; + } + }); + if (topRecipient !== undefined && grand > 0 && topTotal / grand > CONCENTRATION_SHARE) { + const pct = Math.round((topTotal / grand) * 100); + tips.push({ + id: "concentration", + severity: "info", + message: `${pct}% of active value goes to one recipient (${topRecipient}) — consider diversifying.`, + invoiceIds: active.filter((i) => i.recipients.includes(topRecipient as string)).map((i) => i.id), + }); + } + } + + const small = open.filter((i) => i.amount < SMALL_AMOUNT); + if (small.length >= SMALL_MIN_COUNT) { + tips.push({ + id: "small-invoices", + severity: "info", + message: `${plural(small.length, "open invoice")} under ${SMALL_AMOUNT} — batch them to save on fees.`, + invoiceIds: small.map((i) => i.id), + }); + } + + const cancelled = invoices.filter((i) => i.status === "cancelled"); + if (invoices.length >= CANCEL_MIN_INVOICES && cancelled.length / invoices.length > CANCEL_RATE) { + tips.push({ + id: "cancellations", + severity: "warning", + message: `${Math.round((cancelled.length / invoices.length) * 100)}% of your invoices were cancelled — review your templates and deadlines.`, + invoiceIds: cancelled.map((i) => i.id), + }); + } + + return tips.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); +} diff --git a/src/lib/socialProof.test.ts b/src/lib/socialProof.test.ts new file mode 100644 index 0000000..4b05360 --- /dev/null +++ b/src/lib/socialProof.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { buildSocialProof, formatTimeAgo, maskAddress, summarizeSocialProof, type RecentPayment } from "./socialProof"; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; +const NOW = 10 * DAY; +const ADDR_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const ADDR_B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBZZZZ"; + +const pay = (over: Partial & { id: string }): RecentPayment => ({ + payer: ADDR_A, + amount: 10, + asset: "XLM", + paidAt: NOW - MIN, + ...over, +}); + +describe("maskAddress", () => { + it("shortens long addresses and keeps short ones", () => { + expect(maskAddress(ADDR_A)).toBe("GAAA…AWHF"); + expect(maskAddress(ADDR_A)).not.toContain(ADDR_A); + expect(maskAddress("GABC")).toBe("GABC"); + }); +}); + +describe("formatTimeAgo", () => { + it("formats each range", () => { + expect(formatTimeAgo(NOW - 30_000, NOW)).toBe("just now"); + expect(formatTimeAgo(NOW - 5 * MIN, NOW)).toBe("5m ago"); + expect(formatTimeAgo(NOW - 3 * HOUR, NOW)).toBe("3h ago"); + expect(formatTimeAgo(NOW - 2 * DAY, NOW)).toBe("2d ago"); + }); + + it("treats future timestamps as just now", () => { + expect(formatTimeAgo(NOW + HOUR, NOW)).toBe("just now"); + }); +}); + +describe("buildSocialProof", () => { + it("orders newest first, limits results and masks payers", () => { + const items = buildSocialProof( + [pay({ id: "old", paidAt: NOW - HOUR }), pay({ id: "new", paidAt: NOW - MIN, payer: ADDR_B, amount: 2.5 }), pay({ id: "mid", paidAt: NOW - 10 * MIN })], + NOW, + 2, + ); + expect(items.map((i) => i.id)).toEqual(["new", "mid"]); + expect(items[0]).toEqual({ + id: "new", + payerLabel: maskAddress(ADDR_B), + amountLabel: "2.5 XLM", + timeAgo: "1m ago", + }); + }); + + it("drops future-dated and non-positive payments", () => { + const items = buildSocialProof([pay({ id: "future", paidAt: NOW + HOUR }), pay({ id: "zero", amount: 0 }), pay({ id: "ok" })], NOW); + expect(items.map((i) => i.id)).toEqual(["ok"]); + }); + + it("returns an empty list for no payments", () => { + expect(buildSocialProof([], NOW)).toEqual([]); + }); +}); + +describe("summarizeSocialProof", () => { + it("counts payments and unique payers inside the window", () => { + const s = summarizeSocialProof( + [pay({ id: "1" }), pay({ id: "2" }), pay({ id: "3", payer: ADDR_B }), pay({ id: "4", paidAt: NOW - 2 * DAY })], + NOW, + ); + expect(s).toEqual({ count: 3, uniquePayers: 2 }); + }); +}); diff --git a/src/lib/socialProof.ts b/src/lib/socialProof.ts new file mode 100644 index 0000000..3c30365 --- /dev/null +++ b/src/lib/socialProof.ts @@ -0,0 +1,61 @@ +export interface RecentPayment { + id: string; + payer: string; // Stellar address + amount: number; + asset: string; + paidAt: number; // ms since epoch +} + +export interface SocialProofItem { + id: string; + payerLabel: string; + amountLabel: string; + timeAgo: string; +} + +export interface SocialProofSummary { + count: number; + uniquePayers: number; +} + +const MINUTE_MS = 60_000; +const HOUR_MS = 3_600_000; +const DAY_MS = 86_400_000; + +/** Shortens an address for display (never shows a full wallet address). */ +export function maskAddress(address: string): string { + if (address.length <= 8) return address; + return `${address.slice(0, 4)}…${address.slice(-4)}`; +} + +export function formatTimeAgo(timestamp: number, now: number = Date.now()): string { + const diff = Math.max(0, now - timestamp); + if (diff < MINUTE_MS) return "just now"; + if (diff < HOUR_MS) return `${Math.floor(diff / MINUTE_MS)}m ago`; + if (diff < DAY_MS) return `${Math.floor(diff / HOUR_MS)}h ago`; + return `${Math.floor(diff / DAY_MS)}d ago`; +} + +/** Most recent payments first, with masked payers, ready to display. */ +export function buildSocialProof(payments: RecentPayment[], now: number = Date.now(), limit = 5): SocialProofItem[] { + return [...payments] + .filter((p) => p.paidAt <= now && p.amount > 0) + .sort((a, b) => b.paidAt - a.paidAt) + .slice(0, limit) + .map((p) => ({ + id: p.id, + payerLabel: maskAddress(p.payer), + amountLabel: `${p.amount} ${p.asset}`, + timeAgo: formatTimeAgo(p.paidAt, now), + })); +} + +/** Counts payments (and distinct payers) within the trailing window. */ +export function summarizeSocialProof( + payments: RecentPayment[], + now: number = Date.now(), + windowMs: number = DAY_MS, +): SocialProofSummary { + const recent = payments.filter((p) => p.paidAt <= now && now - p.paidAt <= windowMs && p.amount > 0); + return { count: recent.length, uniquePayers: new Set(recent.map((p) => p.payer)).size }; +}