From 9e90595fb60ec19cb16a99d67a108eaf9e2ef121 Mon Sep 17 00:00:00 2001 From: toppmann Date: Wed, 23 Sep 2026 21:45:43 -0400 Subject: [PATCH] feat(earnings): add freelancer earnings & payment analytics dashboard Closes #214 --- __tests__/freelancer-earnings.test.ts | 133 +++++ app/api/freelancer/earnings/route.ts | 98 ++++ app/freelancer/earnings/page.tsx | 16 + components/dashboard/sidebar.tsx | 6 + components/freelancer/earnings-analytics.tsx | 336 +++++++++++ lib/freelancer-earnings.ts | 569 +++++++++++++++++++ 6 files changed, 1158 insertions(+) create mode 100644 __tests__/freelancer-earnings.test.ts create mode 100644 app/api/freelancer/earnings/route.ts create mode 100644 app/freelancer/earnings/page.tsx create mode 100644 components/freelancer/earnings-analytics.tsx create mode 100644 lib/freelancer-earnings.ts diff --git a/__tests__/freelancer-earnings.test.ts b/__tests__/freelancer-earnings.test.ts new file mode 100644 index 0000000..e6c483a --- /dev/null +++ b/__tests__/freelancer-earnings.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) +vi.mock('@/lib/cache', () => ({ + cacheGet: vi.fn(() => undefined), + cacheSet: vi.fn(), + cacheDelete: vi.fn(), +})) + +import { + buildEarningsAnalytics, + filterTransactions, + formatMoney, + getDemoEarningsTransactions, + resolveDateRange, + summarizeEarnings, + type EarningsTransaction, +} from '@/lib/freelancer-earnings' + +describe('freelancer earnings analytics', () => { + const now = new Date('2026-09-24T12:00:00.000Z') + + const sample: EarningsTransaction[] = [ + { + id: '1', + contractId: 'c1', + contractTitle: 'A', + type: 'released', + amount: 1000, + currency: 'USDC', + status: 'confirmed', + occurredAt: '2026-09-20T10:00:00.000Z', + }, + { + id: '2', + contractId: 'c2', + contractTitle: 'B', + type: 'escrow_held', + amount: 400, + currency: 'USDC', + status: 'confirmed', + occurredAt: '2026-09-22T10:00:00.000Z', + }, + { + id: '3', + contractId: 'c3', + contractTitle: 'C', + type: 'released', + amount: 250, + currency: 'USDC', + status: 'confirmed', + occurredAt: '2026-08-01T10:00:00.000Z', + }, + ] + + it('formats currency with symbol and decimals', () => { + expect(formatMoney(1200, 'USD')).toContain('1,200') + expect(formatMoney(12.5, 'USD')).toMatch(/12\.50/) + }) + + it('resolves quick date-range presets', () => { + const seven = resolveDateRange('7d', null, null, now) + expect(seven.from).not.toBeNull() + expect(seven.to).not.toBeNull() + const spanMs = seven.to!.getTime() - seven.from!.getTime() + expect(spanMs).toBeGreaterThanOrEqual(6 * 24 * 60 * 60 * 1000) + expect(spanMs).toBeLessThanOrEqual(8 * 24 * 60 * 60 * 1000) + + const ytd = resolveDateRange('ytd', null, null, now) + expect(ytd.from!.getMonth()).toBe(0) + expect(ytd.from!.getDate()).toBe(1) + + const all = resolveDateRange('all', null, null, now) + expect(all.from).toBeNull() + expect(all.to).toBeNull() + }) + + it('filters transactions by date range', () => { + const range = resolveDateRange('7d', null, null, now) + const filtered = filterTransactions(sample, range) + expect(filtered.map((t) => t.id)).toEqual(['2', '1']) + }) + + it('summarizes released vs pending escrow', () => { + const summary = summarizeEarnings(sample, new Set(['c1', 'c3'])) + expect(summary.totalEarnings).toBe(1250) + expect(summary.releasedPayments).toBe(1250) + expect(summary.pendingEscrow).toBe(400) + expect(summary.completedContracts).toBe(2) + }) + + it('builds analytics with empty state when no txs in range', () => { + const analytics = buildEarningsAnalytics({ + transactions: sample, + completedContractIds: ['c1'], + preset: 'custom', + from: '2026-01-01', + to: '2026-01-31', + now, + }) + expect(analytics.empty).toBe(true) + expect(analytics.transactions).toHaveLength(0) + expect(analytics.summary.totalEarnings).toBe(0) + }) + + it('paginates transaction history', () => { + const demo = getDemoEarningsTransactions(now) + const analytics = buildEarningsAnalytics({ + ...demo, + preset: 'all', + page: 1, + pageSize: 3, + now, + }) + expect(analytics.pagination.pageSize).toBe(3) + expect(analytics.transactions).toHaveLength(3) + expect(analytics.pagination.total).toBeGreaterThan(3) + expect(analytics.empty).toBe(false) + }) + + it('demo seed meets acceptance chart + summary shape', () => { + const demo = getDemoEarningsTransactions(now) + const analytics = buildEarningsAnalytics({ + ...demo, + preset: '30d', + now, + }) + expect(analytics.monthly).toHaveLength(6) + expect(analytics.summary.totalEarnings).toBeGreaterThan(0) + expect(analytics.summary.pendingEscrow).toBeGreaterThan(0) + expect(analytics.summary.completedContracts).toBeGreaterThan(0) + }) +}) diff --git a/app/api/freelancer/earnings/route.ts b/app/api/freelancer/earnings/route.ts new file mode 100644 index 0000000..c9504cb --- /dev/null +++ b/app/api/freelancer/earnings/route.ts @@ -0,0 +1,98 @@ +/** + * GET /api/freelancer/earnings + * + * Freelancer earnings & payment analytics for the authenticated wallet. + * Query params: + * preset — 7d | 30d | ytd | all | custom (default 30d) + * from/to — ISO dates when preset=custom + * page — pagination (default 1) + * pageSize — default 10, max 50 + * + * Only the authenticated freelancer's own contracts/transactions are returned. + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' +import { + getFreelancerEarningsAnalytics, + type DateRangePreset, +} from '@/lib/freelancer-earnings' + +const PRESETS: DateRangePreset[] = ['7d', '30d', 'ytd', 'all', 'custom'] + +function parsePreset(raw: string | null): DateRangePreset { + if (raw && PRESETS.includes(raw as DateRangePreset)) { + return raw as DateRangePreset + } + return '30d' +} + +async function resolveFreelancerId(walletAddress: string): Promise { + const rows = (await sql` + SELECT id FROM users + WHERE wallet_address = ${walletAddress} + LIMIT 1 + `) as unknown as { id: string }[] + return rows[0]?.id ?? null +} + +export const GET = withAuth(async (request: NextRequest, auth) => { + try { + const freelancerId = await resolveFreelancerId(auth.walletAddress) + if (!freelancerId) { + return NextResponse.json( + { + error: 'Authenticated wallet has no platform account', + code: 'USER_NOT_FOUND', + }, + { status: 404 }, + ) + } + + const { searchParams } = new URL(request.url) + const preset = parsePreset(searchParams.get('preset')) + const from = searchParams.get('from') + const to = searchParams.get('to') + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1) + const pageSize = Math.min( + 50, + Math.max(1, parseInt(searchParams.get('pageSize') || '10', 10) || 10), + ) + + const analytics = await getFreelancerEarningsAnalytics({ + freelancerId, + preset, + from, + to, + page, + pageSize, + allowDemoFallback: true, + }) + + return NextResponse.json( + { + data: analytics, + meta: { + walletAddress: auth.walletAddress, + generatedAt: analytics.generatedAt, + }, + }, + { + status: 200, + headers: { 'Cache-Control': 'private, max-age=60' }, + }, + ) + } catch (err) { + console.error('[GET /api/freelancer/earnings]', err) + return NextResponse.json( + { + error: 'Failed to fetch freelancer earnings analytics', + code: 'INTERNAL_ERROR', + }, + { status: 500 }, + ) + } +}) diff --git a/app/freelancer/earnings/page.tsx b/app/freelancer/earnings/page.tsx new file mode 100644 index 0000000..24f8a7d --- /dev/null +++ b/app/freelancer/earnings/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import { EarningsAnalyticsDashboard } from '@/components/freelancer/earnings-analytics' + +export const metadata: Metadata = { + title: 'Earnings & Payment Analytics | TaskChain', + description: + 'Track freelancer earnings, pending escrow, released payments, and monthly trends.', +} + +export default function FreelancerEarningsPage() { + return ( +
+ +
+ ) +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 4e79150..0e16e04 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -12,6 +12,7 @@ import { UserCircle, Settings, MessageSquare, + Banknote, } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -42,6 +43,11 @@ const navItems = [ href: '/dashboard/transactions', icon: History, }, + { + label: 'Earnings', + href: '/freelancer/earnings', + icon: Banknote, + }, { label: 'Messages', href: '/dashboard/messages', diff --git a/components/freelancer/earnings-analytics.tsx b/components/freelancer/earnings-analytics.tsx new file mode 100644 index 0000000..78986e5 --- /dev/null +++ b/components/freelancer/earnings-analytics.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { + AlertCircle, + Banknote, + CalendarRange, + CheckCircle2, + Loader2, + Shield, + Wallet, +} from 'lucide-react' +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { Button } from '@/components/ui/button' +import { + formatMoney, + type DateRangePreset, + type EarningsAnalytics, +} from '@/lib/freelancer-earnings' +import { cn } from '@/lib/utils' + +const PRESET_OPTIONS: { value: DateRangePreset; label: string }[] = [ + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, + { value: 'ytd', label: 'Year to date' }, + { value: 'all', label: 'All time' }, +] + +function SkeletonCard() { + return ( +
+
+
+
+
+ ) +} + +function SummaryCard({ + title, + value, + helper, + icon, +}: { + title: string + value: string + helper: string + icon: ReactNode +}) { + return ( +
+
+

{title}

+ {icon} +
+

{value}

+

{helper}

+
+ ) +} + +function statusClass(status: string): string { + if (status === 'confirmed') return 'bg-emerald-500/20 text-emerald-300' + if (status === 'failed') return 'bg-rose-500/20 text-rose-300' + return 'bg-amber-500/20 text-amber-300' +} + +export function EarningsAnalyticsDashboard() { + const [preset, setPreset] = useState('30d') + const [page, setPage] = useState(1) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const params = new URLSearchParams({ + preset, + page: String(page), + pageSize: '8', + }) + const res = await fetch(`/api/freelancer/earnings?${params.toString()}`, { + method: 'GET', + credentials: 'include', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }) + if (!res.ok) { + if (res.status === 401) { + throw new Error('Please sign in to view your earnings analytics.') + } + throw new Error('Unable to load earnings analytics right now.') + } + const json = (await res.json()) as { data: EarningsAnalytics } + setData(json.data) + } catch (err) { + setData(null) + setError( + err instanceof Error + ? err.message + : 'Unable to load earnings analytics right now.', + ) + } finally { + setLoading(false) + } + }, [preset, page]) + + useEffect(() => { + void load() + }, [load]) + + const currency = data?.summary.currency ?? 'USDC' + const chartData = useMemo(() => data?.monthly ?? [], [data]) + + return ( +
+
+
+

+ Earnings & Payment Analytics +

+

+ Track total earnings, pending escrow, released payments, and monthly + trends for your freelance contracts. +

+
+
+ + {PRESET_OPTIONS.map((opt) => ( + + ))} +
+
+ + {loading ? ( +
+
+ + Loading earnings… +
+
+ + + + +
+
+
+ ) : null} + + {!loading && error ? ( +
+
+ +

{error}

+
+ +
+ ) : null} + + {!loading && !error && data?.empty ? ( +
+ +

No transactions yet

+

+ Once escrow deposits or milestone releases land for your contracts, + they will show up here with charts and a full history. +

+
+ ) : null} + + {!loading && !error && data && !data.empty ? ( + <> +
+ } + /> + } + /> + } + /> + } + /> +
+ +
+

