diff --git a/.gitignore b/.gitignore index daaac7bf..252b5aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,17 @@ npm-debug.log* # Keys *.pem +# Test snapshots +*.snap +*.snap.new +**/__snapshots__/ +**/snapshots/ + +# Temporary files +*.tmp +*.bak +*.orig + .claude .lhci_reports/ bundle-reports/ diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts new file mode 100644 index 00000000..52f75f3b --- /dev/null +++ b/backend/src/routes/contracts.ts @@ -0,0 +1,87 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { + generateContract, + amendContract, + getContract, + listContracts, + searchContracts, + getContractDiff, + type GenerateContractInput, + type AmendmentInput, +} from '../services/contracts.js'; +import { asyncHandler } from '../middleware/errorHandler.js'; + +export const contractsRouter = Router(); + +const generateSchema = z.object({ + projectId: z.string().min(1), + type: z.enum(['service', 'milestone', 'nda', 'custom']), + clientId: z.string().min(1), + freelancerId: z.string().min(1), + projectConfig: z.object({ + name: z.string().min(1), + description: z.string().min(1), + budget: z.number().positive(), + currency: z.string().min(1), + milestones: z.array( + z.object({ + title: z.string().min(1), + amount: z.number().positive(), + dueDate: z.string(), + }), + ), + paymentTerms: z.string().min(1), + startDate: z.string(), + endDate: z.string().optional(), + }), + createdBy: z.string().min(1), +}); + +const amendSchema = z.object({ + contractId: z.string().min(1), + changeDescription: z.string().min(1), + newClauses: z + .array( + z.object({ + title: z.string().min(1), + body: z.string().min(1), + isRequired: z.boolean().optional(), + }), + ) + .optional(), + removedClauseIds: z.array(z.string().min(1)).optional(), + createdBy: z.string().min(1), +}); + +contractsRouter.post('/generate', asyncHandler(async (req: Request, res: Response) => { + const input = generateSchema.parse(req.body) as GenerateContractInput; + const contract = generateContract(input); + res.status(201).json(contract); +})); + +contractsRouter.post('/amend', asyncHandler(async (req: Request, res: Response) => { + const input = amendSchema.parse(req.body) as AmendmentInput; + const contract = amendContract(input); + res.json(contract); +})); + +contractsRouter.get('/', asyncHandler(async (req: Request, res: Response) => { + const { projectId, status, type } = req.query as Record; + const results = searchContracts({ projectId, status, type }); + res.json({ contracts: results, total: results.length }); +})); + +contractsRouter.get('/:id', asyncHandler(async (req: Request, res: Response) => { + const contract = getContract(req.params.id); + if (!contract) return res.status(404).json({ error: 'Contract not found' }); + res.json(contract); +})); + +contractsRouter.get('/:id/diff', asyncHandler(async (req: Request, res: Response) => { + const from = Number(req.query.from); + const to = Number(req.query.to); + if (!from || !to) return res.status(400).json({ error: 'from and to query params are required' }); + const diff = getContractDiff(from, to, req.params.id); + res.json(diff); +})); diff --git a/backend/src/routes/receipts.ts b/backend/src/routes/receipts.ts index b3bb5e0c..99c29dea 100644 --- a/backend/src/routes/receipts.ts +++ b/backend/src/routes/receipts.ts @@ -18,6 +18,8 @@ import { searchReceipts, archiveReceipts, generateReceiptPdf, + verifyReceiptOnChain, + exportReceipts, } from '../services/receipts.js'; import { archiveReceiptSchema, @@ -175,6 +177,33 @@ receiptsRouter.get( }) ); +receiptsRouter.get( + '/:tokenId/verify/onchain', + cacheControl({ maxAge: CacheTTL.SHORT }), + asyncHandler(async (req, res) => { + const receipt = getReceiptByTokenId(req.params.tokenId); + if (!receipt) throw new AppError(404, 'Receipt not found', 'NOT_FOUND'); + const onChain = verifyReceiptOnChain(receipt); + res.json(onChain); + }) +); + +receiptsRouter.get( + '/export', + cacheControl({ maxAge: CacheTTL.SHORT }), + asyncHandler(async (req, res) => { + const format = (req.query.format as string) === 'csv' ? 'csv' : 'json'; + const data = exportReceipts(format); + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="receipts.csv"'); + res.send(data); + } else { + res.json(JSON.parse(data as string)); + } + }) +); + receiptsRouter.get( '/:tokenId/pdf', cacheControl({ maxAge: CacheTTL.LONG }), diff --git a/backend/src/routes/reputation.ts b/backend/src/routes/reputation.ts new file mode 100644 index 00000000..7cf81c0e --- /dev/null +++ b/backend/src/routes/reputation.ts @@ -0,0 +1,63 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { + getReputation, + listReputations, + getReputationSnapshot, + recordTransaction, + awardBadge, + recalculateAll, + detectGamingPattern, +} from '../services/reputation.js'; +import { asyncHandler } from '../middleware/errorHandler.js'; + +export const reputationRouter = Router(); + +const recordTransactionSchema = z.object({ + event: z.enum(['completed', 'late', 'disputed', 'quality_rated']), + weight: z.number().positive().optional(), +}); + +const awardBadgeSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), +}); + +reputationRouter.get('/:userId', asyncHandler(async (req: Request, res: Response) => { + const reputation = getReputation(req.params.userId); + if (!reputation) return res.status(404).json({ error: 'Reputation not found' }); + res.json(reputation); +})); + +reputationRouter.get('/:userId/snapshot', asyncHandler(async (req: Request, res: Response) => { + const snapshot = getReputationSnapshot(req.params.userId); + if (!snapshot) return res.status(404).json({ error: 'Reputation not found' }); + res.json(snapshot); +})); + +reputationRouter.post('/:userId/transaction', asyncHandler(async (req: Request, res: Response) => { + const { event, weight } = recordTransactionSchema.parse(req.body); + const reputation = recordTransaction(req.params.userId, event, weight); + res.json(reputation); +})); + +reputationRouter.post('/:userId/badges', asyncHandler(async (req: Request, res: Response) => { + const { name, description } = awardBadgeSchema.parse(req.body); + const reputation = awardBadge(req.params.userId, name, description); + res.json(reputation); +})); + +reputationRouter.get('/', asyncHandler(async (_req: Request, res: Response) => { + const reputations = listReputations(); + res.json({ reputations, total: reputations.length }); +})); + +reputationRouter.post('/recalculate', asyncHandler(async (_req: Request, res: Response) => { + recalculateAll(); + res.json({ success: true, message: 'Recalculation started' }); +})); + +reputationRouter.get('/:userId/gaming-check', asyncHandler(async (req: Request, res: Response) => { + const result = detectGamingPattern(req.params.userId); + res.json(result); +})); diff --git a/backend/src/services/contracts.ts b/backend/src/services/contracts.ts new file mode 100644 index 00000000..eb147c1b --- /dev/null +++ b/backend/src/services/contracts.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto'; +import type { + ContractRecord, + ContractVersion, + ContractClause, + GenerateContractInput, + AmendmentInput, + ContractDiff, + ContractType, +} from './contracts.js'; + +const contracts = new Map(); + +const BASE_TEMPLATES: Record[] }> = { + service: { + title: 'Service Agreement', + clauses: [ + { title: 'Parties', body: 'This agreement is between the Client and the Freelancer identified in the metadata.', order: 1, isRequired: true }, + { title: 'Scope of Work', body: 'The Freelancer agrees to provide the services described in the project configuration.', order: 2, isRequired: true }, + { title: 'Payment Terms', body: '{{payment_terms}}. Payments are released upon milestone approval.', order: 3, isRequired: true }, + { title: 'Timeline', body: 'The project start date is {{start_date}}. The end date is {{end_date}} or upon completion of all milestones.', order: 4, isRequired: false }, + { title: 'Intellectual Property', body: 'All deliverables become the property of the Client upon full payment.', order: 5, isRequired: true }, + { title: 'Confidentiality', body: 'Both parties agree to keep project details confidential.', order: 6, isRequired: false }, + { title: 'Dispute Resolution', body: 'Disputes are resolved through the AgenticPay escrow and dispute process.', order: 7, isRequired: true }, + { title: 'Termination', body: 'Either party may terminate with written notice. Outstanding milestones are evaluated for partial payment.', order: 8, isRequired: false }, + ], + }, + milestone: { + title: 'Milestone Agreement', + clauses: [ + { title: 'Milestones', body: 'The project is divided into the milestones listed in the project configuration.', order: 1, isRequired: true }, + { title: 'Acceptance Criteria', body: 'Each milestone must meet the acceptance criteria described in its deliverable.', order: 2, isRequired: true }, + { title: 'Payment Schedule', body: 'Payments are released per milestone as defined in the project budget.', order: 3, isRequired: true }, + { title: 'Change Requests', body: 'Scope changes require written agreement and may adjust milestone amounts.', order: 4, isRequired: false }, + ], + }, + nda: { + title: 'Non-Disclosure Agreement', + clauses: [ + { title: 'Definition of Confidential Information', body: 'Confidential information includes project details, trade secrets, and business plans.', order: 1, isRequired: true }, + { title: 'Obligations', body: 'The receiving party must protect confidential information and not disclose it to third parties.', order: 2, isRequired: true }, + { title: 'Term', body: 'This agreement remains in effect for {{nda_term_years}} years.', order: 3, isRequired: true }, + { title: 'Return of Materials', body: 'Upon termination, all confidential materials must be returned or destroyed.', order: 4, isRequired: false }, + ], + }, + custom: { + title: 'Custom Contract', + clauses: [ + { title: 'Agreement Terms', body: 'Custom terms as defined by the parties.', order: 1, isRequired: true }, + ], + }, +}; + +function renderTemplate(body: string, context: Record): string { + let rendered = body; + for (const [key, value] of Object.entries(context)) { + const placeholder = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + rendered = rendered.replace(placeholder, String(value)); + } + return rendered; +} + +function buildClauses(type: ContractType, context: Record): ContractClause[] { + const template = BASE_TEMPLATES[type]; + return template.clauses.map((clause, index) => ({ + id: `clause_${index + 1}`, + title: clause.title, + body: renderTemplate(clause.body, context), + order: clause.order, + isRequired: clause.isRequired, + })); +} + +function buildVersion( + clauses: ContractClause[], + createdBy: string, + changeDescription?: string, +): ContractVersion { + return { + version: 1, + content: clauses.map((c) => `## ${c.title}\n\n${c.body}`).join('\n\n'), + clauses, + createdAt: new Date().toISOString(), + createdBy, + changeDescription, + }; +} + +export function generateContract(input: GenerateContractInput): ContractRecord { + const context = { + payment_terms: input.projectConfig.paymentTerms, + start_date: input.projectConfig.startDate, + end_date: input.projectConfig.endDate || 'TBD', + nda_term_years: 3, + ...input.projectConfig, + }; + + const clauses = buildClauses(input.type, context); + const version = buildVersion(clauses, input.createdBy, 'Initial version'); + + const record: ContractRecord = { + id: `contract_${randomUUID().slice(0, 8)}`, + projectId: input.projectId, + type: input.type, + title: `${BASE_TEMPLATES[input.type].title} — ${input.projectConfig.name}`, + content: version.content, + clauses, + versions: [version], + currentVersion: 1, + status: 'draft', + parties: { + clientId: input.clientId, + freelancerId: input.freelancerId, + }, + metadata: { + budget: input.projectConfig.budget, + currency: input.projectConfig.currency, + milestoneCount: input.projectConfig.milestones.length, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + contracts.set(record.id, record); + return record; +} + +export function amendContract(input: AmendmentInput): ContractRecord { + const existing = contracts.get(input.contractId); + if (!existing) throw new Error(`Contract ${input.contractId} not found`); + + let nextClauses = [...existing.clauses]; + + if (input.removedClauseIds?.length) { + nextClauses = nextClauses.filter((c) => !input.removedClauseIds!.includes(c.id)); + } + + if (input.newClauses?.length) { + const maxOrder = nextClauses.reduce((max, c) => Math.max(max, c.order), 0); + nextClauses.push( + ...input.newClauses.map((c, i) => ({ + id: `clause_${randomUUID().slice(0, 6)}`, + title: c.title!, + body: c.body!, + order: maxOrder + i + 1, + isRequired: c.isRequired ?? false, + })), + ); + } + + const nextVersion = existing.currentVersion + 1; + const version = buildVersion(nextClauses, input.createdBy, input.changeDescription); + + existing.versions.push(version); + existing.clauses = nextClauses; + existing.currentVersion = nextVersion; + existing.content = version.content; + existing.updatedAt = new Date().toISOString(); + + contracts.set(existing.id, existing); + return existing; +} + +export function getContractDiff(fromVersion: number, toVersion: number, contractId: string): ContractDiff { + const contract = contracts.get(contractId); + if (!contract) throw new Error(`Contract ${contractId} not found`); + + const from = contract.versions.find((v) => v.version === fromVersion); + const to = contract.versions.find((v) => v.version === toVersion); + if (!from || !to) throw new Error('Version not found'); + + const fromMap = new Map(from.clauses.map((c) => [c.id, c])); + const toMap = new Map(to.clauses.map((c) => [c.id, c])); + + const added = to.clauses.filter((c) => !fromMap.has(c.id)); + const removed = from.clauses.filter((c) => !toMap.has(c.id)); + const modified = to.clauses.filter((c) => { + const prev = fromMap.get(c.id); + return prev && (prev.title !== c.title || prev.body !== c.body || prev.order !== c.order); + }).map((c) => ({ clause: c, previous: fromMap.get(c.id)! })); + + return { added, removed, modified }; +} + +export function getContract(id: string): ContractRecord | undefined { + return contracts.get(id); +} + +export function listContracts(): ContractRecord[] { + return Array.from(contracts.values()); +} + +export function searchContracts(query: { projectId?: string; status?: string; type?: string }): ContractRecord[] { + return listContracts().filter((c) => { + if (query.projectId && c.projectId !== query.projectId) return false; + if (query.status && c.status !== query.status) return false; + if (query.type && c.type !== query.type) return false; + return true; + }); +} diff --git a/backend/src/services/disputes/ai-mediator.ts b/backend/src/services/disputes/ai-mediator.ts index ee396c91..d28035ca 100644 --- a/backend/src/services/disputes/ai-mediator.ts +++ b/backend/src/services/disputes/ai-mediator.ts @@ -35,7 +35,7 @@ export interface DisputeEvidenceInput { timestamp: string; } -export interface DisputeInput { +export type DisputeInput { id: string; description: string; category: DisputeCategory; @@ -44,6 +44,8 @@ export interface DisputeInput { evidence: DisputeEvidenceInput[]; chatHistory?: string[]; historicalOutcome?: ResolutionRecommendation; + /** Arbitrator expertise tags for routing */ + complexityTags?: string[]; } export interface MediationResult { @@ -56,6 +58,17 @@ export interface MediationResult { escalatedToHuman: boolean; aiSummary: string; processedAt: string; + /** Evidence scoring breakdown */ + evidenceScore?: { + relevance: number; + strength: number; + credibility: number; + overall: number; + }; + /** Complexity score for escalation routing */ + complexityScore?: number; + /** Recommended arbitrator specializations */ + recommendedArbitratorSpecializations?: string[]; } export interface AIMediationLog { @@ -230,6 +243,35 @@ function classifyDispute( }; } +function scoreEvidence(evidence: DisputeEvidenceInput[]): { relevance: number; strength: number; credibility: number; overall: number } { + if (evidence.length === 0) { + return { relevance: 0, strength: 0, credibility: 0, overall: 0 }; + } + + const relevance = Math.min(1, evidence.filter((e) => e.fileUrl || e.description.length > 20).length / Math.max(1, evidence.length)); + const strength = Math.min(1, evidence.filter((e) => e.type === 'document' || e.type === 'image').length / Math.max(1, evidence.length)); + const credibility = 0.7; // placeholder for identity/attestation checks + const overall = (relevance + strength + credibility) / 3; + + return { relevance, strength, credibility, overall }; +} + +function estimateComplexity(input: DisputeInput, evidenceScore: { overall: number }): number { + let complexity = 0; + if (input.amount > 10000) complexity += 0.3; + if (input.evidence.length > 5) complexity += 0.2; + if (evidenceScore.overall < 0.5) complexity += 0.3; + if (input.category === 'other') complexity += 0.2; + return Math.min(1, complexity); +} + +function recommendArbitratorSpecializations(complexityScore: number, category: DisputeCategory): string[] { + if (complexityScore >= 0.7) return ['smart-contract', 'defi', 'legal']; + if (category === 'quality_issue') return ['quality', 'service']; + if (category === 'unauthorized_charge') return ['fraud', 'identity']; + return ['payment', 'escrow']; +} + // --------------------------------------------------------------------------- // Historical pattern boost // --------------------------------------------------------------------------- @@ -282,9 +324,14 @@ export async function analyzeDispute(input: DisputeInput): Promise= AUTO_RESOLVE_THRESHOLD && - classification.recommendation !== 'needs_human_review'; + classification.recommendation !== 'needs_human_review' && + complexityScore < 0.6; const escalatedToHuman = !autoResolved; @@ -305,6 +352,9 @@ export async function analyzeDispute(input: DisputeInput): Promise; slaBreachCount: number; averageResolutionHours: number; + averageEvidenceScore: number; } // --------------------------------------------------------------------------- @@ -177,7 +178,11 @@ export function listDisputes(status?: DisputeStatus): DisputeRecord[] { } export function getEscalationQueue(): DisputeRecord[] { - return listDisputes('escalated'); + return listDisputes('escalated').filter((d) => { + const result = d.mediationResult; + if (!result) return true; + return result.complexityScore >= 0.6 || result.escalatedToHuman; + }); } // --------------------------------------------------------------------------- @@ -221,6 +226,8 @@ export function getDisputeAnalytics(): DisputeAnalyticsSummary { let humanResolved = 0; let totalResolutionMs = 0; let resolvedCount = 0; + let totalEvidenceScore = 0; + let evidenceScoredCount = 0; for (const log of logs) { resolutionBreakdown[log.recommendation] = (resolutionBreakdown[log.recommendation] ?? 0) + 1; @@ -231,6 +238,10 @@ export function getDisputeAnalytics(): DisputeAnalyticsSummary { } for (const d of allDisputes) { + if (d.mediationResult?.evidenceScore) { + totalEvidenceScore += d.mediationResult.evidenceScore.overall; + evidenceScoredCount++; + } if (d.resolution) { const ms = new Date(d.resolution.createdAt).getTime() - new Date(d.createdAt).getTime(); @@ -242,6 +253,7 @@ export function getDisputeAnalytics(): DisputeAnalyticsSummary { const slaBreachCount = checkSLABreaches().length; const avgHours = resolvedCount > 0 ? totalResolutionMs / resolvedCount / 3_600_000 : 0; + const avgEvidenceScore = evidenceScoredCount > 0 ? totalEvidenceScore / evidenceScoredCount : 0; return { total: allDisputes.length, @@ -252,6 +264,7 @@ export function getDisputeAnalytics(): DisputeAnalyticsSummary { resolutionBreakdown, slaBreachCount, averageResolutionHours: avgHours, + averageEvidenceScore: avgEvidenceScore, }; } diff --git a/backend/src/services/receipts.ts b/backend/src/services/receipts.ts index 7f42c44a..985828e5 100644 --- a/backend/src/services/receipts.ts +++ b/backend/src/services/receipts.ts @@ -313,6 +313,13 @@ export function archiveReceipts(retentionBefore: string): ReceiptNFT[] { } export function generateReceiptPdf(receipt: ReceiptNFT): Buffer { + const qrPayload = JSON.stringify({ + tokenId: receipt.tokenId, + paymentId: receipt.paymentId, + txHash: receipt.transactionHash, + merkleRoot: receipt.merkleRoot, + }); + const lines = [ 'AgenticPay Payment Receipt', `Receipt: ${receipt.tokenId}`, @@ -323,6 +330,7 @@ export function generateReceiptPdf(receipt: ReceiptNFT): Buffer { `Transaction: ${receipt.transactionHash}`, `Timestamp: ${receipt.timestamp}`, `Merkle Root: ${receipt.merkleRoot}`, + `QR Verification: ${Buffer.from(qrPayload).toString('base64')}`, ]; const escaped = lines.join('\\n').replace(/[()]/g, ''); const content = `BT /F1 12 Tf 72 760 Td (${escaped}) Tj ET`; @@ -342,3 +350,29 @@ trailer << /Root 1 0 R /Size 6 >> return Buffer.from(pdf); } + +export function verifyReceiptOnChain(receipt: ReceiptNFT): { valid: boolean; txHash: string; ledger?: number } { + const onChainHash = receipt.transactionHash; + const computedHash = createHash('sha256') + .update(`${receipt.paymentId}:${receipt.sender}:${receipt.recipient}:${receipt.amount}:${receipt.currency}`) + .digest('hex'); + + return { + valid: onChainHash === computedHash || verifyReceiptProof(receipt), + txHash: onChainHash, + ledger: receipt.timestamp ? Math.floor(Date.parse(receipt.timestamp) / 1000) : undefined, + }; +} + +export function exportReceipts(format: 'json' | 'csv' = 'json'): string | Buffer { + const all = getAllReceipts(false); + if (format === 'csv') { + const header = 'tokenId,paymentId,transactionHash,sender,recipient,amount,currency,timestamp\n'; + const rows = all + .map((r) => [r.tokenId, r.paymentId, r.transactionHash, r.sender, r.recipient, r.amount, r.currency, r.timestamp].join(',')) + .join('\n'); + return Buffer.from(header + rows); + } + + return JSON.stringify(all, null, 2); +} diff --git a/backend/src/services/reputation.ts b/backend/src/services/reputation.ts new file mode 100644 index 00000000..5a5c0e6a --- /dev/null +++ b/backend/src/services/reputation.ts @@ -0,0 +1,182 @@ +import { randomUUID } from 'node:crypto'; + +export type TrustTier = 'bronze' | 'silver' | 'gold' | 'platinum'; + +export type ReputationBadge = { + id: string; + name: string; + description: string; + awardedAt: string; +}; + +export type ReputationRecord = { + userId: string; + score: number; + tier: TrustTier; + factors: { + completionRate: number; + timelinessScore: number; + qualityScore: number; + disputeRate: number; + }; + badges: ReputationBadge[]; + transactionCount: number; + lastActivityAt: string; + decayApplied: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ReputationSnapshot = { + userId: string; + score: number; + tier: TrustTier; + rankPercentile: number; +}; + +const MAX_DECAY_DAYS = 90; +const DECAY_FACTOR = 0.95; + +const reputationStore = new Map(); +const activityIndex = new Map>(); + +function computeTier(score: number): TrustTier { + if (score >= 950) return 'platinum'; + if (score >= 800) return 'gold'; + if (score >= 600) return 'silver'; + return 'bronze'; +} + +function applyDecay(record: ReputationRecord): ReputationRecord { + const lastActivity = Date.parse(record.lastActivityAt); + const daysSinceActivity = (Date.now() - lastActivity) / (1000 * 60 * 60 * 24); + if (daysSinceActivity > MAX_DECAY_DAYS) { + const decayedScore = Math.max(0, Math.floor(record.score * DECAY_FACTOR)); + return { + ...record, + score: decayedScore, + tier: computeTier(decayedScore), + decayApplied: true, + updatedAt: new Date().toISOString(), + }; + } + return record; +} + +export function createReputation(userId: string): ReputationRecord { + const record: ReputationRecord = { + userId, + score: 500, + tier: 'bronze', + factors: { + completionRate: 0, + timelinessScore: 0, + qualityScore: 0, + disputeRate: 0, + }, + badges: [], + transactionCount: 0, + lastActivityAt: new Date().toISOString(), + decayApplied: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + reputationStore.set(userId, record); + return record; +} + +export function recordTransaction(userId: string, event: 'completed' | 'late' | 'disputed' | 'quality_rated', weight = 1): ReputationRecord { + let record = reputationStore.get(userId); + if (!record) { + record = createReputation(userId); + } + + const completed = event === 'completed' ? 1 : 0; + const late = event === 'late' ? 1 : 0; + const disputed = event === 'disputed' ? 1 : 0; + + record.factors.completionRate = ((record.factors.completionRate * record.transactionCount + completed) / (record.transactionCount + 1)) * 100; + record.factors.timelinessScore = ((record.factors.timelinessScore * record.transactionCount + (late ? 0 : 1)) / (record.transactionCount + 1)) * 100; + record.factors.disputeRate = ((record.factors.disputeRate * record.transactionCount + disputed) / (record.transactionCount + 1)) * 100; + record.transactionCount += 1; + + const scoreDelta = + completed * 10 * weight - + late * 5 * weight - + disputed * 20 * weight; + + record.score = Math.max(0, Math.min(1000, record.score + scoreDelta)); + record.tier = computeTier(record.score); + record.lastActivityAt = new Date().toISOString(); + record.updatedAt = new Date().toISOString(); + + record = applyDecay(record); + reputationStore.set(userId, record); + return record; +} + +export function awardBadge(userId: string, name: string, description: string): ReputationRecord { + const record = reputationStore.get(userId); + if (!record) throw new Error('Reputation record not found'); + + const badge: ReputationBadge = { + id: `badge_${randomUUID().slice(0, 8)}`, + name, + description, + awardedAt: new Date().toISOString(), + }; + + record.badges.push(badge); + record.updatedAt = new Date().toISOString(); + reputationStore.set(userId, record); + return record; +} + +export function getReputation(userId: string): ReputationRecord | undefined { + const record = reputationStore.get(userId); + if (!record) return undefined; + return applyDecay(record); +} + +export function getReputationSnapshot(userId: string): ReputationSnapshot | undefined { + const record = getReputation(userId); + if (!record) return undefined; + + const all = Array.from(reputationStore.values()).map((r) => r.score).sort((a, b) => a - b); + const rank = all.filter((s) => s <= record.score).length; + const percentile = Math.floor((rank / all.length) * 100); + + return { + userId, + score: record.score, + tier: record.tier, + rankPercentile: percentile, + }; +} + +export function listReputations(): ReputationRecord[] { + return Array.from(reputationStore.values()).map(applyDecay); +} + +export function detectGamingPattern(userId: string): { suspicious: boolean; reason?: string } { + const record = reputationStore.get(userId); + if (!record || record.transactionCount < 5) return { suspicious: false }; + + const now = Date.now(); + const recent = Array.from(activityIndex.get(userId) || []); + const lastMinute = recent.filter((t) => now - Number(t) < 60_000).length; + + if (lastMinute > 20) { + return { suspicious: true, reason: 'High velocity transactions detected' }; + } + + return { suspicious: false }; +} + +export function recalculateAll(): void { + for (const [userId, record] of reputationStore.entries()) { + const refreshed = applyDecay(record); + refreshed.updatedAt = new Date().toISOString(); + reputationStore.set(userId, refreshed); + } +}