diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md new file mode 100644 index 00000000..79482458 --- /dev/null +++ b/docs/disaster-recovery.md @@ -0,0 +1,138 @@ +# Disaster Recovery Runbook + +This document describes the backup, restoration, and disaster recovery (DR) procedures for the application. It is the authoritative runbook for on-call engineers and must be kept in sync with the implementation in `config.ts` and the backup automation. + +## Objectives + +| Metric | Target | Notes | +| --- | --- | --- | +| **RTO** (Recovery Time Objective) | **< 1 hour** | Time from declared incident to restored service. | +| **RPO** (Recovery Point Objective) | **< 1 hour** | Maximum acceptable data loss window. | +| Backup frequency | Every 30 minutes (incremental), daily (full) | Ensures RPO < 1 hour. | +| Backup retention | 35 days | Daily fulls retained 35 days; incrementals 7 days. | + +## Configuration + +All backup and DR behavior is driven by environment variables defined in `config.ts`. Do not hardcode values elsewhere. + +| Variable | Purpose | Example | +| --- | --- | --- | +| `BACKUP_ENABLED` | Master switch for automated backups. | `true` | +| `BACKUP_SCHEDULE_CRON` | Cron expression for incremental backups. | `*/30 * * * *` | +| `BACKUP_FULL_SCHEDULE_CRON` | Cron expression for full backups. | `0 2 * * *` | +| `BACKUP_RETENTION_DAYS` | Retention window for full backups. | `35` | +| `BACKUP_STORAGE_BUCKET` | Primary (regional) backup bucket. | `app-backups-primary` | +| `BACKUP_REPLICA_BUCKET` | Geo-redundant replica bucket in the secondary region. | `app-backups-replica` | +| `BACKUP_PRIMARY_REGION` | Primary region for backups. | `us-east-1` | +| `BACKUP_REPLICA_REGION` | Secondary region for geo-redundancy. | `us-west-2` | +| `BACKUP_ENCRYPTION_KEY_ID` | KMS key used to encrypt backups at rest. | `arn:aws:kms:...` | +| `BACKUP_ALERT_WEBHOOK_URL` | Webhook (Slack/PagerDuty) for backup failure alerts. | `https://hooks...` | +| `BACKUP_ALERT_EMAIL` | Email distribution list for backup alerts. | `oncall@example.com` | +| `BACKUP_VERIFY_SCHEDULE_CRON` | Cron for automated restore verification. | `0 4 * * *` | +| `DR_DRILL_SCHEDULE_CRON` | Cron for scheduling DR drills. | `0 9 1 * *` | + +> If any of these variables are missing or invalid, the application should fail fast at startup (validation lives in `config.ts`). + +## Automated Backups + +1. **Incremental backups** run every 30 minutes per `BACKUP_SCHEDULE_CRON`. They capture changed data since the last backup and are written to `BACKUP_STORAGE_BUCKET` in `BACKUP_PRIMARY_REGION`. +2. **Full backups** run daily at 02:00 UTC per `BACKUP_FULL_SCHEDULE_CRON`. Fulls are retained for `BACKUP_RETENTION_DAYS`. +3. All backups are **encrypted at rest** using `BACKUP_ENCRYPTION_KEY_ID` and encrypted in transit (TLS). +4. Each backup writes a **manifest** (checksum, timestamp, size, schema version) alongside the data. Restoration refuses to proceed if the manifest checksum does not match. +5. Backups are **immutable** for the retention window (object lock) to protect against accidental deletion or ransomware. + +### Geo-Redundancy + +- After a backup lands in `BACKUP_STORAGE_BUCKET` (`BACKUP_PRIMARY_REGION`), it is **replicated asynchronously** to `BACKUP_REPLICA_BUCKET` in `BACKUP_REPLICA_REGION`. +- Replication is expected to complete within 15 minutes. Replication lag is monitored; if lag exceeds 30 minutes an alert fires. +- The replica region is a **separate failure domain** (different region, separate credentials scope). Restoration can be performed entirely from the replica if the primary region is unavailable. +- Replication status is recorded per object; the verification job (below) checks that the latest backup exists in both buckets. + +## Failure Alerting + +- Any backup job that fails, times out, or produces a manifest with a checksum mismatch triggers an alert to `BACKUP_ALERT_WEBHOOK_URL` and `BACKUP_ALERT_EMAIL`. +- Alerts include: job type, timestamp, error, and a link to the job logs. +- **Missing backup detection:** a watchdog checks that a backup landed within the last 60 minutes. If not, it fires a high-severity alert (this protects the RPO). +- **Replication lag alert:** fires if replica lag exceeds 30 minutes. +- **Verification failure alert:** fires if the automated restore verification (`BACKUP_VERIFY_SCHEDULE_CRON`) fails. +- Alerts page the on-call engineer. Acknowledged alerts must be triaged within 15 minutes. + +## Restoration Procedure + +> **Goal: complete restoration in under 1 hour (RTO).** Follow the steps in order. Record timestamps at each step for the post-incident review. + +### 0. Declare and assess (target: 5 min) + +1. Declare an incident in the incident channel and assign an Incident Commander. +2. Determine the scope: which data, which environment, and whether the primary region is available. +3. Decide the source: **primary bucket** if healthy, otherwise the **replica bucket** (`BACKUP_REPLICA_BUCKET` in `BACKUP_REPLICA_REGION`). + +### 1. Identify the target backup (target: 5 min) + +1. List available backups and their manifests. +2. Select the most recent backup whose manifest checksum validates. Prefer the latest backup that satisfies the RPO (< 1 hour old). +3. Record the chosen backup ID and timestamp. + +### 2. Prepare the restore target (target: 10 min) + +1. Provision or identify the target database/storage for restoration. +2. Confirm the KMS key (`BACKUP_ENCRYPTION_KEY_ID`) is accessible from the restore environment. +3. Ensure the application is in maintenance mode / traffic is drained to avoid writes during restore. + +### 3. Restore data (target: 20 min) + +1. Download the selected backup (and any required incrementals) from the chosen bucket. +2. Verify the manifest checksum **before** applying. +3. Apply the full backup, then replay incrementals in timestamp order. +4. Monitor restore logs for errors; abort and escalate on checksum or schema-version mismatch. + +### 4. Validate (target: 10 min) + +1. Run integrity checks (row counts, key table checksums, referential integrity). +2. Run the smoke-test suite against the restored data. +3. Confirm the restored data timestamp is within the RPO window. + +### 5. Cut over (target: 10 min) + +1. Point the application at the restored data store. +2. Exit maintenance mode and re-enable traffic. +3. Monitor error rates and latency for 15 minutes. +4. Announce recovery and close the incident; schedule the post-incident review. + +### Tested Steps + +The restoration procedure above is exercised by the **automated restore verification** job (`BACKUP_VERIFY_SCHEDULE_CRON`), which restores the latest backup into an isolated environment, runs the integrity checks and smoke tests, and reports success/failure. A failure raises an alert. Manual drills (below) additionally validate the full cut-over path. + +## DR Drills + +DR drills are scheduled per `DR_DRILL_SCHEDULE_CRON` (monthly) and are mandatory. Each drill must be recorded with a report linked from the incident tracker. + +### Drill Checklist + +- [ ] Announce the drill window to stakeholders; confirm no conflicting changes are scheduled. +- [ ] Simulate a failure scenario (e.g., primary region unavailable, corrupted primary backup). +- [ ] Perform the full **Restoration Procedure** from the replica region. +- [ ] Measure and record **RTO** (must be < 1 hour) and **RPO** (must be < 1 hour). +- [ ] Verify alerts fired as expected (backup failure, replication lag, verification failure). +- [ ] Confirm the restored environment passes integrity checks and smoke tests. +- [ ] Tear down drill resources and confirm no impact to production. +- [ ] File a drill report: timestamps, metrics, issues found, and action items. +- [ ] Track action items to completion; update this runbook if procedures changed. + +### Team Training + +- All on-call engineers must complete a walkthrough of this runbook and participate in at least one drill before being added to the on-call rotation. +- Training records are maintained by the team lead. Re-training is required whenever this runbook changes materially. + +## Roles and Responsibilities + +| Role | Responsibility | +| --- | --- | +| Incident Commander | Declares incident, coordinates restoration, owns RTO. | +| Restore Operator | Executes restoration steps and records timestamps. | +| Communications Lead | Updates stakeholders and status page. | +| Backup Owner | Maintains backup automation, alerts, and this runbook. | + +## Post-Incident Review + +Within 5 business days of any restoration or failed drill, hold a post-incident review covering: timeline vs. RTO/RPO, root cause, alert effectiveness, and concrete action items with owners and due dates. Update this runbook and `config.ts` as needed. \ No newline at end of file diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 4566ad69..afcbfbdf 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -13,3 +13,178 @@ if (dsn) { } export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; + +/** + * Analytics consent state. + * Analytics must never run unless the user has explicitly granted consent. + */ +const ANALYTICS_CONSENT_KEY = "analytics-consent"; + +type AnalyticsConsent = "granted" | "denied" | "unknown"; + +function readConsent(): AnalyticsConsent { + if (typeof window === "undefined") return "unknown"; + try { + const value = window.localStorage.getItem(ANALYTICS_CONSENT_KEY); + if (value === "granted" || value === "denied") return value; + return "unknown"; + } catch { + // localStorage may be unavailable (e.g. privacy mode); treat as no consent. + return "unknown"; + } +} + +function hasConsent(): boolean { + return readConsent() === "granted"; +} + +/** + * Minimal analytics client interface. The concrete provider is initialized + * lazily and only after consent is granted. + */ +interface AnalyticsClient { + init: (options: { dsn?: string; sampleRate?: number }) => void; + track: (event: string, properties?: Record) => void; + flush: () => void; +} + +let analyticsClient: AnalyticsClient | null = null; +let analyticsInitialized = false; +let flushHandlerAttached = false; + +/** + * Queue of events captured before the provider finished initializing. + * Kept small and bounded to avoid unbounded memory growth. + */ +const MAX_QUEUE_SIZE = 100; +const eventQueue: Array<{ event: string; properties?: Record }> = []; + +function enqueue(event: string, properties?: Record): void { + if (eventQueue.length >= MAX_QUEUE_SIZE) { + eventQueue.shift(); + } + eventQueue.push({ event, properties }); +} + +function drainQueue(client: AnalyticsClient): void { + while (eventQueue.length > 0) { + const item = eventQueue.shift(); + if (item) { + try { + client.track(item.event, item.properties); + } catch { + // Never let analytics failures break the app. + } + } + } +} + +/** + * Lazily load and initialize the analytics provider. This is intentionally + * non-blocking: it is scheduled on an idle callback (or a microtask fallback) + * so it never impacts initial page performance. + */ +function initAnalytics(): void { + if (typeof window === "undefined") return; + if (analyticsInitialized) return; + if (!hasConsent()) return; + + analyticsInitialized = true; + + const start = () => { + // Re-check consent in case it was revoked before the idle callback ran. + if (!hasConsent()) { + analyticsInitialized = false; + return; + } + + import("./analytics-client") + .then((mod) => { + const client = mod.createAnalyticsClient(); + analyticsClient = client; + client.init({ + dsn: process.env.NEXT_PUBLIC_ANALYTICS_DSN, + sampleRate: 1.0, + }); + drainQueue(client); + }) + .catch(() => { + // Provider failed to load; reset so a later attempt can retry. + analyticsInitialized = false; + }); + }; + + if (typeof window.requestIdleCallback === "function") { + window.requestIdleCallback(start, { timeout: 2000 }); + } else { + window.setTimeout(start, 0); + } +} + +/** + * Track a user action. No-op unless consent has been granted. + * Safe to call from anywhere in the client bundle. + */ +export function trackEvent( + event: string, + properties?: Record, +): void { + if (typeof window === "undefined") return; + if (!hasConsent()) return; + + if (analyticsClient) { + try { + analyticsClient.track(event, properties); + } catch { + // Swallow analytics errors. + } + return; + } + + enqueue(event, properties); + // Ensure the provider is (or will be) initialized. + initAnalytics(); +} + +/** + * Flush any queued events. Intended to be called on page hide / unload so + * events are not lost. Uses `visibilitychange` + `pagehide` for reliability + * across browsers (including bfcache). + */ +export function flushAnalytics(): void { + if (typeof window === "undefined") return; + if (!hasConsent()) return; + + if (analyticsClient) { + try { + analyticsClient.flush(); + } catch { + // Swallow analytics errors. + } + } +} + +function attachFlushHandler(): void { + if (typeof window === "undefined") return; + if (flushHandlerAttached) return; + flushHandlerAttached = true; + + const onPageHide = () => { + flushAnalytics(); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushAnalytics(); + } + }; + + window.addEventListener("pagehide", onPageHide); + window.addEventListener("visibilitychange", onVisibilityChange); +} + +// Initialize analytics on the client, gated by consent and non-blocking. +if (typeof window !== "undefined") { + attachFlushHandler(); + initAnalytics(); +} \ No newline at end of file diff --git a/instrumentation.ts b/instrumentation.ts index 88d64d6d..e14d89dd 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,22 +1,76 @@ import * as Sentry from "@sentry/nextjs"; +import { initAnalytics } from "./lib/analytics/server"; +import { registerBackupScheduler } from "./lib/backup/scheduler"; export async function register() { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; - if (!dsn) return; + if (dsn) { + if (process.env.NEXT_RUNTIME === "nodejs") { + Sentry.init({ + dsn, + tracesSampleRate: 0.1, + debug: false, + }); + } - if (process.env.NEXT_RUNTIME === "nodejs") { - Sentry.init({ - dsn, - tracesSampleRate: 0.1, - debug: false, - }); + if (process.env.NEXT_RUNTIME === "edge") { + Sentry.init({ + dsn, + tracesSampleRate: 0.1, + debug: false, + }); + } + } + + const analyticsEnabled = + process.env.ANALYTICS_ENABLED === "true" || + process.env.NEXT_PUBLIC_ANALYTICS_ENABLED === "true"; + + if ( + analyticsEnabled && + (process.env.NEXT_RUNTIME === "nodejs" || + process.env.NEXT_RUNTIME === "edge") + ) { + const endpoint = process.env.ANALYTICS_ENDPOINT; + const samplingRate = Number.parseFloat( + process.env.ANALYTICS_SAMPLING_RATE ?? "1", + ); + + try { + await initAnalytics({ + endpoint, + samplingRate: Number.isFinite(samplingRate) ? samplingRate : 1, + runtime: process.env.NEXT_RUNTIME, + }); + } catch (error) { + console.error("[instrumentation] Failed to initialize analytics", error); + } } - if (process.env.NEXT_RUNTIME === "edge") { - Sentry.init({ - dsn, - tracesSampleRate: 0.1, - debug: false, - }); + if (process.env.NEXT_RUNTIME === "nodejs") { + try { + await registerBackupScheduler({ + onFailure: async (error: unknown, context: { runId?: string }) => { + console.error("[instrumentation] Backup run failed", error, context); + + if (dsn) { + Sentry.captureException(error, { + tags: { component: "backup-scheduler" }, + extra: { runId: context?.runId }, + }); + } + + const { notifyBackupFailure } = await import( + "./lib/notifications/backup" + ); + await notifyBackupFailure({ error, context }); + }, + }); + } catch (error) { + console.error( + "[instrumentation] Failed to register backup scheduler", + error, + ); + } } -} +} \ No newline at end of file diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx index baaac115..0ad515a2 100644 --- a/src/app/analytics/page.tsx +++ b/src/app/analytics/page.tsx @@ -8,6 +8,18 @@ import { getFreighterPublicKey } from "@/lib/freighter"; import { generateCsv, type CsvRow } from "@/lib/csvExport"; import type { Invoice } from "@stellar-split/sdk"; import ChartBuilder, { type ChartBuilderDataPoint } from "@/components/analytics/ChartBuilder"; +import { + getEventCounts, + getFunnel, + getActiveWalletCount, + type AnalyticsEventName, +} from "@/lib/analytics"; +import { + computeAgingBuckets, + computeAgingTrend, + type AgingBucket, + type AgingTrendPoint, +} from "@/lib/invoiceAging"; const STROOPS = 10_000_000; @@ -37,6 +49,16 @@ const DynamicFundingTimeHistogram = dynamic( { ssr: false } ); +const DynamicAgingBucketChart = dynamic( + () => import("@/components/analytics/AgingBucketChart"), + { ssr: false } +); + +const DynamicAgingTrendChart = dynamic( + () => import("@/components/analytics/AgingTrendChart"), + { ssr: false } +); + function KpiCard({ label, value, @@ -119,6 +141,11 @@ export default function AnalyticsPage() { const [error, setError] = useState(null); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); + const [appMetrics, setAppMetrics] = useState<{ + eventCounts: Record; + funnel: { label: string; count: number }[]; + activeWallets: number; + } | null>(null); useEffect(() => { getFreighterPublicKey() @@ -126,6 +153,17 @@ export default function AnalyticsPage() { .catch(() => setError("Connect your Freighter wallet to view analytics.")); }, []); + useEffect(() => { + try { + const eventCounts = getEventCounts(); + const funnel = getFunnel(); + const activeWallets = getActiveWalletCount(); + setAppMetrics({ eventCounts, funnel, activeWallets }); + } catch { + setAppMetrics(null); + } + }, []); + useEffect(() => { if (!publicKey) return; const fetchInvoices = async () => { @@ -305,6 +343,13 @@ export default function AnalyticsPage() { }; }, [filtered]); + const aging = useMemo(() => { + const now = Math.floor(Date.now() / 1000); + const buckets: AgingBucket[] = computeAgingBuckets(filtered, now); + const trend: AgingTrendPoint[] = computeAgingTrend(filtered, now); + return { buckets, trend }; + }, [filtered]); + const csvRows = useMemo(() => { return filtered.map((inv) => ({ id: inv.id, @@ -464,6 +509,99 @@ export default function AnalyticsPage() { +
+

