+
+
Certification
+
+ {level === "none" ? "Not certified" : level}
+
+
+
+ {next ? `Requirements for ${next} certification` : "Top certification reached"}
+
+
+
+ {requirements.map((r) => (
+ -
+ {r.met ? "✓" : "○"} {r.label}
+ {r.met ? " (met)" : " (not met)"}
+
+ ))}
+
+
+ );
+}
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 };
+}
From ebdf0c7a3174bd1d8d0141223910c8123119bff3 Mon Sep 17 00:00:00 2001
From: MJigah <56768247+MJigah@users.noreply.github.com>
Date: Tue, 29 Sep 2026 11:20:15 +0100
Subject: [PATCH 2/4] feat(recommendations): add invoice portfolio optimization
recommendations
Closes #853
---
src/__tests__/PortfolioOptimization.test.tsx | 26 ++++
.../recommendations/PortfolioOptimization.tsx | 35 +++++
src/lib/portfolioOptimization.test.ts | 93 +++++++++++++
src/lib/portfolioOptimization.ts | 124 ++++++++++++++++++
4 files changed, 278 insertions(+)
create mode 100644 src/__tests__/PortfolioOptimization.test.tsx
create mode 100644 src/components/recommendations/PortfolioOptimization.tsx
create mode 100644 src/lib/portfolioOptimization.test.ts
create mode 100644 src/lib/portfolioOptimization.ts
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(
+