diff --git a/apps/backend/test/webhook-security.test.js b/apps/backend/test/webhook-security.test.js new file mode 100644 index 0000000..6a96f98 --- /dev/null +++ b/apps/backend/test/webhook-security.test.js @@ -0,0 +1,330 @@ +const { describe, it, expect, beforeEach, jest } = require("@jest/globals"); +const dns = require("dns").promises; +const { + validateWebhookURL, + dispatchSecureWebhook, + isPrivateOrReservedIP, + isPrivateIPv4, + isPrivateIPv6, + resolveHostname, + testWebhookSecurity, +} = require("../webhook-security"); + +// Mock DNS module +jest.mock("dns", () => ({ + promises: { + resolve4: jest.fn(), + resolve6: jest.fn(), + }, +})); + +describe("Webhook Security Scanner (#177)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("isPrivateIPv4", () => { + it("should detect loopback addresses", () => { + expect(isPrivateIPv4("127.0.0.1")).toBe(true); + expect(isPrivateIPv4("127.0.0.254")).toBe(true); + }); + + it("should detect RFC 1918 private addresses", () => { + expect(isPrivateIPv4("10.0.0.1")).toBe(true); + expect(isPrivateIPv4("172.16.0.1")).toBe(true); + expect(isPrivateIPv4("192.168.1.1")).toBe(true); + }); + + it("should detect link-local addresses", () => { + expect(isPrivateIPv4("169.254.169.254")).toBe(true); + }); + + it("should detect carrier-grade NAT addresses", () => { + expect(isPrivateIPv4("100.64.0.1")).toBe(true); + }); + + it("should detect multicast addresses", () => { + expect(isPrivateIPv4("224.0.0.1")).toBe(true); + }); + + it("should allow public IPv4 addresses", () => { + expect(isPrivateIPv4("8.8.8.8")).toBe(false); + expect(isPrivateIPv4("1.1.1.1")).toBe(false); + expect(isPrivateIPv4("151.101.1.69")).toBe(false); + }); + }); + + describe("isPrivateIPv6", () => { + it("should detect loopback addresses", () => { + expect(isPrivateIPv6("::1")).toBe(true); + }); + + it("should detect link-local addresses", () => { + expect(isPrivateIPv6("fe80::1")).toBe(true); + }); + + it("should detect unique local addresses", () => { + expect(isPrivateIPv6("fc00::1")).toBe(true); + expect(isPrivateIPv6("fd00::1")).toBe(true); + }); + + it("should detect multicast addresses", () => { + expect(isPrivateIPv6("ff00::1")).toBe(true); + }); + + it("should allow public IPv6 addresses", () => { + expect(isPrivateIPv6("2606:4700:4700::1111")).toBe(false); + }); + }); + + describe("isPrivateOrReservedIP", () => { + it("should handle both IPv4 and IPv6", () => { + expect(isPrivateOrReservedIP("127.0.0.1")).toBe(true); + expect(isPrivateOrReservedIP("::1")).toBe(true); + expect(isPrivateOrReservedIP("8.8.8.8")).toBe(false); + }); + }); + + describe("validateWebhookURL", () => { + it("should reject invalid URLs", async () => { + await expect(validateWebhookURL("not-a-url")).rejects.toThrow( + "Invalid URL format", + ); + }); + + it("should reject non-HTTP protocols", async () => { + await expect(validateWebhookURL("ftp://example.com")).rejects.toThrow( + "Only HTTP/HTTPS protocols", + ); + await expect(validateWebhookURL("file:///etc/passwd")).rejects.toThrow( + "Only HTTP/HTTPS protocols", + ); + }); + + it("should enforce HTTPS in production", async () => { + await expect( + validateWebhookURL("http://example.com", true), + ).rejects.toThrow("must use HTTPS"); + }); + + it("should reject URLs with credentials", async () => { + await expect( + validateWebhookURL("https://user:pass@example.com"), + ).rejects.toThrow("authentication credentials"); + }); + + it("should reject localhost and loopback", async () => { + await expect( + validateWebhookURL("https://localhost/webhook"), + ).rejects.toThrow("Loopback addresses are not allowed"); + await expect( + validateWebhookURL("https://127.0.0.1/webhook"), + ).rejects.toThrow("Loopback addresses are not allowed"); + }); + + it("should reject private IP addresses after DNS resolution", async () => { + dns.resolve4.mockResolvedValue(["10.0.0.1"]); + + await expect( + validateWebhookURL("https://internal.company.local"), + ).rejects.toThrow("Private or reserved IP"); + }); + + it("should reject link-local and metadata IPs", async () => { + dns.resolve4.mockResolvedValue(["169.254.169.254"]); + + await expect( + validateWebhookURL("https://evil.example.com"), + ).rejects.toThrow("Private or reserved IP"); + }); + + it("should accept valid public URLs", async () => { + dns.resolve4.mockResolvedValue(["151.101.1.69"]); + + const result = await validateWebhookURL( + "https://api.example.com/webhook", + false, + ); + + expect(result.valid).toBe(true); + expect(result.hostname).toBe("api.example.com"); + expect(result.resolvedIPs).toContain("151.101.1.69"); + }); + + it("should fallback to IPv6 if IPv4 fails", async () => { + dns.resolve4.mockRejectedValue(new Error("No IPv4")); + dns.resolve6.mockResolvedValue(["2606:4700:4700::1111"]); + + const result = await validateWebhookURL( + "https://api.example.com/webhook", + false, + ); + + expect(result.valid).toBe(true); + expect(result.resolvedIPs).toContain("2606:4700:4700::1111"); + }); + + it("should reject when DNS resolution fails", async () => { + dns.resolve4.mockRejectedValue(new Error("NXDOMAIN")); + dns.resolve6.mockRejectedValue(new Error("NXDOMAIN")); + + await expect( + validateWebhookURL("https://nonexistent.invalid"), + ).rejects.toThrow("DNS resolution failed"); + }); + }); + + describe("dispatchSecureWebhook", () => { + let mockFetch; + + beforeEach(() => { + mockFetch = jest.fn(); + dns.resolve4.mockResolvedValue(["151.101.1.69"]); + }); + + it("should dispatch webhook with validated DNS", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => '{"received":true}', + }); + + const result = await dispatchSecureWebhook( + "https://api.example.com/webhook", + { event: "subscription.created", data: { id: "123" } }, + { fetchImpl: mockFetch, enforceHTTPS: false }, + ); + + expect(result.success).toBe(true); + expect(result.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/webhook", + expect.objectContaining({ + method: "POST", + redirect: "error", + }), + ); + }); + + it("should reject webhooks to private IPs at dispatch time", async () => { + dns.resolve4.mockResolvedValue(["10.0.0.1"]); + + await expect( + dispatchSecureWebhook( + "https://internal.company.local/webhook", + { event: "test" }, + { fetchImpl: mockFetch, enforceHTTPS: false }, + ), + ).rejects.toThrow("Private or reserved IP"); + }); + + it("should block HTTP redirects", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "{}", + }); + + await dispatchSecureWebhook( + "https://api.example.com/webhook", + { event: "test" }, + { fetchImpl: mockFetch, enforceHTTPS: false }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + redirect: "error", // Redirects are blocked + }), + ); + }); + + it("should timeout long-running requests", async () => { + mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + + await expect( + dispatchSecureWebhook( + "https://api.example.com/webhook", + { event: "test" }, + { fetchImpl: mockFetch, timeout: 100, enforceHTTPS: false }, + ), + ).rejects.toThrow("timeout"); + }, 10000); + + it("should include custom headers", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "{}", + }); + + await dispatchSecureWebhook( + "https://api.example.com/webhook", + { event: "test" }, + { + fetchImpl: mockFetch, + enforceHTTPS: false, + headers: { "X-Custom-Header": "value" }, + }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Custom-Header": "value", + }), + }), + ); + }); + }); + + describe("testWebhookSecurity", () => { + it("should return safe status for public URLs", async () => { + dns.resolve4.mockResolvedValue(["151.101.1.69"]); + + const result = await testWebhookSecurity( + "https://api.example.com/webhook", + ); + + expect(result.safe).toBe(true); + expect(result.resolvedIPs).toBeDefined(); + }); + + it("should return unsafe status for private URLs", async () => { + dns.resolve4.mockResolvedValue(["10.0.0.1"]); + + const result = await testWebhookSecurity("https://internal.local"); + + expect(result.safe).toBe(false); + expect(result.error).toContain("Private or reserved IP"); + }); + }); + + describe("DNS Rebinding Protection", () => { + it("should validate URL at both registration and dispatch", async () => { + // Registration time: resolve to public IP + dns.resolve4.mockResolvedValue(["151.101.1.69"]); + const validation = await validateWebhookURL( + "https://evil.example.com", + false, + ); + expect(validation.valid).toBe(true); + + // Dispatch time: DNS rebinding attack - now resolves to private IP + dns.resolve4.mockResolvedValue(["127.0.0.1"]); + + const mockFetch = jest.fn(); + await expect( + dispatchSecureWebhook( + "https://evil.example.com", + { event: "test" }, + { fetchImpl: mockFetch, enforceHTTPS: false }, + ), + ).rejects.toThrow(); + }); + }); +}); diff --git a/apps/backend/webhook-security.js b/apps/backend/webhook-security.js new file mode 100644 index 0000000..9193d06 --- /dev/null +++ b/apps/backend/webhook-security.js @@ -0,0 +1,277 @@ +const dns = require("dns").promises; +const { URL } = require("url"); +const http = require("http"); +const https = require("https"); + +/** + * SSRF Defense and Webhook Security Scanner (#177) + * + * Implements comprehensive Server-Side Request Forgery (SSRF) protection for webhook + * destination endpoints through DNS resolution, IP filtering, and DNS rebinding defense. + */ + +// RFC 1918 private IPv4 ranges +const PRIVATE_IPV4_RANGES = [ + { start: [10, 0, 0, 0], end: [10, 255, 255, 255] }, // 10.0.0.0/8 + { start: [172, 16, 0, 0], end: [172, 31, 255, 255] }, // 172.16.0.0/12 + { start: [192, 168, 0, 0], end: [192, 168, 255, 255] }, // 192.168.0.0/16 + { start: [127, 0, 0, 0], end: [127, 255, 255, 255] }, // 127.0.0.0/8 (loopback) + { start: [169, 254, 0, 0], end: [169, 254, 255, 255] }, // 169.254.0.0/16 (link-local) + { start: [100, 64, 0, 0], end: [100, 127, 255, 255] }, // 100.64.0.0/10 (carrier-grade NAT) + { start: [224, 0, 0, 0], end: [239, 255, 255, 255] }, // 224.0.0.0/4 (multicast) + { start: [240, 0, 0, 0], end: [255, 255, 255, 255] }, // 240.0.0.0/4 (reserved) +]; + +// Private/reserved IPv6 ranges +const PRIVATE_IPV6_PREFIXES = [ + "::1", // loopback + "fe80:", // link-local + "fc00:", // unique local addresses (ULA) + "fd00:", // unique local addresses (ULA) + "ff00:", // multicast + "::ffff:127.", // IPv4-mapped loopback + "::ffff:10.", // IPv4-mapped private + "::ffff:172.", // IPv4-mapped private + "::ffff:192.168.", // IPv4-mapped private +]; + +/** + * Parse IPv4 address to array of octets + */ +function parseIPv4(ip) { + const octets = ip.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => isNaN(o) || o < 0 || o > 255)) { + return null; + } + return octets; +} + +/** + * Check if IPv4 address is in a specific range + */ +function isIPv4InRange(octets, range) { + for (let i = 0; i < 4; i++) { + if (octets[i] < range.start[i]) return false; + if (octets[i] > range.end[i]) return false; + if (octets[i] > range.start[i] && octets[i] < range.end[i]) return true; + } + return true; +} + +/** + * Check if an IPv4 address is private/reserved + */ +function isPrivateIPv4(ip) { + const octets = parseIPv4(ip); + if (!octets) return false; + + return PRIVATE_IPV4_RANGES.some((range) => isIPv4InRange(octets, range)); +} + +/** + * Check if an IPv6 address is private/reserved + */ +function isPrivateIPv6(ip) { + const normalized = ip.toLowerCase(); + return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + +/** + * Check if an IP address (v4 or v6) is private, loopback, or reserved + */ +function isPrivateOrReservedIP(ip) { + if (ip.includes(":")) { + return isPrivateIPv6(ip); + } + return isPrivateIPv4(ip); +} + +/** + * Resolve hostname to IP addresses + */ +async function resolveHostname(hostname) { + try { + // Try IPv4 first + const ipv4 = await dns.resolve4(hostname); + return ipv4; + } catch (err) { + // Fallback to IPv6 + try { + const ipv6 = await dns.resolve6(hostname); + return ipv6; + } catch (err2) { + throw new Error(`DNS resolution failed for ${hostname}: ${err2.message}`); + } + } +} + +/** + * Validate webhook URL at registration time + * Performs DNS resolution and IP filtering before persisting the endpoint + */ +async function validateWebhookURL(url, enforceHTTPS = true) { + let parsedURL; + + try { + parsedURL = new URL(url); + } catch (err) { + throw new Error("Invalid URL format"); + } + + // Enforce HTTPS in production + if (enforceHTTPS && parsedURL.protocol !== "https:") { + throw new Error("Webhook URLs must use HTTPS protocol in production"); + } + + // Reject file://, ftp://, gopher://, etc. + if (!["http:", "https:"].includes(parsedURL.protocol)) { + throw new Error("Only HTTP/HTTPS protocols are allowed"); + } + + // Reject URLs with authentication credentials + if (parsedURL.username || parsedURL.password) { + throw new Error("URLs with authentication credentials are not allowed"); + } + + const hostname = parsedURL.hostname; + + // Reject localhost and known dangerous hostnames + const dangerousHosts = ["localhost", "0.0.0.0", "127.0.0.1", "[::1]"]; + if (dangerousHosts.includes(hostname.toLowerCase())) { + throw new Error("Loopback addresses are not allowed"); + } + + // Resolve hostname to IP addresses + let ips; + try { + ips = await resolveHostname(hostname); + } catch (err) { + throw new Error(`DNS resolution failed: ${err.message}`); + } + + // Check all resolved IPs + for (const ip of ips) { + if (isPrivateOrReservedIP(ip)) { + throw new Error(`Private or reserved IP address detected: ${ip}`); + } + } + + return { + valid: true, + url: parsedURL.toString(), + hostname, + resolvedIPs: ips, + }; +} + +/** + * Create a custom HTTP/HTTPS agent with DNS rebinding protection + * Pins the validated IP address for the connection socket + */ +function createSecureAgent(hostname, validatedIP, protocol = "https") { + const AgentClass = protocol === "https" ? https.Agent : http.Agent; + + return new AgentClass({ + lookup: (hostname, options, callback) => { + // Pin to the validated IP, preventing DNS rebinding attacks + callback(null, validatedIP, options.family || 4); + }, + timeout: 6000, + maxSockets: 1, + }); +} + +/** + * Dispatch webhook with SSRF protection + * Performs DNS resolution at execution time and blocks redirects + */ +async function dispatchSecureWebhook(url, payload, options = {}) { + const { + headers = {}, + timeout = 6000, + enforceHTTPS = true, + fetchImpl = globalThis.fetch, + } = options; + + // Validate URL at dispatch time (DNS rebinding defense) + let validation; + try { + validation = await validateWebhookURL(url, enforceHTTPS); + } catch (err) { + throw new Error(`Webhook URL validation failed: ${err.message}`); + } + + const { hostname, resolvedIPs } = validation; + const validatedIP = resolvedIPs[0]; + + const parsedURL = new URL(url); + const protocol = parsedURL.protocol === "https:" ? "https" : "http"; + + // Create agent with DNS pinning + const agent = createSecureAgent(hostname, validatedIP, protocol); + + // Prepare request options + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(payload), + signal: controller.signal, + redirect: "error", // Block all HTTP redirects + agent, // Use custom agent with DNS pinning + }); + + clearTimeout(timeoutId); + + return { + success: response.ok, + status: response.status, + statusText: response.statusText, + body: await response.text().catch(() => ""), + }; + } catch (err) { + clearTimeout(timeoutId); + + if (err.name === "AbortError") { + throw new Error("Webhook request timeout"); + } + + throw new Error(`Webhook dispatch failed: ${err.message}`); + } +} + +/** + * Test webhook endpoint security (for unit tests) + */ +async function testWebhookSecurity(url) { + try { + const validation = await validateWebhookURL(url, false); + return { + safe: true, + ...validation, + }; + } catch (err) { + return { + safe: false, + error: err.message, + }; + } +} + +module.exports = { + validateWebhookURL, + dispatchSecureWebhook, + createSecureAgent, + isPrivateOrReservedIP, + resolveHostname, + testWebhookSecurity, + // Export for testing + isPrivateIPv4, + isPrivateIPv6, +}; diff --git a/apps/backend/webhooks.js b/apps/backend/webhooks.js index bc38d79..947f9e5 100644 --- a/apps/backend/webhooks.js +++ b/apps/backend/webhooks.js @@ -1,36 +1,47 @@ -const crypto = require('crypto'); -const { z } = require('zod'); +const crypto = require("crypto"); +const { z } = require("zod"); +const { + validateWebhookURL, + dispatchSecureWebhook, +} = require("./webhook-security"); const createWebhookSchema = z.object({ - merchant_id: z.string().uuid("merchant_id must be a valid UUID"), - url: z.string().url("url must be a valid URL"), - events: z.array(z.string()).min(1, "at least one event must be selected").default(["*"]), - signing_secret: z.string().min(16).optional(), + merchant_id: z.string().uuid("merchant_id must be a valid UUID"), + url: z.string().url("url must be a valid URL"), + events: z + .array(z.string()) + .min(1, "at least one event must be selected") + .default(["*"]), + signing_secret: z.string().min(16).optional(), }); const updateWebhookSchema = z.object({ - url: z.string().url("url must be a valid URL").optional(), - events: z.array(z.string()).min(1).optional(), - active: z.boolean().optional(), - signing_secret: z.string().min(16).optional(), + url: z.string().url("url must be a valid URL").optional(), + events: z.array(z.string()).min(1).optional(), + active: z.boolean().optional(), + signing_secret: z.string().min(16).optional(), }); const generateSigningSecret = () => { - return `whsec_${crypto.randomBytes(16).toString('hex')}`; + return `whsec_${crypto.randomBytes(16).toString("hex")}`; }; const computeWebhookSignature = (secret, payloadString) => { - return `sha256=${crypto.createHmac('sha256', secret).update(payloadString).digest('hex')}`; + return `sha256=${crypto.createHmac("sha256", secret).update(payloadString).digest("hex")}`; }; // During rotation both the current and previous secret are accepted. This keeps // retries in flight valid while new deliveries immediately use the new secret. const verifyWebhookSignature = (signature, payloadString, secrets) => { - if (typeof signature !== 'string' || !signature.startsWith('sha256=')) return false; - return secrets.filter(Boolean).some((secret) => { - const expected = computeWebhookSignature(secret, payloadString); - return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); - }); + if (typeof signature !== "string" || !signature.startsWith("sha256=")) + return false; + return secrets.filter(Boolean).some((secret) => { + const expected = computeWebhookSignature(secret, payloadString); + return crypto.timingSafeEqual( + Buffer.from(expected), + Buffer.from(signature), + ); + }); }; // Optional hook called after every delivery attempt (dispatch and resend). The @@ -39,419 +50,465 @@ const verifyWebhookSignature = (signature, payloadString, secrets) => { let deliveryObserver = null; const setDeliveryObserver = (observer) => { - deliveryObserver = typeof observer === 'function' ? observer : null; + deliveryObserver = typeof observer === "function" ? observer : null; }; const notifyDeliveryObserver = (event) => { - if (!deliveryObserver) return; - Promise.resolve() - .then(() => deliveryObserver(event)) - .catch((err) => console.error('Webhook delivery observer failed:', err)); + if (!deliveryObserver) return; + Promise.resolve() + .then(() => deliveryObserver(event)) + .catch((err) => console.error("Webhook delivery observer failed:", err)); }; /** * Dispatches an event to all active webhooks subscribed to this merchant and event type */ -const dispatchWebhookEvent = async (supabase, { - merchantId, - eventType, - payload, - fetchImpl = globalThis.fetch, -}) => { - if (!supabase || !merchantId) return []; - - try { - const { data: webhooks, error } = await supabase - .from('webhooks') - .select('*') - .eq('merchant_id', merchantId) - .eq('active', true); - - if (error || !webhooks || webhooks.length === 0) { - return []; - } - - const matchingWebhooks = webhooks.filter((wh) => { - if (!wh.events || !Array.isArray(wh.events)) return true; - return wh.events.includes('*') || wh.events.includes(eventType); - }); - - const deliveries = []; - const payloadString = JSON.stringify(payload); - - for (const wh of matchingWebhooks) { - const secret = wh.signing_secret || generateSigningSecret(); - const signature = computeWebhookSignature(secret, payloadString); - const deliveryId = crypto.randomUUID(); - const timestamp = new Date().toISOString(); - - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'Orbit-Webhooks/1.0', - 'X-Orbit-Signature': signature, - 'X-Orbit-Event': eventType, - 'X-Orbit-Delivery': deliveryId, - 'X-Orbit-Timestamp': timestamp, - }; - - let responseStatus = 0; - let responseBody = ''; - let status = 'failed'; - - try { - if (typeof fetchImpl === 'function') { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 6000); - - const res = await fetchImpl(wh.url, { - method: 'POST', - headers, - body: payloadString, - signal: controller.signal, - }); - clearTimeout(timeout); - - responseStatus = res.status; - responseBody = await res.text().catch(() => ''); - status = res.ok ? 'success' : 'failed'; - } else { - responseStatus = 200; - responseBody = '{"received":true}'; - status = 'success'; - } - } catch (fetchErr) { - responseStatus = 500; - responseBody = fetchErr.message || 'Connection error'; - status = 'failed'; - } - - const { data: deliveryRecord, error: deliveryErr } = await supabase - .from('webhook_deliveries') - .insert([{ - id: deliveryId, - webhook_id: wh.id, - merchant_id: merchantId, - event_type: eventType, - payload, - request_headers: headers, - response_status: responseStatus, - response_body: responseBody, - attempt_count: 1, - status, - }]) - .select() - .single(); - - notifyDeliveryObserver({ merchantId, webhook: wh, eventType, responseStatus, deliveryId }); - - if (!deliveryErr && deliveryRecord) { - deliveries.push(deliveryRecord); - } - } - - return deliveries; - } catch (err) { - console.error("Error dispatching webhook event:", err); - return []; +const dispatchWebhookEvent = async ( + supabase, + { merchantId, eventType, payload, fetchImpl = globalThis.fetch }, +) => { + if (!supabase || !merchantId) return []; + + try { + const { data: webhooks, error } = await supabase + .from("webhooks") + .select("*") + .eq("merchant_id", merchantId) + .eq("active", true); + + if (error || !webhooks || webhooks.length === 0) { + return []; } -}; -/** - * Redelivers a specific webhook delivery attempt - */ -const resendWebhookDelivery = async (supabase, deliveryId, fetchImpl = globalThis.fetch) => { - if (!supabase || !deliveryId) return null; - - const { data: delivery, error: delErr } = await supabase - .from('webhook_deliveries') - .select('*, webhooks(*)') - .eq('id', deliveryId) - .single(); - - if (delErr || !delivery) { - return null; - } - - const wh = delivery.webhooks; - const destinationUrl = wh?.url || delivery.destination_url; - const secret = wh?.signing_secret || 'whsec_default_secret'; - const payloadString = JSON.stringify(delivery.payload); - const signature = computeWebhookSignature(secret, payloadString); - - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'Orbit-Webhooks/1.0', - 'X-Orbit-Signature': signature, - 'X-Orbit-Event': delivery.event_type, - 'X-Orbit-Delivery': deliveryId, - 'X-Orbit-Timestamp': new Date().toISOString(), - 'X-Orbit-Resend': 'true', - }; - - let responseStatus = 0; - let responseBody = ''; - let status = 'failed'; + const matchingWebhooks = webhooks.filter((wh) => { + if (!wh.events || !Array.isArray(wh.events)) return true; + return wh.events.includes("*") || wh.events.includes(eventType); + }); - try { - if (typeof fetchImpl === 'function' && destinationUrl) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 6000); - - const res = await fetchImpl(destinationUrl, { - method: 'POST', - headers, - body: payloadString, - signal: controller.signal, - }); - clearTimeout(timeout); - - responseStatus = res.status; - responseBody = await res.text().catch(() => ''); - status = res.ok ? 'success' : 'failed'; + const deliveries = []; + const payloadString = JSON.stringify(payload); + + for (const wh of matchingWebhooks) { + const secret = wh.signing_secret || generateSigningSecret(); + const signature = computeWebhookSignature(secret, payloadString); + const deliveryId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + + const headers = { + "Content-Type": "application/json", + "User-Agent": "Orbit-Webhooks/1.0", + "X-Orbit-Signature": signature, + "X-Orbit-Event": eventType, + "X-Orbit-Delivery": deliveryId, + "X-Orbit-Timestamp": timestamp, + }; + + let responseStatus = 0; + let responseBody = ""; + let status = "failed"; + + try { + if (typeof fetchImpl === "function") { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6000); + + const res = await fetchImpl(wh.url, { + method: "POST", + headers, + body: payloadString, + signal: controller.signal, + }); + clearTimeout(timeout); + + responseStatus = res.status; + responseBody = await res.text().catch(() => ""); + status = res.ok ? "success" : "failed"; } else { - responseStatus = 200; - responseBody = '{"received":true,"resend":true}'; - status = 'success'; + responseStatus = 200; + responseBody = '{"received":true}'; + status = "success"; } - } catch (err) { + } catch (fetchErr) { responseStatus = 500; - responseBody = err.message || 'Connection error'; - status = 'failed'; - } - - const { data: updated, error: updateErr } = await supabase - .from('webhook_deliveries') - .update({ - attempt_count: (delivery.attempt_count || 1) + 1, + responseBody = fetchErr.message || "Connection error"; + status = "failed"; + } + + const { data: deliveryRecord, error: deliveryErr } = await supabase + .from("webhook_deliveries") + .insert([ + { + id: deliveryId, + webhook_id: wh.id, + merchant_id: merchantId, + event_type: eventType, + payload, + request_headers: headers, response_status: responseStatus, response_body: responseBody, + attempt_count: 1, status, - request_headers: headers, - }) - .eq('id', deliveryId) + }, + ]) .select() .single(); - notifyDeliveryObserver({ merchantId: delivery.merchant_id, webhook: wh || { id: delivery.webhook_id, url: destinationUrl }, eventType: delivery.event_type, responseStatus, deliveryId }); + notifyDeliveryObserver({ + merchantId, + webhook: wh, + eventType, + responseStatus, + deliveryId, + }); + + if (!deliveryErr && deliveryRecord) { + deliveries.push(deliveryRecord); + } + } - if (updateErr) throw updateErr; - return updated; + return deliveries; + } catch (err) { + console.error("Error dispatching webhook event:", err); + return []; + } +}; + +/** + * Redelivers a specific webhook delivery attempt + */ +const resendWebhookDelivery = async ( + supabase, + deliveryId, + fetchImpl = globalThis.fetch, +) => { + if (!supabase || !deliveryId) return null; + + const { data: delivery, error: delErr } = await supabase + .from("webhook_deliveries") + .select("*, webhooks(*)") + .eq("id", deliveryId) + .single(); + + if (delErr || !delivery) { + return null; + } + + const wh = delivery.webhooks; + const destinationUrl = wh?.url || delivery.destination_url; + const secret = wh?.signing_secret || "whsec_default_secret"; + const payloadString = JSON.stringify(delivery.payload); + const signature = computeWebhookSignature(secret, payloadString); + + const headers = { + "Content-Type": "application/json", + "User-Agent": "Orbit-Webhooks/1.0", + "X-Orbit-Signature": signature, + "X-Orbit-Event": delivery.event_type, + "X-Orbit-Delivery": deliveryId, + "X-Orbit-Timestamp": new Date().toISOString(), + "X-Orbit-Resend": "true", + }; + + let responseStatus = 0; + let responseBody = ""; + let status = "failed"; + + try { + if (typeof fetchImpl === "function" && destinationUrl) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6000); + + const res = await fetchImpl(destinationUrl, { + method: "POST", + headers, + body: payloadString, + signal: controller.signal, + }); + clearTimeout(timeout); + + responseStatus = res.status; + responseBody = await res.text().catch(() => ""); + status = res.ok ? "success" : "failed"; + } else { + responseStatus = 200; + responseBody = '{"received":true,"resend":true}'; + status = "success"; + } + } catch (err) { + responseStatus = 500; + responseBody = err.message || "Connection error"; + status = "failed"; + } + + const { data: updated, error: updateErr } = await supabase + .from("webhook_deliveries") + .update({ + attempt_count: (delivery.attempt_count || 1) + 1, + response_status: responseStatus, + response_body: responseBody, + status, + request_headers: headers, + }) + .eq("id", deliveryId) + .select() + .single(); + + notifyDeliveryObserver({ + merchantId: delivery.merchant_id, + webhook: wh || { id: delivery.webhook_id, url: destinationUrl }, + eventType: delivery.event_type, + responseStatus, + deliveryId, + }); + + if (updateErr) throw updateErr; + return updated; }; const backfillWebhookSchema = z.object({ - event_types: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), - start_date: z.string().optional(), - end_date: z.string().optional(), + event_types: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), }); /** * Replays historical webhook events for a specific webhook endpoint with rate limiting. */ -const replayWebhookEvents = async (supabase, { +const replayWebhookEvents = async ( + supabase, + { webhookId, - eventTypes = ['*'], + eventTypes = ["*"], startDate, endDate, fetchImpl = globalThis.fetch, sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), rateLimitPerSecond = 10, -}) => { - if (!supabase || !webhookId) return { total_events: 0, replayed_count: 0, deliveries: [] }; - - // 1. Fetch webhook destination - const { data: wh, error: whErr } = await supabase - .from('webhooks') - .select('*') - .eq('id', webhookId) - .single(); - - if (whErr || !wh) { - const notFoundErr = new Error('Webhook not found'); - notFoundErr.status = 404; - throw notFoundErr; - } - - // 2. Query historical events matching criteria from webhook_events (or fallback to webhook_deliveries) - let query = supabase - .from('webhook_events') - .select('*') - .eq('merchant_id', wh.merchant_id); - - const filterTypes = Array.isArray(eventTypes) ? eventTypes : (eventTypes ? [eventTypes] : ['*']); - const shouldFilterTypes = filterTypes.length > 0 && !filterTypes.includes('*'); + }, +) => { + if (!supabase || !webhookId) + return { total_events: 0, replayed_count: 0, deliveries: [] }; + + // 1. Fetch webhook destination + const { data: wh, error: whErr } = await supabase + .from("webhooks") + .select("*") + .eq("id", webhookId) + .single(); + + if (whErr || !wh) { + const notFoundErr = new Error("Webhook not found"); + notFoundErr.status = 404; + throw notFoundErr; + } + + // 2. Query historical events matching criteria from webhook_events (or fallback to webhook_deliveries) + let query = supabase + .from("webhook_events") + .select("*") + .eq("merchant_id", wh.merchant_id); + + const filterTypes = Array.isArray(eventTypes) + ? eventTypes + : eventTypes + ? [eventTypes] + : ["*"]; + const shouldFilterTypes = + filterTypes.length > 0 && !filterTypes.includes("*"); + + if (shouldFilterTypes) { + query = query.in("event_type", filterTypes); + } + if (startDate) { + query = query.gte("created_at", new Date(startDate).toISOString()); + } + if (endDate) { + query = query.lte("created_at", new Date(endDate).toISOString()); + } + query = query.order("created_at", { ascending: true }); + + let { data: events, error: evErr } = await query; + + // Fallback to webhook_deliveries if webhook_events is not populated + if (evErr || !events || events.length === 0) { + let fallbackQuery = supabase + .from("webhook_deliveries") + .select("*") + .eq("merchant_id", wh.merchant_id); if (shouldFilterTypes) { - query = query.in('event_type', filterTypes); + fallbackQuery = fallbackQuery.in("event_type", filterTypes); } if (startDate) { - query = query.gte('created_at', new Date(startDate).toISOString()); + fallbackQuery = fallbackQuery.gte( + "created_at", + new Date(startDate).toISOString(), + ); } if (endDate) { - query = query.lte('created_at', new Date(endDate).toISOString()); + fallbackQuery = fallbackQuery.lte( + "created_at", + new Date(endDate).toISOString(), + ); } - query = query.order('created_at', { ascending: true }); + fallbackQuery = fallbackQuery.order("created_at", { ascending: true }); - let { data: events, error: evErr } = await query; - - // Fallback to webhook_deliveries if webhook_events is not populated - if (evErr || !events || events.length === 0) { - let fallbackQuery = supabase - .from('webhook_deliveries') - .select('*') - .eq('merchant_id', wh.merchant_id); - - if (shouldFilterTypes) { - fallbackQuery = fallbackQuery.in('event_type', filterTypes); - } - if (startDate) { - fallbackQuery = fallbackQuery.gte('created_at', new Date(startDate).toISOString()); - } - if (endDate) { - fallbackQuery = fallbackQuery.lte('created_at', new Date(endDate).toISOString()); - } - fallbackQuery = fallbackQuery.order('created_at', { ascending: true }); - - const { data: fallbackEvents } = await fallbackQuery; - if (fallbackEvents && fallbackEvents.length > 0) { - // Deduplicate fallback delivery events by id or payload timestamp if multiple deliveries exist - events = fallbackEvents; - } + const { data: fallbackEvents } = await fallbackQuery; + if (fallbackEvents && fallbackEvents.length > 0) { + // Deduplicate fallback delivery events by id or payload timestamp if multiple deliveries exist + events = fallbackEvents; + } + } + + if (!events || events.length === 0) { + return { total_events: 0, replayed_count: 0, deliveries: [] }; + } + + // 3. Rate limiting delay calculation: 10 events/sec -> 100ms per event + const delayMs = + rateLimitPerSecond > 0 ? Math.ceil(1000 / rateLimitPerSecond) : 0; + const secret = wh.signing_secret || generateSigningSecret(); + const deliveries = []; + + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + const eventType = ev.event_type; + + // Skip if webhook is not subscribed to this event type + if ( + wh.events && + Array.isArray(wh.events) && + !wh.events.includes("*") && + !wh.events.includes(eventType) + ) { + continue; } - if (!events || events.length === 0) { - return { total_events: 0, replayed_count: 0, deliveries: [] }; + // Throttle delivery + if (i > 0 && delayMs > 0 && typeof sleepImpl === "function") { + await sleepImpl(delayMs); } - // 3. Rate limiting delay calculation: 10 events/sec -> 100ms per event - const delayMs = rateLimitPerSecond > 0 ? Math.ceil(1000 / rateLimitPerSecond) : 0; - const secret = wh.signing_secret || generateSigningSecret(); - const deliveries = []; + // Preserve original timestamp in payload metadata + const rawPayload = + typeof ev.payload === "string" + ? JSON.parse(ev.payload) + : ev.payload || {}; + const originalTimestamp = + ev.created_at || + rawPayload.timestamp || + rawPayload.settled_at || + new Date().toISOString(); + + const replayedPayload = { + ...rawPayload, + metadata: { + ...(rawPayload.metadata || {}), + original_timestamp: originalTimestamp, + replayed: true, + replayed_at: new Date().toISOString(), + }, + }; - for (let i = 0; i < events.length; i++) { - const ev = events[i]; - const eventType = ev.event_type; + const payloadString = JSON.stringify(replayedPayload); + const signature = computeWebhookSignature(secret, payloadString); + const deliveryId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); - // Skip if webhook is not subscribed to this event type - if (wh.events && Array.isArray(wh.events) && !wh.events.includes('*') && !wh.events.includes(eventType)) { - continue; - } + const headers = { + "Content-Type": "application/json", + "User-Agent": "Orbit-Webhooks/1.0", + "X-Orbit-Signature": signature, + "X-Orbit-Event": eventType, + "X-Orbit-Delivery": deliveryId, + "X-Orbit-Timestamp": timestamp, + "X-Orbit-Replay": "true", + }; - // Throttle delivery - if (i > 0 && delayMs > 0 && typeof sleepImpl === 'function') { - await sleepImpl(delayMs); - } + let responseStatus = 0; + let responseBody = ""; + let status = "failed"; - // Preserve original timestamp in payload metadata - const rawPayload = typeof ev.payload === 'string' ? JSON.parse(ev.payload) : (ev.payload || {}); - const originalTimestamp = ev.created_at || rawPayload.timestamp || rawPayload.settled_at || new Date().toISOString(); - - const replayedPayload = { - ...rawPayload, - metadata: { - ...(rawPayload.metadata || {}), - original_timestamp: originalTimestamp, - replayed: true, - replayed_at: new Date().toISOString(), - }, - }; - - const payloadString = JSON.stringify(replayedPayload); - const signature = computeWebhookSignature(secret, payloadString); - const deliveryId = crypto.randomUUID(); - const timestamp = new Date().toISOString(); - - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'Orbit-Webhooks/1.0', - 'X-Orbit-Signature': signature, - 'X-Orbit-Event': eventType, - 'X-Orbit-Delivery': deliveryId, - 'X-Orbit-Timestamp': timestamp, - 'X-Orbit-Replay': 'true', - }; - - let responseStatus = 0; - let responseBody = ''; - let status = 'failed'; - - try { - if (typeof fetchImpl === 'function' && wh.url) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 6000); - - const res = await fetchImpl(wh.url, { - method: 'POST', - headers, - body: payloadString, - signal: controller.signal, - }); - clearTimeout(timeout); - - responseStatus = res.status; - responseBody = await res.text().catch(() => ''); - status = res.ok ? 'success' : 'failed'; - } else { - responseStatus = 200; - responseBody = '{"received":true,"replay":true}'; - status = 'success'; - } - } catch (fetchErr) { - responseStatus = 500; - responseBody = fetchErr.message || 'Connection error'; - status = 'failed'; - } + try { + if (typeof fetchImpl === "function" && wh.url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6000); + + const res = await fetchImpl(wh.url, { + method: "POST", + headers, + body: payloadString, + signal: controller.signal, + }); + clearTimeout(timeout); + + responseStatus = res.status; + responseBody = await res.text().catch(() => ""); + status = res.ok ? "success" : "failed"; + } else { + responseStatus = 200; + responseBody = '{"received":true,"replay":true}'; + status = "success"; + } + } catch (fetchErr) { + responseStatus = 500; + responseBody = fetchErr.message || "Connection error"; + status = "failed"; + } - const { data: deliveryRecord, error: deliveryErr } = await supabase - .from('webhook_deliveries') - .insert([{ - id: deliveryId, - webhook_id: wh.id, - merchant_id: wh.merchant_id, - event_type: eventType, - payload: replayedPayload, - request_headers: headers, - response_status: responseStatus, - response_body: responseBody, - attempt_count: 1, - status, - }]) - .select() - .single(); - - if (!deliveryErr && deliveryRecord) { - deliveries.push(deliveryRecord); - } else { - deliveries.push({ - id: deliveryId, - webhook_id: wh.id, - merchant_id: wh.merchant_id, - event_type: eventType, - payload: replayedPayload, - request_headers: headers, - response_status: responseStatus, - status, - }); - } + const { data: deliveryRecord, error: deliveryErr } = await supabase + .from("webhook_deliveries") + .insert([ + { + id: deliveryId, + webhook_id: wh.id, + merchant_id: wh.merchant_id, + event_type: eventType, + payload: replayedPayload, + request_headers: headers, + response_status: responseStatus, + response_body: responseBody, + attempt_count: 1, + status, + }, + ]) + .select() + .single(); + + if (!deliveryErr && deliveryRecord) { + deliveries.push(deliveryRecord); + } else { + deliveries.push({ + id: deliveryId, + webhook_id: wh.id, + merchant_id: wh.merchant_id, + event_type: eventType, + payload: replayedPayload, + request_headers: headers, + response_status: responseStatus, + status, + }); } + } - return { - total_events: events.length, - replayed_count: deliveries.length, - deliveries, - }; + return { + total_events: events.length, + replayed_count: deliveries.length, + deliveries, + }; }; module.exports = { - createWebhookSchema, - updateWebhookSchema, - backfillWebhookSchema, - generateSigningSecret, - computeWebhookSignature, - verifyWebhookSignature, - dispatchWebhookEvent, - resendWebhookDelivery, - replayWebhookEvents, - setDeliveryObserver, + createWebhookSchema, + updateWebhookSchema, + backfillWebhookSchema, + generateSigningSecret, + computeWebhookSignature, + verifyWebhookSignature, + dispatchWebhookEvent, + resendWebhookDelivery, + replayWebhookEvents, + setDeliveryObserver, }; - - diff --git a/apps/frontend/src/app/dashboard/forecasting/page.tsx b/apps/frontend/src/app/dashboard/forecasting/page.tsx new file mode 100644 index 0000000..ae0ce1d --- /dev/null +++ b/apps/frontend/src/app/dashboard/forecasting/page.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { RevenueForecastingView } from '@/components/dashboard/revenue-forecasting'; +import type { Subscription } from '@/lib/revenue-forecasting'; + +export default function ForecastingPage() { + const [subscriptions, setSubscriptions] = useState([]); + const [historicalChurn, setHistoricalChurn] = useState({ + churned_30d: 0, + churned_60d: 0, + churned_90d: 0, + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchData() { + try { + // Fetch active subscriptions + const subsResponse = await fetch('/api/subscriptions/active'); + const subsData = await subsResponse.json(); + + // Fetch churn metrics + const churnResponse = await fetch('/api/analytics/churn'); + const churnData = await churnResponse.json(); + + setSubscriptions(subsData); + setHistoricalChurn(churnData); + } catch (error) { + console.error('Failed to load forecasting data:', error); + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + if (loading) { + return ( +
+
+
+

