diff --git a/src/index.ts b/src/index.ts index 85eae2a..87e66d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -531,7 +531,7 @@ export { watchContractUpgrade } from "./upgrade.js"; export { calculateFee } from "./fee.js"; -export { formatAddress, parseAmount, formatAmount } from "./utils.js"; +export { formatAddress } from "./utils.js"; export { resolveToken } from "./token.js"; @@ -1156,3 +1156,20 @@ export type { MemoryReport } from "./memoryOptimizer.js"; // Streaming Datasets (#1139) export { StreamingDatasetIterator } from "./streamingDataset.js"; export type { PageResult, StreamingDatasetOptions } from "./streamingDataset.js"; + +// Invoice Reminder Scheduler (#616) +export { + InvoiceReminderScheduler, + DEFAULT_GRACE_PERIOD_MS, + scheduleReminder, + cancelReminder, + getPendingReminders, + clearAllReminders, +} from "./invoiceReminderScheduler.js"; +export type { + InvoiceReminderSchedulerEventMap, + InvoiceDueAtResolver, + InvoiceReminderSchedulerOptions, + PendingReminder, + ScheduleReminderOptions, +} from "./invoiceReminderScheduler.js"; diff --git a/src/invoiceReminderScheduler.ts b/src/invoiceReminderScheduler.ts index c70ac7e..1849d38 100644 --- a/src/invoiceReminderScheduler.ts +++ b/src/invoiceReminderScheduler.ts @@ -118,6 +118,44 @@ 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. */ + getPendingReminders(): PendingReminder[] { + return this.schedules + .filter((s) => s.status === "pending") + .map((s) => ({ + reminderId: s.id, + invoiceId: s.invoiceId, + remindAt: s.fireAt, + })); + } + + /** Clear all reminders and stop all active timers for teardown. */ + 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 +211,121 @@ export class InvoiceReminderScheduler extends TypedEventEmitter void; + onRemind?: (invoiceId: string) => void; +} + +interface StandaloneReminderRecord extends PendingReminder { + status: "pending" | "fired" | "cancelled"; + timer?: ReturnType; + callback?: (invoiceId: string) => void; +} + +const standaloneReminders = new Map(); + +/** + * Schedule an invoice reminder to fire at `remindAt` (Unix timestamp in ms or `Date`). + * + * @returns An opaque unique `reminderId` string that can be passed to {@link cancelReminder}. + */ +export function scheduleReminder( + invoiceIdOrOptions: string | ScheduleReminderOptions, + remindAt?: number | Date, + callback?: (invoiceId: string) => void, +): string { + const invoiceId = + typeof invoiceIdOrOptions === "string" + ? invoiceIdOrOptions + : invoiceIdOrOptions.invoiceId; + const rawRemindAt = + typeof invoiceIdOrOptions === "string" + ? remindAt! + : invoiceIdOrOptions.remindAt; + const cb = + typeof invoiceIdOrOptions === "string" + ? callback + : (invoiceIdOrOptions.callback ?? invoiceIdOrOptions.onRemind); + + const remindAtMs = + rawRemindAt instanceof Date ? rawRemindAt.getTime() : Number(rawRemindAt); + const reminderId = randomUUID(); + const delayMs = Math.max(0, remindAtMs - Date.now()); + + const record: StandaloneReminderRecord = { + reminderId, + invoiceId, + remindAt: remindAtMs, + status: "pending", + callback: cb, + }; + + record.timer = setTimeout(() => { + const current = standaloneReminders.get(reminderId); + if (!current || current.status !== "pending") return; + current.status = "fired"; + current.timer = undefined; + current.callback?.(current.invoiceId); + }, delayMs); + + standaloneReminders.set(reminderId, record); + return reminderId; +} + +/** + * Cancel a scheduled reminder by its `reminderId`. + * + * @returns `true` if the reminder was pending and has been cancelled; `false` if the ID is unknown or already fired/cancelled. + */ +export function cancelReminder(reminderId: string): boolean { + const record = standaloneReminders.get(reminderId); + if (!record || record.status !== "pending") { + return false; + } + if (record.timer !== undefined) { + clearTimeout(record.timer); + record.timer = undefined; + } + record.status = "cancelled"; + return true; +} + +/** + * Return all not-yet-fired, not-cancelled reminders scheduled via {@link scheduleReminder}. + */ +export function getPendingReminders(): PendingReminder[] { + const pending: PendingReminder[] = []; + for (const record of standaloneReminders.values()) { + if (record.status === "pending") { + pending.push({ + reminderId: record.reminderId, + invoiceId: record.invoiceId, + remindAt: record.remindAt, + }); + } + } + return pending; +} + +/** + * Clear all reminders and stop their timers (intended for test teardown). + */ +export function clearAllReminders(): void { + for (const record of standaloneReminders.values()) { + if (record.timer !== undefined) { + clearTimeout(record.timer); + } + } + standaloneReminders.clear(); +} diff --git a/test/invoiceReminderScheduler.test.ts b/test/invoiceReminderScheduler.test.ts index 8c53fcf..ffba9a8 100644 --- a/test/invoiceReminderScheduler.test.ts +++ b/test/invoiceReminderScheduler.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { InvoiceReminderScheduler, DEFAULT_GRACE_PERIOD_MS, + scheduleReminder, + cancelReminder, + getPendingReminders, + clearAllReminders, } from "../src/invoiceReminderScheduler.js"; +import * as sdkIndex from "../src/index.js"; import { loadReminderSchedules } from "../src/snapshot.js"; import type { ReminderEvent } from "../src/types.js"; @@ -152,4 +157,112 @@ describe("InvoiceReminderScheduler", () => { expect(events).toHaveLength(0); expect(scheduler.list()[0]!.status).toBe("expired"); }); + + it("supports cancelReminder, getPendingReminders, and clearAllReminders on InvoiceReminderScheduler instance", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + const created = await scheduler.schedule(INVOICE_ID, [60 * 60 * 1000, 30 * 60 * 1000]); + expect(scheduler.getPendingReminders()).toHaveLength(2); + + const firstId = created[0]!.id; + const secondId = created[1]!.id; + expect(scheduler.cancelReminder(firstId)).toBe(true); + expect(scheduler.cancelReminder(firstId)).toBe(false); + expect(scheduler.cancelReminder("unknown-id")).toBe(false); + + const pending = scheduler.getPendingReminders(); + expect(pending).toEqual([ + { + reminderId: secondId, + invoiceId: INVOICE_ID, + remindAt: DUE_AT - 30 * 60 * 1000, + }, + ]); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); + expect(events).toHaveLength(1); + expect(events[0]!.offsetMs).toBe(30 * 60 * 1000); + expect(scheduler.cancelReminder(secondId)).toBe(false); + + await scheduler.schedule("inv_456", [10 * 60 * 1000]); + expect(scheduler.getPendingReminders()).toHaveLength(1); + scheduler.clearAllReminders(); + expect(scheduler.getPendingReminders()).toEqual([]); + }); +}); + +describe("scheduleReminder / cancelReminder / getPendingReminders / clearAllReminders (#616)", () => { + beforeEach(() => { + clearAllReminders(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + clearAllReminders(); + vi.useRealTimers(); + }); + + it("re-exports reminder helpers from src/index.ts", () => { + expect(typeof sdkIndex.scheduleReminder).toBe("function"); + expect(typeof sdkIndex.cancelReminder).toBe("function"); + expect(typeof sdkIndex.getPendingReminders).toBe("function"); + expect(typeof sdkIndex.clearAllReminders).toBe("function"); + }); + + it("cancelling before fire prevents callback execution and excludes it from getPendingReminders()", () => { + const callback = vi.fn(); + const remindAt = NOW + 60_000; + const reminderId = scheduleReminder(INVOICE_ID, remindAt, callback); + + expect(typeof reminderId).toBe("string"); + expect(reminderId.length).toBeGreaterThan(0); + expect(getPendingReminders()).toEqual([ + { reminderId, invoiceId: INVOICE_ID, remindAt }, + ]); + + expect(cancelReminder(reminderId)).toBe(true); + expect(getPendingReminders()).toEqual([]); + + vi.advanceTimersByTime(120_000); + expect(callback).not.toHaveBeenCalled(); + }); + + it("returns false when cancelling an unknown ID or an already-cancelled ID", () => { + const remindAt = NOW + 60_000; + const reminderId = scheduleReminder(INVOICE_ID, remindAt); + + expect(cancelReminder("non-existent-id")).toBe(false); + expect(cancelReminder(reminderId)).toBe(true); + expect(cancelReminder(reminderId)).toBe(false); + }); + + it("returns false when cancelling an already-fired reminder and excludes fired reminders from getPendingReminders()", () => { + const callback = vi.fn(); + const remindAt = NOW + 30_000; + const reminderId = scheduleReminder(INVOICE_ID, remindAt, callback); + + vi.advanceTimersByTime(30_000); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(INVOICE_ID); + expect(getPendingReminders()).toEqual([]); + expect(cancelReminder(reminderId)).toBe(false); + }); + + it("clearAllReminders() stops all pending timers and empties getPendingReminders()", () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + scheduleReminder("inv_1", NOW + 10_000, cb1); + scheduleReminder({ invoiceId: "inv_2", remindAt: NOW + 20_000, callback: cb2 }); + + expect(getPendingReminders()).toHaveLength(2); + clearAllReminders(); + expect(getPendingReminders()).toEqual([]); + + vi.advanceTimersByTime(30_000); + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).not.toHaveBeenCalled(); + }); });