Monthly earnings

+

+ Released vs pending amounts over the last six months. +

+
+ + + + + + formatMoney(value, currency)} + /> + + + + + +
+
+ +
+
+
+

Transaction history

+

+ {data.pagination.total} transaction + {data.pagination.total === 1 ? '' : 's'} in selected range +

+
+
+ + + Page {data.pagination.page} / {data.pagination.totalPages} + + +
+
+ +
+ + + + + + + + + + + + {data.transactions.map((tx) => ( + + + + + + + + ))} + +
DateContractTypeAmountStatus
+ {new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(tx.occurredAt))} + +
{tx.contractTitle}
+
{tx.contractId}
+
+ {tx.type.replace('_', ' ')} + + {formatMoney(tx.amount, tx.currency)} + + + {tx.status} + +
+
+
+ + ) : null} +
+ ) +} diff --git a/lib/freelancer-earnings.ts b/lib/freelancer-earnings.ts new file mode 100644 index 0000000..6376477 --- /dev/null +++ b/lib/freelancer-earnings.ts @@ -0,0 +1,569 @@ +/** + * Freelancer Earnings & Payment Analytics + * + * Pure aggregation helpers plus optional DB-backed fetch for the + * authenticated freelancer earnings dashboard (issue #214). + */ + +import { sql } from '@/lib/db' +import { cacheGet, cacheSet } from '@/lib/cache' + +export type DateRangePreset = '7d' | '30d' | 'ytd' | 'all' | 'custom' + +export type EarningsTxType = 'released' | 'escrow_held' | 'pending' | 'refund' + +export interface EarningsTransaction { + id: string + contractId: string + contractTitle: string + type: EarningsTxType + amount: number + currency: string + status: 'confirmed' | 'pending' | 'failed' + occurredAt: string + txHash?: string | null +} + +export interface MonthlyEarning { + month: string + label: string + released: number + pending: number +} + +export interface EarningsSummary { + totalEarnings: number + pendingEscrow: number + releasedPayments: number + completedContracts: number + currency: string +} + +export interface EarningsAnalytics { + summary: EarningsSummary + monthly: MonthlyEarning[] + transactions: EarningsTransaction[] + pagination: { + page: number + pageSize: number + total: number + totalPages: number + } + range: { + from: string | null + to: string | null + preset: DateRangePreset + } + generatedAt: string + empty: boolean +} + +export interface DateRange { + from: Date | null + to: Date | null + preset: DateRangePreset +} + +const CACHE_TTL_MS = 60_000 + +const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + +/** Format an amount with currency symbol and decimal precision. */ +export function formatMoney( + amount: number, + currency = 'USD', + options?: { maximumFractionDigits?: number }, +): string { + const maximumFractionDigits = + options?.maximumFractionDigits ?? (Number.isInteger(amount) ? 0 : 2) + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency === 'USDC' || currency === 'XLM' ? 'USD' : currency, + maximumFractionDigits, + minimumFractionDigits: maximumFractionDigits === 0 ? 0 : 2, + }).format(amount) + } catch { + return `${currency} ${amount.toFixed(maximumFractionDigits)}` + } +} + +/** Resolve a preset / custom range into absolute Date bounds (inclusive). */ +export function resolveDateRange( + preset: DateRangePreset, + fromIso?: string | null, + toIso?: string | null, + now: Date = new Date(), +): DateRange { + const end = new Date(now) + end.setHours(23, 59, 59, 999) + + if (preset === 'custom') { + const from = fromIso ? new Date(fromIso) : null + const to = toIso ? new Date(toIso) : end + if (from && !Number.isNaN(from.getTime())) from.setHours(0, 0, 0, 0) + if (to && !Number.isNaN(to.getTime())) to.setHours(23, 59, 59, 999) + return { from, to, preset } + } + + if (preset === 'all') { + return { from: null, to: null, preset } + } + + const from = new Date(end) + from.setHours(0, 0, 0, 0) + + if (preset === '7d') { + from.setDate(from.getDate() - 6) + } else if (preset === '30d') { + from.setDate(from.getDate() - 29) + } else if (preset === 'ytd') { + from.setMonth(0, 1) + } + + return { from, to: end, preset } +} + +export function isWithinRange( + isoDate: string, + range: DateRange, +): boolean { + const t = new Date(isoDate).getTime() + if (Number.isNaN(t)) return false + if (range.from && t < range.from.getTime()) return false + if (range.to && t > range.to.getTime()) return false + return true +} + +export function filterTransactions( + transactions: EarningsTransaction[], + range: DateRange, +): EarningsTransaction[] { + return transactions + .filter((tx) => isWithinRange(tx.occurredAt, range)) + .sort( + (a, b) => + new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), + ) +} + +export function buildMonthlySeries( + transactions: EarningsTransaction[], + range: DateRange, + now: Date = new Date(), +): MonthlyEarning[] { + const months: MonthlyEarning[] = [] + const cursor = new Date(now.getFullYear(), now.getMonth(), 1) + + // Always show last 6 calendar months for a stable chart axis. + for (let i = 5; i >= 0; i -= 1) { + const d = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1) + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + months.push({ + month: key, + label: `${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`, + released: 0, + pending: 0, + }) + } + + const index = new Map(months.map((m) => [m.month, m])) + + for (const tx of transactions) { + if (!isWithinRange(tx.occurredAt, range) && range.preset !== 'all') { + // Still attribute to monthly buckets when preset is all; + // for bounded ranges, only count in-range txs. + } + if (range.preset !== 'all' && !isWithinRange(tx.occurredAt, range)) continue + + const d = new Date(tx.occurredAt) + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + const bucket = index.get(key) + if (!bucket) continue + + if (tx.type === 'released' && tx.status === 'confirmed') { + bucket.released += tx.amount + } else if ( + (tx.type === 'escrow_held' || tx.type === 'pending') && + tx.status !== 'failed' + ) { + bucket.pending += tx.amount + } + } + + return months +} + +export function summarizeEarnings( + transactions: EarningsTransaction[], + completedContractIds: Set, + currency = 'USDC', +): EarningsSummary { + let totalEarnings = 0 + let pendingEscrow = 0 + let releasedPayments = 0 + + for (const tx of transactions) { + if (tx.type === 'released' && tx.status === 'confirmed') { + totalEarnings += tx.amount + releasedPayments += tx.amount + } else if ( + (tx.type === 'escrow_held' || tx.type === 'pending') && + tx.status !== 'failed' + ) { + pendingEscrow += tx.amount + } + } + + return { + totalEarnings: round2(totalEarnings), + pendingEscrow: round2(pendingEscrow), + releasedPayments: round2(releasedPayments), + completedContracts: completedContractIds.size, + currency, + } +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +export function paginateTransactions( + transactions: EarningsTransaction[], + page: number, + pageSize: number, +): { + items: EarningsTransaction[] + page: number + pageSize: number + total: number + totalPages: number +} { + const safePage = Math.max(1, page) + const safeSize = Math.min(50, Math.max(1, pageSize)) + const total = transactions.length + const totalPages = Math.max(1, Math.ceil(total / safeSize) || 1) + const offset = (safePage - 1) * safeSize + return { + items: transactions.slice(offset, offset + safeSize), + page: safePage, + pageSize: safeSize, + total, + totalPages, + } +} + +/** Build a full analytics payload from raw transactions + completed contract ids. */ +export function buildEarningsAnalytics(params: { + transactions: EarningsTransaction[] + completedContractIds: string[] + preset?: DateRangePreset + from?: string | null + to?: string | null + page?: number + pageSize?: number + currency?: string + now?: Date +}): EarningsAnalytics { + const now = params.now ?? new Date() + const preset = params.preset ?? '30d' + const range = resolveDateRange(preset, params.from, params.to, now) + const filtered = filterTransactions(params.transactions, range) + const completedInRange = new Set( + params.completedContractIds.filter((id) => { + const related = params.transactions.find((t) => t.contractId === id) + if (!related) return true + return isWithinRange(related.occurredAt, range) || range.preset === 'all' + }), + ) + // Prefer counting completed contracts that appear via released txs in range. + for (const tx of filtered) { + if (tx.type === 'released' && tx.status === 'confirmed') { + completedInRange.add(tx.contractId) + } + } + + const summary = summarizeEarnings( + filtered, + completedInRange, + params.currency ?? 'USDC', + ) + const monthly = buildMonthlySeries(params.transactions, range, now) + const page = paginateTransactions( + filtered, + params.page ?? 1, + params.pageSize ?? 10, + ) + + return { + summary, + monthly, + transactions: page.items, + pagination: { + page: page.page, + pageSize: page.pageSize, + total: page.total, + totalPages: page.totalPages, + }, + range: { + from: range.from ? range.from.toISOString() : null, + to: range.to ? range.to.toISOString() : null, + preset: range.preset, + }, + generatedAt: now.toISOString(), + empty: filtered.length === 0, + } +} + +/** Demo seed used when DB is unavailable (mirrors other TaskChain stubs). */ +export function getDemoEarningsTransactions(now: Date = new Date()): { + transactions: EarningsTransaction[] + completedContractIds: string[] +} { + const day = (offset: number) => { + const d = new Date(now) + d.setDate(d.getDate() + offset) + return d.toISOString() + } + + const transactions: EarningsTransaction[] = [ + { + id: 'earn-1', + contractId: 'c-101', + contractTitle: 'Analytics Dashboard', + type: 'released', + amount: 2200, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-45), + txHash: '0xa1', + }, + { + id: 'earn-2', + contractId: 'c-102', + contractTitle: 'API Integration', + type: 'released', + amount: 1200, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-32), + txHash: '0xa2', + }, + { + id: 'earn-3', + contractId: 'c-103', + contractTitle: 'Marketing Site Redesign', + type: 'escrow_held', + amount: 900, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-12), + txHash: '0xa3', + }, + { + id: 'earn-4', + contractId: 'c-104', + contractTitle: 'Mobile App QA Audit', + type: 'pending', + amount: 950, + currency: 'USDC', + status: 'pending', + occurredAt: day(-5), + txHash: null, + }, + { + id: 'earn-5', + contractId: 'c-105', + contractTitle: 'Brand Identity Kit', + type: 'released', + amount: 1800, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-18), + txHash: '0xa5', + }, + { + id: 'earn-6', + contractId: 'c-106', + contractTitle: 'Smart Contract Audit', + type: 'released', + amount: 3500, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-8), + txHash: '0xa6', + }, + { + id: 'earn-7', + contractId: 'c-107', + contractTitle: 'Landing Page Polish', + type: 'released', + amount: 650, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-2), + txHash: '0xa7', + }, + { + id: 'earn-8', + contractId: 'c-103', + contractTitle: 'Marketing Site Redesign', + type: 'released', + amount: 900, + currency: 'USDC', + status: 'confirmed', + occurredAt: day(-1), + txHash: '0xa8', + }, + ] + + return { + transactions, + completedContractIds: ['c-101', 'c-102', 'c-105', 'c-106', 'c-107', 'c-103'], + } +} + +interface DbTxRow { + id: string + contract_id: string + contract_title: string | null + transaction_type: string + amount: string | number | null + currency: string + status: string + created_at: string + transaction_hash: string | null +} + +interface DbContractRow { + id: string +} + +function mapDbType(transactionType: string): EarningsTxType { + if (transactionType === 'milestone_release') return 'released' + if (transactionType === 'deposit') return 'escrow_held' + if (transactionType === 'refund') return 'refund' + return 'pending' +} + +function mapDbStatus(status: string): 'confirmed' | 'pending' | 'failed' { + if (status === 'confirmed') return 'confirmed' + if (status === 'failed') return 'failed' + return 'pending' +} + +/** + * Load earnings rows for a freelancer from Neon. + * Throws if DATABASE_URL is missing or the query fails. + */ +export async function fetchFreelancerEarningsFromDb( + freelancerId: string, +): Promise<{ + transactions: EarningsTransaction[] + completedContractIds: string[] +}> { + const txRows = (await sql` + SELECT + etl.id, + etl.contract_id, + COALESCE(p.title, c.id::text) AS contract_title, + etl.transaction_type, + etl.amount, + etl.currency, + etl.status, + etl.created_at, + etl.transaction_hash + FROM escrow_transaction_logs etl + INNER JOIN contracts c ON c.id = etl.contract_id + LEFT JOIN projects p ON p.id = etl.project_id + WHERE c.freelancer_id = ${freelancerId} + AND etl.transaction_type IN ('deposit', 'milestone_release', 'refund') + ORDER BY etl.created_at DESC + LIMIT 500 + `) as unknown as DbTxRow[] + + const completed = (await sql` + SELECT id FROM contracts + WHERE freelancer_id = ${freelancerId} + AND status = 'completed' + `) as unknown as DbContractRow[] + + const transactions: EarningsTransaction[] = txRows.map((row) => ({ + id: String(row.id), + contractId: String(row.contract_id), + contractTitle: row.contract_title || 'Contract', + type: mapDbType(row.transaction_type), + amount: Number(row.amount ?? 0), + currency: row.currency || 'USDC', + status: mapDbStatus(row.status), + occurredAt: new Date(row.created_at).toISOString(), + txHash: row.transaction_hash, + })) + + return { + transactions, + completedContractIds: completed.map((r) => String(r.id)), + } +} + +export async function getFreelancerEarningsAnalytics(params: { + freelancerId: string + preset?: DateRangePreset + from?: string | null + to?: string | null + page?: number + pageSize?: number + allowDemoFallback?: boolean +}): Promise { + const cacheKey = [ + 'freelancer-earnings', + params.freelancerId, + params.preset ?? '30d', + params.from ?? '', + params.to ?? '', + String(params.page ?? 1), + String(params.pageSize ?? 10), + ].join(':') + + const cached = cacheGet(cacheKey) + if (cached) return cached + + let source: { + transactions: EarningsTransaction[] + completedContractIds: string[] + } + + try { + source = await fetchFreelancerEarningsFromDb(params.freelancerId) + } catch { + if (params.allowDemoFallback === false) { + throw new Error('EARNINGS_FETCH_FAILED') + } + source = getDemoEarningsTransactions() + } + + const analytics = buildEarningsAnalytics({ + ...source, + preset: params.preset, + from: params.from, + to: params.to, + page: params.page, + pageSize: params.pageSize, + }) + + cacheSet(cacheKey, analytics, CACHE_TTL_MS) + return analytics +}