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
133 changes: 133 additions & 0 deletions __tests__/freelancer-earnings.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
98 changes: 98 additions & 0 deletions app/api/freelancer/earnings/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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 },
)
}
})
16 changes: 16 additions & 0 deletions app/freelancer/earnings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-background">
<EarningsAnalyticsDashboard />
</main>
)
}
6 changes: 6 additions & 0 deletions components/dashboard/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
UserCircle,
Settings,
MessageSquare,
Banknote,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
Expand Down Expand Up @@ -42,6 +43,11 @@ const navItems = [
href: '/dashboard/transactions',
icon: History,
},
{
label: 'Earnings',
href: '/freelancer/earnings',
icon: Banknote,
},
{
label: 'Messages',
href: '/dashboard/messages',
Expand Down
Loading
Loading