Loading forecast data...

+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/dashboard/revenue-forecasting.tsx b/apps/frontend/src/components/dashboard/revenue-forecasting.tsx new file mode 100644 index 0000000..bcf3320 --- /dev/null +++ b/apps/frontend/src/components/dashboard/revenue-forecasting.tsx @@ -0,0 +1,400 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { + projectRevenue, + calculateChurnMetrics, + generateRenewalCalendar, + formatCurrency, + formatPercentage, + type Subscription, + type ForecastScenario, + type RevenueForecast, + type ChurnMetrics, +} from '@/lib/revenue-forecasting'; + +interface RevenueForecastingProps { + subscriptions: Subscription[]; + historicalChurn: { + churned_30d: number; + churned_60d: number; + churned_90d: number; + }; +} + +export function RevenueForecastingView({ subscriptions, historicalChurn }: RevenueForecastingProps) { + const [forecastDays, setForecastDays] = useState<30 | 60 | 90>(30); + const [scenario, setScenario] = useState({ + churn_rate_adjustment: 0, + growth_rate_adjustment: 0, + }); + + // Calculate churn metrics + const churnMetrics = useMemo(() => + calculateChurnMetrics(subscriptions, historicalChurn), + [subscriptions, historicalChurn] + ); + + // Generate forecast + const forecast = useMemo(() => + projectRevenue(subscriptions, churnMetrics, forecastDays, scenario), + [subscriptions, churnMetrics, forecastDays, scenario] + ); + + // Generate calendar + const calendar = useMemo(() => + generateRenewalCalendar(subscriptions, forecastDays), + [subscriptions, forecastDays] + ); + + return ( +
+ {/* Header */} +
+

Revenue Forecasting

+

+ Predictive cash flow and churn modeling based on subscription renewals +

+
+ + {/* Key Metrics Cards */} +
+ + 5 ? 'negative' : 'positive'} + /> + + +
+ + {/* Scenario Controls */} +
+

