Skip to content

Commit 96982a5

Browse files
authored
Merge pull request #599 from Ebuka042-pixel/feature/550-invoice-version-tracker
feat(#550): add InvoiceVersionTracker for invoice version history
2 parents 8e16558 + 90efc07 commit 96982a5

3 files changed

Lines changed: 602 additions & 0 deletions

File tree

src/client.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,6 +2497,51 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
24972497
return updated;
24982498
}
24992499

2500+
// ---------------------------------------------------------------------------
2501+
// Invoice Version History integration (#550)
2502+
// ---------------------------------------------------------------------------
2503+
2504+
/**
2505+
* Update an invoice with new field values and record a version snapshot
2506+
* via {@link InvoiceVersionTracker} before overwriting the stored state.
2507+
*
2508+
* If no `versionTracker` is provided in the config, the method still applies
2509+
* the update — versioning is opt-in via config.
2510+
*
2511+
* @param invoiceId - The invoice to update.
2512+
* @param updates - Partial invoice fields to apply (merged over the current state).
2513+
* @param changedBy - The Stellar address responsible for the change.
2514+
* @returns The updated invoice after all mutations are applied.
2515+
*/
2516+
async updateInvoice(
2517+
invoiceId: string,
2518+
updates: Partial<Invoice>,
2519+
changedBy: string,
2520+
): Promise<Invoice> {
2521+
const current = await this.getInvoice(invoiceId);
2522+
2523+
// Record current state as a version BEFORE overwriting
2524+
const tracker = (this.config as Record<string, unknown>)["versionTracker"] as
2525+
| import("./invoiceVersionTracker.js").InvoiceVersionTracker
2526+
| undefined;
2527+
2528+
if (tracker) {
2529+
await tracker.record(invoiceId, current, changedBy);
2530+
}
2531+
2532+
const updated: Invoice = { ...current, ...updates };
2533+
2534+
// Write the updated invoice back into the optimistic cache so subsequent
2535+
// getInvoice() calls see the new state immediately.
2536+
if (this._optimisticCache) {
2537+
this._optimisticCache.applyOptimistic(invoiceId, updated, current).commit();
2538+
} else if (this._cache) {
2539+
this._cache.invalidate("getInvoice", [invoiceId]);
2540+
}
2541+
2542+
return updated;
2543+
}
2544+
25002545
/**
25012546
* Subscribe to typed InvoiceEvent payloads for a single invoice via the
25022547
* shared SubscriptionManager, instead of polling fetch methods. The first

src/invoiceVersionTracker.ts

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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

Comments
 (0)