From 746662ea32c189a1b9bb349574509c335941d448 Mon Sep 17 00:00:00 2001 From: Ebuka042-pixel Date: Thu, 30 Jul 2026 03:43:02 +0000 Subject: [PATCH] feat(#550): add InvoiceVersionTracker for invoice version history - Add src/invoiceVersionTracker.ts with InvoiceVersionTracker class - record(invoiceId, newSnapshot, changedBy) creates immutable version - getHistory(invoiceId) returns all versions in ascending order - diff(invoiceId, fromVersion, toVersion) returns structured InvoiceDiff - Uses src/diff.ts diffInvoices() + src/splitPreview.ts for change summary - InMemoryVersionStore default; swappable VersionStore interface for persistence - Add updateInvoice(invoiceId, updates, changedBy) to StellarSplitClient - Calls tracker.record() before overwriting stored invoice state - Add versionTracker to StellarSplitClientConfig - Export from src/index.ts - Add unit tests in test/invoiceVersionTracker.test.ts - Asserts 3 sequential updates produce 3 history entries - Asserts diff(1, 3) captures all intermediate changes --- src/client.ts | 50 +++++ src/index.ts | 12 ++ src/invoiceVersionTracker.ts | 283 +++++++++++++++++++++++++++++ test/invoiceVersionTracker.test.ts | 274 ++++++++++++++++++++++++++++ 4 files changed, 619 insertions(+) create mode 100644 src/invoiceVersionTracker.ts create mode 100644 test/invoiceVersionTracker.test.ts diff --git a/src/client.ts b/src/client.ts index d7b0d69..6f06e55 100644 --- a/src/client.ts +++ b/src/client.ts @@ -487,6 +487,11 @@ export interface StellarSplitClientConfig { * for inspecting in-flight transaction envelopes. Defaults to false. */ debug?: boolean; + /** + * Optional invoice version tracker. When provided, updateInvoice() will + * record a snapshot before applying updates, building a full change history. + */ + versionTracker?: import("./invoiceVersionTracker.js").InvoiceVersionTracker; } /** Network configuration. */ @@ -2330,6 +2335,51 @@ export class StellarSplitClient extends TypedEventEmitter { return updated; } + // --------------------------------------------------------------------------- + // Invoice Version History integration (#550) + // --------------------------------------------------------------------------- + + /** + * Update an invoice with new field values and record a version snapshot + * via {@link InvoiceVersionTracker} before overwriting the stored state. + * + * If no `versionTracker` is provided in the config, the method still applies + * the update — versioning is opt-in via config. + * + * @param invoiceId - The invoice to update. + * @param updates - Partial invoice fields to apply (merged over the current state). + * @param changedBy - The Stellar address responsible for the change. + * @returns The updated invoice after all mutations are applied. + */ + async updateInvoice( + invoiceId: string, + updates: Partial, + changedBy: string, + ): Promise { + const current = await this.getInvoice(invoiceId); + + // Record current state as a version BEFORE overwriting + const tracker = (this.config as Record)["versionTracker"] as + | import("./invoiceVersionTracker.js").InvoiceVersionTracker + | undefined; + + if (tracker) { + await tracker.record(invoiceId, current, changedBy); + } + + const updated: Invoice = { ...current, ...updates }; + + // Write the updated invoice back into the optimistic cache so subsequent + // getInvoice() calls see the new state immediately. + if (this._optimisticCache) { + this._optimisticCache.applyOptimistic(invoiceId, updated, current).commit(); + } else if (this._cache) { + this._cache.invalidate("getInvoice", [invoiceId]); + } + + return updated; + } + /** * Subscribe to typed InvoiceEvent payloads for a single invoice via the * shared SubscriptionManager, instead of polling fetch methods. The first diff --git a/src/index.ts b/src/index.ts index 38d8fe1..4171d99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1112,3 +1112,15 @@ export type { ScValPrimitive, ContractStorageExporterOptions, } from "./diagnostics/ContractStorageExporter.js"; + +// --------------------------------------------------------------------------- +// #550 — InvoiceVersionTracker: invoice version history diff tracker +// --------------------------------------------------------------------------- + +export { InvoiceVersionTracker, InMemoryVersionStore } from "./invoiceVersionTracker.js"; +export type { + InvoiceVersion, + InvoiceVersionDiff, + InvoiceVersionTrackerOptions, + VersionStore, +} from "./invoiceVersionTracker.js"; diff --git a/src/invoiceVersionTracker.ts b/src/invoiceVersionTracker.ts new file mode 100644 index 0000000..d7e3f08 --- /dev/null +++ b/src/invoiceVersionTracker.ts @@ -0,0 +1,283 @@ +/** + * Invoice Version History Diff Tracker + * + * Records ordered, immutable snapshots of invoice state on every update. + * Each version includes the full invoice snapshot, the author, timestamp, and + * a structured change summary derived from src/diff.ts. + * + * Integrates with src/client.ts updateInvoice() which creates a new version + * before overwriting the stored invoice. + * + * Snapshots are stored in memory keyed by invoiceId. For production use, + * replace the in-memory store with a persistent backend by passing a custom + * VersionStore implementation. + */ + +import type { Invoice } from "./types.js"; +import { diffInvoices } from "./diff.js"; +import type { InvoiceDiff } from "./diff.js"; +import { previewSplitRules } from "./splitPreview.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A single immutable version snapshot of an invoice. + */ +export interface InvoiceVersion { + /** 1-based version number (first snapshot = 1). */ + version: number; + /** Full invoice snapshot at this version. */ + snapshot: Readonly; + /** Stellar address of the account that made the change. */ + changedBy: string; + /** When this version was recorded. */ + changedAt: Date; + /** Human-readable summary of what changed, derived from the diff. */ + changeSummary: string; +} + +/** + * Structured diff between two {@link InvoiceVersion} entries. + */ +export interface InvoiceVersionDiff { + /** Source version number. */ + fromVersion: number; + /** Target version number. */ + toVersion: number; + /** Array of changed fields with before/after values. */ + changes: InvoiceDiff; + /** Whether any fields changed between the two versions. */ + hasChanges: boolean; +} + +// --------------------------------------------------------------------------- +// Version storage interface (swappable for persistence) +// --------------------------------------------------------------------------- + +/** Interface for backing storage of invoice versions. */ +export interface VersionStore { + /** Append a version to the history for `invoiceId`. */ + append(invoiceId: string, version: InvoiceVersion): Promise; + /** Return all versions for `invoiceId` in ascending order, or [] if none. */ + getAll(invoiceId: string): Promise; + /** Return the latest version for `invoiceId`, or null if none. */ + getLatest(invoiceId: string): Promise; +} + +/** Default in-memory version store. */ +export class InMemoryVersionStore implements VersionStore { + private readonly _store = new Map(); + + async append(invoiceId: string, version: InvoiceVersion): Promise { + const existing = this._store.get(invoiceId) ?? []; + this._store.set(invoiceId, [...existing, version]); + } + + async getAll(invoiceId: string): Promise { + return [...(this._store.get(invoiceId) ?? [])]; + } + + async getLatest(invoiceId: string): Promise { + const versions = this._store.get(invoiceId); + if (!versions || versions.length === 0) return null; + return versions[versions.length - 1]!; + } + + /** Remove all versions for an invoice (useful in tests). */ + clear(invoiceId?: string): void { + if (invoiceId) { + this._store.delete(invoiceId); + } else { + this._store.clear(); + } + } +} + +// --------------------------------------------------------------------------- +// Summary builder +// --------------------------------------------------------------------------- + +/** + * Build a short human-readable summary of the changes captured by `diff`. + * + * When no previous version exists (initial recording), returns "Initial version". + */ +function buildChangeSummary(diff: InvoiceDiff): string { + if (diff.length === 0) { + return "No fields changed."; + } + + const parts = diff.map((entry) => { + const before = formatValue(entry.before); + const after = formatValue(entry.after); + return `${entry.field}: ${before} → ${after}`; + }); + + return parts.join("; "); +} + +function formatValue(v: unknown): string { + if (v === undefined || v === null) return "(none)"; + if (typeof v === "bigint") return v.toString(); + if (Array.isArray(v)) return `[${v.length} items]`; + if (typeof v === "object") return "[object]"; + return String(v); +} + +// --------------------------------------------------------------------------- +// InvoiceVersionTracker +// --------------------------------------------------------------------------- + +/** Options for creating a {@link InvoiceVersionTracker}. */ +export interface InvoiceVersionTrackerOptions { + /** Custom version store implementation (defaults to in-memory). */ + store?: VersionStore; +} + +/** + * Tracks the full version history of invoices, storing an ordered sequence of + * immutable snapshots that can be diffed between any two versions. + * + * @example + * ```typescript + * const tracker = new InvoiceVersionTracker(); + * + * // Record initial version + * await tracker.record(invoice.id, invoice, "GCREATOR..."); + * + * // After an update: + * const updated = { ...invoice, memo: "Updated description" }; + * await tracker.record(invoice.id, updated, "GCREATOR..."); + * + * // Inspect history + * const history = await tracker.getHistory(invoice.id); + * console.log(history.length); // 2 + * + * // Diff between version 1 and 2 + * const diff = await tracker.diff(invoice.id, 1, 2); + * console.log(diff.changes); // [{ field: "memo", before: undefined, after: "Updated description" }] + * ``` + */ +export class InvoiceVersionTracker { + private readonly _store: VersionStore; + + constructor(options?: InvoiceVersionTrackerOptions) { + this._store = options?.store ?? new InMemoryVersionStore(); + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Record a new version of an invoice. + * + * If this is the first snapshot for the invoice, the version is 1 and the + * change summary is "Initial version.". Subsequent recordings compute a diff + * against the previous version to produce the change summary. + * + * Also appends a split-preview change summary when split_rules differ, using + * `generateSplitDiff` logic from `previewSplitRules`. + * + * @param invoiceId - The invoice being versioned. + * @param newSnapshot - The current (new) state of the invoice. + * @param changedBy - The Stellar address of the account making the change. + * @returns The newly created {@link InvoiceVersion}. + */ + async record( + invoiceId: string, + newSnapshot: Invoice, + changedBy: string, + ): Promise { + const latest = await this._store.getLatest(invoiceId); + const nextVersionNumber = latest ? latest.version + 1 : 1; + + let changeSummary: string; + if (!latest) { + changeSummary = "Initial version."; + } else { + const diff = diffInvoices(latest.snapshot as Invoice, newSnapshot); + changeSummary = buildChangeSummary(diff); + + // Append split-rules preview diff if rules changed + if (diff.some((d) => d.field === "split_rules")) { + const funded = newSnapshot.funded ?? 0n; + const splitInfo = previewSplitRules(newSnapshot, funded); + const splitLines = splitInfo + .map((e) => `${e.recipient}: ${e.amount} stroops`) + .join(", "); + changeSummary += ` | Split preview: [${splitLines}]`; + } + } + + const version: InvoiceVersion = { + version: nextVersionNumber, + snapshot: Object.freeze({ ...newSnapshot }), + changedBy, + changedAt: new Date(), + changeSummary, + }; + + await this._store.append(invoiceId, version); + return version; + } + + /** + * Return all recorded versions for an invoice in ascending (oldest-first) order. + * + * @param invoiceId - The invoice to retrieve history for. + * @returns Array of {@link InvoiceVersion} entries, possibly empty. + */ + async getHistory(invoiceId: string): Promise { + return this._store.getAll(invoiceId); + } + + /** + * Compute a structured diff between two version snapshots. + * + * @param invoiceId - The invoice to diff. + * @param fromVersion - The source version number (1-based). + * @param toVersion - The target version number (1-based). + * @returns {@link InvoiceVersionDiff} with all changed fields. + * @throws {Error} if either version does not exist. + */ + async diff( + invoiceId: string, + fromVersion: number, + toVersion: number, + ): Promise { + const history = await this._store.getAll(invoiceId); + + const from = history.find((v) => v.version === fromVersion); + const to = history.find((v) => v.version === toVersion); + + if (!from) { + throw new Error( + `Version ${fromVersion} not found for invoice "${invoiceId}".`, + ); + } + if (!to) { + throw new Error( + `Version ${toVersion} not found for invoice "${invoiceId}".`, + ); + } + + const changes = diffInvoices(from.snapshot as Invoice, to.snapshot as Invoice); + + return { + fromVersion, + toVersion, + changes, + hasChanges: changes.length > 0, + }; + } + + /** + * Return the latest version snapshot for an invoice, or `null` if none exists. + */ + async getLatest(invoiceId: string): Promise { + return this._store.getLatest(invoiceId); + } +} diff --git a/test/invoiceVersionTracker.test.ts b/test/invoiceVersionTracker.test.ts new file mode 100644 index 0000000..231e289 --- /dev/null +++ b/test/invoiceVersionTracker.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + InvoiceVersionTracker, + InMemoryVersionStore, +} from "../src/invoiceVersionTracker.js"; +import type { InvoiceVersion } from "../src/invoiceVersionTracker.js"; +import type { Invoice } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CREATOR = "GCZST3XVCDTUJ76ZAV2HA72KYTZ4KXX52HRXVWWRWXH2NBDXZWQS2FB2"; +const RECIPIENT1 = "GBRPYHIL2CI3WHSCULNJJMA3CJBYWR5LK662LFXISKW3P7UKDXTX5AGE"; +const RECIPIENT2 = "GDQJUTQYK2MQX2CBBFKVZBNE4RG4H3ZVJJJX5RFHPB4DXX6IQHTHYKF"; + +/** Minimal valid Invoice for testing. */ +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "1001", + 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, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("InvoiceVersionTracker", () => { + let tracker: InvoiceVersionTracker; + + beforeEach(() => { + tracker = new InvoiceVersionTracker(); + }); + + // ── record() ───────────────────────────────────────────────────────────── + + describe("record()", () => { + it("creates the first version with version number 1", async () => { + const invoice = makeInvoice(); + const version = await tracker.record(invoice.id, invoice, CREATOR); + + expect(version.version).toBe(1); + expect(version.changedBy).toBe(CREATOR); + expect(version.changeSummary).toBe("Initial version."); + }); + + it("creates sequential version numbers for multiple updates", async () => { + const invoice = makeInvoice(); + const v1 = await tracker.record(invoice.id, invoice, CREATOR); + const updated = { ...invoice, memo: "updated memo" }; + const v2 = await tracker.record(invoice.id, updated, CREATOR); + const updated2 = { ...updated, memo: "second update" }; + const v3 = await tracker.record(invoice.id, updated2, CREATOR); + + expect(v1.version).toBe(1); + expect(v2.version).toBe(2); + expect(v3.version).toBe(3); + }); + + it("stores an immutable snapshot (changes to original do not affect stored version)", async () => { + const invoice = makeInvoice({ memo: "original" }); + await tracker.record(invoice.id, invoice, CREATOR); + + // Mutating original should not affect the snapshot + (invoice as any).memo = "mutated"; + + const history = await tracker.getHistory(invoice.id); + expect(history[0]!.snapshot.memo).toBe("original"); + }); + + it("captures changedAt timestamp close to now", async () => { + const before = Date.now(); + const invoice = makeInvoice(); + const version = await tracker.record(invoice.id, invoice, CREATOR); + const after = Date.now(); + + expect(version.changedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(version.changedAt.getTime()).toBeLessThanOrEqual(after); + }); + + it("builds a diff-based change summary for subsequent versions", async () => { + const invoice = makeInvoice({ memo: undefined }); + await tracker.record(invoice.id, invoice, CREATOR); // v1 + + const updated = { ...invoice, memo: "Added memo" }; + const v2 = await tracker.record(invoice.id, updated, CREATOR); + + expect(v2.changeSummary).toContain("memo"); + expect(v2.changeSummary).toContain("Added memo"); + }); + }); + + // ── getHistory() ───────────────────────────────────────────────────────── + + describe("getHistory()", () => { + it("returns empty array when no versions exist", async () => { + const history = await tracker.getHistory("nonexistent-invoice"); + expect(history).toEqual([]); + }); + + it("returns all versions in ascending order", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + await tracker.record(invoice.id, { ...invoice, memo: "v2" }, CREATOR); + await tracker.record(invoice.id, { ...invoice, memo: "v3" }, CREATOR); + + const history = await tracker.getHistory(invoice.id); + expect(history).toHaveLength(3); + expect(history.map((v) => v.version)).toEqual([1, 2, 3]); + }); + + it("produces 3 history entries after 3 sequential updates", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + await tracker.record(invoice.id, { ...invoice, funded: 50_000_000n }, CREATOR); + await tracker.record(invoice.id, { ...invoice, funded: 100_000_000n }, CREATOR); + + const history = await tracker.getHistory(invoice.id); + expect(history).toHaveLength(3); + }); + }); + + // ── diff() ─────────────────────────────────────────────────────────────── + + describe("diff()", () => { + it("returns empty changes for diff between identical versions", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + await tracker.record(invoice.id, { ...invoice }, CREATOR); // identical copy + + const result = await tracker.diff(invoice.id, 1, 2); + expect(result.hasChanges).toBe(false); + expect(result.changes).toHaveLength(0); + }); + + it("captures memo change between version 1 and 2", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + const updated = { ...invoice, memo: "New memo for audit" }; + await tracker.record(invoice.id, updated, CREATOR); + + const result = await tracker.diff(invoice.id, 1, 2); + + expect(result.hasChanges).toBe(true); + expect(result.changes.some((c) => c.field === "memo")).toBe(true); + }); + + it("captures all intermediate changes between version 1 and 3", async () => { + const invoice = makeInvoice({ memo: undefined }); + await tracker.record(invoice.id, invoice, CREATOR); // v1 + + const v2 = { ...invoice, memo: "Intermediate change" }; + await tracker.record(invoice.id, v2, CREATOR); // v2 + + const v3 = { + ...v2, + funded: 50_000_000n, + memo: "Final memo", + }; + await tracker.record(invoice.id, v3, CREATOR); // v3 + + const diff13 = await tracker.diff(invoice.id, 1, 3); + + expect(diff13.fromVersion).toBe(1); + expect(diff13.toVersion).toBe(3); + expect(diff13.hasChanges).toBe(true); + + const changedFields = diff13.changes.map((c) => c.field); + expect(changedFields).toContain("memo"); + expect(changedFields).toContain("funded"); + }); + + it("throws when fromVersion does not exist", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + + await expect(tracker.diff(invoice.id, 99, 1)).rejects.toThrow(/Version 99 not found/); + }); + + it("throws when toVersion does not exist", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + + await expect(tracker.diff(invoice.id, 1, 99)).rejects.toThrow(/Version 99 not found/); + }); + + it("can diff non-consecutive versions (1 → 3 skipping 2)", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); // v1 + await tracker.record(invoice.id, { ...invoice, memo: "skip" }, CREATOR); // v2 + await tracker.record(invoice.id, { ...invoice, memo: "final" }, CREATOR); // v3 + + const result = await tracker.diff(invoice.id, 1, 3); + expect(result.changes.some((c) => c.field === "memo")).toBe(true); + }); + }); + + // ── getLatest() ─────────────────────────────────────────────────────────── + + describe("getLatest()", () => { + it("returns null when no versions exist", async () => { + expect(await tracker.getLatest("nonexistent")).toBeNull(); + }); + + it("returns the most recent version", async () => { + const invoice = makeInvoice(); + await tracker.record(invoice.id, invoice, CREATOR); + await tracker.record(invoice.id, { ...invoice, memo: "v2" }, CREATOR); + + const latest = await tracker.getLatest(invoice.id); + expect(latest?.version).toBe(2); + }); + }); + + // ── InMemoryVersionStore ────────────────────────────────────────────────── + + describe("InMemoryVersionStore", () => { + it("can clear all versions for a specific invoice", async () => { + const store = new InMemoryVersionStore(); + const localTracker = new InvoiceVersionTracker({ store }); + const invoice = makeInvoice(); + + await localTracker.record(invoice.id, invoice, CREATOR); + await localTracker.record(invoice.id, { ...invoice, memo: "v2" }, CREATOR); + + store.clear(invoice.id); + + expect(await localTracker.getHistory(invoice.id)).toHaveLength(0); + }); + + it("keeps other invoices when clearing a specific one", async () => { + const store = new InMemoryVersionStore(); + const localTracker = new InvoiceVersionTracker({ store }); + + const inv1 = makeInvoice({ id: "1001" }); + const inv2 = makeInvoice({ id: "1002" }); + + await localTracker.record(inv1.id, inv1, CREATOR); + await localTracker.record(inv2.id, inv2, CREATOR); + + store.clear(inv1.id); + + expect(await localTracker.getHistory(inv1.id)).toHaveLength(0); + expect(await localTracker.getHistory(inv2.id)).toHaveLength(1); + }); + }); + + // ── Integration: custom VersionStore ────────────────────────────────────── + + describe("custom VersionStore", () => { + it("delegates storage operations to the custom store", async () => { + const customStore: InMemoryVersionStore = new InMemoryVersionStore(); + const appendSpy = vi.spyOn(customStore, "append"); + const getAllSpy = vi.spyOn(customStore, "getAll"); + + const localTracker = new InvoiceVersionTracker({ store: customStore }); + const invoice = makeInvoice(); + + await localTracker.record(invoice.id, invoice, CREATOR); + await localTracker.getHistory(invoice.id); + + expect(appendSpy).toHaveBeenCalledTimes(1); + expect(getAllSpy).toHaveBeenCalledTimes(1); + }); + }); +});