Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
156 changes: 156 additions & 0 deletions src/invoiceReminderScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,44 @@ export class InvoiceReminderScheduler extends TypedEventEmitter<InvoiceReminderS
this._persist();
}

/**
* Cancel a single pending reminder by its unique reminder ID.
* Returns `true` if cancelled, or `false` if the ID is unknown or already fired/cancelled.
*/
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. */
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];
Expand Down Expand Up @@ -173,3 +211,121 @@ export class InvoiceReminderScheduler extends TypedEventEmitter<InvoiceReminderS
saveReminderSchedules(this.schedules);
}
}

/** Represents a pending reminder entry returned by {@link getPendingReminders}. */
export interface PendingReminder {
reminderId: string;
invoiceId: string;
remindAt: number;
}

/** Options object accepted by {@link scheduleReminder}. */
export interface ScheduleReminderOptions {
invoiceId: string;
remindAt: number | Date;
callback?: (invoiceId: string) => void;
onRemind?: (invoiceId: string) => void;
}

interface StandaloneReminderRecord extends PendingReminder {
status: "pending" | "fired" | "cancelled";
timer?: ReturnType<typeof setTimeout>;
callback?: (invoiceId: string) => void;
}

const standaloneReminders = new Map<string, StandaloneReminderRecord>();

/**
* 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();
}
113 changes: 113 additions & 0 deletions test/invoiceReminderScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});
});