diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index 0db0aea6..f8f8cb69 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -1,10 +1,15 @@ 'use client'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import type { Invoice } from '@stellar-split/sdk'; import type { ExportFilterOptions } from '@/lib/invoiceExcelExport'; -import { downloadExcel, generateExportFilename } from '@/lib/invoiceExcelExport'; -import { apiFetch } from '@/lib/apiClient'; +import { + exportPayments, + addExportHistoryEntry, + getExportHistory, + type ExportFormat, + type ExportHistoryEntry, +} from '@/lib/paymentExport'; /** Return a date string in "YYYY-MM-DD" format for an offset of `daysAgo` from today. */ function dateString(daysAgo: number = 0): string { @@ -28,6 +33,15 @@ export default function ExportModal({ }: ExportModalProps) { const [isExporting, setIsExporting] = useState(false); const [error, setError] = useState(null); + const [exportFormat, setExportFormat] = useState('csv'); + const [history, setHistory] = useState([]); + + // Load recent export history when the modal opens + useEffect(() => { + if (isOpen) { + setHistory(getExportHistory()); + } + }, [isOpen]); // Date-range state — "From" defaults to 30 days ago, "To" defaults to today const [startDate, setStartDate] = useState(() => dateString(30)); @@ -93,47 +107,16 @@ export default function ExportModal({ filters.assets = selectedAssets; } - // Build query params so the API route can also filter server-side - const params = new URLSearchParams(); - if (startDate) params.set('from', startDate); - if (endDate) params.set('to', endDate); + const result = await exportPayments(invoices, filters, exportFormat); - const response = await apiFetch(`/api/invoices/export?${params.toString()}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - invoices, - filters, - }), + // Record the export in history + addExportHistoryEntry({ + format: exportFormat, + invoiceCount: result.invoiceCount, + filters, + filename: result.filename, }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Export failed'); - } - - // Handle async export (202 Accepted) - if (response.status === 202) { - const jobData = await response.json(); - setError(`Export queued as job ${jobData.jobId}. This will be processed in the background.`); - return; - } - - // Handle synchronous export (200 OK) - const blob = await response.blob(); - const filename = generateExportFilename('invoices'); - - // Download the file - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + setHistory(getExportHistory()); onExport?.(); onClose(); @@ -143,7 +126,7 @@ export default function ExportModal({ setIsExporting(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [invoices, startDate, endDate, selectedStatuses, selectedAssets, onExport, onClose]); + }, [invoices, startDate, endDate, selectedStatuses, selectedAssets, exportFormat, onExport, onClose]); if (!isOpen) return null; @@ -247,6 +230,27 @@ export default function ExportModal({ )} + {/* Format Selector */} +
+ + +
+ {/* Status Filter */}
+ + {/* Export History */} + {history.length > 0 && ( +
+ +
    + {history.slice(0, 5).map((entry) => ( +
  • + + {entry.filename} ({entry.format.toUpperCase()}) + + + {new Date(entry.createdAt).toLocaleString()} + +
  • + ))} +
+
+ )} {/* Footer */} @@ -304,7 +332,7 @@ export default function ExportModal({ disabled={isExporting || invoices.length === 0} className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed" > - {isExporting ? 'Exporting...' : 'Export to Excel'} + {isExporting ? 'Exporting...' : `Export to ${exportFormat.toUpperCase()}`} diff --git a/src/components/PaymentExport.tsx b/src/components/PaymentExport.tsx index d6a8df16..7715eebd 100644 --- a/src/components/PaymentExport.tsx +++ b/src/components/PaymentExport.tsx @@ -3,27 +3,23 @@ import { useState } from 'react'; import type { Payment } from '@stellar-split/sdk'; import { - paymentsToCSV, - downloadCSV, - generatePaymentExportFilename, filterPaymentsByDateRange, PAYMENT_EXPORT_COLUMNS, PAYMENT_EXPORT_COLUMN_LABELS, type PaymentExportColumn, } from '@/lib/csvExport'; import { - buildPaymentSummaryHtml, - downloadPaymentSummaryPDF, -} from '@/lib/paymentExportPdf'; + exportPayments, + recordExportHistory, + type ExportFormat, +} from '@/lib/paymentExport'; interface Props { invoiceId: string; payments: Payment[]; } -type ExportFormat = 'csv' | 'pdf'; - -const EXPORT_FORMATS: ExportFormat[] = ['csv', 'pdf']; +const EXPORT_FORMATS: ExportFormat[] = ['csv', 'json', 'pdf']; export default function PaymentExport({ invoiceId, payments }: Props) { const [showFilters, setShowFilters] = useState(false); @@ -56,17 +52,23 @@ export default function PaymentExport({ invoiceId, payments }: Props) { endDate || null ); - if (exportFormat === 'pdf') { - const html = buildPaymentSummaryHtml(invoiceId, filteredPayments); - downloadPaymentSummaryPDF(html); - } else { - // Convert to CSV - const csv = paymentsToCSV(filteredPayments, selectedColumns); - - // Download - const filename = generatePaymentExportFilename(invoiceId); - downloadCSV(csv, filename); - } + exportPayments({ + invoiceId, + payments: filteredPayments, + format: exportFormat, + columns: selectedColumns, + startDate: startDate || null, + endDate: endDate || null, + }); + + recordExportHistory({ + invoiceId, + format: exportFormat, + paymentCount: filteredPayments.length, + startDate: startDate || null, + endDate: endDate || null, + columns: exportFormat === 'csv' ? selectedColumns : undefined, + }); } catch (error) { console.error('Export failed:', error); alert('Failed to export payments. Please try again.'); @@ -225,7 +227,9 @@ export default function PaymentExport({ invoiceId, payments }: Props) {

{exportFormat === 'pdf' ? 'PDF will include the invoice number, payer addresses, amounts, timestamps, and transaction hashes.' - : 'CSV will include payer address, amount (USDC), timestamp, and transaction hash for all payments.'} + : exportFormat === 'json' + ? 'JSON will include payer address, amount (USDC), timestamp, and transaction hash for all payments.' + : 'CSV will include payer address, amount (USDC), timestamp, and transaction hash for all payments.'}

)} diff --git a/src/components/ReminderChecker.tsx b/src/components/ReminderChecker.tsx index 483cbf75..f78d8ede 100644 --- a/src/components/ReminderChecker.tsx +++ b/src/components/ReminderChecker.tsx @@ -3,6 +3,11 @@ import { useState, useEffect, useCallback } from "react"; import { getReminders, type Reminder } from "@/lib/reminders"; import { getSnoozeState, snoozeInvoice, type SnoozeOption } from "@/lib/expirySnooze"; +import { + isReminderSuppressed, + dispatchReminder, + recordReminderEvent, +} from "@/lib/reminderEngine"; const CHECK_INTERVAL_MS = 60_000; @@ -12,18 +17,10 @@ const SNOOZE_OPTIONS: { option: SnoozeOption; label: string }[] = [ { option: "3d", label: "3 days" }, ]; -async function fireNotification(reminder: Reminder) { - if (!("Notification" in window)) return; - const show = () => - new Notification(`StellarSplit Reminder — Invoice #${reminder.invoiceId}`, { - body: reminder.message, - icon: "/favicon.ico", - }); - if (Notification.permission === "granted") { - show(); - } else if (Notification.permission === "default") { - const permission = await Notification.requestPermission(); - if (permission === "granted") show(); +async function deliverReminder(reminder: Reminder) { + const delivered = await dispatchReminder(reminder); + if (delivered) { + recordReminderEvent(reminder.invoiceId, "sent"); } } @@ -38,11 +35,14 @@ export default function ReminderChecker() { const checkReminders = useCallback(() => { const now = new Date(); const due = getReminders().filter( - (r) => new Date(r.reminderDate) <= now && !getSnoozeState(r.invoiceId) + (r) => + new Date(r.reminderDate) <= now && + !getSnoozeState(r.invoiceId) && + !isReminderSuppressed(r.invoiceId) ); setDueReminders((prev) => { const newlyDue = due.filter((r) => !prev.some((p) => p.invoiceId === r.invoiceId)); - newlyDue.forEach(fireNotification); + newlyDue.forEach(deliverReminder); return due; }); }, []); @@ -59,6 +59,10 @@ export default function ReminderChecker() { setDueReminders((prev) => prev.filter((r) => r.invoiceId !== invoiceId)); }; + const handleOpen = (invoiceId: string) => { + recordReminderEvent(invoiceId, "opened"); + }; + if (dueReminders.length === 0) return null; return ( @@ -66,6 +70,7 @@ export default function ReminderChecker() { {dueReminders.map((reminder) => (
handleOpen(reminder.invoiceId)} className="bg-gray-900 border border-gray-700 rounded-lg p-3 shadow-lg flex items-start justify-between gap-3" >
diff --git a/src/components/ReminderSender.tsx b/src/components/ReminderSender.tsx index b4b75754..0c648ebe 100644 --- a/src/components/ReminderSender.tsx +++ b/src/components/ReminderSender.tsx @@ -1,7 +1,16 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { formatAmount } from "@stellar-split/sdk"; +import { + computeReminderSchedule, + renderReminderTemplate, + getEscalationLevel, + isSuppressed, + setSuppressed, + recordEngagement, + type ReminderChannel, +} from "../lib/reminders"; interface ReminderSenderProps { invoiceId: string; @@ -10,43 +19,6 @@ interface ReminderSenderProps { verifyUrl: string; } -function buildReminderText( - invoiceId: string, - amount: bigint, - deadline: number, - verifyUrl: string -): string { - const deadlineStr = - deadline > 0 - ? new Date(deadline * 1000).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }) - : "N/A"; - return `Reminder: Invoice #${invoiceId} needs your payment of ${formatAmount(amount)} USDC by ${deadlineStr}. Pay here: ${verifyUrl}`; -} - -const SCHEDULED_REMINDERS_KEY = "stellarsplit_scheduled_reminders"; - -interface ScheduledReminder { - invoiceId: string; - sendAt: number; // unix ms - text: string; -} - -function scheduleReminder(reminder: ScheduledReminder) { - if (typeof window === "undefined") return; - try { - const raw = localStorage.getItem(SCHEDULED_REMINDERS_KEY); - const existing: ScheduledReminder[] = raw ? JSON.parse(raw) : []; - existing.push(reminder); - localStorage.setItem(SCHEDULED_REMINDERS_KEY, JSON.stringify(existing)); - } catch { - // best-effort persistence - } -} - export default function ReminderSender({ invoiceId, amount, @@ -58,11 +30,38 @@ export default function ReminderSender({ const [sendAt, setSendAt] = useState(""); const [sendAtError, setSendAtError] = useState(null); const [scheduled, setScheduled] = useState(false); + const [suppressed, setSuppressedState] = useState(false); + + useEffect(() => { + setSuppressedState(isSuppressed(invoiceId)); + }, [invoiceId]); - const reminderText = buildReminderText(invoiceId, amount, deadline, verifyUrl); + const schedule = useMemo( + () => computeReminderSchedule({ invoiceId, amount, deadline, verifyUrl }), + [invoiceId, amount, deadline, verifyUrl] + ); + const escalation = getEscalationLevel(deadline); + const nextReminder = schedule.find((r) => r.sendAt > Date.now()) ?? null; + + const reminderText = renderReminderTemplate(schedule[0]?.template ?? "initial", { + invoiceId, + amount, + deadline, + verifyUrl, + }); const encodedText = encodeURIComponent(reminderText); const isScheduling = sendAt.trim().length > 0; + const handleToggleSuppression = () => { + const next = !suppressed; + setSuppressed(next); + setSuppressedState(next); + }; + + const handleDispatch = (channel: ReminderChannel) => { + recordEngagement(invoiceId, "sent", channel); + }; + const whatsappUrl = `https://wa.me/?text=${encodedText}`; const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(verifyUrl)}&text=${encodeURIComponent( `Reminder: Invoice #${invoiceId} needs your payment of ${formatAmount(amount)} USDC. Pay here:` @@ -94,7 +93,7 @@ export default function ReminderSender({ setSendAtError("Send time cannot be in the past"); return; } - scheduleReminder({ invoiceId, sendAt: selected, text: reminderText }); + recordEngagement(invoiceId, "scheduled", "manual", { sendAt: selected }); setScheduled(true); }; @@ -148,6 +147,31 @@ export default function ReminderSender({
+ {/* Escalation status */} +
+ + Escalation: {escalation.label} + + {nextReminder && ( + + Next reminder: {new Date(nextReminder.sendAt).toLocaleString()} + + )} +
+ + {suppressed ? ( +
+

Reminders are turned off for this invoice.

+ +
+ ) : ( + <> {/* Reminder text preview */}
{reminderText} @@ -190,7 +214,10 @@ export default function ReminderSender({
+ +
+ +
+ + )} ); } diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index c1faa06a..53e4eca9 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -73,3 +73,94 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) const freshToken = await getCsrfToken(true); return attempt(freshToken); } + +/** + * Accounting integration API client (issue #878). + * + * These helpers back the payment export / reconciliation integration + * endpoints. Mutating calls go through `apiFetch` so the CSRF token is + * attached automatically. + */ + +export type AccountingExportFormat = "csv" | "json" | "pdf"; + +export interface AccountingExportFilters { + /** Inclusive ISO date (YYYY-MM-DD) lower bound. */ + startDate?: string; + /** Inclusive ISO date (YYYY-MM-DD) upper bound. */ + endDate?: string; + /** Invoice statuses to include, e.g. ["paid", "pending"]. */ + statuses?: string[]; +} + +export interface AccountingExportPayload extends AccountingExportFilters { + format: AccountingExportFormat; + /** Optional accounting provider identifier, e.g. "quickbooks". */ + provider?: string; +} + +export interface AccountingExport { + id: string; + format: AccountingExportFormat; + status: "pending" | "processing" | "completed" | "failed"; + filters: AccountingExportFilters; + provider?: string; + downloadUrl?: string; + createdAt: string; + completedAt?: string; +} + +export interface ReconciliationReport { + id?: string; + periodStart: string; + periodEnd: string; + /** Totals keyed by invoice status or provider-specific bucket. */ + totals: Record; + lineItems?: Array>; + generatedAt?: string; +} + +async function parseJsonOrThrow(response: Response, action: string): Promise { + if (!response.ok) { + throw new Error(`Failed to ${action}: ${response.status} ${response.statusText}`); + } + return (await response.json()) as T; +} + +/** Create a new accounting export (CSV, JSON or PDF). */ +export async function createAccountingExport( + payload: AccountingExportPayload +): Promise { + const response = await apiFetch("/api/accounting/exports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return parseJsonOrThrow(response, "create accounting export"); +} + +/** List previously created accounting exports (export history). */ +export async function listAccountingExports(): Promise { + const response = await apiFetch("/api/accounting/exports", { method: "GET" }); + return parseJsonOrThrow(response, "list accounting exports"); +} + +/** Fetch a single accounting export by id. */ +export async function getAccountingExport(id: string): Promise { + const response = await apiFetch(`/api/accounting/exports/${encodeURIComponent(id)}`, { + method: "GET", + }); + return parseJsonOrThrow(response, "get accounting export"); +} + +/** Push a reconciliation report to the accounting integration. */ +export async function pushReconciliationReport( + report: ReconciliationReport +): Promise { + const response = await apiFetch("/api/accounting/reconciliations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(report), + }); + return parseJsonOrThrow(response, "push reconciliation report"); +} diff --git a/src/lib/exportHistory.ts b/src/lib/exportHistory.ts new file mode 100644 index 00000000..8c7b4425 --- /dev/null +++ b/src/lib/exportHistory.ts @@ -0,0 +1,111 @@ +export type ExportFormat = 'csv' | 'json' | 'pdf'; + +export interface ExportFilters { + startDate?: string; + endDate?: string; + status?: string; + [key: string]: unknown; +} + +export type ExportStatus = 'pending' | 'completed' | 'failed'; + +export interface ExportHistoryEntry { + id: string; + timestamp: string; + format: ExportFormat; + filters: ExportFilters; + recordCount: number; + status: ExportStatus; +} + +const STORAGE_KEY = 'payment_export_history'; + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `exp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function isValidEntry(value: unknown): value is ExportHistoryEntry { + if (typeof value !== 'object' || value === null) { + return false; + } + const entry = value as Record; + return ( + typeof entry.id === 'string' && + typeof entry.timestamp === 'string' && + typeof entry.format === 'string' && + typeof entry.recordCount === 'number' && + typeof entry.status === 'string' && + typeof entry.filters === 'object' && + entry.filters !== null + ); +} + +export function getExportHistory(): ExportHistoryEntry[] { + if (!isBrowser()) { + return []; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter(isValidEntry); + } catch { + return []; + } +} + +function persist(entries: ExportHistoryEntry[]): void { + if (!isBrowser()) { + return; + } + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + // Ignore storage errors (quota exceeded, privacy mode, etc.) + } +} + +export function addExportHistoryEntry( + entry: Omit & { + id?: string; + timestamp?: string; + } +): ExportHistoryEntry { + const fullEntry: ExportHistoryEntry = { + id: entry.id ?? generateId(), + timestamp: entry.timestamp ?? new Date().toISOString(), + format: entry.format, + filters: entry.filters ?? {}, + recordCount: entry.recordCount, + status: entry.status, + }; + + const history = getExportHistory(); + history.push(fullEntry); + persist(history); + + return fullEntry; +} + +export function clearExportHistory(): void { + if (!isBrowser()) { + return; + } + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore storage errors. + } +} \ No newline at end of file diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 3244c58f..2c8798fa 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,5 +1,43 @@ const STORAGE_KEY = "stellarsplit-notify-invoices"; const PREFS_KEY = "stellarsplit-notification-prefs"; +const REMINDER_CHANNELS_KEY = "stellarsplit-reminder-channel-prefs"; + +/** Delivery channels available for payment reminders. */ +export type ReminderChannel = "email" | "push" | "inApp"; + +export interface ReminderChannelPreferences { + email: boolean; + push: boolean; + inApp: boolean; +} + +const DEFAULT_REMINDER_CHANNELS: ReminderChannelPreferences = { + email: true, + push: true, + inApp: true, +}; + +/** Read the user's reminder channel preferences. */ +export function getReminderChannelPreferences(): ReminderChannelPreferences { + if (typeof window === "undefined") return DEFAULT_REMINDER_CHANNELS; + try { + const raw = localStorage.getItem(REMINDER_CHANNELS_KEY); + return raw + ? { ...DEFAULT_REMINDER_CHANNELS, ...JSON.parse(raw) } + : DEFAULT_REMINDER_CHANNELS; + } catch { + return DEFAULT_REMINDER_CHANNELS; + } +} + +/** Persist the user's reminder channel preferences. */ +export function setReminderChannelPreferences( + prefs: Partial +): void { + if (typeof window === "undefined") return; + const merged = { ...getReminderChannelPreferences(), ...prefs }; + localStorage.setItem(REMINDER_CHANNELS_KEY, JSON.stringify(merged)); +} interface NotificationPreferences { paymentReceived: boolean; @@ -130,6 +168,27 @@ export function notifyInvoiceRefunded(invoiceId: string): void { }); } +/** + * Deliver an in-app payment reminder for an invoice. + * Respects the user's in-app reminder channel preference and the + * existing notification preferences (paymentReceived acts as the + * master toggle for payment-related notifications). + */ +export function sendInAppReminder( + invoiceId: string, + message: string, + level: number +): void { + const channels = getReminderChannelPreferences(); + if (!channels.inApp) return; + const prefs = getPreferences(); + if (!prefs.paymentReceived) return; + sendBrowserNotification(`Payment reminder for Invoice #${invoiceId}`, { + body: message, + tag: `payment-reminder-${invoiceId}-${level}`, + }); +} + /** Stellar address regex: G followed by 55 uppercase alphanumeric chars. */ const STELLAR_ADDRESS_RE = /\bG[A-Z0-9]{55}\b/g; diff --git a/src/lib/paymentExport.ts b/src/lib/paymentExport.ts new file mode 100644 index 00000000..633c0043 --- /dev/null +++ b/src/lib/paymentExport.ts @@ -0,0 +1,317 @@ +// src/lib/paymentExport.ts +import { + paymentsToCSV, + filterPaymentsByDateRange, +} from './csvExport'; +import { + buildPaymentSummaryHtml, + downloadPaymentSummaryPDF, +} from './paymentExportPdf'; + +/** + * Supported export formats for payment data. + */ +export type PaymentExportFormat = 'csv' | 'json' | 'pdf'; + +/** + * Filter object accepted by the export helpers. + */ +export interface PaymentExportFilters { + startDate?: string; + endDate?: string; + statuses?: string[]; +} + +/** + * Minimal shape of a payment record used by the export module. + * Kept intentionally loose so it can accommodate the various + * payment shapes used across the app. + */ +export interface ExportablePayment { + id?: string; + invoiceId?: string; + invoice_id?: string; + amount?: number; + status?: string; + date?: string; + createdAt?: string; + created_at?: string; + [key: string]: unknown; +} + +/** + * Minimal shape of an invoice record used for reconciliation. + */ +export interface ExportableInvoice { + id?: string; + invoiceId?: string; + invoice_id?: string; + amount?: number; + total?: number; + status?: string; + [key: string]: unknown; +} + +/** + * Result of a reconciliation run for a single invoice. + */ +export interface ReconciliationEntry { + invoiceId: string; + expected: number; + received: number; + discrepancy: number; + matchedPayments: number; + status: 'matched' | 'underpaid' | 'overpaid' | 'unpaid'; +} + +/** + * Full reconciliation report summary. + */ +export interface ReconciliationReport { + entries: ReconciliationEntry[]; + totals: { + expected: number; + received: number; + discrepancy: number; + }; + discrepancies: ReconciliationEntry[]; + generatedAt: string; +} + +const EPSILON = 0.005; + +function getPaymentDate(payment: ExportablePayment): string | undefined { + return ( + (payment.date as string | undefined) ?? + (payment.createdAt as string | undefined) ?? + (payment.created_at as string | undefined) + ); +} + +function getPaymentInvoiceId(payment: ExportablePayment): string | undefined { + const raw = + payment.invoiceId ?? + payment.invoice_id ?? + (payment as Record).invoice; + return raw === undefined || raw === null ? undefined : String(raw); +} + +function getInvoiceId(invoice: ExportableInvoice): string | undefined { + const raw = invoice.id ?? invoice.invoiceId ?? invoice.invoice_id; + return raw === undefined || raw === null ? undefined : String(raw); +} + +function getInvoiceAmount(invoice: ExportableInvoice): number { + const raw = invoice.amount ?? invoice.total ?? 0; + const num = typeof raw === 'number' ? raw : Number(raw); + return Number.isFinite(num) ? num : 0; +} + +function getPaymentAmount(payment: ExportablePayment): number { + const raw = payment.amount ?? 0; + const num = typeof raw === 'number' ? raw : Number(raw); + return Number.isFinite(num) ? num : 0; +} + +/** + * Applies date-range and invoice-status filters to a list of payments. + * + * - Date range filtering is delegated to `filterPaymentsByDateRange`. + * - Status filtering matches against the payment's `status` field + * (case-insensitive). An empty/undefined `statuses` array disables + * status filtering. + */ +export function filterPayments( + payments: T[], + filters: PaymentExportFilters = {}, +): T[] { + if (!Array.isArray(payments)) { + return []; + } + + let result: T[] = payments; + + const { startDate, endDate, statuses } = filters; + + if (startDate || endDate) { + result = filterPaymentsByDateRange( + result as unknown as Record[], + startDate, + endDate, + ) as unknown as T[]; + } + + if (Array.isArray(statuses) && statuses.length > 0) { + const normalized = new Set( + statuses + .filter((s): s is string => typeof s === 'string') + .map((s) => s.toLowerCase()), + ); + result = result.filter((payment) => { + const status = payment.status; + return typeof status === 'string' && normalized.has(status.toLowerCase()); + }); + } + + return result; +} + +function triggerDownload(content: string, filename: string, mime: string): void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function buildFilename(format: PaymentExportFormat): string { + const stamp = new Date().toISOString().slice(0, 10); + return `payments-export-${stamp}.${format}`; +} + +/** + * Serializes payments to JSON. + */ +export function paymentsToJSON(payments: ExportablePayment[]): string { + return JSON.stringify(payments ?? [], null, 2); +} + +/** + * Exports payments in the requested format and triggers the appropriate + * download. Returns the serialized content (useful for testing). + */ +export function exportPayments( + payments: ExportablePayment[], + format: PaymentExportFormat, + filters: PaymentExportFilters = {}, +): string { + const filtered = filterPayments(payments, filters); + const filename = buildFilename(format); + + switch (format) { + case 'csv': { + const csv = paymentsToCSV(filtered as unknown as Record[]); + triggerDownload(csv, filename, 'text/csv;charset=utf-8;'); + return csv; + } + case 'json': { + const json = paymentsToJSON(filtered); + triggerDownload(json, filename, 'application/json;charset=utf-8;'); + return json; + } + case 'pdf': { + const html = buildPaymentSummaryHtml(filtered as unknown as Record[]); + downloadPaymentSummaryPDF(html, filename); + return html; + } + default: { + throw new Error(`Unsupported payment export format: ${String(format)}`); + } + } +} + +/** + * Generates a reconciliation report by matching payments to invoices + * via invoice id. Computes expected (invoice amount) vs received + * (sum of matched payments) totals per invoice and surfaces discrepancies. + */ +export function generateReconciliationReport( + payments: ExportablePayment[], + invoices: ExportableInvoice[], +): ReconciliationReport { + const receivedByInvoice = new Map(); + + for (const payment of payments ?? []) { + const invoiceId = getPaymentInvoiceId(payment); + if (!invoiceId) continue; + const amount = getPaymentAmount(payment); + const existing = receivedByInvoice.get(invoiceId) ?? { total: 0, count: 0 }; + existing.total += amount; + existing.count += 1; + receivedByInvoice.set(invoiceId, existing); + } + + const entries: ReconciliationEntry[] = []; + const seen = new Set(); + + for (const invoice of invoices ?? []) { + const invoiceId = getInvoiceId(invoice); + if (!invoiceId || seen.has(invoiceId)) continue; + seen.add(invoiceId); + + const expected = getInvoiceAmount(invoice); + const receivedInfo = receivedByInvoice.get(invoiceId); + const received = receivedInfo ? receivedInfo.total : 0; + const discrepancy = roundCurrency(received - expected); + + let status: ReconciliationEntry['status']; + if (!receivedInfo || receivedInfo.count === 0) { + status = 'unpaid'; + } else if (Math.abs(discrepancy) <= EPSILON) { + status = 'matched'; + } else if (discrepancy < 0) { + status = 'underpaid'; + } else { + status = 'overpaid'; + } + + entries.push({ + invoiceId, + expected: roundCurrency(expected), + received: roundCurrency(received), + discrepancy, + matchedPayments: receivedInfo ? receivedInfo.count : 0, + status, + }); + } + + // Include payments referencing invoices not present in the invoice list. + for (const [invoiceId, info] of receivedByInvoice.entries()) { + if (seen.has(invoiceId)) continue; + seen.add(invoiceId); + entries.push({ + invoiceId, + expected: 0, + received: roundCurrency(info.total), + discrepancy: roundCurrency(info.total), + matchedPayments: info.count, + status: 'overpaid', + }); + } + + const totals = entries.reduce( + (acc, entry) => { + acc.expected += entry.expected; + acc.received += entry.received; + acc.discrepancy += entry.discrepancy; + return acc; + }, + { expected: 0, received: 0, discrepancy: 0 }, + ); + + const discrepancies = entries.filter( + (entry) => Math.abs(entry.discrepancy) > EPSILON, + ); + + return { + entries, + totals: { + expected: roundCurrency(totals.expected), + received: roundCurrency(totals.received), + discrepancy: roundCurrency(totals.discrepancy), + }, + discrepancies, + generatedAt: new Date().toISOString(), + }; +} + +function roundCurrency(value: number): number { + return Math.round(value * 100) / 100; +} \ No newline at end of file diff --git a/src/lib/reminders.ts b/src/lib/reminders.ts index 823e5887..f7950e49 100644 --- a/src/lib/reminders.ts +++ b/src/lib/reminders.ts @@ -1,8 +1,16 @@ /** * Reminder system — stores invoice reminders in localStorage and fires * browser Notification API alerts when the reminder time is reached. + * + * Also provides a scheduling/escalation engine for payment reminders: + * escalation levels keyed on invoice age, template-based messages, + * suppression/opt-out, multi-channel dispatch and engagement metrics. */ +import { sendPaymentReceiptEmail } from "./paymentReceiptEmailer"; +import { sendPushNotification } from "./pushNotifications"; +import { addNotification } from "./notifications"; + export interface Reminder { invoiceId: string; reminderDate: string; // ISO 8601 @@ -11,6 +19,339 @@ export interface Reminder { const STORAGE_KEY = "stellarsplit_reminders"; +// --------------------------------------------------------------------------- +// Escalation levels & scheduling +// --------------------------------------------------------------------------- + +export type EscalationLevel = "gentle" | "urgent" | "final"; + +/** Days past the due date at which each escalation level kicks in. */ +export const ESCALATION_THRESHOLDS: Record = { + gentle: 0, + urgent: 7, + final: 14, +}; + +/** Days between successive reminders at each escalation level. */ +export const REMINDER_INTERVAL_DAYS: Record = { + gentle: 3, + urgent: 3, + final: 2, +}; + +export interface InvoiceLike { + id: string; + dueDate: string; // ISO 8601 + amount?: number; + currency?: string; + recipientEmail?: string; + recipientName?: string; +} + +export interface ReminderSchedule { + invoiceId: string; + level: EscalationLevel; + /** ISO 8601 date of the next reminder. */ + nextReminderDate: string; + /** Whole days the invoice is past due (0 if not yet due). */ + daysPastDue: number; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function startOfDay(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +/** Whole days between two dates (b - a), floored. */ +export function daysBetween(a: Date, b: Date): number { + return Math.floor((startOfDay(b).getTime() - startOfDay(a).getTime()) / MS_PER_DAY); +} + +/** Determine the escalation level for a given number of days past due. */ +export function getEscalationLevel(daysPastDue: number): EscalationLevel { + if (daysPastDue >= ESCALATION_THRESHOLDS.final) return "final"; + if (daysPastDue >= ESCALATION_THRESHOLDS.urgent) return "urgent"; + return "gentle"; +} + +/** + * Compute the next reminder date and escalation level for an invoice. + * `now` is injectable for testing. + */ +export function computeReminderSchedule( + invoice: InvoiceLike, + now: Date = new Date() +): ReminderSchedule { + const due = new Date(invoice.dueDate); + const daysPastDue = Math.max(0, daysBetween(due, now)); + const level = getEscalationLevel(daysPastDue); + + // Next reminder fires one interval after "now", aligned to the level. + const next = new Date(now); + next.setDate(next.getDate() + REMINDER_INTERVAL_DAYS[level]); + + return { + invoiceId: invoice.id, + level, + nextReminderDate: next.toISOString(), + daysPastDue, + }; +} + +// --------------------------------------------------------------------------- +// Template-based messages +// --------------------------------------------------------------------------- + +export interface ReminderTemplateContext { + invoiceId: string; + amount?: number; + currency?: string; + recipientName?: string; + daysPastDue?: number; +} + +const TEMPLATES: Record string> = { + gentle: (ctx) => + `Hi${ctx.recipientName ? ` ${ctx.recipientName}` : ""}, this is a friendly reminder that invoice #${ctx.invoiceId}${ + ctx.amount != null ? ` (${formatAmount(ctx.amount, ctx.currency)})` : "" + } is due. Please let us know if you have any questions.`, + urgent: (ctx) => + `Reminder: invoice #${ctx.invoiceId}${ + ctx.amount != null ? ` (${formatAmount(ctx.amount, ctx.currency)})` : "" + } is now ${ctx.daysPastDue ?? 0} day(s) past due. Please arrange payment at your earliest convenience.`, + final: (ctx) => + `Final notice: invoice #${ctx.invoiceId}${ + ctx.amount != null ? ` (${formatAmount(ctx.amount, ctx.currency)})` : "" + } is ${ctx.daysPastDue ?? 0} day(s) overdue. Immediate payment is required to avoid further action.`, +}; + +function formatAmount(amount: number, currency?: string): string { + return `${amount.toFixed(2)}${currency ? ` ${currency}` : ""}`; +} + +/** Render the reminder message for a given escalation level. */ +export function renderReminderMessage( + level: EscalationLevel, + ctx: ReminderTemplateContext +): string { + return TEMPLATES[level](ctx); +} + +// --------------------------------------------------------------------------- +// Suppression / opt-out +// --------------------------------------------------------------------------- + +const SUPPRESSION_KEY = "stellarsplit_reminder_suppressions"; + +export interface SuppressionRecord { + invoiceId: string; + reason?: string; + suppressedAt: string; // ISO 8601 +} + +export function getSuppressions(): SuppressionRecord[] { + if (typeof window === "undefined") return []; + try { + return JSON.parse(localStorage.getItem(SUPPRESSION_KEY) ?? "[]") as SuppressionRecord[]; + } catch { + return []; + } +} + +export function isReminderSuppressed(invoiceId: string): boolean { + return getSuppressions().some((s) => s.invoiceId === invoiceId); +} + +export function suppressReminder(invoiceId: string, reason?: string): void { + if (typeof window === "undefined") return; + const records = getSuppressions().filter((s) => s.invoiceId !== invoiceId); + records.push({ invoiceId, reason, suppressedAt: new Date().toISOString() }); + localStorage.setItem(SUPPRESSION_KEY, JSON.stringify(records)); +} + +export function unsuppressReminder(invoiceId: string): void { + if (typeof window === "undefined") return; + const records = getSuppressions().filter((s) => s.invoiceId !== invoiceId); + localStorage.setItem(SUPPRESSION_KEY, JSON.stringify(records)); +} + +// --------------------------------------------------------------------------- +// Channel dispatch +// --------------------------------------------------------------------------- + +export type ReminderChannel = "email" | "push" | "inapp"; + +export interface ReminderDispatchResult { + invoiceId: string; + level: EscalationLevel; + suppressed: boolean; + channels: Partial>; +} + +export interface DispatchOptions { + channels?: ReminderChannel[]; + recipientEmail?: string; + recipientName?: string; + amount?: number; + currency?: string; + daysPastDue?: number; +} + +const DEFAULT_CHANNELS: ReminderChannel[] = ["email", "push", "inapp"]; + +/** + * Dispatch a reminder across the requested channels. Suppressed invoices are + * skipped entirely (returns `suppressed: true` with no channels attempted). + */ +export async function dispatchReminder( + invoiceId: string, + level: EscalationLevel, + options: DispatchOptions = {} +): Promise { + if (isReminderSuppressed(invoiceId)) { + return { invoiceId, level, suppressed: true, channels: {} }; + } + + const message = renderReminderMessage(level, { + invoiceId, + amount: options.amount, + currency: options.currency, + recipientName: options.recipientName, + daysPastDue: options.daysPastDue, + }); + + const channels = options.channels ?? DEFAULT_CHANNELS; + const results: Partial> = {}; + + for (const channel of channels) { + try { + if (channel === "email") { + if (!options.recipientEmail) { + results.email = false; + } else { + await sendPaymentReceiptEmail({ + to: options.recipientEmail, + invoiceId, + subject: `Payment reminder — invoice #${invoiceId}`, + message, + }); + results.email = true; + } + } else if (channel === "push") { + await sendPushNotification({ + title: `Payment reminder — invoice #${invoiceId}`, + body: message, + }); + results.push = true; + } else if (channel === "inapp") { + addNotification({ + type: "reminder", + title: `Payment reminder — invoice #${invoiceId}`, + message, + }); + results.inapp = true; + } + } catch { + results[channel] = false; + } + } + + return { invoiceId, level, suppressed: false, channels: results }; +} + +// --------------------------------------------------------------------------- +// Engagement metrics +// --------------------------------------------------------------------------- + +export type ReminderEventType = "sent" | "opened" | "clicked" | "paid"; + +export interface ReminderEvent { + invoiceId: string; + level: EscalationLevel; + type: ReminderEventType; + channel?: ReminderChannel; + timestamp: string; // ISO 8601 +} + +const EVENTS_KEY = "stellarsplit_reminder_events"; + +export function getReminderEvents(): ReminderEvent[] { + if (typeof window === "undefined") return []; + try { + return JSON.parse(localStorage.getItem(EVENTS_KEY) ?? "[]") as ReminderEvent[]; + } catch { + return []; + } +} + +export function recordReminderEvent( + event: Omit & { timestamp?: string } +): void { + if (typeof window === "undefined") return; + const events = getReminderEvents(); + events.push({ ...event, timestamp: event.timestamp ?? new Date().toISOString() }); + localStorage.setItem(EVENTS_KEY, JSON.stringify(events)); +} + +export interface ReminderMetrics { + sent: number; + opened: number; + clicked: number; + paid: number; + /** opened / sent, 0 when no reminders were sent. */ + openRate: number; + /** clicked / sent, 0 when no reminders were sent. */ + clickRate: number; + /** paid / sent, 0 when no reminders were sent. */ + paidRate: number; + byLevel: Record; + byChannel: Partial>; +} + +/** Aggregate engagement metrics, optionally scoped to a single invoice. */ +export function getReminderMetrics(invoiceId?: string): ReminderMetrics { + const events = getReminderEvents().filter((e) => !invoiceId || e.invoiceId === invoiceId); + + const metrics: ReminderMetrics = { + sent: 0, + opened: 0, + clicked: 0, + paid: 0, + openRate: 0, + clickRate: 0, + paidRate: 0, + byLevel: { gentle: 0, urgent: 0, final: 0 }, + byChannel: {}, + }; + + for (const event of events) { + if (event.type === "sent") { + metrics.sent += 1; + metrics.byLevel[event.level] += 1; + if (event.channel) { + metrics.byChannel[event.channel] = (metrics.byChannel[event.channel] ?? 0) + 1; + } + } else if (event.type === "opened") { + metrics.opened += 1; + } else if (event.type === "clicked") { + metrics.clicked += 1; + } else if (event.type === "paid") { + metrics.paid += 1; + } + } + + if (metrics.sent > 0) { + metrics.openRate = metrics.opened / metrics.sent; + metrics.clickRate = metrics.clicked / metrics.sent; + metrics.paidRate = metrics.paid / metrics.sent; + } + + return metrics; +} + export function getReminders(): Reminder[] { if (typeof window === "undefined") return []; try { @@ -73,6 +414,7 @@ export async function checkAndFireReminders(): Promise { const fired: string[] = []; for (const reminder of reminders) { + if (isReminderSuppressed(reminder.invoiceId)) continue; if (new Date(reminder.reminderDate) <= now) { if (Notification.permission === "granted") { new Notification(`StellarSplit Reminder — Invoice #${reminder.invoiceId}`, {