|
| 1 | +/** |
| 2 | + * Invoice Version History Diff Tracker |
| 3 | + * |
| 4 | + * Records ordered, immutable snapshots of invoice state on every update. |
| 5 | + * Each version includes the full invoice snapshot, the author, timestamp, and |
| 6 | + * a structured change summary derived from src/diff.ts. |
| 7 | + * |
| 8 | + * Integrates with src/client.ts updateInvoice() which creates a new version |
| 9 | + * before overwriting the stored invoice. |
| 10 | + * |
| 11 | + * Snapshots are stored in memory keyed by invoiceId. For production use, |
| 12 | + * replace the in-memory store with a persistent backend by passing a custom |
| 13 | + * VersionStore implementation. |
| 14 | + */ |
| 15 | + |
| 16 | +import type { Invoice } from "./types.js"; |
| 17 | +import { diffInvoices } from "./diff.js"; |
| 18 | +import type { InvoiceDiff } from "./diff.js"; |
| 19 | +import { previewSplitRules } from "./splitPreview.js"; |
| 20 | + |
| 21 | +// --------------------------------------------------------------------------- |
| 22 | +// Types |
| 23 | +// --------------------------------------------------------------------------- |
| 24 | + |
| 25 | +/** |
| 26 | + * A single immutable version snapshot of an invoice. |
| 27 | + */ |
| 28 | +export interface InvoiceVersion { |
| 29 | + /** 1-based version number (first snapshot = 1). */ |
| 30 | + version: number; |
| 31 | + /** Full invoice snapshot at this version. */ |
| 32 | + snapshot: Readonly<Invoice>; |
| 33 | + /** Stellar address of the account that made the change. */ |
| 34 | + changedBy: string; |
| 35 | + /** When this version was recorded. */ |
| 36 | + changedAt: Date; |
| 37 | + /** Human-readable summary of what changed, derived from the diff. */ |
| 38 | + changeSummary: string; |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Structured diff between two {@link InvoiceVersion} entries. |
| 43 | + */ |
| 44 | +export interface InvoiceVersionDiff { |
| 45 | + /** Source version number. */ |
| 46 | + fromVersion: number; |
| 47 | + /** Target version number. */ |
| 48 | + toVersion: number; |
| 49 | + /** Array of changed fields with before/after values. */ |
| 50 | + changes: InvoiceDiff; |
| 51 | + /** Whether any fields changed between the two versions. */ |
| 52 | + hasChanges: boolean; |
| 53 | +} |
| 54 | + |
| 55 | +// --------------------------------------------------------------------------- |
| 56 | +// Version storage interface (swappable for persistence) |
| 57 | +// --------------------------------------------------------------------------- |
| 58 | + |
| 59 | +/** Interface for backing storage of invoice versions. */ |
| 60 | +export interface VersionStore { |
| 61 | + /** Append a version to the history for `invoiceId`. */ |
| 62 | + append(invoiceId: string, version: InvoiceVersion): Promise<void>; |
| 63 | + /** Return all versions for `invoiceId` in ascending order, or [] if none. */ |
| 64 | + getAll(invoiceId: string): Promise<InvoiceVersion[]>; |
| 65 | + /** Return the latest version for `invoiceId`, or null if none. */ |
| 66 | + getLatest(invoiceId: string): Promise<InvoiceVersion | null>; |
| 67 | +} |
| 68 | + |
| 69 | +/** Default in-memory version store. */ |
| 70 | +export class InMemoryVersionStore implements VersionStore { |
| 71 | + private readonly _store = new Map<string, InvoiceVersion[]>(); |
| 72 | + |
| 73 | + async append(invoiceId: string, version: InvoiceVersion): Promise<void> { |
| 74 | + const existing = this._store.get(invoiceId) ?? []; |
| 75 | + this._store.set(invoiceId, [...existing, version]); |
| 76 | + } |
| 77 | + |
| 78 | + async getAll(invoiceId: string): Promise<InvoiceVersion[]> { |
| 79 | + return [...(this._store.get(invoiceId) ?? [])]; |
| 80 | + } |
| 81 | + |
| 82 | + async getLatest(invoiceId: string): Promise<InvoiceVersion | null> { |
| 83 | + const versions = this._store.get(invoiceId); |
| 84 | + if (!versions || versions.length === 0) return null; |
| 85 | + return versions[versions.length - 1]!; |
| 86 | + } |
| 87 | + |
| 88 | + /** Remove all versions for an invoice (useful in tests). */ |
| 89 | + clear(invoiceId?: string): void { |
| 90 | + if (invoiceId) { |
| 91 | + this._store.delete(invoiceId); |
| 92 | + } else { |
| 93 | + this._store.clear(); |
| 94 | + } |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +// --------------------------------------------------------------------------- |
| 99 | +// Summary builder |
| 100 | +// --------------------------------------------------------------------------- |
| 101 | + |
| 102 | +/** |
| 103 | + * Build a short human-readable summary of the changes captured by `diff`. |
| 104 | + * |
| 105 | + * When no previous version exists (initial recording), returns "Initial version". |
| 106 | + */ |
| 107 | +function buildChangeSummary(diff: InvoiceDiff): string { |
| 108 | + if (diff.length === 0) { |
| 109 | + return "No fields changed."; |
| 110 | + } |
| 111 | + |
| 112 | + const parts = diff.map((entry) => { |
| 113 | + const before = formatValue(entry.before); |
| 114 | + const after = formatValue(entry.after); |
| 115 | + return `${entry.field}: ${before} → ${after}`; |
| 116 | + }); |
| 117 | + |
| 118 | + return parts.join("; "); |
| 119 | +} |
| 120 | + |
| 121 | +function formatValue(v: unknown): string { |
| 122 | + if (v === undefined || v === null) return "(none)"; |
| 123 | + if (typeof v === "bigint") return v.toString(); |
| 124 | + if (Array.isArray(v)) return `[${v.length} items]`; |
| 125 | + if (typeof v === "object") return "[object]"; |
| 126 | + return String(v); |
| 127 | +} |
| 128 | + |
| 129 | +// --------------------------------------------------------------------------- |
| 130 | +// InvoiceVersionTracker |
| 131 | +// --------------------------------------------------------------------------- |
| 132 | + |
| 133 | +/** Options for creating a {@link InvoiceVersionTracker}. */ |
| 134 | +export interface InvoiceVersionTrackerOptions { |
| 135 | + /** Custom version store implementation (defaults to in-memory). */ |
| 136 | + store?: VersionStore; |
| 137 | +} |
| 138 | + |
| 139 | +/** |
| 140 | + * Tracks the full version history of invoices, storing an ordered sequence of |
| 141 | + * immutable snapshots that can be diffed between any two versions. |
| 142 | + * |
| 143 | + * @example |
| 144 | + * ```typescript |
| 145 | + * const tracker = new InvoiceVersionTracker(); |
| 146 | + * |
| 147 | + * // Record initial version |
| 148 | + * await tracker.record(invoice.id, invoice, "GCREATOR..."); |
| 149 | + * |
| 150 | + * // After an update: |
| 151 | + * const updated = { ...invoice, memo: "Updated description" }; |
| 152 | + * await tracker.record(invoice.id, updated, "GCREATOR..."); |
| 153 | + * |
| 154 | + * // Inspect history |
| 155 | + * const history = await tracker.getHistory(invoice.id); |
| 156 | + * console.log(history.length); // 2 |
| 157 | + * |
| 158 | + * // Diff between version 1 and 2 |
| 159 | + * const diff = await tracker.diff(invoice.id, 1, 2); |
| 160 | + * console.log(diff.changes); // [{ field: "memo", before: undefined, after: "Updated description" }] |
| 161 | + * ``` |
| 162 | + */ |
| 163 | +export class InvoiceVersionTracker { |
| 164 | + private readonly _store: VersionStore; |
| 165 | + |
| 166 | + constructor(options?: InvoiceVersionTrackerOptions) { |
| 167 | + this._store = options?.store ?? new InMemoryVersionStore(); |
| 168 | + } |
| 169 | + |
| 170 | + // --------------------------------------------------------------------------- |
| 171 | + // Public API |
| 172 | + // --------------------------------------------------------------------------- |
| 173 | + |
| 174 | + /** |
| 175 | + * Record a new version of an invoice. |
| 176 | + * |
| 177 | + * If this is the first snapshot for the invoice, the version is 1 and the |
| 178 | + * change summary is "Initial version.". Subsequent recordings compute a diff |
| 179 | + * against the previous version to produce the change summary. |
| 180 | + * |
| 181 | + * Also appends a split-preview change summary when split_rules differ, using |
| 182 | + * `generateSplitDiff` logic from `previewSplitRules`. |
| 183 | + * |
| 184 | + * @param invoiceId - The invoice being versioned. |
| 185 | + * @param newSnapshot - The current (new) state of the invoice. |
| 186 | + * @param changedBy - The Stellar address of the account making the change. |
| 187 | + * @returns The newly created {@link InvoiceVersion}. |
| 188 | + */ |
| 189 | + async record( |
| 190 | + invoiceId: string, |
| 191 | + newSnapshot: Invoice, |
| 192 | + changedBy: string, |
| 193 | + ): Promise<InvoiceVersion> { |
| 194 | + const latest = await this._store.getLatest(invoiceId); |
| 195 | + const nextVersionNumber = latest ? latest.version + 1 : 1; |
| 196 | + |
| 197 | + let changeSummary: string; |
| 198 | + if (!latest) { |
| 199 | + changeSummary = "Initial version."; |
| 200 | + } else { |
| 201 | + const diff = diffInvoices(latest.snapshot as Invoice, newSnapshot); |
| 202 | + changeSummary = buildChangeSummary(diff); |
| 203 | + |
| 204 | + // Append split-rules preview diff if rules changed |
| 205 | + if (diff.some((d) => d.field === "split_rules")) { |
| 206 | + const funded = newSnapshot.funded ?? 0n; |
| 207 | + const splitInfo = previewSplitRules(newSnapshot, funded); |
| 208 | + const splitLines = splitInfo |
| 209 | + .map((e) => `${e.recipient}: ${e.amount} stroops`) |
| 210 | + .join(", "); |
| 211 | + changeSummary += ` | Split preview: [${splitLines}]`; |
| 212 | + } |
| 213 | + } |
| 214 | + |
| 215 | + const version: InvoiceVersion = { |
| 216 | + version: nextVersionNumber, |
| 217 | + snapshot: Object.freeze({ ...newSnapshot }), |
| 218 | + changedBy, |
| 219 | + changedAt: new Date(), |
| 220 | + changeSummary, |
| 221 | + }; |
| 222 | + |
| 223 | + await this._store.append(invoiceId, version); |
| 224 | + return version; |
| 225 | + } |
| 226 | + |
| 227 | + /** |
| 228 | + * Return all recorded versions for an invoice in ascending (oldest-first) order. |
| 229 | + * |
| 230 | + * @param invoiceId - The invoice to retrieve history for. |
| 231 | + * @returns Array of {@link InvoiceVersion} entries, possibly empty. |
| 232 | + */ |
| 233 | + async getHistory(invoiceId: string): Promise<InvoiceVersion[]> { |
| 234 | + return this._store.getAll(invoiceId); |
| 235 | + } |
| 236 | + |
| 237 | + /** |
| 238 | + * Compute a structured diff between two version snapshots. |
| 239 | + * |
| 240 | + * @param invoiceId - The invoice to diff. |
| 241 | + * @param fromVersion - The source version number (1-based). |
| 242 | + * @param toVersion - The target version number (1-based). |
| 243 | + * @returns {@link InvoiceVersionDiff} with all changed fields. |
| 244 | + * @throws {Error} if either version does not exist. |
| 245 | + */ |
| 246 | + async diff( |
| 247 | + invoiceId: string, |
| 248 | + fromVersion: number, |
| 249 | + toVersion: number, |
| 250 | + ): Promise<InvoiceVersionDiff> { |
| 251 | + const history = await this._store.getAll(invoiceId); |
| 252 | + |
| 253 | + const from = history.find((v) => v.version === fromVersion); |
| 254 | + const to = history.find((v) => v.version === toVersion); |
| 255 | + |
| 256 | + if (!from) { |
| 257 | + throw new Error( |
| 258 | + `Version ${fromVersion} not found for invoice "${invoiceId}".`, |
| 259 | + ); |
| 260 | + } |
| 261 | + if (!to) { |
| 262 | + throw new Error( |
| 263 | + `Version ${toVersion} not found for invoice "${invoiceId}".`, |
| 264 | + ); |
| 265 | + } |
| 266 | + |
| 267 | + const changes = diffInvoices(from.snapshot as Invoice, to.snapshot as Invoice); |
| 268 | + |
| 269 | + return { |
| 270 | + fromVersion, |
| 271 | + toVersion, |
| 272 | + changes, |
| 273 | + hasChanges: changes.length > 0, |
| 274 | + }; |
| 275 | + } |
| 276 | + |
| 277 | + /** |
| 278 | + * Return the latest version snapshot for an invoice, or `null` if none exists. |
| 279 | + */ |
| 280 | + async getLatest(invoiceId: string): Promise<InvoiceVersion | null> { |
| 281 | + return this._store.getLatest(invoiceId); |
| 282 | + } |
| 283 | +} |
0 commit comments