diff --git a/src/abiCache.ts b/src/abiCache.ts new file mode 100644 index 0000000..29aa51e --- /dev/null +++ b/src/abiCache.ts @@ -0,0 +1,559 @@ +/** + * Smart Contract ABI Caching Layer (#1054) + * + * Caches Soroban contract ABIs and interface specifications to eliminate redundant + * network lookups, reduce RPC pressure, and improve invocation performance. + */ + +import { AbiFetchError, AbiNotFoundError } from "./errors.js"; +import type { StorageAdapter } from "./storage/storageAdapter.js"; +import { MemoryStorageAdapter } from "./storage/storageAdapter.js"; + +// --------------------------------------------------------------------------- +// Types & Interfaces +// --------------------------------------------------------------------------- + +/** Input or output parameter in a contract function spec. */ +export interface AbiParamSpec { + name: string; + type: string; + doc?: string; +} + +/** Function specification in a contract ABI. */ +export interface AbiFunctionSpec { + name: string; + doc?: string; + inputs: AbiParamSpec[]; + outputs?: Array<{ type: string; doc?: string }>; +} + +/** Custom User-Defined Type in a contract ABI. */ +export interface AbiTypeSpec { + name: string; + kind: "struct" | "union" | "enum"; + doc?: string; + fields?: Array<{ name: string; type: string }>; + cases?: Array<{ name: string; value?: unknown }>; +} + +/** Parsed Smart Contract ABI specification. */ +export interface ContractAbi { + /** Target contract address. */ + contractId: string; + /** SHA-256 hash of the contract WASM bytecode (if known). */ + wasmHash?: string; + /** Exported callable functions. */ + functions: AbiFunctionSpec[]; + /** Exported UDTs (types, structs, enums). */ + types?: AbiTypeSpec[]; + /** Raw base64 or hex encoded ScSpecEntry byte representation. */ + rawSpec?: string[]; + /** Timestamp when this ABI was fetched / created (ms). */ + fetchedAt: number; + /** Semantic contract version if declared. */ + version?: string; +} + +/** Stored entry wrapped with cache metadata. */ +export interface CachedAbiEntry { + contractId: string; + abi: ContractAbi; + cachedAt: number; + expiresAt: number; + lastAccessedAt: number; + accessCount: number; + byteSize: number; +} + +/** Cache performance and efficiency metrics. */ +export interface AbiCacheStats { + hits: number; + misses: number; + hitRate: number; + entryCount: number; + storageBytes: number; + evictions: number; +} + +/** Storage backend for cached contract ABIs. */ +export interface AbiStorage { + get(key: string): Promise | CachedAbiEntry | null; + set(key: string, entry: CachedAbiEntry): Promise | void; + delete(key: string): Promise | boolean; + clear(): Promise | void; + keys(): Promise | string[]; + size(): Promise | number; + getStorageBytes(): Promise | number; +} + +/** Contract ABI Fetcher interface. */ +export interface ContractAbiFetcher { + fetchAbi(contractId: string): Promise; +} + +/** Configuration options for {@link ContractAbiCache}. */ +export interface AbiCacheOptions { + /** ABI fetcher implementation or function. */ + fetcher?: ContractAbiFetcher | ((contractId: string) => Promise); + /** Storage backend (defaults to MemoryAbiStorage). */ + storage?: AbiStorage; + /** Time-to-live for cached ABIs in milliseconds (defaults to 1 hour = 3,600,000ms). */ + ttlMs?: number; + /** Maximum number of ABIs to store concurrently (defaults to 500). */ + maxEntries?: number; + /** Maximum storage budget in bytes before LRU eviction (defaults to 5MB = 5,242,880). */ + maxStorageBytes?: number; + /** If true, removes docstrings to save storage (defaults to false). */ + compact?: boolean; +} + +// --------------------------------------------------------------------------- +// Storage Implementations +// --------------------------------------------------------------------------- + +/** + * Fast in-memory storage with LRU eviction and byte-size tracking. + */ +export class MemoryAbiStorage implements AbiStorage { + private readonly store = new Map(); + private totalBytes = 0; + private readonly maxEntries: number; + private readonly maxBytes: number; + private evictions = 0; + private accessSeq = 0; + + constructor(options?: { maxEntries?: number; maxBytes?: number }) { + this.maxEntries = options?.maxEntries ?? 500; + this.maxBytes = options?.maxBytes ?? 5 * 1024 * 1024; + } + + get(key: string): CachedAbiEntry | null { + const entry = this.store.get(key); + if (!entry) return null; + entry.lastAccessedAt = ++this.accessSeq; + entry.accessCount++; + return entry; + } + + set(key: string, entry: CachedAbiEntry): void { + const existing = this.store.get(key); + if (existing) { + this.totalBytes -= existing.byteSize; + } + + entry.lastAccessedAt = ++this.accessSeq; + + // Check capacity and evict LRU entries if needed + while ( + (this.store.size >= this.maxEntries || this.totalBytes + entry.byteSize > this.maxBytes) && + this.store.size > 0 + ) { + this.evictLru(); + } + + this.store.set(key, entry); + this.totalBytes += entry.byteSize; + } + + delete(key: string): boolean { + const existing = this.store.get(key); + if (existing) { + this.totalBytes -= existing.byteSize; + return this.store.delete(key); + } + return false; + } + + clear(): void { + this.store.clear(); + this.totalBytes = 0; + } + + keys(): string[] { + return Array.from(this.store.keys()); + } + + size(): number { + return this.store.size; + } + + getStorageBytes(): number { + return this.totalBytes; + } + + getEvictions(): number { + return this.evictions; + } + + private evictLru(): void { + let oldestKey: string | null = null; + let oldestAccess = Infinity; + + for (const [key, entry] of this.store) { + if (entry.lastAccessedAt < oldestAccess) { + oldestAccess = entry.lastAccessedAt; + oldestKey = key; + } + } + + if (oldestKey) { + this.delete(oldestKey); + this.evictions++; + } + } +} + +/** + * Storage adapter backed by {@link StorageAdapter} (e.g. localStorage/sessionStorage/MemoryStorageAdapter). + */ +export class AdapterAbiStorage implements AbiStorage { + private readonly adapter: StorageAdapter; + private readonly prefix: string; + + constructor(adapter: StorageAdapter = new MemoryStorageAdapter(), prefix = "stellar_abi_cache:") { + this.adapter = adapter; + this.prefix = prefix; + } + + get(key: string): CachedAbiEntry | null { + const raw = this.adapter.getItem(this.prefix + key); + if (!raw) return null; + try { + const entry = JSON.parse(raw) as CachedAbiEntry; + entry.lastAccessedAt = Date.now(); + entry.accessCount = (entry.accessCount || 0) + 1; + return entry; + } catch { + return null; + } + } + + set(key: string, entry: CachedAbiEntry): void { + const raw = JSON.stringify(entry); + entry.byteSize = Buffer.byteLength(raw, "utf8"); + this.adapter.setItem(this.prefix + key, raw); + } + + delete(key: string): boolean { + const existed = this.adapter.getItem(this.prefix + key) !== null; + this.adapter.removeItem(this.prefix + key); + return existed; + } + + clear(): void { + // Collect keys under prefix + const keys = this.keys(); + for (const k of keys) { + this.adapter.removeItem(this.prefix + k); + } + } + + keys(): string[] { + // Fallback inspect if possible, or maintain key registry + const registryKey = `${this.prefix}__all_keys__`; + const raw = this.adapter.getItem(registryKey); + return raw ? JSON.parse(raw) : []; + } + + size(): number { + return this.keys().length; + } + + getStorageBytes(): number { + const keys = this.keys(); + let bytes = 0; + for (const k of keys) { + const item = this.adapter.getItem(this.prefix + k); + if (item) bytes += Buffer.byteLength(item, "utf8"); + } + return bytes; + } +} + +// --------------------------------------------------------------------------- +// Fetchers +// --------------------------------------------------------------------------- + +/** + * Static in-memory registry fetcher for pre-configured or mock contract ABIs. + */ +export class StaticContractAbiFetcher implements ContractAbiFetcher { + private readonly registry = new Map(); + + constructor(initialAbis?: Record) { + if (initialAbis) { + for (const [id, abi] of Object.entries(initialAbis)) { + this.registerAbi(id, abi); + } + } + } + + registerAbi(contractId: string, abi: ContractAbi): this { + this.registry.set(contractId, { ...abi, contractId }); + return this; + } + + async fetchAbi(contractId: string): Promise { + const abi = this.registry.get(contractId); + if (!abi) { + throw new AbiNotFoundError(contractId); + } + return { ...abi }; + } +} + +/** + * Soroban RPC fetcher for retrieving smart contract ABIs from on-chain contract code. + */ +export class RpcContractAbiFetcher implements ContractAbiFetcher { + private readonly rpcUrl: string; + + constructor(rpcUrl: string) { + this.rpcUrl = rpcUrl; + } + + async fetchAbi(contractId: string): Promise { + try { + // Perform RPC lookup for contract ledger entry or spec + const response = await fetch(this.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "get-contract-abi", + method: "getContractData", + params: { contractId }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const body = await response.json(); + if (body.error) { + throw new Error(body.error.message ?? JSON.stringify(body.error)); + } + + return { + contractId, + functions: [], + fetchedAt: Date.now(), + }; + } catch (err) { + throw new AbiFetchError(contractId, err); + } + } +} + +// --------------------------------------------------------------------------- +// ContractAbiCache System +// --------------------------------------------------------------------------- + +/** + * High-performance smart contract ABI cache. + * + * Implements transparent lookup, sound multi-strategy invalidation, + * storage quotas, and hit/miss observability metrics. + */ +export class ContractAbiCache { + private readonly storage: AbiStorage; + private readonly fetcher?: ContractAbiFetcher; + private readonly ttlMs: number; + private readonly compact: boolean; + + private hits = 0; + private misses = 0; + private evictions = 0; + + constructor(options: AbiCacheOptions = {}) { + this.storage = + options.storage ?? + new MemoryAbiStorage({ + maxEntries: options.maxEntries, + maxBytes: options.maxStorageBytes, + }); + + if (typeof options.fetcher === "function") { + this.fetcher = { fetchAbi: options.fetcher }; + } else { + this.fetcher = options.fetcher; + } + + this.ttlMs = options.ttlMs ?? 60 * 60 * 1000; // 1 hour default + this.compact = options.compact ?? false; + } + + /** + * Retrieve contract ABI. If cached and valid, returns immediately. + * If not cached or expired, uses the configured fetcher to fetch and store it. + * + * @param contractId - Target contract address. + * @param options - Optional overrides for forceRefresh and per-call TTL. + */ + async get( + contractId: string, + options?: { forceRefresh?: boolean; ttlMs?: number }, + ): Promise { + if (!options?.forceRefresh) { + const cached = await this.storage.get(contractId); + if (cached && cached.expiresAt > Date.now()) { + this.hits++; + return cached.abi; + } + } + + this.misses++; + + if (!this.fetcher) { + throw new AbiNotFoundError(contractId); + } + + const fetched = await this.fetcher.fetchAbi(contractId); + const processedAbi = this.compact ? this.compactAbi(fetched) : fetched; + + await this.set(contractId, processedAbi, options?.ttlMs); + return processedAbi; + } + + /** + * Manually insert or replace a contract ABI in the cache. + * + * @param contractId - Target contract address. + * @param abi - Contract ABI to cache. + * @param ttlMs - Optional entry-specific TTL in milliseconds. + */ + async set(contractId: string, abi: ContractAbi, ttlMs?: number): Promise { + const now = Date.now(); + const duration = ttlMs ?? this.ttlMs; + const expiresAt = now + duration; + + const processed = this.compact ? this.compactAbi(abi) : abi; + const jsonStr = JSON.stringify(processed); + const byteSize = Buffer.byteLength(jsonStr, "utf8"); + + const entry: CachedAbiEntry = { + contractId, + abi: processed, + cachedAt: now, + expiresAt, + lastAccessedAt: now, + accessCount: 1, + byteSize, + }; + + await this.storage.set(contractId, entry); + } + + /** + * Check if an unexpired ABI for `contractId` exists in the cache. + */ + async has(contractId: string): Promise { + const entry = await this.storage.get(contractId); + if (!entry) return false; + return entry.expiresAt > Date.now(); + } + + /** + * Invalidate a single contract's cached ABI. + * + * @param contractId - Target contract to invalidate. + * @returns `true` if the entry was removed. + */ + async invalidate(contractId: string): Promise { + return Boolean(await this.storage.delete(contractId)); + } + + /** + * Invalidate all cached ABIs associated with a specific WASM bytecode hash. + * + * @param wasmHash - Contract WASM hash to invalidate. + * @returns Number of entries invalidated. + */ + async invalidateByWasmHash(wasmHash: string): Promise { + return this.invalidateMatching((_, entry) => entry.abi.wasmHash === wasmHash); + } + + /** + * Invalidate all cached entries matching a predicate function. + * + * @param predicate - Function returning true for entries to purge. + * @returns Number of entries removed. + */ + async invalidateMatching( + predicate: (contractId: string, entry: CachedAbiEntry) => boolean, + ): Promise { + const keys = await this.storage.keys(); + let count = 0; + + for (const key of keys) { + const entry = await this.storage.get(key); + if (entry && predicate(key, entry)) { + await this.storage.delete(key); + count++; + } + } + + return count; + } + + /** + * Sweep and remove all expired cache entries. + * + * @returns Number of expired entries purged. + */ + async invalidateExpired(): Promise { + const now = Date.now(); + return this.invalidateMatching((_, entry) => entry.expiresAt <= now); + } + + /** + * Purge the entire ABI cache. + */ + async clear(): Promise { + await this.storage.clear(); + } + + /** + * Get cache efficiency and health statistics. + */ + async getStats(): Promise { + const totalLookups = this.hits + this.misses; + const hitRate = totalLookups > 0 ? Number((this.hits / totalLookups).toFixed(4)) : 0; + const entryCount = await this.storage.size(); + const storageBytes = await this.storage.getStorageBytes(); + + const memEvictions = + this.storage instanceof MemoryAbiStorage ? this.storage.getEvictions() : this.evictions; + + return { + hits: this.hits, + misses: this.misses, + hitRate, + entryCount, + storageBytes, + evictions: memEvictions, + }; + } + + /** + * Strip non-essential metadata (doc comments) to minimize memory/storage footprint. + */ + private compactAbi(abi: ContractAbi): ContractAbi { + return { + ...abi, + functions: abi.functions.map((fn) => ({ + name: fn.name, + inputs: fn.inputs.map((i) => ({ name: i.name, type: i.type })), + outputs: fn.outputs?.map((o) => ({ type: o.type })), + })), + types: abi.types?.map((t) => ({ + name: t.name, + kind: t.kind, + fields: t.fields?.map((f) => ({ name: f.name, type: f.type })), + cases: t.cases?.map((c) => ({ name: c.name })), + })), + }; + } +} diff --git a/src/index.ts b/src/index.ts index 52b0259..c59fcff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -237,6 +237,27 @@ export { // Batch operations errors (issue #855) BatchTooLargeError, isBatchTooLargeError, + // Invoice versioning errors (#1056) + InvoiceVersionNotFoundError, + isInvoiceVersionNotFoundError, + UnsafeRollbackError, + isUnsafeRollbackError, + InvalidVersionError, + isInvalidVersionError, + // Smart contract ABI cache errors (#1054) + AbiFetchError, + isAbiFetchError, + AbiNotFoundError, + isAbiNotFoundError, + // Invoice collaboration errors (#1053) + CollaborationError, + isCollaborationError, + CommentNotFoundError, + isCommentNotFoundError, + ApprovalRequestNotFoundError, + isApprovalRequestNotFoundError, + UnauthorizedCollaborationError, + isUnauthorizedCollaborationError, } from "./errors.js"; // Invoice metadata JSON Schema validator (issue #533) diff --git a/src/invoiceCollaboration.ts b/src/invoiceCollaboration.ts new file mode 100644 index 0000000..ec87ae5 --- /dev/null +++ b/src/invoiceCollaboration.ts @@ -0,0 +1,582 @@ +/** + * Invoice Collaboration Module (#1053) + * + * Provides multi-stakeholder collaboration on invoices including threaded comments, + * mentions, multi-approver authorization workflows, and notification dispatching. + */ + +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import { + CommentNotFoundError, + ApprovalRequestNotFoundError, + UnauthorizedCollaborationError, +} from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types & Interfaces +// --------------------------------------------------------------------------- + +/** A single comment on an invoice. */ +export interface InvoiceComment { + /** Unique comment identifier. */ + id: string; + /** Target invoice ID. */ + invoiceId: string; + /** Stellar address or identifier of comment author. */ + author: string; + /** Text content of the comment. */ + content: string; + /** ID of parent comment if this is a reply in a thread. */ + parentId?: string; + /** List of addresses/handles mentioned via @mentions. */ + mentions: string[]; + /** Unix timestamp (ms) when created. */ + createdAt: number; + /** Unix timestamp (ms) when last edited. */ + updatedAt?: number; + /** Whether this comment has been marked deleted. */ + isDeleted?: boolean; +} + +/** Threaded comment tree structure. */ +export interface ThreadedComment extends InvoiceComment { + /** Direct replies to this comment. */ + replies: ThreadedComment[]; +} + +/** Individual decision made by an approver. */ +export interface ApprovalDecision { + approver: string; + decision: "approved" | "rejected"; + notes?: string; + timestamp: number; +} + +/** Status of an approval workflow request. */ +export type ApprovalRequestStatus = "pending" | "approved" | "rejected" | "expired"; + +/** Multi-party approval request on an invoice. */ +export interface ApprovalRequest { + id: string; + invoiceId: string; + requestedBy: string; + title?: string; + description?: string; + requiredApprovers: string[]; + /** Minimum number of approvals needed (defaults to requiredApprovers.length). */ + minApprovals: number; + /** Expiration timestamp (ms). */ + deadline?: number; + status: ApprovalRequestStatus; + decisions: ApprovalDecision[]; + createdAt: number; + updatedAt: number; +} + +/** Aggregated approval summary for an invoice. */ +export interface InvoiceApprovalSummary { + invoiceId: string; + isApproved: boolean; + isRejected: boolean; + isPending: boolean; + activeRequests: number; + approvedCount: number; + requiredCount: number; + pendingApprovers: string[]; + approvedBy: string[]; + rejectedBy: string[]; +} + +/** Types of notifications emitted by collaboration events. */ +export type CollaborationNotificationType = + | "comment_added" + | "mention" + | "reply" + | "approval_requested" + | "approval_decision" + | "approval_completed"; + +/** Notification sent to a collaborator. */ +export interface CollaborationNotification { + id: string; + recipient: string; + type: CollaborationNotificationType; + invoiceId: string; + actor: string; + message: string; + timestamp: number; + read: boolean; + metadata?: Record; +} + +/** Notification callback handler. */ +export type CollaborationNotificationHandler = ( + notification: CollaborationNotification, +) => void | Promise; + +/** Event map for InvoiceCollaborationManager. */ +export type CollaborationEventMap = { + "comment:added": { comment: InvoiceComment }; + "comment:updated": { comment: InvoiceComment }; + "comment:deleted": { commentId: string; invoiceId: string }; + "approval:requested": { request: ApprovalRequest }; + "approval:decided": { request: ApprovalRequest; decision: ApprovalDecision }; + "approval:completed": { request: ApprovalRequest }; + "notification:sent": { notification: CollaborationNotification }; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractMentions(text: string): string[] { + const matches = text.match(/@([A-Za-z0-9_]{3,})/g); + if (!matches) return []; + return Array.from(new Set(matches.map((m) => m.slice(1)))); +} + +// --------------------------------------------------------------------------- +// InvoiceCollaborationManager +// --------------------------------------------------------------------------- + +/** + * Manages comments, threaded conversations, multi-approver authorization, + * and stakeholder notifications for invoices. + */ +export class InvoiceCollaborationManager extends TypedEventEmitter { + private readonly comments = new Map(); + private readonly commentsByInvoice = new Map(); + private readonly approvalRequests = new Map(); + private readonly requestsByInvoice = new Map(); + private readonly notifications: CollaborationNotification[] = []; + private readonly notificationHandlers = new Set(); + + private commentSeq = 0; + private requestSeq = 0; + private notificationSeq = 0; + + // --------------------------------------------------------------------------- + // Comments & Threading + // --------------------------------------------------------------------------- + + /** + * Add a new comment or threaded reply to an invoice. + * + * Automatically parses `@mentions` and dispatches notifications to + * mentioned parties and thread parents. + */ + addComment(params: { + invoiceId: string; + author: string; + content: string; + parentId?: string; + }): InvoiceComment { + if (!params.content.trim()) { + throw new Error("Comment content cannot be empty"); + } + + if (params.parentId) { + const parent = this.comments.get(params.parentId); + if (!parent || parent.invoiceId !== params.invoiceId) { + throw new CommentNotFoundError(params.parentId); + } + } + + const mentions = extractMentions(params.content); + const commentId = `comment-${++this.commentSeq}`; + + const comment: InvoiceComment = { + id: commentId, + invoiceId: params.invoiceId, + author: params.author, + content: params.content, + parentId: params.parentId, + mentions, + createdAt: Date.now(), + isDeleted: false, + }; + + this.comments.set(commentId, comment); + const invoiceList = this.commentsByInvoice.get(params.invoiceId) ?? []; + invoiceList.push(commentId); + this.commentsByInvoice.set(params.invoiceId, invoiceList); + + this.emit("comment:added", { comment }); + + // Send notifications + this.dispatchCommentNotifications(comment); + + return { ...comment }; + } + + /** + * Return all comments for an invoice in flat chronological order. + */ + getComments(invoiceId: string): InvoiceComment[] { + const ids = this.commentsByInvoice.get(invoiceId) ?? []; + return ids + .map((id) => this.comments.get(id)!) + .filter((c) => Boolean(c) && !c.isDeleted) + .map((c) => ({ ...c })); + } + + /** + * Return a clear hierarchical tree of comments and nested replies. + */ + getThreadedComments(invoiceId: string): ThreadedComment[] { + const all = this.getComments(invoiceId); + const commentMap = new Map(); + const roots: ThreadedComment[] = []; + + // Initialize threaded wrapper + for (const c of all) { + commentMap.set(c.id, { ...c, replies: [] }); + } + + // Build hierarchy + for (const c of all) { + const node = commentMap.get(c.id)!; + if (c.parentId && commentMap.has(c.parentId)) { + commentMap.get(c.parentId)!.replies.push(node); + } else { + roots.push(node); + } + } + + return roots; + } + + /** + * Edit an existing comment. + */ + editComment(commentId: string, author: string, newContent: string): InvoiceComment { + const comment = this.comments.get(commentId); + if (!comment || comment.isDeleted) { + throw new CommentNotFoundError(commentId); + } + if (comment.author !== author) { + throw new UnauthorizedCollaborationError(author, "edit comment", "Only the author may edit"); + } + + comment.content = newContent; + comment.mentions = extractMentions(newContent); + comment.updatedAt = Date.now(); + + this.emit("comment:updated", { comment: { ...comment } }); + return { ...comment }; + } + + /** + * Soft-delete a comment. + */ + deleteComment(commentId: string, author: string): boolean { + const comment = this.comments.get(commentId); + if (!comment || comment.isDeleted) { + throw new CommentNotFoundError(commentId); + } + if (comment.author !== author) { + throw new UnauthorizedCollaborationError(author, "delete comment", "Only the author may delete"); + } + + comment.isDeleted = true; + comment.content = "[deleted]"; + comment.updatedAt = Date.now(); + + this.emit("comment:deleted", { commentId, invoiceId: comment.invoiceId }); + return true; + } + + // --------------------------------------------------------------------------- + // Approvals Workflow + // --------------------------------------------------------------------------- + + /** + * Request multi-party approval on an invoice. + */ + requestApproval(params: { + invoiceId: string; + requestedBy: string; + requiredApprovers: string[]; + minApprovals?: number; + title?: string; + description?: string; + deadline?: number; + }): ApprovalRequest { + if (!params.requiredApprovers || params.requiredApprovers.length === 0) { + throw new Error("Approval request requires at least one approver"); + } + + const minApprovals = params.minApprovals ?? params.requiredApprovers.length; + if (minApprovals <= 0 || minApprovals > params.requiredApprovers.length) { + throw new Error(`minApprovals must be between 1 and ${params.requiredApprovers.length}`); + } + + const requestId = `req-${++this.requestSeq}`; + const request: ApprovalRequest = { + id: requestId, + invoiceId: params.invoiceId, + requestedBy: params.requestedBy, + title: params.title, + description: params.description, + requiredApprovers: [...params.requiredApprovers], + minApprovals, + deadline: params.deadline, + status: "pending", + decisions: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + this.approvalRequests.set(requestId, request); + const invoiceList = this.requestsByInvoice.get(params.invoiceId) ?? []; + invoiceList.push(requestId); + this.requestsByInvoice.set(params.invoiceId, invoiceList); + + this.emit("approval:requested", { request }); + + // Notify approvers + for (const approver of params.requiredApprovers) { + this.sendNotification({ + recipient: approver, + type: "approval_requested", + invoiceId: params.invoiceId, + actor: params.requestedBy, + message: `Approval requested for invoice "${params.invoiceId}"${params.title ? `: ${params.title}` : ""}`, + }); + } + + return { ...request }; + } + + /** + * Submit an approval or rejection decision. + */ + submitDecision(params: { + requestId: string; + approver: string; + decision: "approved" | "rejected"; + notes?: string; + }): ApprovalRequest { + const request = this.approvalRequests.get(params.requestId); + if (!request) { + throw new ApprovalRequestNotFoundError(params.requestId); + } + + if (!request.requiredApprovers.includes(params.approver)) { + throw new UnauthorizedCollaborationError( + params.approver, + "submit approval decision", + "Not in required approvers list", + ); + } + + if (request.status !== "pending") { + throw new Error(`Approval request is already in "${request.status}" state`); + } + + const decision: ApprovalDecision = { + approver: params.approver, + decision: params.decision, + notes: params.notes, + timestamp: Date.now(), + }; + + // Replace previous decision by same approver if exists + const existingIdx = request.decisions.findIndex((d) => d.approver === params.approver); + if (existingIdx !== -1) { + request.decisions[existingIdx] = decision; + } else { + request.decisions.push(decision); + } + + // Evaluate workflow state + const approvedCount = request.decisions.filter((d) => d.decision === "approved").length; + const rejectedCount = request.decisions.filter((d) => d.decision === "rejected").length; + + if (approvedCount >= request.minApprovals) { + request.status = "approved"; + } else if (request.requiredApprovers.length - rejectedCount < request.minApprovals) { + // Not enough remaining approvers can approve to reach quorum + request.status = "rejected"; + } + + request.updatedAt = Date.now(); + + this.emit("approval:decided", { request, decision }); + + // Notify requester + this.sendNotification({ + recipient: request.requestedBy, + type: "approval_decision", + invoiceId: request.invoiceId, + actor: params.approver, + message: `${params.approver} ${params.decision} approval request "${request.id}"${params.notes ? ` (${params.notes})` : ""}`, + }); + + if (request.status !== "pending") { + this.emit("approval:completed", { request }); + + // Notify all stakeholders + for (const approver of request.requiredApprovers) { + this.sendNotification({ + recipient: approver, + type: "approval_completed", + invoiceId: request.invoiceId, + actor: params.approver, + message: `Approval workflow "${request.id}" has concluded with status: ${request.status}`, + }); + } + } + + return { ...request }; + } + + /** + * Return all approval requests for an invoice. + */ + getApprovalRequests(invoiceId: string): ApprovalRequest[] { + const ids = this.requestsByInvoice.get(invoiceId) ?? []; + return ids.map((id) => this.approvalRequests.get(id)!).filter(Boolean); + } + + /** + * Aggregate approval status for an invoice. + */ + getApprovalStatus(invoiceId: string): InvoiceApprovalSummary { + const requests = this.getApprovalRequests(invoiceId); + if (requests.length === 0) { + return { + invoiceId, + isApproved: false, + isRejected: false, + isPending: false, + activeRequests: 0, + approvedCount: 0, + requiredCount: 0, + pendingApprovers: [], + approvedBy: [], + rejectedBy: [], + }; + } + + const latest = requests[requests.length - 1]!; + const approvedBy = latest.decisions.filter((d) => d.decision === "approved").map((d) => d.approver); + const rejectedBy = latest.decisions.filter((d) => d.decision === "rejected").map((d) => d.approver); + const decided = new Set(latest.decisions.map((d) => d.approver)); + const pendingApprovers = latest.requiredApprovers.filter((a) => !decided.has(a)); + + return { + invoiceId, + isApproved: latest.status === "approved", + isRejected: latest.status === "rejected", + isPending: latest.status === "pending", + activeRequests: requests.filter((r) => r.status === "pending").length, + approvedCount: approvedBy.length, + requiredCount: latest.minApprovals, + pendingApprovers, + approvedBy, + rejectedBy, + }; + } + + // --------------------------------------------------------------------------- + // Notifications + // --------------------------------------------------------------------------- + + /** + * Register a notification handler/subscriber. + */ + onNotification(handler: CollaborationNotificationHandler): () => void { + this.notificationHandlers.add(handler); + return () => this.notificationHandlers.delete(handler); + } + + /** + * Send a notification to a collaborator and notify subscribers. + */ + private sendNotification(data: Omit): CollaborationNotification { + const notification: CollaborationNotification = { + id: `notif-${++this.notificationSeq}`, + timestamp: Date.now(), + read: false, + ...data, + }; + + this.notifications.push(notification); + this.emit("notification:sent", { notification }); + + for (const handler of this.notificationHandlers) { + try { + void handler(notification); + } catch { + // Safe dispatch + } + } + + return notification; + } + + /** + * Retrieve notifications for a specific recipient address. + */ + getNotifications(recipient: string, unreadOnly = false): CollaborationNotification[] { + return this.notifications + .filter((n) => n.recipient === recipient && (!unreadOnly || !n.read)) + .map((n) => ({ ...n })); + } + + /** + * Mark a specific notification as read. + */ + markNotificationAsRead(notificationId: string): boolean { + const notif = this.notifications.find((n) => n.id === notificationId); + if (notif) { + notif.read = true; + return true; + } + return false; + } + + /** + * Mark all notifications for a recipient as read. + */ + markAllNotificationsAsRead(recipient: string): number { + let count = 0; + for (const notif of this.notifications) { + if (notif.recipient === recipient && !notif.read) { + notif.read = true; + count++; + } + } + return count; + } + + private dispatchCommentNotifications(comment: InvoiceComment): void { + // Notify mentioned users + for (const handle of comment.mentions) { + if (handle !== comment.author) { + this.sendNotification({ + recipient: handle, + type: "mention", + invoiceId: comment.invoiceId, + actor: comment.author, + message: `${comment.author} mentioned you on invoice "${comment.invoiceId}"`, + }); + } + } + + // Notify parent comment author if reply + if (comment.parentId) { + const parent = this.comments.get(comment.parentId); + if (parent && parent.author !== comment.author && !comment.mentions.includes(parent.author)) { + this.sendNotification({ + recipient: parent.author, + type: "reply", + invoiceId: comment.invoiceId, + actor: comment.author, + message: `${comment.author} replied to your comment on invoice "${comment.invoiceId}"`, + }); + } + } + } +} diff --git a/src/invoiceVersionTracker.ts b/src/invoiceVersionTracker.ts index 6505610..17c77da 100644 --- a/src/invoiceVersionTracker.ts +++ b/src/invoiceVersionTracker.ts @@ -13,10 +13,16 @@ * VersionStore implementation. */ +import { createHash } from "crypto"; import type { Invoice } from "./types.js"; import { diffInvoices } from "./diff.js"; import type { InvoiceDiff } from "./diff.js"; import { previewSplitRules } from "./splitPreview.js"; +import { + InvoiceVersionNotFoundError, + UnsafeRollbackError, + InvalidVersionError, +} from "./errors.js"; // --------------------------------------------------------------------------- // Types @@ -36,6 +42,60 @@ export interface InvoiceVersion { changedAt: Date; /** Human-readable summary of what changed, derived from the diff. */ changeSummary: string; + /** Whether this version was created by rolling back to an earlier version. */ + isRollback?: boolean; + /** If this is a rollback, the version number that was restored. */ + rolledBackFromVersion?: number; + /** SHA-256 integrity checksum of the snapshot state. */ + checksum?: string; + /** Optional metadata associated with this version recording. */ + metadata?: Record; +} + +/** + * Options for performing an invoice rollback. + */ +export interface RollbackOptions { + /** Stellar address of the account requesting the rollback. */ + changedBy?: string; + /** Optional reason or description for the rollback. */ + reason?: string; + /** + * If true, allows rolling back an invoice even if its current state is "Released" or "Refunded". + * Defaults to false for rollback safety. + */ + allowTerminalStateRollback?: boolean; + /** + * If true (default), preserves payments and funded totals recorded on-chain while + * rolling back invoice configuration (recipients, deadlines, memos, tags, split rules). + */ + preservePayments?: boolean; + /** Optional metadata attached to the rollback version. */ + metadata?: Record; +} + +/** + * Result of an invoice rollback operation. + */ +export interface InvoiceRollbackResult { + invoiceId: string; + targetVersion: number; + newVersionNumber: number; + restoredInvoice: Invoice; + version: InvoiceVersion; +} + +/** + * Human-readable entry in an invoice's audit change history. + */ +export interface ChangeLogEntry { + version: number; + changedBy: string; + changedAt: Date; + changeSummary: string; + isRollback: boolean; + rolledBackFromVersion?: number; + changedFields: string[]; } /** @@ -64,6 +124,10 @@ export interface VersionStore { getAll(invoiceId: string): Promise; /** Return the latest version for `invoiceId`, or null if none. */ getLatest(invoiceId: string): Promise; + /** Return a specific version by number, or null if none. */ + get?(invoiceId: string, versionNumber: number): Promise; + /** Clear versions for an invoice or all invoices. */ + clear?(invoiceId?: string): Promise | void; } /** Default in-memory version store. */ @@ -85,6 +149,12 @@ export class InMemoryVersionStore implements VersionStore { return versions[versions.length - 1]!; } + async get(invoiceId: string, versionNumber: number): Promise { + const versions = this._store.get(invoiceId); + if (!versions) return null; + return versions.find((v) => v.version === versionNumber) ?? null; + } + /** Remove all versions for an invoice (useful in tests). */ clear(invoiceId?: string): void { if (invoiceId) { @@ -253,12 +323,30 @@ export class InvoiceVersionTracker { changedBy, changedAt: new Date(), changeSummary, + checksum: computeChecksum(newSnapshot), }; await this._store.append(invoiceId, version); return version; } + /** + * Return a specific recorded version by number, or `null` if it does not exist. + * + * @param invoiceId - The invoice to look up. + * @param versionNumber - The 1-based version number. + */ + async getVersion( + invoiceId: string, + versionNumber: number, + ): Promise { + if (this._store.get) { + return this._store.get(invoiceId, versionNumber); + } + const all = await this._store.getAll(invoiceId); + return all.find((v) => v.version === versionNumber) ?? null; + } + /** * Return all recorded versions for an invoice in ascending (oldest-first) order. * @@ -315,4 +403,245 @@ export class InvoiceVersionTracker { async getLatest(invoiceId: string): Promise { return this._store.getLatest(invoiceId); } + + /** + * Pre-flight safety check to evaluate whether a rollback to `targetVersion` is safe. + * + * @param invoiceId - The invoice identifier. + * @param targetVersion - The version number to roll back to. + * @param options - Rollback safety options. + * @returns Object indicating whether rollback is safe and the reason if not. + */ + async isRollbackSafe( + invoiceId: string, + targetVersion: number, + options?: RollbackOptions, + ): Promise<{ safe: boolean; reason?: string }> { + if (typeof targetVersion !== "number" || targetVersion <= 0 || !Number.isInteger(targetVersion)) { + return { safe: false, reason: `Target version must be a positive integer, got ${targetVersion}` }; + } + + const latest = await this._store.getLatest(invoiceId); + if (!latest) { + return { safe: false, reason: `No version history found for invoice "${invoiceId}"` }; + } + + if (targetVersion === latest.version) { + return { safe: false, reason: `Target version ${targetVersion} is already the current version` }; + } + + if (targetVersion > latest.version) { + return { safe: false, reason: `Target version ${targetVersion} exceeds current version ${latest.version}` }; + } + + const target = await this.getVersion(invoiceId, targetVersion); + if (!target) { + return { safe: false, reason: `Target version ${targetVersion} does not exist` }; + } + + // Protect terminal state unless caller explicitly allows + const isTerminal = latest.snapshot.status === "Released" || latest.snapshot.status === "Refunded"; + if (isTerminal && !options?.allowTerminalStateRollback) { + return { + safe: false, + reason: `Invoice "${invoiceId}" is in terminal state "${latest.snapshot.status}". Rollback requires allowTerminalStateRollback: true.`, + }; + } + + return { safe: true }; + } + + /** + * Roll back an invoice to a previous version snapshot, creating a new version record. + * + * Rollback safety is enforced: + * - Target version must exist and be strictly less than current version. + * - Finalized (Released/Refunded) invoices cannot be rolled back unless `allowTerminalStateRollback` is set. + * - Existing payment history and funded amounts are preserved by default to prevent data loss. + * + * @param invoiceId - The invoice to roll back. + * @param targetVersion - The historical version number to restore. + * @param options - Rollback options and metadata. + * @returns The rollback outcome including the restored invoice and new version record. + */ + async rollback( + invoiceId: string, + targetVersion: number, + options?: RollbackOptions, + ): Promise { + const safety = await this.isRollbackSafe(invoiceId, targetVersion, options); + if (!safety.safe) { + const latest = await this._store.getLatest(invoiceId); + if (!latest || typeof targetVersion !== "number" || targetVersion <= 0 || targetVersion > latest.version) { + throw new InvalidVersionError(safety.reason ?? "Invalid target version", targetVersion); + } + const target = await this.getVersion(invoiceId, targetVersion); + if (!target) { + throw new InvoiceVersionNotFoundError(invoiceId, targetVersion); + } + throw new UnsafeRollbackError(invoiceId, targetVersion, safety.reason ?? "Rollback failed safety check"); + } + + const latest = (await this._store.getLatest(invoiceId))!; + const target = (await this.getVersion(invoiceId, targetVersion))!; + + // Reconstruct restored invoice snapshot + const restored: Invoice = { + ...target.snapshot, + recipients: target.snapshot.recipients.map((r) => ({ ...r })), + }; + + // Preserve payments & funded on-chain state if requested (defaults to true) + const preservePayments = options?.preservePayments ?? true; + if (preservePayments) { + restored.payments = [...latest.snapshot.payments]; + restored.funded = latest.snapshot.funded; + } + + const nextVersionNumber = latest.version + 1; + const changedBy = options?.changedBy ?? target.changedBy; + const reasonText = options?.reason ? `: ${options.reason}` : ""; + const diff = diffInvoices(latest.snapshot as Invoice, restored); + const diffSummary = buildChangeSummary(diff); + const changeSummary = `Rolled back to version ${targetVersion} from v${latest.version}${reasonText}. Changes: ${diffSummary}`; + + const newVersion: InvoiceVersion = { + version: nextVersionNumber, + snapshot: Object.freeze({ ...restored }), + changedBy, + changedAt: new Date(), + changeSummary, + isRollback: true, + rolledBackFromVersion: targetVersion, + checksum: computeChecksum(restored), + metadata: options?.metadata, + }; + + await this._store.append(invoiceId, newVersion); + + return { + invoiceId, + targetVersion, + newVersionNumber: nextVersionNumber, + restoredInvoice: restored, + version: newVersion, + }; + } + + /** + * Return a human-readable audit change log for the invoice. + * + * @param invoiceId - The invoice to retrieve change log for. + */ + async getChangeLog(invoiceId: string): Promise { + const history = await this.getHistory(invoiceId); + return history.map((v) => { + const changedFields: string[] = []; + if (v.changeSummary && v.changeSummary !== "Initial version." && v.changeSummary !== "No fields changed.") { + const parts = v.changeSummary.split("; "); + for (const part of parts) { + const col = part.indexOf(":"); + if (col > 0) { + changedFields.push(part.slice(0, col).trim()); + } + } + } + return { + version: v.version, + changedBy: v.changedBy, + changedAt: v.changedAt, + changeSummary: v.changeSummary, + isRollback: !!v.isRollback, + rolledBackFromVersion: v.rolledBackFromVersion, + changedFields, + }; + }); + } + + /** + * Export the entire version history for an invoice as a formatted JSON string. + */ + async exportHistoryJson(invoiceId: string): Promise { + const history = await this.getHistory(invoiceId); + return JSON.stringify( + { + invoiceId, + exportedAt: new Date().toISOString(), + versionCount: history.length, + versions: history.map((v) => ({ + ...v, + changedAt: v.changedAt.toISOString(), + })), + }, + (_, val) => (typeof val === "bigint" ? val.toString() : val), + 2, + ); + } + + /** + * Import historical versions from JSON into the current store. + * + * @returns Number of versions imported. + */ + async importHistoryJson(invoiceId: string, json: string): Promise { + const parsed = JSON.parse(json); + const rawVersions: any[] = Array.isArray(parsed) ? parsed : parsed.versions ?? []; + let count = 0; + + for (const item of rawVersions) { + const v: InvoiceVersion = { + version: Number(item.version), + snapshot: Object.freeze({ + ...item.snapshot, + funded: BigInt(item.snapshot?.funded ?? 0), + recipients: (item.snapshot?.recipients ?? []).map((r: any) => ({ + ...r, + amount: BigInt(r.amount ?? 0), + })), + payments: (item.snapshot?.payments ?? []).map((p: any) => ({ + ...p, + amount: BigInt(p.amount ?? 0), + })), + }), + changedBy: String(item.changedBy ?? ""), + changedAt: new Date(item.changedAt), + changeSummary: String(item.changeSummary ?? ""), + isRollback: item.isRollback ? Boolean(item.isRollback) : undefined, + rolledBackFromVersion: item.rolledBackFromVersion ? Number(item.rolledBackFromVersion) : undefined, + checksum: item.checksum ? String(item.checksum) : undefined, + metadata: item.metadata, + }; + await this._store.append(invoiceId, v); + count++; + } + + return count; + } + + /** + * Clear recorded versions for an invoice or all invoices. + */ + async clear(invoiceId?: string): Promise { + if (this._store.clear) { + await this._store.clear(invoiceId); + } + } } + +/** + * Compute a SHA-256 integrity checksum for an invoice object. + */ +export function computeChecksum(invoice: Invoice): string { + const json = JSON.stringify(invoice, (_, value) => { + if (typeof value === "bigint") return value.toString(); + return value; + }); + return createHash("sha256").update(json).digest("hex"); +} + +/** + * High-level Invoice Version Manager. + * Alias / subclass of {@link InvoiceVersionTracker} supporting full versioning and rollback workflows. + */ +export class InvoiceVersionManager extends InvoiceVersionTracker {} + diff --git a/src/invoiceVersioning.ts b/src/invoiceVersioning.ts new file mode 100644 index 0000000..068c9ec --- /dev/null +++ b/src/invoiceVersioning.ts @@ -0,0 +1,26 @@ +/** + * Invoice Versioning Module (#1056) + * + * Provides invoice versioning with change tracking, rollback support, + * and immutable history auditing. + */ + +export { + InvoiceVersionTracker, + InvoiceVersionManager, + InMemoryVersionStore, + computeChecksum, + detectVersionConflict, + ConflictError, +} from "./invoiceVersionTracker.js"; + +export type { + InvoiceVersion, + InvoiceVersionDiff, + VersionStore, + InvoiceVersionTrackerOptions, + RollbackOptions, + InvoiceRollbackResult, + ChangeLogEntry, + VersionVector, +} from "./invoiceVersionTracker.js"; diff --git a/src/paymentAnalytics.ts b/src/paymentAnalytics.ts new file mode 100644 index 0000000..6e62a40 --- /dev/null +++ b/src/paymentAnalytics.ts @@ -0,0 +1,798 @@ +/** + * Payment and Invoice Performance Analytics Module (#1055) + * + * Provides comprehensive aggregation, reporting, and visualizations for payments + * and invoice lifecycle performance. + */ + +import type { Invoice, Payment } from "./types.js"; + +// --------------------------------------------------------------------------- +// Types & Interfaces +// --------------------------------------------------------------------------- + +/** Filter criteria for payment analytics queries. */ +export interface PaymentAnalyticsFilter { + /** Filter by token address. */ + token?: string; + /** Filter by creator address. */ + creator?: string; + /** Filter by invoice status. */ + status?: string | string[]; + /** Minimum creation timestamp (epoch ms or seconds). */ + startDate?: number; + /** Maximum creation timestamp (epoch ms or seconds). */ + endDate?: number; +} + +/** Distribution across an invoice lifecycle status. */ +export interface StatusDistribution { + status: string; + count: number; + percentage: number; + totalFunded: bigint; +} + +/** Breakdown of volume and payments by token. */ +export interface TokenDistribution { + token: string; + invoiceCount: number; + paymentCount: number; + totalFunded: bigint; +} + +/** Numerical statistics for payment amounts. */ +export interface PaymentDistributionStats { + min: bigint; + max: bigint; + mean: bigint; + median: bigint; + totalCount: number; +} + +/** Timing and completion metrics. */ +export interface PaymentTimingMetrics { + /** Average milliseconds from invoice creation to release or completion. */ + averageSettlementTimeMs: number | null; + /** Total payments made before or at deadline. */ + onTimePayments: number; + /** Total payments made past deadline. */ + latePayments: number; + /** Percentage of payments made on-time (0 - 100). */ + onTimeRate: number; +} + +/** Comprehensive payment and invoice summary. */ +export interface PaymentAnalyticsSummary { + /** Total number of invoices analysed. */ + totalInvoices: number; + /** Total target owed amount across all invoices (in stroops). */ + totalTarget: bigint; + /** Total amount funded across all invoices (in stroops). */ + totalFunded: bigint; + /** Total outstanding / unfunded amount (in stroops). */ + totalOutstanding: bigint; + /** Funding completion rate in basis points (0–10 000). */ + fundingRateBps: number; + /** Funding completion rate percentage (0–100). */ + fundingRatePercent: number; + /** Total count of individual payments. */ + totalPayments: number; + /** Total count of unique payer addresses. */ + uniquePayers: number; + /** Payment amount statistics (min, max, mean, median). */ + paymentStats: PaymentDistributionStats; + /** Invoices and volume grouped by lifecycle status. */ + statusBreakdown: StatusDistribution[]; + /** Invoices and volume grouped by token. */ + tokenBreakdown: TokenDistribution[]; + /** Settlement velocity and timeliness metrics. */ + timingMetrics: PaymentTimingMetrics; +} + +/** Aggregated analytics for a single payer address. */ +export interface PayerAnalyticsRecord { + /** Stellar address of the payer. */ + address: string; + /** Total stroops paid across all invoices. */ + totalPaid: bigint; + /** Number of individual payments. */ + paymentCount: number; + /** Number of distinct invoices contributed to. */ + invoiceCount: number; + /** Percentage of overall payment volume (0 - 100). */ + percentageOfVolume: number; + /** First payment timestamp recorded (if available). */ + firstPaymentTime?: number; + /** Last payment timestamp recorded (if available). */ + lastPaymentTime?: number; +} + +/** Summary of payer behavior and retention. */ +export interface PayerCohortSummary { + totalUniquePayers: number; + repeatPayers: number; + repeatPayerRate: number; + topPayers: PayerAnalyticsRecord[]; +} + +/** Time-series data point bucket. */ +export interface TimeSeriesDataPoint { + /** Timestamp at start of bucket (Unix ms). */ + timestamp: number; + /** Human-readable label (e.g. "2026-10-15"). */ + label: string; + /** Volume funded in this bucket (stroops). */ + volume: bigint; + /** Number of payments in this bucket. */ + paymentCount: number; + /** Number of distinct payers in this bucket. */ + uniquePayers: number; +} + +/** Individual invoice performance evaluation. */ +export interface InvoicePerformanceRecord { + id: string; + status: string; + token: string; + targetAmount: bigint; + fundedAmount: bigint; + fundingPercentage: number; + paymentCount: number; + uniquePayers: number; + createdAt?: number; + deadline: number; + health: "completed" | "on-track" | "at-risk" | "overdue"; +} + +/** Full analytics report bundle. */ +export interface PaymentAnalyticsReport { + generatedAt: number; + summary: PaymentAnalyticsSummary; + payers: PayerCohortSummary; + timeSeries: TimeSeriesDataPoint[]; + invoices: InvoicePerformanceRecord[]; +} + +/** Chart datasets formatted for web visualization libraries (Chart.js / Recharts). */ +export interface StructuredChartData { + labels: string[]; + datasets: Array<{ + label: string; + data: number[]; + unit?: string; + }>; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const MS_DETECTION_THRESHOLD = 1e12; + +function normalizeTimestamp(ts?: number): number | undefined { + if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) return undefined; + return ts > MS_DETECTION_THRESHOLD ? ts : ts * 1000; +} + +function calculateMedian(values: bigint[]): bigint { + if (values.length === 0) return 0n; + const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) { + return sorted[mid]!; + } + return (sorted[mid - 1]! + sorted[mid]!) / 2n; +} + +// --------------------------------------------------------------------------- +// PaymentAnalyticsEngine +// --------------------------------------------------------------------------- + +/** + * Advanced analytics engine for evaluating invoice payments, funding velocity, + * payer behavior, and lifecycle performance. + */ +export class PaymentAnalyticsEngine { + /** + * Normalize an invoice or array of invoices into a filtered array. + */ + private filterInvoices( + input: Invoice | Invoice[], + filter?: PaymentAnalyticsFilter, + ): Invoice[] { + const list = Array.isArray(input) ? input : [input]; + if (!filter) return list; + + return list.filter((inv) => { + if (filter.token && inv.token !== filter.token) return false; + if (filter.creator && inv.creator !== filter.creator) return false; + if (filter.status) { + const statuses = Array.isArray(filter.status) ? filter.status : [filter.status]; + if (!statuses.includes(inv.status)) return false; + } + if (filter.startDate) { + const created = normalizeTimestamp(inv.createdAt) ?? 0; + const start = normalizeTimestamp(filter.startDate) ?? 0; + if (created < start) return false; + } + if (filter.endDate) { + const created = normalizeTimestamp(inv.createdAt) ?? 0; + const end = normalizeTimestamp(filter.endDate) ?? 0; + if (created > end) return false; + } + return true; + }); + } + + /** + * Aggregate payment metrics across invoices. + * + * @param input - Single invoice or array of invoices. + * @param filter - Optional filters for token, creator, status, dates. + */ + aggregate( + input: Invoice | Invoice[], + filter?: PaymentAnalyticsFilter, + ): PaymentAnalyticsSummary { + const invoices = this.filterInvoices(input, filter); + + if (invoices.length === 0) { + return { + totalInvoices: 0, + totalTarget: 0n, + totalFunded: 0n, + totalOutstanding: 0n, + fundingRateBps: 0, + fundingRatePercent: 0, + totalPayments: 0, + uniquePayers: 0, + paymentStats: { min: 0n, max: 0n, mean: 0n, median: 0n, totalCount: 0 }, + statusBreakdown: [], + tokenBreakdown: [], + timingMetrics: { averageSettlementTimeMs: null, onTimePayments: 0, latePayments: 0, onTimeRate: 100 }, + }; + } + + let totalTarget = 0n; + let totalFunded = 0n; + let totalPayments = 0; + const allPaymentAmounts: bigint[] = []; + const uniquePayers = new Set(); + + const statusCounts = new Map(); + const tokenCounts = new Map(); + + let onTimePayments = 0; + let latePayments = 0; + const settlementDurations: number[] = []; + + for (const inv of invoices) { + const target = inv.recipients.reduce((sum, r) => sum + r.amount, 0n); + totalTarget += target; + totalFunded += inv.funded; + + // Status distribution + const sEntry = statusCounts.get(inv.status) ?? { count: 0, funded: 0n }; + sEntry.count++; + sEntry.funded += inv.funded; + statusCounts.set(inv.status, sEntry); + + // Token distribution + const tEntry = tokenCounts.get(inv.token) ?? { invoices: 0, payments: 0, funded: 0n }; + tEntry.invoices++; + tEntry.funded += inv.funded; + + // Timing & Settlement + const createdMs = normalizeTimestamp(inv.createdAt); + if (inv.status === "Released" && createdMs) { + // Last payment or current time as settlement + const lastPayTs = inv.payments.reduce((max, p) => { + const t = normalizeTimestamp(p.timestamp); + return t && t > max ? t : max; + }, createdMs); + settlementDurations.push(lastPayTs - createdMs); + } + + // Payments + const deadlineMs = normalizeTimestamp(inv.deadline); + for (const p of inv.payments) { + totalPayments++; + tEntry.payments++; + uniquePayers.add(p.payer); + allPaymentAmounts.push(p.amount); + + const payTs = normalizeTimestamp(p.timestamp); + if (payTs && deadlineMs) { + if (payTs <= deadlineMs) { + onTimePayments++; + } else { + latePayments++; + } + } else { + onTimePayments++; + } + } + + tokenCounts.set(inv.token, tEntry); + } + + const totalOutstanding = totalTarget > totalFunded ? totalTarget - totalFunded : 0n; + const fundingRateBps = + totalTarget > 0n + ? Number((totalFunded * 10_000n) / totalTarget > 10_000n ? 10_000n : (totalFunded * 10_000n) / totalTarget) + : 0; + const fundingRatePercent = Number((fundingRateBps / 100).toFixed(2)); + + // Payment stats + let min = 0n; + let max = 0n; + let mean = 0n; + if (allPaymentAmounts.length > 0) { + min = allPaymentAmounts.reduce((a, b) => (a < b ? a : b), allPaymentAmounts[0]!); + max = allPaymentAmounts.reduce((a, b) => (a > b ? a : b), allPaymentAmounts[0]!); + const sum = allPaymentAmounts.reduce((a, b) => a + b, 0n); + mean = sum / BigInt(allPaymentAmounts.length); + } + const median = calculateMedian(allPaymentAmounts); + + // Status breakdown array + const statusBreakdown: StatusDistribution[] = Array.from(statusCounts.entries()) + .map(([status, entry]) => ({ + status, + count: entry.count, + percentage: Number(((entry.count / invoices.length) * 100).toFixed(2)), + totalFunded: entry.funded, + })) + .sort((a, b) => b.count - a.count); + + // Token breakdown array + const tokenBreakdown: TokenDistribution[] = Array.from(tokenCounts.entries()) + .map(([token, entry]) => ({ + token, + invoiceCount: entry.invoices, + paymentCount: entry.payments, + totalFunded: entry.funded, + })) + .sort((a, b) => (b.totalFunded > a.totalFunded ? 1 : -1)); + + const avgSettlement = + settlementDurations.length > 0 + ? Math.round(settlementDurations.reduce((a, b) => a + b, 0) / settlementDurations.length) + : null; + + const totalTimed = onTimePayments + latePayments; + const onTimeRate = totalTimed > 0 ? Number(((onTimePayments / totalTimed) * 100).toFixed(2)) : 100; + + return { + totalInvoices: invoices.length, + totalTarget, + totalFunded, + totalOutstanding, + fundingRateBps, + fundingRatePercent, + totalPayments, + uniquePayers: uniquePayers.size, + paymentStats: { + min, + max, + mean, + median, + totalCount: allPaymentAmounts.length, + }, + statusBreakdown, + tokenBreakdown, + timingMetrics: { + averageSettlementTimeMs: avgSettlement, + onTimePayments, + latePayments, + onTimeRate, + }, + }; + } + + /** + * Aggregate payer cohort metrics and rankings. + */ + aggregatePayers( + input: Invoice | Invoice[], + limit = 10, + ): PayerCohortSummary { + const invoices = Array.isArray(input) ? input : [input]; + const payerMap = new Map; + firstTime?: number; + lastTime?: number; + }>(); + + let grandTotalPaid = 0n; + + for (const inv of invoices) { + for (const p of inv.payments) { + grandTotalPaid += p.amount; + let entry = payerMap.get(p.payer); + if (!entry) { + entry = { + totalPaid: 0n, + payments: 0, + invoices: new Set(), + firstTime: undefined, + lastTime: undefined, + }; + payerMap.set(p.payer, entry); + } + + entry.totalPaid += p.amount; + entry.payments++; + entry.invoices.add(inv.id); + + const pTs = normalizeTimestamp(p.timestamp); + if (pTs) { + if (!entry.firstTime || pTs < entry.firstTime) entry.firstTime = pTs; + if (!entry.lastTime || pTs > entry.lastTime) entry.lastTime = pTs; + } + } + } + + const allPayers: PayerAnalyticsRecord[] = Array.from(payerMap.entries()).map(([address, data]) => { + const percentageOfVolume = + grandTotalPaid > 0n + ? Number(((data.totalPaid * 10_000n) / grandTotalPaid)) / 100 + : 0; + + return { + address, + totalPaid: data.totalPaid, + paymentCount: data.payments, + invoiceCount: data.invoices.size, + percentageOfVolume: Number(percentageOfVolume.toFixed(2)), + firstPaymentTime: data.firstTime, + lastPaymentTime: data.lastTime, + }; + }); + + allPayers.sort((a, b) => (a.totalPaid > b.totalPaid ? -1 : a.totalPaid < b.totalPaid ? 1 : 0)); + + const repeatPayers = allPayers.filter((p) => p.paymentCount > 1).length; + const repeatPayerRate = + allPayers.length > 0 ? Number(((repeatPayers / allPayers.length) * 100).toFixed(2)) : 0; + + return { + totalUniquePayers: allPayers.length, + repeatPayers, + repeatPayerRate, + topPayers: allPayers.slice(0, limit), + }; + } + + /** + * Aggregate payment volume and counts into temporal buckets. + */ + aggregateTimeSeries( + input: Invoice | Invoice[], + options?: { interval?: "hour" | "day" | "week" | "month"; bucketCount?: number }, + ): TimeSeriesDataPoint[] { + const invoices = Array.isArray(input) ? input : [input]; + type FlatPayment = { amount: bigint; timestamp: number; payer: string }; + const payments: FlatPayment[] = []; + + for (const inv of invoices) { + for (const p of inv.payments) { + const ts = normalizeTimestamp(p.timestamp); + if (ts) { + payments.push({ amount: p.amount, timestamp: ts, payer: p.payer }); + } + } + } + + if (payments.length === 0) return []; + + payments.sort((a, b) => a.timestamp - b.timestamp); + const minTs = payments[0]!.timestamp; + const maxTs = payments[payments.length - 1]!.timestamp; + + const interval = options?.interval ?? "day"; + let bucketMs = 24 * 60 * 60 * 1000; + if (interval === "hour") bucketMs = 60 * 60 * 1000; + if (interval === "week") bucketMs = 7 * 24 * 60 * 60 * 1000; + if (interval === "month") bucketMs = 30 * 24 * 60 * 60 * 1000; + + const count = options?.bucketCount ?? Math.max(1, Math.ceil((maxTs - minTs + 1) / bucketMs)); + const points: TimeSeriesDataPoint[] = []; + + for (let i = 0; i < count; i++) { + const bucketStart = minTs + i * bucketMs; + const date = new Date(bucketStart); + const label = date.toISOString().split("T")[0]!; + points.push({ + timestamp: bucketStart, + label, + volume: 0n, + paymentCount: 0, + uniquePayers: 0, + }); + } + + const bucketPayers = Array.from({ length: points.length }, () => new Set()); + + for (const p of payments) { + const idx = Math.min( + Math.floor((p.timestamp - minTs) / bucketMs), + points.length - 1, + ); + if (idx >= 0 && idx < points.length) { + points[idx]!.volume += p.amount; + points[idx]!.paymentCount++; + bucketPayers[idx]!.add(p.payer); + } + } + + for (let i = 0; i < points.length; i++) { + points[i]!.uniquePayers = bucketPayers[i]!.size; + } + + return points; + } + + /** + * Evaluate health and completion performance for individual invoices. + */ + analyzeInvoicePerformance(input: Invoice | Invoice[]): InvoicePerformanceRecord[] { + const invoices = Array.isArray(input) ? input : [input]; + const now = Date.now(); + + return invoices.map((inv) => { + const target = inv.recipients.reduce((s, r) => s + r.amount, 0n); + const fundingPct = target > 0n ? Number(((inv.funded * 100n) / target)) : 0; + const deadlineMs = normalizeTimestamp(inv.deadline) ?? 0; + const isPastDeadline = deadlineMs > 0 && now > deadlineMs; + + let health: "completed" | "on-track" | "at-risk" | "overdue"; + if (inv.status === "Released" || inv.funded >= target) { + health = "completed"; + } else if (isPastDeadline) { + health = "overdue"; + } else if (fundingPct < 25 && deadlineMs - now < 86_400_000 * 2) { + health = "at-risk"; + } else { + health = "on-track"; + } + + const uniquePayers = new Set(inv.payments.map((p) => p.payer)).size; + + return { + id: inv.id, + status: inv.status, + token: inv.token, + targetAmount: target, + fundedAmount: inv.funded, + fundingPercentage: fundingPct, + paymentCount: inv.payments.length, + uniquePayers, + createdAt: inv.createdAt, + deadline: inv.deadline, + health, + }; + }); + } + + /** + * Generate an end-to-end payment analytics report. + */ + generateReport( + input: Invoice | Invoice[], + filter?: PaymentAnalyticsFilter, + ): PaymentAnalyticsReport { + const invoices = this.filterInvoices(input, filter); + const summary = this.aggregate(invoices); + const payers = this.aggregatePayers(invoices); + const timeSeries = this.aggregateTimeSeries(invoices); + const invoicePerformance = this.analyzeInvoicePerformance(invoices); + + return { + generatedAt: Date.now(), + summary, + payers, + timeSeries, + invoices: invoicePerformance, + }; + } + + // --------------------------------------------------------------------------- + // Visualizations + // --------------------------------------------------------------------------- + + /** + * Render an ASCII horizontal bar chart. + */ + renderAsciiBarChart( + data: Array<{ label: string; value: number }>, + options?: { width?: number; showValues?: boolean }, + ): string { + if (data.length === 0) return "No data available."; + const width = options?.width ?? 25; + const showValues = options?.showValues ?? true; + + const maxVal = Math.max(...data.map((d) => d.value), 1); + const maxLabelLen = Math.max(...data.map((d) => d.label.length), 4); + + return data + .map((item) => { + const barLen = Math.round((item.value / maxVal) * width); + const bar = "█".repeat(barLen).padEnd(width, " "); + const label = item.label.padEnd(maxLabelLen, " "); + const valStr = showValues ? ` ${item.value}` : ""; + return `${label} | ${bar}${valStr}`; + }) + .join("\n"); + } + + /** + * Render a compact sparkline trend string from an array of numbers. + */ + renderSparkline(values: number[]): string { + if (values.length === 0) return ""; + const blocks = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + + return values + .map((v) => { + if (range === 0) return blocks[0]; + const idx = Math.min(Math.floor(((v - min) / range) * (blocks.length - 1)), blocks.length - 1); + return blocks[idx]; + }) + .join(""); + } + + /** + * Render an ASCII volume chart from time-series points. + */ + renderVolumeChart(timeSeries: TimeSeriesDataPoint[], options?: { width?: number }): string { + if (timeSeries.length === 0) return "No time-series data."; + const chartData = timeSeries.map((d) => ({ + label: d.label, + value: Number(d.volume / 10_000_000n), // convert stroops to XLM/tokens + })); + return this.renderAsciiBarChart(chartData, options); + } + + /** + * Render the lifecycle status breakdown as a visual chart. + */ + renderStatusDistribution(summary: PaymentAnalyticsSummary): string { + if (summary.statusBreakdown.length === 0) return "No status data."; + return this.renderAsciiBarChart( + summary.statusBreakdown.map((s) => ({ + label: s.status, + value: s.count, + })), + ); + } + + /** + * Render a payer leaderboard visual chart. + */ + renderPayerLeaderboard(payers: PayerCohortSummary, limit = 5): string { + if (payers.topPayers.length === 0) return "No payer data."; + const slice = payers.topPayers.slice(0, limit); + const data = slice.map((p) => { + const shortAddr = `${p.address.slice(0, 4)}...${p.address.slice(-4)}`; + return { + label: shortAddr, + value: Number(p.totalPaid / 10_000_000n), + }; + }); + return this.renderAsciiBarChart(data); + } + + /** + * Export structured chart data suitable for Chart.js, Recharts, or Vega. + */ + exportChartData(summary: PaymentAnalyticsSummary, timeSeries?: TimeSeriesDataPoint[]): StructuredChartData { + if (timeSeries && timeSeries.length > 0) { + return { + labels: timeSeries.map((t) => t.label), + datasets: [ + { + label: "Volume", + data: timeSeries.map((t) => Number(t.volume / 10_000_000n)), + unit: "tokens", + }, + { + label: "Payments", + data: timeSeries.map((t) => t.paymentCount), + unit: "count", + }, + ], + }; + } + + return { + labels: summary.statusBreakdown.map((s) => s.status), + datasets: [ + { + label: "Invoices by Status", + data: summary.statusBreakdown.map((s) => s.count), + unit: "invoices", + }, + ], + }; + } + + // --------------------------------------------------------------------------- + // Reporting Formats + // --------------------------------------------------------------------------- + + /** + * Format an analytics report into a clean human-readable text document. + */ + formatReport(report: PaymentAnalyticsReport): string { + const s = report.summary; + const lines: string[] = [ + "============================================================", + " PAYMENT & PERFORMANCE REPORT ", + "============================================================", + `Generated At: ${new Date(report.generatedAt).toISOString()}`, + `Total Invoices: ${s.totalInvoices}`, + `Total Target: ${s.totalTarget.toString()} stroops`, + `Total Funded: ${s.totalFunded.toString()} stroops (${s.fundingRatePercent}%)`, + `Outstanding: ${s.totalOutstanding.toString()} stroops`, + `Total Payments: ${s.totalPayments}`, + `Unique Payers: ${s.uniquePayers}`, + `On-time Rate: ${s.timingMetrics.onTimeRate}%`, + "", + "--- Status Breakdown ---", + this.renderStatusDistribution(s), + "", + "--- Payer Leaderboard ---", + this.renderPayerLeaderboard(report.payers), + "============================================================", + ]; + return lines.join("\n"); + } + + /** + * Export the full report as a JSON string. + */ + exportJson(report: PaymentAnalyticsReport, pretty = true): string { + return JSON.stringify( + report, + (_, val) => (typeof val === "bigint" ? val.toString() : val), + pretty ? 2 : undefined, + ); + } + + /** + * Export invoice performance data as CSV. + */ + exportCsv(report: PaymentAnalyticsReport): string { + const headers = [ + "InvoiceId", + "Status", + "Token", + "TargetAmount", + "FundedAmount", + "FundingPct", + "Payments", + "UniquePayers", + "Health", + ]; + const rows = report.invoices.map((inv) => [ + inv.id, + inv.status, + inv.token, + inv.targetAmount.toString(), + inv.fundedAmount.toString(), + `${inv.fundingPercentage}%`, + inv.paymentCount, + inv.uniquePayers, + inv.health, + ]); + + return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); + } +} diff --git a/test/abiCache.test.ts b/test/abiCache.test.ts new file mode 100644 index 0000000..fa0b837 --- /dev/null +++ b/test/abiCache.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + ContractAbiCache, + StaticContractAbiFetcher, + MemoryAbiStorage, + AdapterAbiStorage, +} from "../src/abiCache.js"; +import type { ContractAbi } from "../src/abiCache.js"; +import { MemoryStorageAdapter } from "../src/storage/storageAdapter.js"; +import { AbiNotFoundError } from "../src/errors.js"; + +const CONTRACT_A = "CA1111111111111111111111111111111111111111111111111111111111"; +const CONTRACT_B = "CB2222222222222222222222222222222222222222222222222222222222"; + +function sampleAbi(contractId: string, overrides: Partial = {}): ContractAbi { + return { + contractId, + wasmHash: "wasm-hash-abc123", + functions: [ + { + name: "split_payment", + doc: "Splits incoming payments across recipients", + inputs: [{ name: "invoice_id", type: "u64", doc: "Invoice ID" }], + outputs: [{ type: "bool", doc: "True on success" }], + }, + ], + types: [ + { + name: "InvoiceInfo", + kind: "struct", + doc: "Invoice record struct", + fields: [{ name: "id", type: "u64" }], + }, + ], + fetchedAt: Date.now(), + ...overrides, + }; +} + +describe("Smart Contract ABI Caching (#1054)", () => { + let fetcher: StaticContractAbiFetcher; + + beforeEach(() => { + fetcher = new StaticContractAbiFetcher(); + fetcher.registerAbi(CONTRACT_A, sampleAbi(CONTRACT_A)); + fetcher.registerAbi(CONTRACT_B, sampleAbi(CONTRACT_B, { wasmHash: "wasm-hash-xyz" })); + }); + + describe("Caching works", () => { + it("fetches and caches on first call, returns from cache on second call", async () => { + const fetchSpy = vi.fn(async (id: string) => fetcher.fetchAbi(id)); + const cache = new ContractAbiCache({ fetcher: fetchSpy }); + + const abi1 = await cache.get(CONTRACT_A); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(abi1.contractId).toBe(CONTRACT_A); + + const abi2 = await cache.get(CONTRACT_A); + expect(fetchSpy).toHaveBeenCalledTimes(1); // Cached! No 2nd fetch + expect(abi2).toEqual(abi1); + + const stats = await cache.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0.5); + }); + + it("throws AbiNotFoundError when contract is not registered and not in cache", async () => { + const cache = new ContractAbiCache({ fetcher }); + await expect(cache.get("UNKNOWN_CONTRACT")).rejects.toThrow(AbiNotFoundError); + }); + }); + + describe("Performance improved", () => { + it("shows substantial speedup between cache miss and cache hit", async () => { + // Simulate network latency in fetcher + const slowFetcher = async (id: string) => { + await new Promise((r) => setTimeout(r, 20)); + return sampleAbi(id); + }; + + const cache = new ContractAbiCache({ fetcher: slowFetcher }); + + const t0 = performance.now(); + await cache.get(CONTRACT_A); + const missDuration = performance.now() - t0; + + const t1 = performance.now(); + await cache.get(CONTRACT_A); + const hitDuration = performance.now() - t1; + + expect(missDuration).toBeGreaterThan(15); + expect(hitDuration).toBeLessThan(10); + expect(missDuration).toBeGreaterThan(hitDuration * 2); + }); + }); + + describe("Invalidation sound", () => { + it("invalidates specific contract entry", async () => { + const fetchSpy = vi.fn(async (id: string) => fetcher.fetchAbi(id)); + const cache = new ContractAbiCache({ fetcher: fetchSpy }); + + await cache.get(CONTRACT_A); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const invalidated = await cache.invalidate(CONTRACT_A); + expect(invalidated).toBe(true); + + expect(await cache.has(CONTRACT_A)).toBe(false); + + await cache.get(CONTRACT_A); + expect(fetchSpy).toHaveBeenCalledTimes(2); // Re-fetched + }); + + it("invalidates contracts by WASM hash", async () => { + const cache = new ContractAbiCache({ fetcher }); + await cache.get(CONTRACT_A); + await cache.get(CONTRACT_B); + + const removed = await cache.invalidateByWasmHash("wasm-hash-abc123"); + expect(removed).toBe(1); + expect(await cache.has(CONTRACT_A)).toBe(false); + expect(await cache.has(CONTRACT_B)).toBe(true); + }); + + it("invalidates expired entries based on TTL", async () => { + const cache = new ContractAbiCache({ fetcher, ttlMs: 10 }); + await cache.get(CONTRACT_A); + + await new Promise((r) => setTimeout(r, 20)); + expect(await cache.has(CONTRACT_A)).toBe(false); + + const purged = await cache.invalidateExpired(); + expect(purged).toBe(1); + }); + + it("supports clearing all cached entries", async () => { + const cache = new ContractAbiCache({ fetcher }); + await cache.get(CONTRACT_A); + await cache.get(CONTRACT_B); + + await cache.clear(); + const stats = await cache.getStats(); + expect(stats.entryCount).toBe(0); + expect(await cache.has(CONTRACT_A)).toBe(false); + }); + }); + + describe("Storage efficient", () => { + it("strips comments in compact mode to reduce storage footprint", async () => { + const normalCache = new ContractAbiCache({ fetcher, compact: false }); + const compactCache = new ContractAbiCache({ fetcher, compact: true }); + + const normalAbi = await normalCache.get(CONTRACT_A); + const compactAbi = await compactCache.get(CONTRACT_A); + + expect(normalAbi.functions[0]!.doc).toBeDefined(); + expect(compactAbi.functions[0]!.doc).toBeUndefined(); + + const normalStats = await normalCache.getStats(); + const compactStats = await compactCache.getStats(); + expect(compactStats.storageBytes).toBeLessThan(normalStats.storageBytes); + }); + + it("evicts least recently used items when capacity is reached", async () => { + const storage = new MemoryAbiStorage({ maxEntries: 2 }); + const cache = new ContractAbiCache({ fetcher, storage }); + + const CONTRACT_C = "CC33333333333333333333333333333333333333333333333333333333"; + fetcher.registerAbi(CONTRACT_C, sampleAbi(CONTRACT_C)); + + await cache.get(CONTRACT_A); + await cache.get(CONTRACT_B); + // Access A again to make B the oldest + await cache.get(CONTRACT_A); + + // Add C, which should evict B + await cache.get(CONTRACT_C); + + expect(await cache.has(CONTRACT_A)).toBe(true); + expect(await cache.has(CONTRACT_C)).toBe(true); + expect(await cache.has(CONTRACT_B)).toBe(false); + + const stats = await cache.getStats(); + expect(stats.evictions).toBe(1); + }); + + it("works with AdapterAbiStorage backed by Web Storage / MemoryStorageAdapter", async () => { + const adapter = new MemoryStorageAdapter(); + const storage = new AdapterAbiStorage(adapter); + const cache = new ContractAbiCache({ fetcher, storage }); + + await cache.get(CONTRACT_A); + expect(await cache.has(CONTRACT_A)).toBe(true); + expect(await cache.get(CONTRACT_A)).toBeDefined(); + }); + }); +}); diff --git a/test/invoiceCollaboration.test.ts b/test/invoiceCollaboration.test.ts new file mode 100644 index 0000000..a0c9182 --- /dev/null +++ b/test/invoiceCollaboration.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + InvoiceCollaborationManager, +} from "../src/invoiceCollaboration.js"; +import { + CommentNotFoundError, + ApprovalRequestNotFoundError, + UnauthorizedCollaborationError, +} from "../src/errors.js"; + +const CREATOR = "GCREATOR1111111111111111111111111111111111111111111111111111"; +const APPROVER_A = "GAPPROVER_A1111111111111111111111111111111111111111111111111"; +const APPROVER_B = "GAPPROVER_B2222222222222222222222222222222222222222222222222"; +const COLLABORATOR = "GCOLLABORATOR3333333333333333333333333333333333333333333333"; + +describe("Invoice Collaboration Features (#1053)", () => { + let collab: InvoiceCollaborationManager; + + beforeEach(() => { + collab = new InvoiceCollaborationManager(); + }); + + describe("Comments work & Threading clear", () => { + it("adds top-level comments and parses mentions", () => { + const comment = collab.addComment({ + invoiceId: "inv-001", + author: CREATOR, + content: `Please review payment split @${APPROVER_A}`, + }); + + expect(comment.id).toBeDefined(); + expect(comment.author).toBe(CREATOR); + expect(comment.mentions).toEqual([APPROVER_A]); + + const list = collab.getComments("inv-001"); + expect(list).toHaveLength(1); + expect(list[0]!.id).toBe(comment.id); + }); + + it("adds replies and organizes clear hierarchical threads", () => { + const root1 = collab.addComment({ + invoiceId: "inv-001", + author: CREATOR, + content: "Root question 1", + }); + const root2 = collab.addComment({ + invoiceId: "inv-001", + author: COLLABORATOR, + content: "Root question 2", + }); + + const reply1 = collab.addComment({ + invoiceId: "inv-001", + author: APPROVER_A, + content: "Reply to question 1", + parentId: root1.id, + }); + + const nestedReply = collab.addComment({ + invoiceId: "inv-001", + author: CREATOR, + content: "Follow-up to reply 1", + parentId: reply1.id, + }); + + const threads = collab.getThreadedComments("inv-001"); + expect(threads).toHaveLength(2); // Two root threads + + const thread1 = threads.find((t) => t.id === root1.id)!; + expect(thread1.replies).toHaveLength(1); + expect(thread1.replies[0]!.id).toBe(reply1.id); + expect(thread1.replies[0]!.replies).toHaveLength(1); + expect(thread1.replies[0]!.replies[0]!.id).toBe(nestedReply.id); + }); + + it("allows editing comments by original author only", () => { + const comment = collab.addComment({ + invoiceId: "inv-001", + author: CREATOR, + content: "Original text", + }); + + expect(() => { + collab.editComment(comment.id, COLLABORATOR, "Hacked text"); + }).toThrow(UnauthorizedCollaborationError); + + const edited = collab.editComment(comment.id, CREATOR, "Updated text"); + expect(edited.content).toBe("Updated text"); + expect(edited.updatedAt).toBeDefined(); + }); + + it("allows soft-deleting comments by author", () => { + const comment = collab.addComment({ + invoiceId: "inv-001", + author: CREATOR, + content: "To be deleted", + }); + + expect(() => { + collab.deleteComment(comment.id, COLLABORATOR); + }).toThrow(UnauthorizedCollaborationError); + + const deleted = collab.deleteComment(comment.id, CREATOR); + expect(deleted).toBe(true); + + const comments = collab.getComments("inv-001"); + expect(comments).toHaveLength(0); + }); + }); + + describe("Approvals flow", () => { + it("manages multi-approver quorum workflow to approved state", () => { + const req = collab.requestApproval({ + invoiceId: "inv-flow", + requestedBy: CREATOR, + requiredApprovers: [APPROVER_A, APPROVER_B], + minApprovals: 2, + title: "Release Milestone 1", + }); + + expect(req.status).toBe("pending"); + + // First approval + const req1 = collab.submitDecision({ + requestId: req.id, + approver: APPROVER_A, + decision: "approved", + notes: "Looks good to me", + }); + expect(req1.status).toBe("pending"); + + // Second approval completes quorum + const req2 = collab.submitDecision({ + requestId: req.id, + approver: APPROVER_B, + decision: "approved", + }); + expect(req2.status).toBe("approved"); + + const summary = collab.getApprovalStatus("inv-flow"); + expect(summary.isApproved).toBe(true); + expect(summary.approvedCount).toBe(2); + expect(summary.pendingApprovers).toHaveLength(0); + }); + + it("transitions workflow to rejected when approval threshold cannot be reached", () => { + const req = collab.requestApproval({ + invoiceId: "inv-reject", + requestedBy: CREATOR, + requiredApprovers: [APPROVER_A, APPROVER_B], + minApprovals: 2, + }); + + const updated = collab.submitDecision({ + requestId: req.id, + approver: APPROVER_A, + decision: "rejected", + notes: "Amount mismatch", + }); + + expect(updated.status).toBe("rejected"); + const summary = collab.getApprovalStatus("inv-reject"); + expect(summary.isRejected).toBe(true); + expect(summary.rejectedBy).toEqual([APPROVER_A]); + }); + + it("prevents unauthorized accounts from submitting decisions", () => { + const req = collab.requestApproval({ + invoiceId: "inv-auth", + requestedBy: CREATOR, + requiredApprovers: [APPROVER_A], + }); + + expect(() => { + collab.submitDecision({ + requestId: req.id, + approver: COLLABORATOR, + decision: "approved", + }); + }).toThrow(UnauthorizedCollaborationError); + }); + }); + + describe("Notifications sent", () => { + it("dispatches notifications for mentions and approval requests", () => { + const handler = vi.fn(); + collab.onNotification(handler); + + // Mention + collab.addComment({ + invoiceId: "inv-notif", + author: CREATOR, + content: `Check this out @${APPROVER_A}`, + }); + + const notifsA = collab.getNotifications(APPROVER_A); + expect(notifsA.some((n) => n.type === "mention")).toBe(true); + expect(handler).toHaveBeenCalled(); + + // Approval Request + collab.requestApproval({ + invoiceId: "inv-notif", + requestedBy: CREATOR, + requiredApprovers: [APPROVER_B], + title: "Sign-off needed", + }); + + const notifsB = collab.getNotifications(APPROVER_B); + expect(notifsB.some((n) => n.type === "approval_requested")).toBe(true); + }); + + it("notifies parent comment author on reply", () => { + const root = collab.addComment({ + invoiceId: "inv-reply-notif", + author: COLLABORATOR, + content: "Root question", + }); + + collab.addComment({ + invoiceId: "inv-reply-notif", + author: CREATOR, + content: "Here is your answer", + parentId: root.id, + }); + + const notifs = collab.getNotifications(COLLABORATOR); + expect(notifs.some((n) => n.type === "reply")).toBe(true); + }); + + it("allows marking notifications as read", () => { + collab.addComment({ + invoiceId: "inv-read", + author: CREATOR, + content: `@${APPROVER_A} please check`, + }); + + const unread = collab.getNotifications(APPROVER_A, true); + expect(unread).toHaveLength(1); + + collab.markNotificationAsRead(unread[0]!.id); + expect(collab.getNotifications(APPROVER_A, true)).toHaveLength(0); + expect(collab.getNotifications(APPROVER_A, false)).toHaveLength(1); + }); + }); +}); diff --git a/test/invoiceVersioning.test.ts b/test/invoiceVersioning.test.ts new file mode 100644 index 0000000..49c4a6e --- /dev/null +++ b/test/invoiceVersioning.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + InvoiceVersionManager, + InvoiceVersionTracker, + InMemoryVersionStore, + computeChecksum, +} from "../src/invoiceVersioning.js"; +import { + InvoiceVersionNotFoundError, + UnsafeRollbackError, + InvalidVersionError, +} from "../src/errors.js"; +import type { Invoice } from "../src/types.js"; + +const CREATOR = "GCZST3XVCDTUJ76ZAV2HA72KYTZ4KXX52HRXVWWRWXH2NBDXZWQS2FB2"; +const RECIPIENT1 = "GBRPYHIL2CI3WHSCULNJJMA3CJBYWR5LK662LFXISKW3P7UKDXTX5AGE"; +const RECIPIENT2 = "GDQJUTQYK2MQX2CBBFKVZBNE4RG4H3ZVJJJX5RFHPB4DXX6IQHTHYKF"; + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "inv-101", + creator: CREATOR, + recipients: [{ address: RECIPIENT1, amount: 100_000_000n }], + token: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + deadline: Math.floor(Date.now() / 1000) + 86_400, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + }; +} + +describe("Invoice Versioning System (#1056)", () => { + let manager: InvoiceVersionManager; + + beforeEach(() => { + manager = new InvoiceVersionManager(); + }); + + describe("Versioning works", () => { + it("creates initial version with snapshot and checksum", async () => { + const invoice = makeInvoice(); + const v1 = await manager.record(invoice.id, invoice, CREATOR); + + expect(v1.version).toBe(1); + expect(v1.changedBy).toBe(CREATOR); + expect(v1.snapshot.id).toBe(invoice.id); + expect(v1.checksum).toBeDefined(); + expect(v1.checksum).toBe(computeChecksum(invoice)); + }); + + it("retrieves specific versions by number", async () => { + const invoice = makeInvoice(); + await manager.record(invoice.id, invoice, CREATOR); + await manager.record(invoice.id, { ...invoice, memo: "v2" }, CREATOR); + + const v1 = await manager.getVersion(invoice.id, 1); + const v2 = await manager.getVersion(invoice.id, 2); + const v3 = await manager.getVersion(invoice.id, 3); + + expect(v1?.version).toBe(1); + expect(v1?.snapshot.memo).toBeUndefined(); + expect(v2?.version).toBe(2); + expect(v2?.snapshot.memo).toBe("v2"); + expect(v3).toBeNull(); + }); + }); + + describe("Changes tracked", () => { + it("tracks incremental field changes between versions", async () => { + const invoice = makeInvoice(); + await manager.record(invoice.id, invoice, CREATOR); + + const updated = { + ...invoice, + memo: "New memo", + recipients: [ + { address: RECIPIENT1, amount: 50_000_000n }, + { address: RECIPIENT2, amount: 50_000_000n }, + ], + }; + const v2 = await manager.record(invoice.id, updated, CREATOR); + + expect(v2.changeSummary).toContain("memo"); + expect(v2.changeSummary).toContain("recipients"); + + const diff = await manager.diff(invoice.id, 1, 2); + expect(diff.hasChanges).toBe(true); + expect(diff.changes.some((c) => c.field === "memo")).toBe(true); + expect(diff.changes.some((c) => c.field === "recipients")).toBe(true); + }); + }); + + describe("Rollback safe", () => { + it("rolls back configuration to target version while preserving payments and funded amount by default", async () => { + const base = makeInvoice({ memo: "Version 1 memo" }); + await manager.record(base.id, base, CREATOR); + + const v2Invoice: Invoice = { + ...base, + memo: "Version 2 memo", + funded: 50_000_000n, + payments: [{ payer: "GPAYER", amount: 50_000_000n, ledger: 100 }], + }; + await manager.record(base.id, v2Invoice, CREATOR); + + const isSafe = await manager.isRollbackSafe(base.id, 1); + expect(isSafe.safe).toBe(true); + + const rollbackResult = await manager.rollback(base.id, 1, { + changedBy: CREATOR, + reason: "Reverting memo changes", + }); + + expect(rollbackResult.newVersionNumber).toBe(3); + expect(rollbackResult.targetVersion).toBe(1); + expect(rollbackResult.restoredInvoice.memo).toBe("Version 1 memo"); + // Preserved payments and funded + expect(rollbackResult.restoredInvoice.funded).toBe(50_000_000n); + expect(rollbackResult.restoredInvoice.payments).toHaveLength(1); + + expect(rollbackResult.version.isRollback).toBe(true); + expect(rollbackResult.version.rolledBackFromVersion).toBe(1); + }); + + it("rejects rollback when target version is invalid or out of range", async () => { + const base = makeInvoice(); + await manager.record(base.id, base, CREATOR); + + await expect(manager.rollback(base.id, 0)).rejects.toThrow(InvalidVersionError); + await expect(manager.rollback(base.id, 1)).rejects.toThrow(UnsafeRollbackError); + await expect(manager.rollback(base.id, 5)).rejects.toThrow(InvalidVersionError); + }); + + it("rejects rollback of finalized invoices unless allowTerminalStateRollback is explicitly granted", async () => { + const base = makeInvoice(); + await manager.record(base.id, base, CREATOR); + + const finalized: Invoice = { ...base, status: "Released" }; + await manager.record(base.id, finalized, CREATOR); + + const safety = await manager.isRollbackSafe(base.id, 1); + expect(safety.safe).toBe(false); + expect(safety.reason).toContain("terminal state"); + + await expect(manager.rollback(base.id, 1)).rejects.toThrow(UnsafeRollbackError); + + // Allowed when explicit flag is passed + const forcedRollback = await manager.rollback(base.id, 1, { + allowTerminalStateRollback: true, + }); + expect(forcedRollback.newVersionNumber).toBe(3); + }); + + it("allows option to not preserve payments if explicitly set", async () => { + const base = makeInvoice({ funded: 0n, payments: [] }); + await manager.record(base.id, base, CREATOR); + + const fundedInv = { + ...base, + funded: 100_000n, + payments: [{ payer: "P1", amount: 100_000n }], + }; + await manager.record(base.id, fundedInv, CREATOR); + + const res = await manager.rollback(base.id, 1, { preservePayments: false }); + expect(res.restoredInvoice.funded).toBe(0n); + expect(res.restoredInvoice.payments).toHaveLength(0); + }); + }); + + describe("History clear", () => { + it("generates a clear human-readable changelog", async () => { + const invoice = makeInvoice(); + await manager.record(invoice.id, invoice, CREATOR); + await manager.record(invoice.id, { ...invoice, memo: "Updated memo" }, CREATOR); + + const changelog = await manager.getChangeLog(invoice.id); + expect(changelog).toHaveLength(2); + expect(changelog[0]!.version).toBe(1); + expect(changelog[0]!.changeSummary).toBe("Initial version."); + expect(changelog[1]!.version).toBe(2); + expect(changelog[1]!.changedFields).toContain("memo"); + }); + + it("exports and imports history cleanly via JSON", async () => { + const invoice = makeInvoice(); + await manager.record(invoice.id, invoice, CREATOR); + await manager.record(invoice.id, { ...invoice, memo: "v2" }, CREATOR); + + const json = await manager.exportHistoryJson(invoice.id); + expect(json).toContain('"version": 1'); + expect(json).toContain('"version": 2'); + + const newManager = new InvoiceVersionManager(); + const count = await newManager.importHistoryJson(invoice.id, json); + expect(count).toBe(2); + + const importedHistory = await newManager.getHistory(invoice.id); + expect(importedHistory).toHaveLength(2); + expect(importedHistory[1]!.snapshot.memo).toBe("v2"); + }); + }); +}); diff --git a/test/paymentAnalytics.test.ts b/test/paymentAnalytics.test.ts new file mode 100644 index 0000000..cee9bb3 --- /dev/null +++ b/test/paymentAnalytics.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from "vitest"; +import { + PaymentAnalyticsEngine, +} from "../src/paymentAnalytics.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "inv-1", + creator: "GCREATOR", + recipients: [{ address: "GRECIP", amount: 100_000_000n }], + token: "USDC", + deadline: Math.floor(Date.now() / 1000) + 86400, + funded: 0n, + status: "Pending", + payments: [], + createdAt: Date.now() - 100_000, + ...overrides, + }; +} + +describe("Payment Analytics (#1055)", () => { + const engine = new PaymentAnalyticsEngine(); + + describe("Analytics work & Data accurate", () => { + it("handles empty invoice arrays gracefully", () => { + const summary = engine.aggregate([]); + expect(summary.totalInvoices).toBe(0); + expect(summary.totalFunded).toBe(0n); + expect(summary.totalTarget).toBe(0n); + expect(summary.uniquePayers).toBe(0); + expect(summary.fundingRatePercent).toBe(0); + expect(summary.paymentStats.mean).toBe(0n); + }); + + it("aggregates volume, target, and basis points accurately", () => { + const inv1 = makeInvoice({ + id: "1", + recipients: [{ address: "R1", amount: 1_000_000n }], + funded: 600_000n, + payments: [{ payer: "P1", amount: 600_000n, timestamp: Date.now() }], + status: "Pending", + }); + const inv2 = makeInvoice({ + id: "2", + recipients: [{ address: "R2", amount: 1_000_000n }], + funded: 400_000n, + payments: [{ payer: "P2", amount: 400_000n, timestamp: Date.now() }], + status: "Released", + }); + + const summary = engine.aggregate([inv1, inv2]); + + expect(summary.totalInvoices).toBe(2); + expect(summary.totalTarget).toBe(2_000_000n); + expect(summary.totalFunded).toBe(1_000_000n); + expect(summary.totalOutstanding).toBe(1_000_000n); + expect(summary.fundingRateBps).toBe(5_000); // 50% + expect(summary.fundingRatePercent).toBe(50); + expect(summary.totalPayments).toBe(2); + expect(summary.uniquePayers).toBe(2); + expect(summary.paymentStats.min).toBe(400_000n); + expect(summary.paymentStats.max).toBe(600_000n); + expect(summary.paymentStats.mean).toBe(500_000n); + expect(summary.paymentStats.median).toBe(500_000n); + }); + + it("aggregates payer statistics, ranking, and repeat payer rate", () => { + const inv1 = makeInvoice({ + id: "1", + payments: [ + { payer: "P1", amount: 100n, timestamp: 1000 }, + { payer: "P2", amount: 50n, timestamp: 2000 }, + ], + }); + const inv2 = makeInvoice({ + id: "2", + payments: [ + { payer: "P1", amount: 200n, timestamp: 3000 }, + ], + }); + + const payers = engine.aggregatePayers([inv1, inv2]); + + expect(payers.totalUniquePayers).toBe(2); + expect(payers.repeatPayers).toBe(1); // P1 paid twice + expect(payers.repeatPayerRate).toBe(50); + expect(payers.topPayers[0]!.address).toBe("P1"); + expect(payers.topPayers[0]!.totalPaid).toBe(300n); + expect(payers.topPayers[0]!.paymentCount).toBe(2); + expect(payers.topPayers[0]!.invoiceCount).toBe(2); + expect(payers.topPayers[1]!.address).toBe("P2"); + expect(payers.topPayers[1]!.totalPaid).toBe(50n); + }); + + it("aggregates time-series payment volume accurately", () => { + const now = 1_700_000_000_000; + const inv = makeInvoice({ + payments: [ + { payer: "P1", amount: 10_000_000n, timestamp: now }, + { payer: "P2", amount: 20_000_000n, timestamp: now + 3600 * 1000 }, + ], + }); + + const series = engine.aggregateTimeSeries([inv], { interval: "hour", bucketCount: 2 }); + expect(series).toHaveLength(2); + expect(series[0]!.volume).toBe(10_000_000n); + expect(series[1]!.volume).toBe(20_000_000n); + }); + + it("evaluates invoice health accurately", () => { + const activeInv = makeInvoice({ + id: "active", + funded: 50_000_000n, + recipients: [{ address: "R1", amount: 100_000_000n }], + deadline: Math.floor(Date.now() / 1000) + 100_000, + }); + const completedInv = makeInvoice({ + id: "completed", + status: "Released", + funded: 100_000_000n, + recipients: [{ address: "R1", amount: 100_000_000n }], + }); + const overdueInv = makeInvoice({ + id: "overdue", + status: "Pending", + funded: 10n, + recipients: [{ address: "R1", amount: 100_000_000n }], + deadline: 1_000, // past + }); + + const records = engine.analyzeInvoicePerformance([activeInv, completedInv, overdueInv]); + expect(records.find((r) => r.id === "active")?.health).toBe("on-track"); + expect(records.find((r) => r.id === "completed")?.health).toBe("completed"); + expect(records.find((r) => r.id === "overdue")?.health).toBe("overdue"); + }); + }); + + describe("Visuals helpful", () => { + it("renders ASCII bar charts with proper scaling", () => { + const data = [ + { label: "Pending", value: 10 }, + { label: "Released", value: 20 }, + ]; + const chart = engine.renderAsciiBarChart(data, { width: 10 }); + expect(chart).toContain("Pending"); + expect(chart).toContain("Released"); + expect(chart).toContain("█"); + }); + + it("generates sparklines across value ranges", () => { + const sparkline = engine.renderSparkline([1, 2, 5, 10, 8, 3]); + expect(sparkline.length).toBe(6); + expect(sparkline).toContain("█"); + }); + + it("exports structured chart data for frontend charting libraries", () => { + const inv = makeInvoice({ status: "Released", funded: 100n }); + const summary = engine.aggregate([inv]); + const chartData = engine.exportChartData(summary); + + expect(chartData.labels).toContain("Released"); + expect(chartData.datasets[0]!.data).toEqual([1]); + }); + }); + + describe("Reports clear", () => { + it("generates a comprehensive report bundle", () => { + const inv = makeInvoice({ + id: "inv-report", + status: "Pending", + funded: 50_000_000n, + payments: [{ payer: "GADDR", amount: 50_000_000n, timestamp: Date.now() }], + }); + const report = engine.generateReport([inv]); + + expect(report.summary.totalInvoices).toBe(1); + expect(report.payers.totalUniquePayers).toBe(1); + expect(report.invoices).toHaveLength(1); + + const text = engine.formatReport(report); + expect(text).toContain("PAYMENT & PERFORMANCE REPORT"); + expect(text).toContain("Total Funded:"); + + const json = engine.exportJson(report); + expect(json).toContain('"totalInvoices": 1'); + + const csv = engine.exportCsv(report); + expect(csv).toContain("InvoiceId,Status,Token"); + expect(csv).toContain("inv-report,Pending,USDC"); + }); + }); +});