From 1fb08a69e6a32f2d3c6af30300cacc6a97da8fb6 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Sat, 29 Aug 2026 10:08:50 -0400 Subject: [PATCH] feat: add cancelReminder and getPendingReminders to invoiceReminderScheduler (#616) --- src/index.ts | 17 ++- src/invoiceReminderScheduler.ts | 172 +++++++++++++++++++++++- src/types.ts | 10 ++ test/invoiceReminderScheduler.test.ts | 181 ++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 35bb785..11f6597 100644 --- a/src/index.ts +++ b/src/index.ts @@ -792,14 +792,27 @@ export type { export { ScheduledPaymentManager } from "./scheduler.js"; export type { ScheduledPayment } from "./scheduler.js"; -export { InvoiceReminderScheduler, DEFAULT_GRACE_PERIOD_MS } from "./invoiceReminderScheduler.js"; +export { + InvoiceReminderScheduler, + DEFAULT_GRACE_PERIOD_MS, + scheduleReminder, + cancelReminder, + getPendingReminders, + clearAllReminders, +} from "./invoiceReminderScheduler.js"; export type { InvoiceReminderSchedulerEventMap, InvoiceDueAtResolver, InvoiceReminderSchedulerOptions, + ScheduleReminderOptions, } from "./invoiceReminderScheduler.js"; export { loadReminderSchedules, saveReminderSchedules } from "./snapshot.js"; -export type { ReminderSchedule, ReminderEvent, ReminderStatus } from "./types.js"; +export type { + ReminderSchedule, + ReminderEvent, + ReminderStatus, + PendingReminder, +} from "./types.js"; export { compileFilter, applyFilter, FilterIndex } from "./invoiceFilter.js"; export type { FilterCriteria, CompiledFilter } from "./invoiceFilter.js"; diff --git a/src/invoiceReminderScheduler.ts b/src/invoiceReminderScheduler.ts index c70ac7e..a5b085e 100644 --- a/src/invoiceReminderScheduler.ts +++ b/src/invoiceReminderScheduler.ts @@ -17,7 +17,9 @@ import { randomUUID } from "crypto"; import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; import { loadReminderSchedules, saveReminderSchedules } from "./snapshot.js"; -import type { ReminderSchedule, ReminderEvent } from "./types.js"; +import type { ReminderSchedule, ReminderEvent, PendingReminder } from "./types.js"; + +export type { PendingReminder }; /** Events emitted by {@link InvoiceReminderScheduler}. */ export interface InvoiceReminderSchedulerEventMap { @@ -118,6 +120,47 @@ export class InvoiceReminderScheduler extends TypedEventEmitter 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 for this scheduler instance. + */ + getPendingReminders(): PendingReminder[] { + return this.schedules + .filter((s) => s.status === "pending") + .map((s) => ({ + reminderId: s.id, + invoiceId: s.invoiceId, + remindAt: s.fireAt, + })); + } + + /** + * Clear all pending reminders, stopping all timers and clearing persisted state. + */ + clearAllReminders(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.schedules = []; + this._persist(); + } + /** Return the current set of reminder schedules (all invoices, all statuses). */ list(): ReminderSchedule[] { return [...this.schedules]; @@ -173,3 +216,130 @@ export class InvoiceReminderScheduler extends TypedEventEmitter void | Promise; +} + +interface StandaloneReminderEntry { + reminderId: string; + invoiceId: string; + remindAt: number; + status: "pending" | "fired" | "cancelled"; + timer?: ReturnType; + callback?: () => void | Promise; +} + +const activeStandaloneReminders = new Map(); + +/** + * Schedules a reminder to fire at `remindAt` (Unix timestamp in milliseconds). + * + * @param invoiceIdOrOptions - Invoice identifier or an options object + * @param remindAt - Unix timestamp (ms) when the reminder should fire + * @param callback - Optional callback executed when the reminder fires + * @returns reminderId - Opaque unique identifier for the reminder + */ +export function scheduleReminder( + invoiceIdOrOptions: string | ScheduleReminderOptions, + remindAt?: number, + callback?: () => void | Promise, +): string { + let invoiceId: string; + let targetRemindAt: number; + let targetCallback: (() => void | Promise) | undefined; + + if (typeof invoiceIdOrOptions === "object" && invoiceIdOrOptions !== null) { + invoiceId = invoiceIdOrOptions.invoiceId; + targetRemindAt = invoiceIdOrOptions.remindAt; + targetCallback = invoiceIdOrOptions.callback; + } else { + invoiceId = invoiceIdOrOptions; + targetRemindAt = remindAt!; + targetCallback = callback; + } + + const reminderId = randomUUID(); + const delayMs = Math.max(0, targetRemindAt - Date.now()); + + const entry: StandaloneReminderEntry = { + reminderId, + invoiceId, + remindAt: targetRemindAt, + status: "pending", + callback: targetCallback, + }; + + const timer = setTimeout(async () => { + if (entry.status !== "pending") return; + entry.status = "fired"; + entry.timer = undefined; + if (entry.callback) { + try { + await entry.callback(); + } catch { + /* prevent unhandled rejection from bubbling to timer loop */ + } + } + }, delayMs); + + entry.timer = timer; + activeStandaloneReminders.set(reminderId, entry); + return reminderId; +} + +/** + * Cancels a reminder by its reminderId. + * + * @param reminderId - Unique ID of the reminder to cancel + * @returns true if reminder was pending and cancelled; false if unknown or already fired + */ +export function cancelReminder(reminderId: string): boolean { + const entry = activeStandaloneReminders.get(reminderId); + if (!entry || entry.status !== "pending") { + return false; + } + if (entry.timer !== undefined) { + clearTimeout(entry.timer); + entry.timer = undefined; + } + entry.status = "cancelled"; + return true; +} + +/** + * Returns all not-yet-fired, not-cancelled reminders. + */ +export function getPendingReminders(): PendingReminder[] { + const pending: PendingReminder[] = []; + for (const entry of activeStandaloneReminders.values()) { + if (entry.status === "pending") { + pending.push({ + reminderId: entry.reminderId, + invoiceId: entry.invoiceId, + remindAt: entry.remindAt, + }); + } + } + return pending; +} + +/** + * Cancels all pending reminders and clears scheduler state (for test teardown). + */ +export function clearAllReminders(): void { + for (const entry of activeStandaloneReminders.values()) { + if (entry.timer !== undefined) { + clearTimeout(entry.timer); + entry.timer = undefined; + } + } + activeStandaloneReminders.clear(); +} diff --git a/src/types.ts b/src/types.ts index ee300a5..6f1fa70 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1388,6 +1388,16 @@ export interface ReminderEvent { dueAt: number; } +/** Pending reminder descriptor returned by getPendingReminders. */ +export interface PendingReminder { + /** Unique identifier of the scheduled reminder. */ + reminderId: string; + /** Invoice the reminder is associated with. */ + invoiceId: string; + /** Unix timestamp (milliseconds) when the reminder is scheduled to fire. */ + remindAt: number; +} + // --------------------------------------------------------------------------- // XDR Decoder Types // --------------------------------------------------------------------------- diff --git a/test/invoiceReminderScheduler.test.ts b/test/invoiceReminderScheduler.test.ts index 8c53fcf..0c1f034 100644 --- a/test/invoiceReminderScheduler.test.ts +++ b/test/invoiceReminderScheduler.test.ts @@ -152,4 +152,185 @@ describe("InvoiceReminderScheduler", () => { expect(events).toHaveLength(0); expect(scheduler.list()[0]!.status).toBe("expired"); }); + + describe("cancelReminder and getPendingReminders (instance methods)", () => { + it("cancels a specific reminder by ID and prevents its event from firing", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + const schedules = await scheduler.schedule(INVOICE_ID, [60 * 60 * 1000, 30 * 60 * 1000]); + const [first, second] = schedules; + + const cancelRes = scheduler.cancelReminder(first.id); + expect(cancelRes).toBe(true); + + const pending = scheduler.getPendingReminders(); + expect(pending).toHaveLength(1); + expect(pending[0].reminderId).toBe(second.id); + expect(pending[0].invoiceId).toBe(INVOICE_ID); + expect(pending[0].remindAt).toBe(second.fireAt); + + // Advance past both reminder times + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + + // Only the second uncancelled reminder should have fired + expect(events).toHaveLength(1); + expect(events[0].offsetMs).toBe(second.offsetMs); + }); + + it("returns false when cancelling unknown ID or already-fired reminder", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + expect(scheduler.cancelReminder("non_existent_id")).toBe(false); + + const [reminder] = await scheduler.schedule(INVOICE_ID, [60 * 60 * 1000]); + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + + expect(scheduler.cancelReminder(reminder.id)).toBe(false); + }); + + it("clearAllReminders() cancels all pending reminders on the instance", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + await scheduler.schedule(INVOICE_ID, [60 * 60 * 1000, 30 * 60 * 1000]); + expect(scheduler.getPendingReminders()).toHaveLength(2); + + scheduler.clearAllReminders(); + expect(scheduler.getPendingReminders()).toHaveLength(0); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(events).toHaveLength(0); + }); + }); +}); + +describe("Direct module-level reminder functions", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(async () => { + const { clearAllReminders } = await import("../src/invoiceReminderScheduler.js"); + clearAllReminders(); + vi.useRealTimers(); + }); + + it("scheduleReminder returns a unique reminderId", async () => { + const { scheduleReminder } = await import("../src/invoiceReminderScheduler.js"); + const id1 = scheduleReminder(INVOICE_ID, NOW + 10_000); + const id2 = scheduleReminder(INVOICE_ID, NOW + 20_000); + + expect(typeof id1).toBe("string"); + expect(typeof id2).toBe("string"); + expect(id1).not.toBe(id2); + expect(id1.length).toBeGreaterThan(0); + }); + + it("cancel before fire prevents callback and returns true", async () => { + const { scheduleReminder, cancelReminder } = await import("../src/invoiceReminderScheduler.js"); + let called = false; + const reminderId = scheduleReminder(INVOICE_ID, NOW + 5_000, () => { + called = true; + }); + + const result = cancelReminder(reminderId); + expect(result).toBe(true); + + vi.advanceTimersByTime(10_000); + expect(called).toBe(false); + }); + + it("cancel unknown ID returns false", async () => { + const { cancelReminder } = await import("../src/invoiceReminderScheduler.js"); + const result = cancelReminder("unknown_id_xyz"); + expect(result).toBe(false); + }); + + it("cancel already-fired returns false", async () => { + const { scheduleReminder, cancelReminder } = await import("../src/invoiceReminderScheduler.js"); + let called = false; + const reminderId = scheduleReminder(INVOICE_ID, NOW + 5_000, () => { + called = true; + }); + + vi.advanceTimersByTime(6_000); + expect(called).toBe(true); + + const result = cancelReminder(reminderId); + expect(result).toBe(false); + }); + + it("cancel already-cancelled returns false", async () => { + const { scheduleReminder, cancelReminder } = await import("../src/invoiceReminderScheduler.js"); + const reminderId = scheduleReminder(INVOICE_ID, NOW + 5_000); + + expect(cancelReminder(reminderId)).toBe(true); + expect(cancelReminder(reminderId)).toBe(false); + }); + + it("getPendingReminders excludes cancelled and fired reminders", async () => { + const { scheduleReminder, cancelReminder, getPendingReminders } = await import( + "../src/invoiceReminderScheduler.js" + ); + + const id1 = scheduleReminder("inv_1", NOW + 5_000); + const id2 = scheduleReminder("inv_2", NOW + 10_000); + const id3 = scheduleReminder("inv_3", NOW + 15_000); + + const initialPending = getPendingReminders(); + expect(initialPending).toHaveLength(3); + expect(initialPending).toContainEqual({ reminderId: id1, invoiceId: "inv_1", remindAt: NOW + 5_000 }); + expect(initialPending).toContainEqual({ reminderId: id2, invoiceId: "inv_2", remindAt: NOW + 10_000 }); + expect(initialPending).toContainEqual({ reminderId: id3, invoiceId: "inv_3", remindAt: NOW + 15_000 }); + + // Cancel id2 + cancelReminder(id2); + const afterCancel = getPendingReminders(); + expect(afterCancel).toHaveLength(2); + expect(afterCancel.some((r) => r.reminderId === id2)).toBe(false); + + // Fire id1 + vi.advanceTimersByTime(6_000); + const afterFire = getPendingReminders(); + expect(afterFire).toHaveLength(1); + expect(afterFire[0].reminderId).toBe(id3); + }); + + it("clearAllReminders cancels all pending reminders and prevents callbacks", async () => { + const { scheduleReminder, clearAllReminders, getPendingReminders } = await import( + "../src/invoiceReminderScheduler.js" + ); + + let called1 = false; + let called2 = false; + + scheduleReminder("inv_1", NOW + 5_000, () => { + called1 = true; + }); + scheduleReminder("inv_2", NOW + 10_000, () => { + called2 = true; + }); + + expect(getPendingReminders()).toHaveLength(2); + + clearAllReminders(); + expect(getPendingReminders()).toHaveLength(0); + + vi.advanceTimersByTime(20_000); + expect(called1).toBe(false); + expect(called2).toBe(false); + }); + + it("functions and types are accessible from module export", async () => { + const exports = await import("../src/invoiceReminderScheduler.js"); + expect(typeof exports.scheduleReminder).toBe("function"); + expect(typeof exports.cancelReminder).toBe("function"); + expect(typeof exports.getPendingReminders).toBe("function"); + expect(typeof exports.clearAllReminders).toBe("function"); + expect(typeof exports.InvoiceReminderScheduler).toBe("function"); + expect(typeof exports.DEFAULT_GRACE_PERIOD_MS).toBe("number"); + }); });