From 8d990648e6cf247c529f3111624c98d6ce43fe13 Mon Sep 17 00:00:00 2001 From: David Amunga Date: Wed, 12 Nov 2025 14:01:54 +0300 Subject: [PATCH] feat: implement push webhook service --- .changeset/add-webhook-service.md | 5 + src-tauri/tauri.conf.json | 4 +- src/App.tsx | 1 + src/components/export-options.tsx | 176 +++++++++++++++++++++++++++++- src/services/webhookService.ts | 121 ++++++++++++++++++++ 5 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 .changeset/add-webhook-service.md create mode 100644 src/services/webhookService.ts diff --git a/.changeset/add-webhook-service.md b/.changeset/add-webhook-service.md new file mode 100644 index 0000000..c9c3b35 --- /dev/null +++ b/.changeset/add-webhook-service.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +feat: add webhook service for external integrations \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e5acb8f..dbf584f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,7 +14,7 @@ { "title": "mpesa2csv - Convert M-PESA Statements to CSV/Excel", "width": 640, - "height": 700, + "height": 730, "minWidth": 500, "minHeight": 400, "resizable": true @@ -69,7 +69,7 @@ }, "windowSize": { "width": 660, - "height": 700 + "height": 730 } } }, diff --git a/src/App.tsx b/src/App.tsx index 335f85d..5c64efa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -546,6 +546,7 @@ function App() { diff --git a/src/components/export-options.tsx b/src/components/export-options.tsx index 86ef755..330622b 100644 --- a/src/components/export-options.tsx +++ b/src/components/export-options.tsx @@ -1,10 +1,13 @@ +import { useState } from "react"; import { ExportFormat, ExportOptions as ExportOptionsType, SortOrder, DateFormat, + MPesaStatement, } from "../types"; import { ExportService } from "../services/exportService"; +import { WebhookService, WebhookResult } from "../services/webhookService"; import { Select, SelectContent, @@ -15,7 +18,16 @@ import { import { Checkbox } from "./ui/checkbox"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { Label } from "./ui/label"; -import { Info } from "lucide-react"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { + Info, + Send, + CheckCircle2, + AlertCircle, + ChevronDown, + ChevronUp, +} from "lucide-react"; import { getDateFormatDisplayName, getAllDateFormats, @@ -24,6 +36,7 @@ import { interface ExportOptionsProps { exportFormat: ExportFormat; exportOptions: ExportOptionsType; + statement: MPesaStatement; onFormatChange: (format: ExportFormat) => void; onOptionsChange: (options: ExportOptionsType) => void; } @@ -70,9 +83,15 @@ const SHEET_OPTIONS = [ export default function ExportOptions({ exportFormat, exportOptions, + statement, onFormatChange, onOptionsChange, }: ExportOptionsProps) { + const [isWebhookOpen, setIsWebhookOpen] = useState(false); + const [endpoint, setEndpoint] = useState(""); + const [isSending, setIsSending] = useState(false); + const [result, setResult] = useState(null); + const handleFormatChange = (value: ExportFormat) => { onFormatChange(value); }; @@ -115,6 +134,37 @@ export default function ExportOptions({ onOptionsChange(newOptions); }; + const handleSend = async () => { + if (!endpoint.trim()) { + setResult({ + success: false, + error: "Please enter a webhook URL", + }); + return; + } + + setIsSending(true); + setResult(null); + + try { + const webhookResult = await WebhookService.sendToWebhook( + statement, + endpoint, + exportOptions + ); + setResult(webhookResult); + } catch (error: any) { + setResult({ + success: false, + error: error.message || "An unexpected error occurred", + }); + } finally { + setIsSending(false); + } + }; + + const isValidUrl = endpoint.trim() && WebhookService.isValidUrl(endpoint); + return (
@@ -218,6 +268,130 @@ export default function ExportOptions({
+
+ + + {isWebhookOpen && ( +
+

+ Send your transaction data as JSON to a webhook endpoint for + reconciliation or integration with external systems. +

+ +
+ + setEndpoint(e.target.value)} + disabled={isSending} + className="w-full" + /> +
+ + {result && ( +
+
+ {result.success ? ( + + ) : ( + + )} +
+

+ {result.success + ? "Successfully sent to webhook" + : "Failed to send data"} +

+ + {result.statusCode && ( +

+ Status: {result.statusCode} {result.statusText} +

+ )} + + {result.error && ( +

+ {result.error} +

+ )} + + {result.responseBody && ( +
+ + View response + +
+                          {result.responseBody}
+                        
+
+ )} +
+
+
+ )} + +
+

+ Ready to send {statement.transactions.length} transaction + {statement.transactions.length !== 1 ? "s" : ""} as JSON +

+
+ + +
+ )} +
+ {exportFormat === ExportFormat.XLSX && (
diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts new file mode 100644 index 0000000..90179b1 --- /dev/null +++ b/src/services/webhookService.ts @@ -0,0 +1,121 @@ +import { MPesaStatement, ExportOptions } from "../types"; +import { JsonService } from "./jsonService"; + +export interface WebhookResult { + success: boolean; + statusCode?: number; + statusText?: string; + responseBody?: string; + error?: string; +} + +export class WebhookService { + /** + * Sends M-Pesa statement data to a webhook endpoint as JSON + * @param statement The M-Pesa statement to send + * @param endpoint The URL of the webhook endpoint + * @param options Export options for filtering/formatting + * @returns WebhookResult with success status and details + */ + static async sendToWebhook( + statement: MPesaStatement, + endpoint: string, + options?: ExportOptions + ): Promise { + try { + // Validate URL format + let url: URL; + try { + url = new URL(endpoint); + } catch (e) { + return { + success: false, + error: "Invalid URL format. Please enter a valid HTTP or HTTPS URL.", + }; + } + + // Only allow HTTP and HTTPS protocols + if (!["http:", "https:"].includes(url.protocol)) { + return { + success: false, + error: "Only HTTP and HTTPS protocols are supported.", + }; + } + + // Convert statement to JSON format + const jsonContent = JsonService.convertStatementToJson(statement, options); + + // Make POST request + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: jsonContent, + }); + + // Read response body + let responseBody = ""; + try { + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + const jsonResponse = await response.json(); + responseBody = JSON.stringify(jsonResponse, null, 2); + } else { + responseBody = await response.text(); + } + } catch (e) { + responseBody = "Unable to read response body"; + } + + if (response.ok) { + return { + success: true, + statusCode: response.status, + statusText: response.statusText, + responseBody: responseBody, + }; + } else { + return { + success: false, + statusCode: response.status, + statusText: response.statusText, + responseBody: responseBody, + error: `Server returned ${response.status} ${response.statusText}`, + }; + } + } catch (error: any) { + let errorMessage = "Failed to connect to endpoint"; + + if (error.message) { + if (error.message.includes("Failed to fetch")) { + errorMessage = "Network error: Unable to connect to the endpoint. Please check your internet connection and the URL."; + } else if (error.message.includes("CORS")) { + errorMessage = "CORS error: The endpoint does not allow requests from this application."; + } else { + errorMessage = error.message; + } + } + + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Validates if a URL string is properly formatted + * @param urlString The URL to validate + * @returns true if valid, false otherwise + */ + static isValidUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + return ["http:", "https:"].includes(url.protocol); + } catch { + return false; + } + } +} +