From b44df76eecf5cd32be05eba4505d7a52a653fe93 Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Thu, 20 Aug 2026 23:08:03 +0800 Subject: [PATCH] feat: add cancelReminder/getPendingReminders to scheduler + receipt registry (Closes #616, #613) --- src/index.ts | 4 ++ src/invoiceReminderScheduler.ts | 57 +++++++++++++++++++++ src/receipt.ts | 43 ++++++++++++++++ test/invoiceReminderScheduler.test.ts | 74 +++++++++++++++++++++++++++ test/receipt.test.ts | 60 +++++++++++++++++++++- 5 files changed, 237 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2fe2b4a..c46f0fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -638,6 +638,10 @@ export { serializePaymentReceipt, deserializePaymentReceipt, finalizePaymentReceipt, + registerReceipt, + getReceiptByTxHash, + getAllReceipts, + clearReceipts, } from "./receipt.js"; export type { PaymentReceipt, diff --git a/src/invoiceReminderScheduler.ts b/src/invoiceReminderScheduler.ts index c70ac7e..3aa97d9 100644 --- a/src/invoiceReminderScheduler.ts +++ b/src/invoiceReminderScheduler.ts @@ -99,6 +99,63 @@ export class InvoiceReminderScheduler extends TypedEventEmitter { + const dueAt = await this.getDueAt(invoiceId); + const entry: ReminderSchedule = { + id: randomUUID(), + invoiceId, + offsetMs, + dueAt, + fireAt: dueAt - offsetMs, + status: "pending", + }; + this.schedules.push(entry); + this._arm(entry); + this._persist(); + return entry.id; + } + + /** + * Cancel a single reminder by its opaque reminder ID. + * Returns `true` when the reminder was pending and is now cancelled; + * returns `false` if the ID is unknown or the reminder already fired. + */ + cancelReminder(reminderId: string): boolean { + const entry = this.schedules.find((s) => s.id === reminderId); + if (!entry || entry.status !== "pending") return false; + + const timer = this.timers.get(reminderId); + if (timer !== undefined) { + clearTimeout(timer); + this.timers.delete(reminderId); + } + entry.status = "cancelled"; + this._persist(); + return true; + } + + /** + * Return all not-yet-fired, not-cancelled reminders as lightweight + * `{ reminderId, invoiceId, remindAt }` descriptors. + */ + getPendingReminders(): Array<{ reminderId: string; invoiceId: string; remindAt: number }> { + return this.schedules + .filter((s) => s.status === "pending") + .map((s) => ({ reminderId: s.id, invoiceId: s.invoiceId, remindAt: s.fireAt })); + } + + /** Clear all reminders and their timers. Useful for testing/teardown. */ + clearAllReminders(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.schedules = []; + this._persist(); + } + /** Remove all pending reminders for an invoice from the store. */ cancel(invoiceId: string): void { const cancelled = this.schedules.filter( diff --git a/src/receipt.ts b/src/receipt.ts index 62a63c1..0c9fd5d 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -63,6 +63,49 @@ export interface InvoiceFetcher { getInvoice(invoiceId: string): Promise; } +// --------------------------------------------------------------------------- +// Receipt registry (module-level singleton) +// --------------------------------------------------------------------------- + +/** + * Module-level receipt registry keyed by transaction hash. + * + * Receipts registered here are retained in memory (per process) and can be + * looked up by their transaction hash after creation. Useful for workflow + * steps that need to correlate a previously generated receipt with a later + * transaction fingerprint. + */ +const receiptRegistry = new Map(); + +/** + * Store a receipt in the module-level registry, keyed by `receipt.proofHash`/`txHash`. + * Receipts keyed by the same hash are overwritten. + */ +export function registerReceipt(receipt: PaymentReceipt): void { + const key = (receipt as PaymentReceipt & { txHash?: string }).txHash ?? receipt.proofHash; + receiptRegistry.set(key, receipt); +} + +/** + * Retrieve a previously registered receipt by its transaction hash + * (or proof hash). Returns `null` when no receipt matches. + */ +export function getReceiptByTxHash(txHash: string): PaymentReceipt | null { + return receiptRegistry.get(txHash) ?? null; +} + +/** + * Return all registered receipts ordered by `generatedAt` ascending. + */ +export function getAllReceipts(): PaymentReceipt[] { + return [...receiptRegistry.values()].sort((a, b) => a.generatedAt - b.generatedAt); +} + +/** Clear the module-level registry. Useful for test teardown. */ +export function clearReceipts(): void { + receiptRegistry.clear(); +} + /** * Compile a payment receipt synchronously from a known Invoice object. * Works for both completed and in-progress invoices. diff --git a/test/invoiceReminderScheduler.test.ts b/test/invoiceReminderScheduler.test.ts index 8c53fcf..0d6e6c1 100644 --- a/test/invoiceReminderScheduler.test.ts +++ b/test/invoiceReminderScheduler.test.ts @@ -152,4 +152,78 @@ describe("InvoiceReminderScheduler", () => { expect(events).toHaveLength(0); expect(scheduler.list()[0]!.status).toBe("expired"); }); + + it("scheduleReminder returns a unique opaque reminder ID", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const id1 = await scheduler.scheduleReminder(INVOICE_ID, 60 * 60 * 1000); + const id2 = await scheduler.scheduleReminder(INVOICE_ID, 30 * 60 * 1000); + + expect(typeof id1).toBe("string"); + expect(id1).not.toBe(id2); + }); + + it("cancelReminder cancels a pending reminder and prevents it from firing", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + const id = await scheduler.scheduleReminder(INVOICE_ID, 60 * 60 * 1000); + + expect(scheduler.cancelReminder(id)).toBe(true); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); + expect(events).toHaveLength(0); + expect(scheduler.list()[0]!.status).toBe("cancelled"); + }); + + it("cancelReminder returns false for an unknown ID", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + + expect(scheduler.cancelReminder("does-not-exist")).toBe(false); + }); + + it("cancelReminder returns false for an already-fired reminder", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + const id = await scheduler.scheduleReminder(INVOICE_ID, 60 * 60 * 1000); + vi.advanceTimersByTime(DUE_AT - 60 * 60 * 1000 - NOW); + expect(events).toHaveLength(1); + + expect(scheduler.cancelReminder(id)).toBe(false); + }); + + it("getPendingReminders excludes cancelled and fired reminders", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const keepId = await scheduler.scheduleReminder(INVOICE_ID, 60 * 60 * 1000); + const cancelId = await scheduler.scheduleReminder(INVOICE_ID, 30 * 60 * 1000); + const fireId = await scheduler.scheduleReminder(INVOICE_ID, 12 * 60 * 60 * 1000); + + scheduler.cancelReminder(cancelId); + vi.advanceTimersByTime(DUE_AT - 12 * 60 * 60 * 1000 - NOW); + // fireId fired; keepId (60m before due) and cancelId (30m before due) not reached yet + + const pending = scheduler.getPendingReminders(); + expect(pending).toHaveLength(1); + expect(pending[0]!.reminderId).toBe(keepId); + expect(pending[0]!.invoiceId).toBe(INVOICE_ID); + expect(pending[0]!.remindAt).toBe(DUE_AT - 60 * 60 * 1000); + }); + + it("clearAllReminders removes all scheduled reminders and timers", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + await scheduler.scheduleReminder(INVOICE_ID, 60 * 60 * 1000); + await scheduler.scheduleReminder(INVOICE_ID, 30 * 60 * 1000); + + scheduler.clearAllReminders(); + expect(scheduler.list()).toHaveLength(0); + expect(scheduler.getPendingReminders()).toHaveLength(0); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); + expect(events).toHaveLength(0); + }); }); diff --git a/test/receipt.test.ts b/test/receipt.test.ts index 89f4340..41c56c2 100644 --- a/test/receipt.test.ts +++ b/test/receipt.test.ts @@ -1,10 +1,14 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { createHash } from "crypto"; import { compilePaymentReceipt, generatePaymentReceipt, serializePaymentReceipt, deserializePaymentReceipt, + registerReceipt, + getReceiptByTxHash, + getAllReceipts, + clearReceipts, } from "../src/receipt.js"; import type { Invoice, CollectionPage } from "../src/types.js"; @@ -137,3 +141,57 @@ describe("generatePaymentReceipt", () => { expect(receipt.effectSummary).toBeUndefined(); }); }); + +describe("receipt registry", () => { + beforeEach(() => { + clearReceipts(); + }); + + it("registers and retrieves a receipt by hash", () => { + const receipt = compilePaymentReceipt(mockInvoice, "GPAYER_A"); + registerReceipt(receipt); + + expect(getReceiptByTxHash(receipt.proofHash)).toEqual(receipt); + }); + + it("returns null for an unknown hash", () => { + expect(getReceiptByTxHash("unknown-hash")).toBeNull(); + }); + + it("returns all receipts ordered by generatedAt ascending", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_700_000_000_000); + const first = compilePaymentReceipt(mockInvoice, "GPAYER_A"); + + vi.setSystemTime(1_700_000_001_000); + const second = compilePaymentReceipt({ ...mockInvoice, id: "inv_98" }, "GPAYER_B"); + + vi.setSystemTime(1_700_000_000_500); + const third = compilePaymentReceipt({ ...mockInvoice, id: "inv_97" }, "GPAYER_C"); + + registerReceipt(second); + registerReceipt(first); + registerReceipt(third); + + const pending = getAllReceipts(); + expect(pending.map((r) => r.proofHash)).toEqual([ + first.proofHash, + third.proofHash, + second.proofHash, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("clearReceipts empties the registry", () => { + const receipt = compilePaymentReceipt(mockInvoice, "GPAYER_A"); + registerReceipt(receipt); + expect(getReceiptByTxHash(receipt.proofHash)).not.toBeNull(); + + clearReceipts(); + expect(getReceiptByTxHash(receipt.proofHash)).toBeNull(); + expect(getAllReceipts()).toEqual([]); + }); +});