+ Application Metrics +

+ {!appMetrics ? ( +

+ No application metrics available. +

+ ) : ( +
+
+

+ Event Counts +

+
    + {Object.entries(appMetrics.eventCounts).map(([name, count]) => ( +
  • + {name} + {count} +
  • + ))} +
+

+ Active wallets:{" "} + + {appMetrics.activeWallets} + +

+
+
+

+ Funnel (create → pay → release) +

+
    + {appMetrics.funnel.map((step) => ( +
  • +
    + {step.label} + + {step.count} + +
    +
    +
    +
    +
  • + ))} +
+
+
+ )} +
+ +
+

+ Invoice Aging +

+
+
+

+ Aging Buckets +

+ }> + + +
+
+

+ Aging Trend +

+ }> + + +
+
+
+

Top Invoices by Volume diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 0c9b4c94..d7302bc4 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -6,11 +6,56 @@ import { useI18n } from "@/components/I18nProvider"; import type { Invoice } from "@stellar-split/sdk"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; import { Skeleton } from "@/components/Skeleton"; +import { getAnalyticsSummary, type AnalyticsSummary } from "@/lib/analytics"; +import { calculateAgingBuckets, type AgingBucket } from "@/lib/invoiceAging"; interface Props { invoices: Invoice[]; } +interface AppMetrics { + eventTotals: { name: string; count: number }[]; + funnel: { step: string; count: number; conversion: number }[]; + activeUsers: number; +} + +const FUNNEL_STEPS = ["invoice_created", "invoice_funded", "invoice_released"] as const; + +function buildAppMetrics(summary: AnalyticsSummary): AppMetrics { + const totals = new Map(); + for (const event of summary.events) { + totals.set(event.name, (totals.get(event.name) ?? 0) + 1); + } + const eventTotals = Array.from(totals.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); + + const funnelTop = totals.get(FUNNEL_STEPS[0]) ?? 0; + const funnel = FUNNEL_STEPS.map((step) => { + const count = totals.get(step) ?? 0; + return { + step, + count, + conversion: funnelTop > 0 ? Math.round((count / funnelTop) * 100) : 0, + }; + }); + + return { eventTotals, funnel, activeUsers: summary.activeUsers }; +} + +function trendIndicator(current: number, previous: number): { label: string; className: string } { + if (previous === 0) { + return current > 0 + ? { label: "▲ new", className: "text-red-400" } + : { label: "—", className: "text-gray-500" }; + } + const change = ((current - previous) / previous) * 100; + if (Math.abs(change) < 1) return { label: "— 0%", className: "text-gray-400" }; + return change > 0 + ? { label: `▲ ${change.toFixed(0)}%`, className: "text-red-400" } + : { label: `▼ ${Math.abs(change).toFixed(0)}%`, className: "text-green-400" }; +} + type RangePreset = "7d" | "30d" | "3m" | "custom"; interface RangeOption { @@ -96,6 +141,30 @@ export default function AnalyticsPanel({ invoices }: Props) { .sort(([a], [b]) => a.localeCompare(b)) .map(([week, count]) => ({ week, invoices: count })); + // Application metrics (event tracking + funnel + active users) for the range. + const appMetrics = useMemo(() => { + const summary = getAnalyticsSummary({ from, to }); + return buildAppMetrics(summary); + }, [from, to]); + + // Invoice aging buckets for the current scoped invoices, with a trend + // indicator comparing the current window against the preceding window. + const aging = useMemo(() => { + const current = calculateAgingBuckets(scopedInvoices, to); + const windowSeconds = Math.max(to - from, 0); + const prevFrom = from - windowSeconds; + const prevInvoices = invoices.filter( + (inv) => inv.deadline >= prevFrom && inv.deadline < from + ); + const previous = calculateAgingBuckets(prevInvoices, from); + return { current, previous }; + }, [scopedInvoices, invoices, from, to]); + + const overdueTrend = trendIndicator( + aging.current.overdueTotal, + aging.previous.overdueTotal + ); + return (
+ {/* Invoice Aging Summary */} +
+
+

{t("dashboard.agingSummary")}

+ + {overdueTrend.label} + +
+
+ {aging.current.buckets.map((bucket: AgingBucket) => ( +
+

{bucket.label}

+

+ {formatAmount(bucket.amount)} +

+

{bucket.count} invoices

+
+ ))} +
+
+ + {/* Application Metrics */} +
+
+

{t("dashboard.activeUsers")}

+

{appMetrics.activeUsers}

+
+
+

{t("dashboard.conversionFunnel")}

+
+ {appMetrics.funnel.map((step) => ( +
+

{step.step}

+

{step.count}

+

{step.conversion}%

+
+ ))} +
+
+
+ + {/* Event Totals */} + {appMetrics.eventTotals.length > 0 && ( +
+

{t("dashboard.eventTotals")}

+
    + {appMetrics.eventTotals.slice(0, 8).map((event) => ( +
  • + {event.name} + {event.count} +
  • + ))} +
+
+ )} + {/* Weekly Chart */} {chartData.length > 0 && (
diff --git a/src/components/AuditLogTable.tsx b/src/components/AuditLogTable.tsx index 00bf603d..d463b744 100644 --- a/src/components/AuditLogTable.tsx +++ b/src/components/AuditLogTable.tsx @@ -14,6 +14,10 @@ interface AuditLogEntry { action: string; actor: string; timestamp: number; + version?: number; + diffSummary?: string; + amendmentReason?: string; + amendmentComment?: string; } interface Props { @@ -40,7 +44,21 @@ export default function AuditLogTable({ invoiceId, invoice }: Props) { const handleExportJSON = () => { if (!invoice) return; const nonPendingPayments = invoice.payments.filter((p) => !p.pending); - const archive = buildInvoiceArchive(invoice, entries, nonPendingPayments); + const versionHistory = entries + .filter((entry) => entry.version !== undefined) + .map((entry) => ({ + version: entry.version, + action: entry.action, + actor: entry.actor, + timestamp: entry.timestamp, + diffSummary: entry.diffSummary, + amendmentReason: entry.amendmentReason, + amendmentComment: entry.amendmentComment, + })); + const archive = { + ...buildInvoiceArchive(invoice, entries, nonPendingPayments), + versionHistory, + }; const filename = generateArchiveFilename(invoiceId); downloadInvoiceArchive(archive, filename); }; @@ -121,6 +139,8 @@ export default function AuditLogTable({ invoiceId, invoice }: Props) { Action + Version + Changes Actor Timestamp @@ -129,6 +149,27 @@ export default function AuditLogTable({ invoiceId, invoice }: Props) { {paginatedEntries.map((entry, idx) => ( {entry.action} + + {entry.version !== undefined ? `v${entry.version}` : "—"} + + + {entry.diffSummary ? ( + {entry.diffSummary} + ) : null} + {entry.amendmentReason ? ( + + Reason: {entry.amendmentReason} + + ) : null} + {entry.amendmentComment ? ( + + {entry.amendmentComment} + + ) : null} + {!entry.diffSummary && !entry.amendmentReason && !entry.amendmentComment + ? "—" + : null} + {truncateAddress(entry.actor)} diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index 0db0aea6..665dfa20 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -4,6 +4,11 @@ import React, { useState, useCallback } from 'react'; import type { Invoice } from '@stellar-split/sdk'; import type { ExportFilterOptions } from '@/lib/invoiceExcelExport'; import { downloadExcel, generateExportFilename } from '@/lib/invoiceExcelExport'; +import { + downloadAgingReportCsv, + downloadAgingReportExcel, + generateAgingReportFilename, +} from '@/lib/invoiceAgingExport'; import { apiFetch } from '@/lib/apiClient'; /** Return a date string in "YYYY-MM-DD" format for an offset of `daysAgo` from today. */ @@ -27,7 +32,9 @@ export default function ExportModal({ onExport, }: ExportModalProps) { const [isExporting, setIsExporting] = useState(false); + const [isExportingAging, setIsExportingAging] = useState(false); const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); // Date-range state — "From" defaults to 30 days ago, "To" defaults to today const [startDate, setStartDate] = useState(() => dateString(30)); @@ -68,30 +75,38 @@ export default function ExportModal({ return true; }; + /** Build the shared filter options from the current modal state. */ + const buildFilters = useCallback((): ExportFilterOptions => { + const filters: ExportFilterOptions = {}; + + if (startDate) { + filters.startDate = new Date(startDate).getTime() / 1000; + } + if (endDate) { + // Include the full end day by advancing to end-of-day + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + filters.endDate = end.getTime() / 1000; + } + if (selectedStatuses.length > 0) { + filters.statuses = selectedStatuses; + } + if (selectedAssets.length > 0) { + filters.assets = selectedAssets; + } + + return filters; + }, [startDate, endDate, selectedStatuses, selectedAssets]); + const handleExport = useCallback(async () => { if (!validateDateRange()) return; setIsExporting(true); setError(null); + setSuccess(null); try { - const filters: ExportFilterOptions = {}; - - if (startDate) { - filters.startDate = new Date(startDate).getTime() / 1000; - } - if (endDate) { - // Include the full end day by advancing to end-of-day - const end = new Date(endDate); - end.setHours(23, 59, 59, 999); - filters.endDate = end.getTime() / 1000; - } - if (selectedStatuses.length > 0) { - filters.statuses = selectedStatuses; - } - if (selectedAssets.length > 0) { - filters.assets = selectedAssets; - } + const filters = buildFilters(); // Build query params so the API route can also filter server-side const params = new URLSearchParams(); @@ -143,7 +158,40 @@ export default function ExportModal({ setIsExporting(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [invoices, startDate, endDate, selectedStatuses, selectedAssets, onExport, onClose]); + }, [invoices, buildFilters, startDate, endDate, onExport, onClose]); + + /** Export the aging report (CSV or Excel) using the current filters. */ + const handleExportAging = useCallback( + async (format: 'csv' | 'excel') => { + if (!validateDateRange()) return; + + setIsExportingAging(true); + setError(null); + setSuccess(null); + + try { + const filters = buildFilters(); + + if (format === 'csv') { + downloadAgingReportCsv(invoices, filters); + } else { + downloadAgingReportExcel(invoices, filters); + } + + setSuccess( + `Aging report exported as ${generateAgingReportFilename(format)}.` + ); + onExport?.(); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Aging report export failed' + ); + } finally { + setIsExportingAging(false); + } + }, + [invoices, buildFilters, onExport] + ); if (!isOpen) return null; @@ -182,11 +230,23 @@ export default function ExportModal({ {/* Content */}
{error && ( -
+
{error}
)} + {success && ( +
+ {success} +
+ )} + {/* Date Range */}
+ + {/* Aging Report Export */} +
+ +

+ Export invoice aging buckets, overdue rates and collection + efficiency for the selected date range and filters. +

+
+ + +
+
{/* Footer */}