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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-webhook-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mpesa2csv": minor
---

feat: add webhook service for external integrations
4 changes: 2 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,7 +69,7 @@
},
"windowSize": {
"width": 660,
"height": 700
"height": 730
}
}
},
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ function App() {
<ExportOptions
exportFormat={exportFormat}
exportOptions={exportOptions}
statement={statements[0]}
onFormatChange={handleFormatChange}
onOptionsChange={handleOptionsChange}
/>
Expand Down
176 changes: 175 additions & 1 deletion src/components/export-options.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -24,6 +36,7 @@ import {
interface ExportOptionsProps {
exportFormat: ExportFormat;
exportOptions: ExportOptionsType;
statement: MPesaStatement;
onFormatChange: (format: ExportFormat) => void;
onOptionsChange: (options: ExportOptionsType) => void;
}
Expand Down Expand Up @@ -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<string>("");
const [isSending, setIsSending] = useState<boolean>(false);
const [result, setResult] = useState<WebhookResult | null>(null);

const handleFormatChange = (value: ExportFormat) => {
onFormatChange(value);
};
Expand Down Expand Up @@ -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 (
<div className="space-y-4">
<div>
Expand Down Expand Up @@ -218,6 +268,130 @@ export default function ExportOptions({
</div>
</div>

<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setIsWebhookOpen(!isWebhookOpen)}
className="w-full flex items-center justify-between py-2 px-3 bg-muted/10 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Send className="w-4 h-4" />
<Label className="text-sm font-medium cursor-pointer">
Send to Webhook
</Label>
</div>
{isWebhookOpen ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>

{isWebhookOpen && (
<div className="p-4 space-y-3 border-t border-border">
<p className="text-xs">
Send your transaction data as JSON to a webhook endpoint for
reconciliation or integration with external systems.
</p>

<div className="space-y-2">
<Label htmlFor="endpoint-url" className="text-sm">
Webhook URL
</Label>
<Input
id="endpoint-url"
type="url"
placeholder="https://api.example.com/webhooks/transactions"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
disabled={isSending}
className="w-full"
/>
</div>

{result && (
<div
className={`rounded-lg border px-2 py-2 ${
result.success
? "bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900"
: "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900"
}`}
>
<div className="flex items-start gap-2">
{result.success ? (
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-1.5">
<p
className={`text-xs font-medium ${
result.success
? "text-green-800 dark:text-green-300"
: "text-red-800 dark:text-red-300"
}`}
>
{result.success
? "Successfully sent to webhook"
: "Failed to send data"}
</p>

{result.statusCode && (
<p className="text-xs text-muted-foreground">
Status: {result.statusCode} {result.statusText}
</p>
)}

{result.error && (
<p className="text-xs text-red-700 dark:text-red-400 break-words">
{result.error}
</p>
)}

{result.responseBody && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
View response
</summary>
<pre className="mt-1.5 p-2 bg-zinc-900 rounded text-xs overflow-x-auto max-h-32">
{result.responseBody}
</pre>
</details>
)}
</div>
</div>
</div>
)}

<div className="text-xs text-muted-foreground rounded-md">
<p>
Ready to send {statement.transactions.length} transaction
{statement.transactions.length !== 1 ? "s" : ""} as JSON
</p>
</div>

<Button
onClick={handleSend}
disabled={isSending || !isValidUrl}
className="w-full"
size="sm"
>
{isSending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2" />
Sending...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Send to Webhook
</>
)}
</Button>
</div>
)}
</div>

{exportFormat === ExportFormat.XLSX && (
<div>
<div className="flex items-center justify-between mb-3">
Expand Down
121 changes: 121 additions & 0 deletions src/services/webhookService.ts
Original file line number Diff line number Diff line change
@@ -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<WebhookResult> {
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;
}
}
}

Loading