Interactive Scenario Modeling

+
+ {/* Forecast Period Selector */} +
+ +
+ {([30, 60, 90] as const).map((days) => ( + + ))} +
+
+ + {/* Churn Rate Adjustment Slider */} +
+ + setScenario({ + ...scenario, + churn_rate_adjustment: Number(e.target.value), + })} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + /> +
+ -50% (Better) + Baseline + +50% (Worse) +
+
+ + {/* Growth Rate Adjustment Slider */} +
+ + setScenario({ + ...scenario, + growth_rate_adjustment: Number(e.target.value), + })} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + /> +
+ -10% (Decline) + 0% (Flat) + +20% (Growth) +
+
+
+
+ + {/* Forecast Chart */} +
+

Projected Cash Flow Trajectory

+ +
+ + {/* Renewal Calendar */} +
+

Upcoming Settlement Calendar

+ +
+
+ ); +} + +function MetricCard({ + title, + value, + subtitle, + trend +}: { + title: string; + value: string; + subtitle: string; + trend?: 'positive' | 'negative' +}) { + return ( +
+
+
+
+
{title}
+
{value}
+
+ {trend && ( +
+ {trend === 'positive' ? '↓' : '↑'} +
+ )} +
+

{subtitle}

+
+
+ ); +} + +function ForecastChart({ forecast }: { forecast: RevenueForecast }) { + const maxMRR = Math.max(...forecast.periods.map(p => p.projected_mrr)); + const maxCashFlow = Math.max(...forecast.periods.map(p => p.projected_cash_flow)); + const chartHeight = 200; + + // Sample every Nth period for readable x-axis + const sampleRate = Math.ceil(forecast.periods.length / 10); + + return ( +
+ + {/* Y-axis grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => ( + + ))} + + {/* MRR Line */} + { + const x = (i / (forecast.periods.length - 1)) * 100; + const y = chartHeight * (1 - p.projected_mrr / maxMRR); + return `${x}%,${y}`; + }) + .join(' ')} + /> + + {/* Cash Flow Bars */} + {forecast.periods.map((p, i) => { + const x = (i / (forecast.periods.length - 1)) * 100; + const barHeight = (p.projected_cash_flow / maxCashFlow) * chartHeight * 0.8; + const y = chartHeight - barHeight; + return ( + + ); + })} + + + {/* Legend */} +
+
+
+ Projected MRR +
+
+
+ Daily Cash Flow +
+
+ + {/* Summary Stats */} +
+
+ Total Projected Revenue: + {formatCurrency(forecast.total_projected_revenue)} +
+
+ Average MRR: + {formatCurrency(forecast.average_mrr)} +
+
+
+ ); +} + +function RenewalCalendar({ events }: { events: Array<{ date: Date; renewal_count: number; total_volume: number }> }) { + const [selectedMonth, setSelectedMonth] = useState(new Date()); + + // Group events by month + const monthlyEvents = useMemo(() => { + const grouped = new Map(); + + for (const event of events) { + const monthKey = `${event.date.getFullYear()}-${event.date.getMonth()}`; + if (!grouped.has(monthKey)) { + grouped.set(monthKey, []); + } + grouped.get(monthKey)!.push(event); + } + + return grouped; + }, [events]); + + const currentMonthKey = `${selectedMonth.getFullYear()}-${selectedMonth.getMonth()}`; + const currentMonthEvents = monthlyEvents.get(currentMonthKey) || []; + + // Generate calendar grid + const firstDay = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth(), 1); + const lastDay = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + const calendarDays = Array.from({ length: 42 }, (_, i) => { + const dayNum = i - startingDayOfWeek + 1; + if (dayNum < 1 || dayNum > daysInMonth) return null; + + const date = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth(), dayNum); + const event = currentMonthEvents.find( + e => e.date.toDateString() === date.toDateString() + ); + + return { dayNum, date, event }; + }); + + return ( +
+ {/* Month Navigator */} +
+ +

+ {selectedMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +

+ +
+ + {/* Calendar Grid */} +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => ( +
+ {day} +
+ ))} + + {calendarDays.map((day, i) => ( +
+ {day && ( + <> +
{day.dayNum}
+ {day.event && ( +
+
+ {day.event.renewal_count} renewals +
+
+ {formatCurrency(day.event.total_volume)} +
+
+ )} + + )} +
+ ))} +
+
+ ); +} diff --git a/apps/frontend/src/lib/revenue-forecasting.ts b/apps/frontend/src/lib/revenue-forecasting.ts new file mode 100644 index 0000000..7cba57d --- /dev/null +++ b/apps/frontend/src/lib/revenue-forecasting.ts @@ -0,0 +1,255 @@ +/** + * Revenue Forecasting and Predictive Churn Modeling (#178) + * + * Provides forward-looking visibility into projected cash flow, renewal probabilities, + * and revenue forecasting based on historical churn trends. + */ + +export interface Subscription { + id: string; + amount: number; + interval_seconds: number; + last_pull_timestamp: number; + created_at: string; + status: 'active' | 'paused' | 'defaulted'; +} + +export interface ChurnMetrics { + monthly_churn_rate: number; // percentage (0-100) + total_active_subscribers: number; + churned_last_30_days: number; + churned_last_60_days: number; + churned_last_90_days: number; +} + +export interface ForecastScenario { + churn_rate_adjustment: number; // percentage adjustment (-100 to +100) + growth_rate_adjustment: number; // percentage adjustment (-100 to +100) +} + +export interface ForecastPeriod { + date: Date; + projected_mrr: number; + projected_cash_flow: number; + active_subscribers: number; + new_subscribers: number; + churned_subscribers: number; + renewal_count: number; +} + +export interface RevenueForecast { + periods: ForecastPeriod[]; + total_projected_revenue: number; + average_mrr: number; + confidence_level: number; // 0-1 +} + +export interface RenewalEvent { + subscriber_id: string; + renewal_date: Date; + amount: number; + subscription_id: string; +} + +/** + * Calculate historical churn rate from subscription data + */ +export function calculateChurnMetrics( + subscriptions: Subscription[], + historicalData: { + churned_30d: number; + churned_60d: number; + churned_90d: number; + } +): ChurnMetrics { + const activeSubscribers = subscriptions.filter(s => s.status === 'active').length; + + // Calculate monthly churn rate based on last 30 days + const monthly_churn_rate = activeSubscribers > 0 + ? (historicalData.churned_30d / activeSubscribers) * 100 + : 0; + + return { + monthly_churn_rate: Math.min(monthly_churn_rate, 100), + total_active_subscribers: activeSubscribers, + churned_last_30_days: historicalData.churned_30d, + churned_last_60_days: historicalData.churned_60d, + churned_last_90_days: historicalData.churned_90d, + }; +} + +/** + * Calculate next renewal dates for all active subscriptions + */ +export function calculateRenewalSchedule(subscriptions: Subscription[]): RenewalEvent[] { + const now = Date.now() / 1000; // Current timestamp in seconds + const renewals: RenewalEvent[] = []; + + for (const sub of subscriptions) { + if (sub.status !== 'active') continue; + + // Calculate next renewal timestamp + const lastPull = sub.last_pull_timestamp || now; + const nextRenewal = lastPull + sub.interval_seconds; + + renewals.push({ + subscriber_id: sub.id, + renewal_date: new Date(nextRenewal * 1000), + amount: sub.amount, + subscription_id: sub.id, + }); + } + + return renewals.sort((a, b) => a.renewal_date.getTime() - b.renewal_date.getTime()); +} + +/** + * Project revenue for the next N days based on renewal schedule and churn rate + */ +export function projectRevenue( + subscriptions: Subscription[], + churnMetrics: ChurnMetrics, + daysForward: number, + scenario: ForecastScenario = { churn_rate_adjustment: 0, growth_rate_adjustment: 0 } +): RevenueForecast { + const periodsPerDay = 1; + const periods: ForecastPeriod[] = []; + + // Apply scenario adjustments + const adjusted_churn_rate = Math.max(0, Math.min(100, + churnMetrics.monthly_churn_rate * (1 + scenario.churn_rate_adjustment / 100) + )); + + const daily_churn_rate = adjusted_churn_rate / 30; // Convert monthly to daily + const monthly_growth_rate = scenario.growth_rate_adjustment; + const daily_growth_rate = monthly_growth_rate / 30; + + // Get initial active subscribers and renewals + let activeSubscribers = churnMetrics.total_active_subscribers; + const renewalSchedule = calculateRenewalSchedule(subscriptions); + + // Calculate average subscription value + const avgSubscriptionValue = subscriptions.length > 0 + ? subscriptions.reduce((sum, s) => sum + s.amount, 0) / subscriptions.length + : 0; + + let totalProjectedRevenue = 0; + const startDate = new Date(); + + for (let day = 0; day < daysForward; day++) { + const periodDate = new Date(startDate); + periodDate.setDate(periodDate.getDate() + day); + + // Calculate renewals for this day + const dayStart = periodDate.getTime(); + const dayEnd = dayStart + 24 * 60 * 60 * 1000; + + const renewalsToday = renewalSchedule.filter(r => { + const renewalTime = r.renewal_date.getTime(); + return renewalTime >= dayStart && renewalTime < dayEnd; + }); + + const renewal_count = renewalsToday.length; + const projected_cash_flow = renewalsToday.reduce((sum, r) => sum + r.amount, 0); + + // Calculate churn for this day + const churned_today = Math.round(activeSubscribers * (daily_churn_rate / 100)); + + // Calculate new subscribers for this day + const new_subscribers_today = Math.round(activeSubscribers * (daily_growth_rate / 100)); + + // Update active subscriber count + activeSubscribers = activeSubscribers - churned_today + new_subscribers_today; + activeSubscribers = Math.max(0, activeSubscribers); + + // Calculate MRR (Monthly Recurring Revenue) + const projected_mrr = activeSubscribers * avgSubscriptionValue; + + totalProjectedRevenue += projected_cash_flow; + + periods.push({ + date: periodDate, + projected_mrr, + projected_cash_flow, + active_subscribers: activeSubscribers, + new_subscribers: new_subscribers_today, + churned_subscribers: churned_today, + renewal_count, + }); + } + + // Calculate average MRR + const average_mrr = periods.reduce((sum, p) => sum + p.projected_mrr, 0) / periods.length; + + // Confidence level decreases as we project further out + const confidence_level = Math.max(0.3, 1 - (daysForward / 365)); + + return { + periods, + total_projected_revenue: totalProjectedRevenue, + average_mrr, + confidence_level, + }; +} + +/** + * Generate calendar view of upcoming settlement spikes + */ +export interface CalendarEvent { + date: Date; + renewal_count: number; + total_volume: number; + peak_hour?: number; +} + +export function generateRenewalCalendar( + subscriptions: Subscription[], + daysForward: number = 90 +): CalendarEvent[] { + const renewals = calculateRenewalSchedule(subscriptions); + const calendar: Map = new Map(); + + const endDate = new Date(); + endDate.setDate(endDate.getDate() + daysForward); + + for (const renewal of renewals) { + if (renewal.renewal_date > endDate) continue; + + const dateKey = renewal.renewal_date.toISOString().split('T')[0]; + + if (!calendar.has(dateKey)) { + calendar.set(dateKey, { + date: new Date(dateKey), + renewal_count: 0, + total_volume: 0, + }); + } + + const event = calendar.get(dateKey)!; + event.renewal_count++; + event.total_volume += renewal.amount; + } + + return Array.from(calendar.values()).sort((a, b) => + a.date.getTime() - b.date.getTime() + ); +} + +/** + * Format currency for display + */ +export function formatCurrency(amount: number, decimals: number = 7): string { + return (amount / Math.pow(10, decimals)).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +/** + * Format percentage for display + */ +export function formatPercentage(value: number): string { + return `${value.toFixed(2)}%`; +} diff --git a/contracts/soroban/src/lib.rs b/contracts/soroban/src/lib.rs index 4a92522..1236019 100644 --- a/contracts/soroban/src/lib.rs +++ b/contracts/soroban/src/lib.rs @@ -337,6 +337,31 @@ pub enum FeatureKey { RecoveryRequest(Address, Address), Elasticity(Address, Address), GasSubsidy(Address), + YieldStrategy, +} + +// ---------------------------------------------------------------------------- +// YIELD-BEARING COLLATERAL STRATEGY (#176) +// ---------------------------------------------------------------------------- + +/// Configuration for the yield strategy adapter. Stores the approved lending +/// pool where idle escrow balances are deployed to generate passive yield. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct YieldStrategyConfig { + pub yield_pool: Option
, + pub token: Address, + pub liquidity_buffer: i128, + pub total_deployed: i128, +} + +/// A trusted Soroban lending market (e.g., Blend Protocol) that accepts token +/// deposits, accrues interest, and allows atomic redemption of principal + yield. +#[contractclient(name = "LendingPoolClient")] +pub trait LendingPool { + fn supply(env: Env, token: Address, amount: i128, from: Address); + fn withdraw(env: Env, token: Address, amount: i128, to: Address) -> i128; + fn balance_of(env: Env, token: Address, account: Address) -> i128; } // ---------------------------------------------------------------------------- @@ -3105,6 +3130,163 @@ impl OrbitContract { 0, ); } + + // -------------------------------------------------------------------- + // YIELD-BEARING COLLATERAL STRATEGY (#176) + // -------------------------------------------------------------------- + + /// Configure an automated yield strategy for idle contract escrow. When + /// escrow balances exceed the `liquidity_buffer`, excess tokens are + /// automatically deployed to the approved lending pool to generate yield. + pub fn configure_yield_strategy( + env: Env, + admin: Address, + yield_pool: Address, + token: Address, + liquidity_buffer: i128, + ) { + admin.require_auth(); + assert!(liquidity_buffer >= 0, "Liquidity buffer must be non-negative"); + + let key = FeatureKey::YieldStrategy; + let config = YieldStrategyConfig { + yield_pool: Some(yield_pool.clone()), + token: token.clone(), + liquidity_buffer, + total_deployed: 0, + }; + env.storage().persistent().set(&key, &config); + extend_feature_key(&env, &key, MIN_TTL_LEDGERS as u64 * SECONDS_PER_LEDGER); + + env.events().publish( + (Symbol::new(&env, "yield_strategy_configured"), admin), + (yield_pool, token, liquidity_buffer), + ); + } + + /// Deploy excess idle capital to the configured yield market. This checks + /// the contract's token balance against the liquidity buffer and supplies + /// the surplus to the lending pool to earn passive interest. + pub fn deploy_idle_capital(env: Env, token: Address) { + let key = FeatureKey::YieldStrategy; + let mut config: YieldStrategyConfig = env + .storage() + .persistent() + .get(&key) + .expect("Yield strategy not configured"); + assert!(config.token == token, "Wrong token for yield strategy"); + + let pool_address = config + .yield_pool + .as_ref() + .expect("No yield pool configured"); + let contract_addr = env.current_contract_address(); + let token_client = token::Client::new(&env, &token); + let current_balance = token_client.balance(&contract_addr); + + let deployable = current_balance + .checked_sub(config.liquidity_buffer) + .unwrap_or(0); + assert!(deployable > 0, "No excess capital to deploy"); + + // Supply tokens to the lending pool + let pool_client = LendingPoolClient::new(&env, pool_address); + pool_client.supply(&token, &deployable, &contract_addr); + + config.total_deployed = config + .total_deployed + .checked_add(deployable) + .expect("Total deployed overflow"); + env.storage().persistent().set(&key, &config); + extend_feature_key(&env, &key, MIN_TTL_LEDGERS as u64 * SECONDS_PER_LEDGER); + + env.events().publish( + (Symbol::new(&env, "capital_deployed"), token), + (deployable, config.total_deployed), + ); + } + + /// Redeem principal plus accrued interest from the yield market. This is + /// called when merchants withdraw funds or escrow is released, ensuring + /// available liquidity while capturing the earned yield. + pub fn redeem_yield_principal(env: Env, token: Address, amount: i128) { + assert!(amount > 0, "Redemption amount must be positive"); + + let key = FeatureKey::YieldStrategy; + let mut config: YieldStrategyConfig = env + .storage() + .persistent() + .get(&key) + .expect("Yield strategy not configured"); + assert!(config.token == token, "Wrong token for yield strategy"); + + let pool_address = config + .yield_pool + .as_ref() + .expect("No yield pool configured"); + let contract_addr = env.current_contract_address(); + + // Withdraw from the lending pool (principal + interest) + let pool_client = LendingPoolClient::new(&env, pool_address); + let redeemed = pool_client.withdraw(&token, &amount, &contract_addr); + assert!(redeemed >= amount, "Insufficient yield pool liquidity"); + + config.total_deployed = config + .total_deployed + .checked_sub(amount.min(config.total_deployed)) + .unwrap_or(0); + env.storage().persistent().set(&key, &config); + extend_feature_key(&env, &key, MIN_TTL_LEDGERS as u64 * SECONDS_PER_LEDGER); + + let yield_earned = redeemed.saturating_sub(amount); + env.events().publish( + (Symbol::new(&env, "principal_redeemed"), token), + (amount, redeemed, yield_earned), + ); + } + + /// Emergency unwind: immediately withdraw all deployed funds from the yield + /// market back to the base contract. This is admin-guarded and used when + /// market utilization spikes or safety concerns require immediate liquidity. + pub fn emergency_unwind_yield(env: Env, admin: Address) { + admin.require_auth(); + + let key = FeatureKey::YieldStrategy; + let mut config: YieldStrategyConfig = env + .storage() + .persistent() + .get(&key) + .expect("Yield strategy not configured"); + + let pool_address = config + .yield_pool + .as_ref() + .expect("No yield pool configured"); + let contract_addr = env.current_contract_address(); + let pool_client = LendingPoolClient::new(&env, &config.token); + + // Query the contract's balance in the lending pool + let pool_balance = pool_client.balance_of(&config.token, &contract_addr); + if pool_balance > 0 { + let pool = LendingPoolClient::new(&env, pool_address); + let recovered = pool.withdraw(&config.token, &pool_balance, &contract_addr); + + config.total_deployed = 0; + env.storage().persistent().set(&key, &config); + + env.events().publish( + (Symbol::new(&env, "emergency_yield_unwind"), admin), + (config.token, recovered), + ); + } + } + + /// Read the current yield strategy configuration. No authorization required. + pub fn get_yield_strategy(env: Env) -> Option { + env.storage() + .persistent() + .get(&FeatureKey::YieldStrategy) + } } /// Checks every split upfront so a bad entry fails before any transfer. Returns the total. diff --git a/contracts/soroban/src/test.rs b/contracts/soroban/src/test.rs index fbba40d..94c0c94 100644 --- a/contracts/soroban/src/test.rs +++ b/contracts/soroban/src/test.rs @@ -3859,3 +3859,166 @@ fn test_merkle_payroll_requires_sender_auth() { .try_create_merkle_payroll(&sender, &token, &root, &1_0000000, &3_600) .is_err()); } + +// ---------------------------------------------------------------------------- +// YIELD-BEARING COLLATERAL STRATEGY TESTS (#176) +// ---------------------------------------------------------------------------- + +// Mock lending pool contract for testing +#[contract] +pub struct MockLendingPool; + +#[contractimpl] +impl MockLendingPool { + pub fn supply(_env: Env, _token: Address, _amount: i128, _from: Address) { + // Mock supply - funds are tracked by the contract itself + } + + pub fn withdraw(_env: Env, _token: Address, amount: i128, _to: Address) -> i128 { + // Mock withdraw - return slightly more than requested (simulating yield) + amount + (amount / 100) // 1% yield + } + + pub fn balance_of(_env: Env, _token: Address, _account: Address) -> i128 { + 1000_0000000 // Mock balance + } +} + +#[test] +fn test_configure_yield_strategy() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let pool_id = env.register(MockLendingPool, ()); + + let contract_id = env.register(OrbitContract, ()); + let orbit_client = OrbitContractClient::new(&env, &contract_id); + + orbit_client.configure_yield_strategy( + &admin, + &pool_id, + &token_address, + &10_0000000, // 10 token liquidity buffer + ); + + let config = orbit_client.get_yield_strategy(); + assert!(config.is_some()); + let config = config.unwrap(); + assert_eq!(config.token, token_address); + assert_eq!(config.liquidity_buffer, 10_0000000); + assert_eq!(config.total_deployed, 0); +} + +#[test] +fn test_deploy_idle_capital() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = TokenClient::new(&env, &token_address); + let token_admin_client = StellarAssetClient::new(&env, &token_address); + + let pool_id = env.register(MockLendingPool, ()); + let contract_id = env.register(OrbitContract, ()); + let orbit_client = OrbitContractClient::new(&env, &contract_id); + + // Configure yield strategy with 10 token buffer + orbit_client.configure_yield_strategy(&admin, &pool_id, &token_address, &10_0000000); + + // Mint 50 tokens to the contract (simulating escrow deposits) + token_admin_client.mint(&contract_id, &50_0000000); + + // Deploy excess capital (50 - 10 = 40 should be deployed) + orbit_client.deploy_idle_capital(&token_address); + + let config = orbit_client.get_yield_strategy().unwrap(); + assert_eq!(config.total_deployed, 40_0000000); +} + +#[test] +fn test_redeem_yield_principal() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = StellarAssetClient::new(&env, &token_address); + + let pool_id = env.register(MockLendingPool, ()); + let contract_id = env.register(OrbitContract, ()); + let orbit_client = OrbitContractClient::new(&env, &contract_id); + + orbit_client.configure_yield_strategy(&admin, &pool_id, &token_address, &10_0000000); + token_admin_client.mint(&contract_id, &50_0000000); + orbit_client.deploy_idle_capital(&token_address); + + // Redeem 20 tokens (will get 20 + 1% = 20.2 due to mock yield) + orbit_client.redeem_yield_principal(&token_address, &20_0000000); + + let config = orbit_client.get_yield_strategy().unwrap(); + assert_eq!(config.total_deployed, 20_0000000); // 40 - 20 = 20 remaining deployed +} + +#[test] +fn test_emergency_unwind_yield() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = StellarAssetClient::new(&env, &token_address); + + let pool_id = env.register(MockLendingPool, ()); + let contract_id = env.register(OrbitContract, ()); + let orbit_client = OrbitContractClient::new(&env, &contract_id); + + orbit_client.configure_yield_strategy(&admin, &pool_id, &token_address, &10_0000000); + token_admin_client.mint(&contract_id, &50_0000000); + orbit_client.deploy_idle_capital(&token_address); + + // Emergency unwind all funds + orbit_client.emergency_unwind_yield(&admin); + + let config = orbit_client.get_yield_strategy().unwrap(); + assert_eq!(config.total_deployed, 0); +} + +#[test] +#[should_panic(expected = "No excess capital to deploy")] +fn test_deploy_fails_when_no_excess() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = StellarAssetClient::new(&env, &token_address); + + let pool_id = env.register(MockLendingPool, ()); + let contract_id = env.register(OrbitContract, ()); + let orbit_client = OrbitContractClient::new(&env, &contract_id); + + orbit_client.configure_yield_strategy(&admin, &pool_id, &token_address, &50_0000000); + // Mint exactly the buffer amount - no excess to deploy + token_admin_client.mint(&contract_id, &50_0000000); + + orbit_client.deploy_idle_capital(&token_address); +} diff --git a/packages/checkout-widget/public/sw.js b/packages/checkout-widget/public/sw.js new file mode 100644 index 0000000..3797055 --- /dev/null +++ b/packages/checkout-widget/public/sw.js @@ -0,0 +1,258 @@ +/** + * Service Worker for Background Sync (#179) + * + * Registers a background sync listener that continues polling Soroban RPC + * even if the user switches browser tabs or network connection flickers. + */ + +const CACHE_NAME = "orbit-checkout-v1"; +const RPC_ENDPOINT = "https://soroban-testnet.stellar.org"; +const POLL_INTERVAL = 5000; // 5 seconds +const MAX_POLL_TIME = 5 * 60 * 1000; // 5 minutes + +self.addEventListener("install", (event) => { + console.log("Service Worker installing..."); + self.skipWaiting(); // Activate immediately +}); + +self.addEventListener("activate", (event) => { + console.log("Service Worker activated"); + event.waitUntil(self.clients.claim()); +}); + +/** + * Background sync for transaction confirmation polling + */ +self.addEventListener("sync", (event) => { + console.log("Background sync triggered:", event.tag); + + if (event.tag === "poll-transaction") { + event.waitUntil(pollPendingTransactions()); + } +}); + +/** + * Poll all pending transactions and update their status + */ +async function pollPendingTransactions() { + try { + // Open IndexedDB in Service Worker context + const db = await openIndexedDB(); + const pendingTransactions = await getAllPendingFromDB(db); + + console.log("Polling pending transactions:", pendingTransactions.length); + + for (const transaction of pendingTransactions) { + await checkTransactionStatus(db, transaction); + } + } catch (error) { + console.error("Failed to poll pending transactions:", error); + } +} + +/** + * Open IndexedDB connection + */ +function openIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open("orbit_checkout_cache", 1); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** + * Get all pending transactions from IndexedDB + */ +function getAllPendingFromDB(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction("pending_transactions", "readonly"); + const store = tx.objectStore("pending_transactions"); + const index = store.index("status"); + const request = index.getAll("pending"); + + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); +} + +/** + * Check transaction status on Soroban RPC + */ +async function checkTransactionStatus(db, transaction) { + const startTime = Date.now(); + const maxRetries = Math.floor(MAX_POLL_TIME / POLL_INTERVAL); + + // Skip if we've been polling too long + const timeSinceSubmit = Date.now() - transaction.timestamp; + if (timeSinceSubmit > MAX_POLL_TIME) { + console.log("Transaction polling timeout:", transaction.transaction_hash); + await updateTransactionInDB(db, transaction.transaction_hash, "timeout", { + error: "Transaction confirmation timeout", + }); + await notifyClient({ + type: "transaction_timeout", + hash: transaction.transaction_hash, + }); + return; + } + + try { + // Call Soroban RPC to check transaction status + const response = await fetch(RPC_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getTransaction", + params: [transaction.transaction_hash], + }), + }); + + const data = await response.json(); + + if (data.result && data.result.status === "SUCCESS") { + // Transaction confirmed! + console.log("Transaction confirmed:", transaction.transaction_hash); + + await updateTransactionInDB( + db, + transaction.transaction_hash, + "confirmed", + { + receipt: data.result, + }, + ); + + // Notify the client to display success modal + await notifyClient({ + type: "transaction_confirmed", + hash: transaction.transaction_hash, + transaction: transaction, + receipt: data.result, + }); + } else if (data.result && data.result.status === "FAILED") { + // Transaction failed + console.error("Transaction failed:", transaction.transaction_hash); + + await updateTransactionInDB(db, transaction.transaction_hash, "failed", { + error: data.result.resultXdr || "Transaction failed", + }); + + await notifyClient({ + type: "transaction_failed", + hash: transaction.transaction_hash, + error: data.result.resultXdr, + }); + } else { + // Still pending - schedule next check + console.log("Transaction still pending:", transaction.transaction_hash); + + // Register for another sync attempt + if ("sync" in self.registration) { + await self.registration.sync.register("poll-transaction"); + } + } + } catch (error) { + console.error("Failed to check transaction status:", error); + + // Network error - will retry on next sync + if ("sync" in self.registration) { + await self.registration.sync.register("poll-transaction"); + } + } +} + +/** + * Update transaction status in IndexedDB + */ +function updateTransactionInDB(db, hash, status, metadata = {}) { + return new Promise((resolve, reject) => { + const tx = db.transaction("pending_transactions", "readwrite"); + const store = tx.objectStore("pending_transactions"); + + const getRequest = store.get(hash); + + getRequest.onsuccess = () => { + const transaction = getRequest.result; + + if (!transaction) { + reject(new Error("Transaction not found")); + return; + } + + transaction.status = status; + transaction.last_check = Date.now(); + transaction.retry_count = (transaction.retry_count || 0) + 1; + + if (metadata.receipt) { + transaction.receipt = metadata.receipt; + } + + if (metadata.error) { + transaction.error = metadata.error; + } + + const putRequest = store.put(transaction); + + putRequest.onsuccess = () => resolve(transaction); + putRequest.onerror = () => reject(putRequest.error); + }; + + getRequest.onerror = () => reject(getRequest.error); + }); +} + +/** + * Notify all connected clients about transaction status update + */ +async function notifyClient(message) { + const allClients = await self.clients.matchAll({ includeUncontrolled: true }); + + for (const client of allClients) { + client.postMessage(message); + } +} + +/** + * Periodic background sync (fallback for browsers without Background Sync API) + */ +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "START_POLLING") { + console.log("Starting periodic polling:", event.data.hash); + startPeriodicPolling(event.data.hash); + } +}); + +/** + * Fallback polling mechanism + */ +async function startPeriodicPolling(hash) { + const db = await openIndexedDB(); + const transaction = await getTransactionFromDB(db, hash); + + if (!transaction || transaction.status !== "pending") { + return; + } + + await checkTransactionStatus(db, transaction); + + // Schedule next poll + setTimeout(() => startPeriodicPolling(hash), POLL_INTERVAL); +} + +/** + * Get specific transaction from IndexedDB + */ +function getTransactionFromDB(db, hash) { + return new Promise((resolve, reject) => { + const tx = db.transaction("pending_transactions", "readonly"); + const store = tx.objectStore("pending_transactions"); + const request = store.get(hash); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} diff --git a/packages/checkout-widget/src/TransactionRecovery.jsx b/packages/checkout-widget/src/TransactionRecovery.jsx new file mode 100644 index 0000000..32303d6 --- /dev/null +++ b/packages/checkout-widget/src/TransactionRecovery.jsx @@ -0,0 +1,278 @@ +import { useEffect, useState } from 'react'; +import { + restoreCheckoutState, + updateTransactionStatus, + removeTransaction, +} from './transaction-cache'; + +/** + * Transaction Recovery Component (#179) + * + * Restores active checkout state when user reconnects or revisits the page, + * confirms ledger inclusion, and displays the verified payment receipt. + */ +export function TransactionRecovery({ onRecovered, onDismissed }) { + const [recoveredTransaction, setRecoveredTransaction] = useState(null); + const [polling, setPolling] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Attempt to restore checkout state on mount + async function restoreState() { + try { + const transaction = await restoreCheckoutState(); + + if (transaction) { + setRecoveredTransaction(transaction); + + // Register Service Worker for background sync + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + + // Listen for transaction status updates from Service Worker + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); + + // Start background sync + if ('sync' in registration) { + await registration.sync.register('poll-transaction'); + } else { + // Fallback: message Service Worker to start periodic polling + registration.active.postMessage({ + type: 'START_POLLING', + hash: transaction.transaction_hash, + }); + } + } else { + // Fallback: poll directly in main thread + await pollTransactionStatus(transaction); + } + + setPolling(true); + } + } catch (err) { + console.error('Failed to restore checkout state:', err); + setError(err.message); + } + } + + restoreState(); + + return () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); + } + }; + }, []); + + const handleServiceWorkerMessage = (event) => { + const { type, hash, receipt, error: swError } = event.data; + + if (type === 'transaction_confirmed' && hash === recoveredTransaction?.transaction_hash) { + setPolling(false); + setConfirmed(true); + + if (onRecovered) { + onRecovered({ + ...recoveredTransaction, + status: 'confirmed', + receipt, + }); + } + } else if (type === 'transaction_failed' && hash === recoveredTransaction?.transaction_hash) { + setPolling(false); + setError(swError || 'Transaction failed'); + } else if (type === 'transaction_timeout' && hash === recoveredTransaction?.transaction_hash) { + setPolling(false); + setError('Transaction confirmation timeout'); + } + }; + + // Fallback polling function for browsers without Service Worker support + const pollTransactionStatus = async (transaction, attempt = 0) => { + const MAX_ATTEMPTS = 60; // 5 minutes at 5-second intervals + + if (attempt >= MAX_ATTEMPTS) { + setPolling(false); + setError('Transaction confirmation timeout'); + return; + } + + try { + const response = await fetch('https://soroban-testnet.stellar.org', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getTransaction', + params: [transaction.transaction_hash], + }), + }); + + const data = await response.json(); + + if (data.result && data.result.status === 'SUCCESS') { + setPolling(false); + setConfirmed(true); + + await updateTransactionStatus(transaction.transaction_hash, 'confirmed', { + receipt: data.result, + }); + + if (onRecovered) { + onRecovered({ + ...transaction, + status: 'confirmed', + receipt: data.result, + }); + } + } else if (data.result && data.result.status === 'FAILED') { + setPolling(false); + setError('Transaction failed'); + + await updateTransactionStatus(transaction.transaction_hash, 'failed', { + error: data.result.resultXdr, + }); + } else { + // Still pending - poll again + setTimeout(() => pollTransactionStatus(transaction, attempt + 1), 5000); + } + } catch (err) { + console.error('Failed to poll transaction:', err); + // Retry on network error + setTimeout(() => pollTransactionStatus(transaction, attempt + 1), 5000); + } + }; + + const handleDismiss = async () => { + if (confirmed && recoveredTransaction) { + // Remove from cache once confirmed + await removeTransaction(recoveredTransaction.transaction_hash); + } + + if (onDismissed) { + onDismissed(); + } + + setRecoveredTransaction(null); + }; + + if (!recoveredTransaction) { + return null; + } + + return ( +
+
+ {polling && !confirmed && !error && ( + <> +
+
+
+

+ Verifying Transaction +

+

+ We're checking the status of your subscription payment on the Stellar ledger... +

+
+
+
+ Plan: + {recoveredTransaction.plan_name} +
+
+ Amount: + + ${(recoveredTransaction.amount / 10000000).toFixed(2)} + +
+
+ Transaction: + + {recoveredTransaction.transaction_hash.substring(0, 12)}... + +
+
+
+ + )} + + {confirmed && ( + <> +
+
+ + + +
+
+

+ Subscription Activated! +

+

+ Your recurring payment has been confirmed on the Stellar ledger. +

+
+
+
+ Plan: + {recoveredTransaction.plan_name} +
+
+ Amount: + + ${(recoveredTransaction.amount / 10000000).toFixed(2)} + +
+
+ Status: + Confirmed +
+
+
+ + + )} + + {error && ( + <> +
+
+ + + +
+
+

+ Transaction Issue +

+

+ {error} +

+
+ + +
+ + )} +
+
+ ); +} diff --git a/packages/checkout-widget/src/transaction-cache.js b/packages/checkout-widget/src/transaction-cache.js new file mode 100644 index 0000000..e2d013e --- /dev/null +++ b/packages/checkout-widget/src/transaction-cache.js @@ -0,0 +1,270 @@ +/** + * IndexedDB Session Cache and Transaction Recovery (#179) + * + * Persists submitted transaction hashes, plan details, and merchant ID into IndexedDB + * immediately upon wallet signature to prevent loss of transaction progress during + * network disconnections or browser crashes. + */ + +const DB_NAME = "orbit_checkout_cache"; +const DB_VERSION = 1; +const STORE_NAME = "pending_transactions"; + +/** + * Initialize IndexedDB connection + */ +export function initTransactionCache() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error("Failed to open IndexedDB:", request.error); + reject(request.error); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { + keyPath: "transaction_hash", + }); + store.createIndex("merchant_id", "merchant_id", { unique: false }); + store.createIndex("timestamp", "timestamp", { unique: false }); + store.createIndex("status", "status", { unique: false }); + } + }; + }); +} + +/** + * Persist transaction state immediately after wallet signature + */ +export async function cacheTransaction(transactionData) { + const db = await initTransactionCache(); + + const transaction = { + transaction_hash: transactionData.hash, + merchant_id: transactionData.merchant_id, + plan_id: transactionData.plan_id, + plan_name: transactionData.plan_name, + amount: transactionData.amount, + token: transactionData.token, + interval_seconds: transactionData.interval_seconds, + user_address: transactionData.user_address, + timestamp: Date.now(), + status: "pending", + retry_count: 0, + last_check: Date.now(), + }; + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.put(transaction); + + request.onsuccess = () => { + console.log("Transaction cached:", transaction.transaction_hash); + resolve(transaction); + }; + + request.onerror = () => { + console.error("Failed to cache transaction:", request.error); + reject(request.error); + }; + }); +} + +/** + * Retrieve pending transaction by hash + */ +export async function getTransaction(hash) { + const db = await initTransactionCache(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(hash); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * Get all pending transactions + */ +export async function getAllPendingTransactions() { + const db = await initTransactionCache(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const index = store.index("status"); + const request = index.getAll("pending"); + + request.onsuccess = () => { + resolve(request.result || []); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * Update transaction status after confirmation + */ +export async function updateTransactionStatus(hash, status, metadata = {}) { + const db = await initTransactionCache(); + + return new Promise(async (resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + + // Get existing transaction + const getRequest = store.get(hash); + + getRequest.onsuccess = () => { + const transaction = getRequest.result; + + if (!transaction) { + reject(new Error("Transaction not found")); + return; + } + + // Update status and metadata + transaction.status = status; + transaction.last_check = Date.now(); + transaction.retry_count = (transaction.retry_count || 0) + 1; + + if (metadata.receipt) { + transaction.receipt = metadata.receipt; + } + + if (metadata.error) { + transaction.error = metadata.error; + } + + // Save updated transaction + const putRequest = store.put(transaction); + + putRequest.onsuccess = () => { + console.log("Transaction status updated:", hash, status); + resolve(transaction); + }; + + putRequest.onerror = () => { + reject(putRequest.error); + }; + }; + + getRequest.onerror = () => { + reject(getRequest.error); + }; + }); +} + +/** + * Remove confirmed transaction from cache + */ +export async function removeTransaction(hash) { + const db = await initTransactionCache(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.delete(hash); + + request.onsuccess = () => { + console.log("Transaction removed from cache:", hash); + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * Clean up old transactions (older than 7 days) + */ +export async function cleanupOldTransactions() { + const db = await initTransactionCache(); + const cutoffTime = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7 days ago + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const index = store.index("timestamp"); + const request = index.openCursor(); + + const deleted = []; + + request.onsuccess = (event) => { + const cursor = event.target.result; + + if (cursor) { + const transaction = cursor.value; + + if (transaction.timestamp < cutoffTime) { + cursor.delete(); + deleted.push(transaction.transaction_hash); + } + + cursor.continue(); + } else { + console.log("Cleaned up old transactions:", deleted.length); + resolve(deleted); + } + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * Restore active checkout state on page load + */ +export async function restoreCheckoutState() { + try { + const pendingTransactions = await getAllPendingTransactions(); + + if (pendingTransactions.length === 0) { + return null; + } + + // Get the most recent pending transaction + const latest = pendingTransactions.sort( + (a, b) => b.timestamp - a.timestamp, + )[0]; + + // Only restore if less than 24 hours old + const hoursSinceSubmission = + (Date.now() - latest.timestamp) / (1000 * 60 * 60); + + if (hoursSinceSubmission > 24) { + console.log("Pending transaction too old, not restoring"); + return null; + } + + console.log("Restoring checkout state:", latest.transaction_hash); + return latest; + } catch (error) { + console.error("Failed to restore checkout state:", error); + return null; + } +}