diff --git a/README.md b/README.md index e85e508..041bc09 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide. - #991: Implement custom report builder and scheduling + + +- #981: Add invoice template library with versioning diff --git a/src/lib/invoiceLinkAnalytics.ts b/src/lib/invoiceLinkAnalytics.ts new file mode 100644 index 0000000..65fca25 --- /dev/null +++ b/src/lib/invoiceLinkAnalytics.ts @@ -0,0 +1,206 @@ +export type InvoiceLinkType = 'dependency' | 'conditional'; + +export interface InvoiceLink { + id: string; + sourceInvoiceId: string; + targetInvoiceId: string; + type: InvoiceLinkType; + /** For conditional links: the condition that must hold for the link to activate. */ + condition?: string; + createdAt: string; +} + +export interface LinkValidationResult { + valid: boolean; + errors: string[]; +} + +export interface CircularDependencyResult { + hasCycle: boolean; + cycle: string[]; +} + +export interface DependencyResolutionResult { + /** Invoice ids in the order they must be processed (dependencies first). */ + order: string[]; + /** Invoice ids that could not be resolved due to cycles. */ + unresolved: string[]; +} + +export interface LinkAnalytics { + totalLinks: number; + byType: Record; + /** Number of links pointing to each invoice. */ + inboundCounts: Record; + /** Number of links originating from each invoice. */ + outboundCounts: Record; + /** Invoices that participate in no links at all. */ + orphanedInvoices: string[]; +} + +const LINK_TYPES: InvoiceLinkType[] = ['dependency', 'conditional']; + +/** + * Validate a set of invoice links: no self-links, no duplicate links, and all + * referenced invoices must exist in the provided invoice id set. + */ +export function validateLinks( + links: InvoiceLink[], + invoiceIds: string[], +): LinkValidationResult { + const errors: string[] = []; + const known = new Set(invoiceIds); + const seen = new Set(); + + for (const link of links) { + if (link.sourceInvoiceId === link.targetInvoiceId) { + errors.push(`Invoice ${link.sourceInvoiceId} cannot link to itself`); + } + if (!known.has(link.sourceInvoiceId)) { + errors.push(`Unknown source invoice: ${link.sourceInvoiceId}`); + } + if (!known.has(link.targetInvoiceId)) { + errors.push(`Unknown target invoice: ${link.targetInvoiceId}`); + } + if (!LINK_TYPES.includes(link.type)) { + errors.push(`Invalid link type: ${link.type}`); + } + if (link.type === 'conditional' && !link.condition) { + errors.push(`Conditional link ${link.id} requires a condition`); + } + const key = `${link.sourceInvoiceId}->${link.targetInvoiceId}:${link.type}`; + if (seen.has(key)) { + errors.push(`Duplicate link: ${key}`); + } + seen.add(key); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Detect a circular dependency among dependency links using depth-first search. + * Returns the first cycle found, if any. + */ +export function detectCircularDependency( + links: InvoiceLink[], +): CircularDependencyResult { + const graph = new Map(); + for (const link of links) { + if (link.type !== 'dependency') continue; + const edges = graph.get(link.sourceInvoiceId) ?? []; + edges.push(link.targetInvoiceId); + graph.set(link.sourceInvoiceId, edges); + } + + const visited = new Set(); + const stack = new Set(); + const path: string[] = []; + + const visit = (node: string): string[] | null => { + if (stack.has(node)) { + const start = path.indexOf(node); + return path.slice(start).concat(node); + } + if (visited.has(node)) return null; + visited.add(node); + stack.add(node); + path.push(node); + for (const next of graph.get(node) ?? []) { + const cycle = visit(next); + if (cycle) return cycle; + } + stack.delete(node); + path.pop(); + return null; + }; + + for (const node of graph.keys()) { + const cycle = visit(node); + if (cycle) return { hasCycle: true, cycle }; + } + return { hasCycle: false, cycle: [] }; +} + +/** + * Resolve dependency order (topological sort) for dependency links. Invoices + * involved in cycles are reported as unresolved rather than silently dropped. + */ +export function resolveDependencies( + links: InvoiceLink[], + invoiceIds: string[], +): DependencyResolutionResult { + const deps = new Map>(); + for (const id of invoiceIds) deps.set(id, new Set()); + for (const link of links) { + if (link.type !== 'dependency') continue; + if (!deps.has(link.sourceInvoiceId)) deps.set(link.sourceInvoiceId, new Set()); + deps.get(link.sourceInvoiceId)!.add(link.targetInvoiceId); + } + + const order: string[] = []; + const resolved = new Set(); + const visiting = new Set(); + const unresolved = new Set(); + + const visit = (node: string): boolean => { + if (resolved.has(node)) return true; + if (visiting.has(node)) { + unresolved.add(node); + return false; + } + visiting.add(node); + let ok = true; + for (const dep of deps.get(node) ?? []) { + if (!visit(dep)) ok = false; + } + visiting.delete(node); + if (ok) { + resolved.add(node); + order.push(node); + } else { + unresolved.add(node); + } + return ok; + }; + + for (const id of deps.keys()) visit(id); + + return { order, unresolved: Array.from(unresolved) }; +} + +/** + * Compute analytics over the current set of links, including orphan detection. + */ +export function computeLinkAnalytics( + links: InvoiceLink[], + invoiceIds: string[], +): LinkAnalytics { + const byType: Record = { dependency: 0, conditional: 0 }; + const inboundCounts: Record = {}; + const outboundCounts: Record = {}; + const linked = new Set(); + + for (const id of invoiceIds) { + inboundCounts[id] = 0; + outboundCounts[id] = 0; + } + + for (const link of links) { + byType[link.type] = (byType[link.type] ?? 0) + 1; + outboundCounts[link.sourceInvoiceId] = (outboundCounts[link.sourceInvoiceId] ?? 0) + 1; + inboundCounts[link.targetInvoiceId] = (inboundCounts[link.targetInvoiceId] ?? 0) + 1; + linked.add(link.sourceInvoiceId); + linked.add(link.targetInvoiceId); + } + + const orphanedInvoices = invoiceIds.filter((id) => !linked.has(id)); + + return { + totalLinks: links.length, + byType, + inboundCounts, + outboundCounts, + orphanedInvoices, + }; +} diff --git a/src/lib/invoiceLinkValidation.ts b/src/lib/invoiceLinkValidation.ts new file mode 100644 index 0000000..b296758 --- /dev/null +++ b/src/lib/invoiceLinkValidation.ts @@ -0,0 +1,232 @@ +export type InvoiceLinkType = 'dependency' | 'conditional'; + +export interface InvoiceLink { + id: string; + sourceInvoiceId: string; + targetInvoiceId: string; + type: InvoiceLinkType; + /** For conditional links: the condition that must hold for the link to be active. */ + condition?: string; + createdAt?: string; +} + +export interface LinkValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface LinkAnalytics { + totalLinks: number; + byType: Record; + /** Invoices that participate in at least one link. */ + linkedInvoiceIds: string[]; + /** Invoices referenced by links but not present in the known invoice set. */ + orphanedInvoiceIds: string[]; + /** Cycles detected in the dependency graph (each cycle is a list of invoice ids). */ + circularDependencies: string[][]; +} + +const LINK_TYPES: InvoiceLinkType[] = ['dependency', 'conditional']; + +/** + * Validate a single link's shape and self-references. + */ +export function validateLink(link: InvoiceLink): LinkValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!link.id) { + errors.push('Link id is required.'); + } + if (!link.sourceInvoiceId) { + errors.push('Source invoice id is required.'); + } + if (!link.targetInvoiceId) { + errors.push('Target invoice id is required.'); + } + if (link.sourceInvoiceId && link.sourceInvoiceId === link.targetInvoiceId) { + errors.push('An invoice cannot link to itself.'); + } + if (!LINK_TYPES.includes(link.type)) { + errors.push(`Unsupported link type: ${String(link.type)}.`); + } + if (link.type === 'conditional' && !link.condition) { + warnings.push('Conditional link has no condition; it will always be active.'); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * Validate a collection of links, including duplicate detection and + * orphaned invoice references. + */ +export function validateLinks( + links: InvoiceLink[], + knownInvoiceIds: string[] = [], +): LinkValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + const seen = new Set(); + + for (const link of links) { + const result = validateLink(link); + errors.push(...result.errors); + warnings.push(...result.warnings); + + const key = `${link.sourceInvoiceId}->${link.targetInvoiceId}:${link.type}`; + if (seen.has(key)) { + errors.push(`Duplicate link detected: ${key}.`); + } + seen.add(key); + } + + if (knownInvoiceIds.length > 0) { + const known = new Set(knownInvoiceIds); + for (const link of links) { + if (link.sourceInvoiceId && !known.has(link.sourceInvoiceId)) { + warnings.push(`Source invoice ${link.sourceInvoiceId} is not a known invoice.`); + } + if (link.targetInvoiceId && !known.has(link.targetInvoiceId)) { + warnings.push(`Target invoice ${link.targetInvoiceId} is not a known invoice.`); + } + } + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * Build an adjacency map of dependency links (source depends on target). + */ +function buildDependencyGraph(links: InvoiceLink[]): Map { + const graph = new Map(); + for (const link of links) { + if (link.type !== 'dependency') continue; + const edges = graph.get(link.sourceInvoiceId) ?? []; + edges.push(link.targetInvoiceId); + graph.set(link.sourceInvoiceId, edges); + } + return graph; +} + +/** + * Detect circular dependencies in the dependency graph using DFS. + * Returns each detected cycle as an ordered list of invoice ids. + */ +export function detectCircularDependencies(links: InvoiceLink[]): string[][] { + const graph = buildDependencyGraph(links); + const cycles: string[][] = []; + const visited = new Set(); + const stack: string[] = []; + const inStack = new Set(); + + const visit = (node: string): void => { + if (inStack.has(node)) { + const start = stack.indexOf(node); + if (start !== -1) { + cycles.push([...stack.slice(start), node]); + } + return; + } + if (visited.has(node)) return; + + visited.add(node); + inStack.add(node); + stack.push(node); + + for (const next of graph.get(node) ?? []) { + visit(next); + } + + stack.pop(); + inStack.delete(node); + }; + + for (const node of graph.keys()) { + visit(node); + } + + return cycles; +} + +/** + * Resolve the ordered list of invoices that must be processed before the + * given invoice, based on dependency links. Throws if a cycle is detected. + */ +export function resolveDependencies( + invoiceId: string, + links: InvoiceLink[], +): string[] { + const cycles = detectCircularDependencies(links); + if (cycles.length > 0) { + throw new Error( + `Circular dependency detected: ${cycles.map((c) => c.join(' -> ')).join('; ')}`, + ); + } + + const graph = buildDependencyGraph(links); + const resolved: string[] = []; + const seen = new Set(); + + const walk = (node: string): void => { + for (const dep of graph.get(node) ?? []) { + if (seen.has(dep)) continue; + seen.add(dep); + walk(dep); + resolved.push(dep); + } + }; + + walk(invoiceId); + return resolved; +} + +/** + * Determine whether a conditional link is currently active. + */ +export function isConditionalLinkActive( + link: InvoiceLink, + evaluate: (condition: string) => boolean, +): boolean { + if (link.type !== 'conditional') return true; + if (!link.condition) return true; + return evaluate(link.condition); +} + +/** + * Compute analytics over the provided links and known invoices. + */ +export function computeLinkAnalytics( + links: InvoiceLink[], + knownInvoiceIds: string[] = [], +): LinkAnalytics { + const byType: Record = { dependency: 0, conditional: 0 }; + const linked = new Set(); + const referenced = new Set(); + + for (const link of links) { + if (link.type in byType) { + byType[link.type] += 1; + } + if (link.sourceInvoiceId) linked.add(link.sourceInvoiceId); + if (link.targetInvoiceId) linked.add(link.targetInvoiceId); + if (link.sourceInvoiceId) referenced.add(link.sourceInvoiceId); + if (link.targetInvoiceId) referenced.add(link.targetInvoiceId); + } + + const known = new Set(knownInvoiceIds); + const orphanedInvoiceIds = + knownInvoiceIds.length > 0 + ? [...referenced].filter((id) => !known.has(id)) + : []; + + return { + totalLinks: links.length, + byType, + linkedInvoiceIds: [...linked], + orphanedInvoiceIds, + circularDependencies: detectCircularDependencies(links), + }; +} diff --git a/src/lib/invoiceLinks.ts b/src/lib/invoiceLinks.ts new file mode 100644 index 0000000..1c36c91 --- /dev/null +++ b/src/lib/invoiceLinks.ts @@ -0,0 +1,200 @@ +export type InvoiceLinkType = 'dependency' | 'conditional'; + +export interface InvoiceLink { + id: string; + fromInvoiceId: string; + toInvoiceId: string; + type: InvoiceLinkType; + /** For conditional links: the linked invoice is only required when this predicate holds. */ + condition?: (context: LinkContext) => boolean; + createdAt: string; +} + +export interface LinkContext { + invoiceStatus?: Record; + [key: string]: unknown; +} + +export interface LinkValidationResult { + valid: boolean; + errors: string[]; +} + +export interface LinkAnalytics { + totalLinks: number; + byType: Record; + orphanedInvoiceIds: string[]; + circularLinkIds: string[]; + dependencyCounts: Record; +} + +let linkCounter = 0; + +function nextLinkId(): string { + linkCounter += 1; + return `link_${Date.now()}_${linkCounter}`; +} + +/** + * Create a link between two invoices. Throws on self-links or duplicate links. + */ +export function createLink( + fromInvoiceId: string, + toInvoiceId: string, + type: InvoiceLinkType, + condition?: (context: LinkContext) => boolean, +): InvoiceLink { + if (fromInvoiceId === toInvoiceId) { + throw new Error('An invoice cannot be linked to itself'); + } + if (type === 'conditional' && typeof condition !== 'function') { + throw new Error('Conditional links require a condition predicate'); + } + return { + id: nextLinkId(), + fromInvoiceId, + toInvoiceId, + type, + condition, + createdAt: new Date().toISOString(), + }; +} + +/** + * Validate a set of links: no self-links, no duplicates, no dangling references. + */ +export function validateLinks( + links: InvoiceLink[], + knownInvoiceIds: string[], +): LinkValidationResult { + const errors: string[] = []; + const known = new Set(knownInvoiceIds); + const seen = new Set(); + + for (const link of links) { + if (link.fromInvoiceId === link.toInvoiceId) { + errors.push(`Link ${link.id} references the same invoice on both ends`); + } + if (!known.has(link.fromInvoiceId)) { + errors.push(`Link ${link.id} has unknown source invoice ${link.fromInvoiceId}`); + } + if (!known.has(link.toInvoiceId)) { + errors.push(`Link ${link.id} has unknown target invoice ${link.toInvoiceId}`); + } + const key = `${link.fromInvoiceId}->${link.toInvoiceId}:${link.type}`; + if (seen.has(key)) { + errors.push(`Duplicate ${link.type} link from ${link.fromInvoiceId} to ${link.toInvoiceId}`); + } + seen.add(key); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Detect circular dependencies among dependency links using DFS. + * Returns the ids of links that participate in a cycle. + */ +export function detectCircularDependencies(links: InvoiceLink[]): string[] { + const adjacency = new Map(); + for (const link of links) { + if (link.type !== 'dependency') continue; + const list = adjacency.get(link.fromInvoiceId) ?? []; + list.push(link); + adjacency.set(link.fromInvoiceId, list); + } + + const circular = new Set(); + const visiting = new Set(); + const visited = new Set(); + const stack: InvoiceLink[] = []; + + function visit(node: string): void { + if (visiting.has(node)) { + const cycleStart = stack.findIndex((l) => l.fromInvoiceId === node); + for (const link of stack.slice(cycleStart)) { + circular.add(link.id); + } + return; + } + if (visited.has(node)) return; + visiting.add(node); + for (const link of adjacency.get(node) ?? []) { + stack.push(link); + visit(link.toInvoiceId); + stack.pop(); + } + visiting.delete(node); + visited.add(node); + } + + for (const node of adjacency.keys()) { + visit(node); + } + + return Array.from(circular); +} + +/** + * Resolve the ordered list of invoices that must be settled before the given invoice. + * Conditional links are only followed when their predicate holds for the context. + * Throws if a circular dependency is detected. + */ +export function resolveDependencies( + invoiceId: string, + links: InvoiceLink[], + context: LinkContext = {}, +): string[] { + const circular = detectCircularDependencies(links); + if (circular.length > 0) { + throw new Error(`Circular dependency detected: ${circular.join(', ')}`); + } + + const resolved: string[] = []; + const seen = new Set(); + + function walk(current: string): void { + for (const link of links) { + if (link.fromInvoiceId !== current) continue; + if (link.type === 'conditional' && link.condition && !link.condition(context)) { + continue; + } + if (seen.has(link.toInvoiceId)) continue; + seen.add(link.toInvoiceId); + walk(link.toInvoiceId); + resolved.push(link.toInvoiceId); + } + } + + walk(invoiceId); + return resolved; +} + +/** + * Compute analytics over the current link graph. + */ +export function computeLinkAnalytics( + links: InvoiceLink[], + knownInvoiceIds: string[], +): LinkAnalytics { + const byType: Record = { dependency: 0, conditional: 0 }; + const dependencyCounts: Record = {}; + const linked = new Set(); + + for (const link of links) { + byType[link.type] += 1; + linked.add(link.fromInvoiceId); + linked.add(link.toInvoiceId); + dependencyCounts[link.toInvoiceId] = (dependencyCounts[link.toInvoiceId] ?? 0) + 1; + } + + const orphanedInvoiceIds = knownInvoiceIds.filter((id) => !linked.has(id)); + + return { + totalLinks: links.length, + byType, + orphanedInvoiceIds, + circularLinkIds: detectCircularDependencies(links), + dependencyCounts, + }; +} diff --git a/src/types/invoice.ts b/src/types/invoice.ts new file mode 100644 index 0000000..1da2731 --- /dev/null +++ b/src/types/invoice.ts @@ -0,0 +1,75 @@ +export type InvoiceStatus = 'draft' | 'pending' | 'paid' | 'overdue' | 'cancelled'; + +export type InvoiceLinkType = 'dependency' | 'conditional'; + +export interface InvoiceLink { + id: string; + sourceInvoiceId: string; + targetInvoiceId: string; + type: InvoiceLinkType; + /** For conditional links: the condition that must be satisfied before the target is unblocked. */ + condition?: InvoiceLinkCondition; + createdAt: string; + createdBy?: string; +} + +export type InvoiceLinkConditionOperator = 'equals' | 'notEquals' | 'greaterThan' | 'lessThan' | 'exists'; + +export interface InvoiceLinkCondition { + field: string; + operator: InvoiceLinkConditionOperator; + value?: string | number | boolean; +} + +export interface Invoice { + id: string; + number: string; + status: InvoiceStatus; + amount: number; + currency: string; + issuedAt: string; + dueAt: string; + customerId: string; + links?: InvoiceLink[]; +} + +export interface InvoiceLinkValidationError { + code: + | 'selfLink' + | 'duplicateLink' + | 'missingInvoice' + | 'circularDependency' + | 'invalidCondition'; + message: string; + linkId?: string; + invoiceIds?: string[]; +} + +export interface InvoiceLinkValidationResult { + valid: boolean; + errors: InvoiceLinkValidationError[]; +} + +export interface InvoiceDependencyResolution { + invoiceId: string; + /** Invoices that must be resolved before this invoice can proceed. */ + blockedBy: string[]; + /** Invoices that depend on this invoice. */ + blocks: string[]; + /** True when all dependencies are resolved and conditions satisfied. */ + resolved: boolean; + /** Unresolved dependency invoice ids preventing resolution. */ + unresolved: string[]; +} + +export interface InvoiceLinkAnalytics { + totalLinks: number; + dependencyLinks: number; + conditionalLinks: number; + /** Invoices with no incoming or outgoing links. */ + orphanedInvoiceIds: string[]; + /** Detected circular dependency chains (each is an ordered list of invoice ids). */ + circularChains: string[][]; + /** Number of links per invoice id. */ + linksByInvoice: Record; +}