diff --git a/README.md b/README.md
index bc5f514..26c6bdb 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,7 @@ The following list contains such features - they are only available for [etke.cc
* 📬 [Server Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240)
* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365)
* 🚀 [Server Actions page](https://github.com/etkecc/synapse-admin/pull/457)
+* 💳 [Billing page](https://github.com/etkecc/synapse-admin/pull/691)
### Development
diff --git a/screenshots/etke.cc/billing/page.webp b/screenshots/etke.cc/billing/page.webp
new file mode 100644
index 0000000..2f933d6
Binary files /dev/null and b/screenshots/etke.cc/billing/page.webp differ
diff --git a/src/App.tsx b/src/App.tsx
index c2f3bb7..654d7ab 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin
import { Route } from "react-router-dom";
import AdminLayout from "./components/AdminLayout";
+import BillingPage from "./components/etke.cc/BillingPage";
import ServerActionsPage from "./components/etke.cc/ServerActionsPage";
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
@@ -79,6 +80,7 @@ export const App = () => (
} />
} />
} />
+ } />
diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx
index 311955d..8178a5e 100644
--- a/src/components/AdminLayout.tsx
+++ b/src/components/AdminLayout.tsx
@@ -1,4 +1,5 @@
import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
+import PaymentIcon from "@mui/icons-material/Payment";
import { useEffect, useState, Suspense } from "react";
import {
CheckForApplicationUpdate,
@@ -120,6 +121,7 @@ const AdminMenu = props => {
primaryText="Server Actions"
/>
)}
+ {etkeRoutesEnabled &&
} primaryText="Billing" />}
{menu &&
menu.map((item, index) => {
diff --git a/src/components/etke.cc/BillingPage.tsx b/src/components/etke.cc/BillingPage.tsx
new file mode 100644
index 0000000..93c0ac9
--- /dev/null
+++ b/src/components/etke.cc/BillingPage.tsx
@@ -0,0 +1,208 @@
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
+import DownloadIcon from "@mui/icons-material/Download";
+import PaymentIcon from "@mui/icons-material/Payment";
+import {
+ Box,
+ Typography,
+ Link,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Chip,
+ Button,
+ Tooltip,
+} from "@mui/material";
+import { Stack } from "@mui/material";
+import IconButton from "@mui/material/IconButton";
+import { useState, useEffect } from "react";
+import { useDataProvider, useNotify } from "react-admin";
+
+import { useAppContext } from "../../Context";
+import { SynapseDataProvider, Payment } from "../../synapse/dataProvider";
+
+const TruncatedUUID = ({ uuid }): React.ReactElement => {
+ const short = `${uuid.slice(0, 8)}...${uuid.slice(-6)}`;
+ const copyToClipboard = () => navigator.clipboard.writeText(uuid);
+
+ return (
+
+
+ {short}
+
+
+
+
+
+ );
+};
+
+const BillingPage = () => {
+ const { etkeccAdmin } = useAppContext();
+ const dataProvider = useDataProvider() as SynapseDataProvider;
+ const notify = useNotify();
+ const [paymentsData, setPaymentsData] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [failure, setFailure] = useState(null);
+ const [downloadingInvoice, setDownloadingInvoice] = useState(null);
+
+ useEffect(() => {
+ const fetchBillingData = async () => {
+ if (!etkeccAdmin) return;
+
+ try {
+ setLoading(true);
+ const response = await dataProvider.getPayments(etkeccAdmin);
+ setPaymentsData(response.payments);
+ setTotal(response.total);
+ } catch (error) {
+ console.error("Error fetching billing data:", error);
+ setFailure(error instanceof Error ? error.message : error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchBillingData();
+ }, [etkeccAdmin, dataProvider, notify]);
+
+ const handleInvoiceDownload = async (transactionId: string) => {
+ if (!etkeccAdmin || downloadingInvoice) return;
+
+ try {
+ setDownloadingInvoice(transactionId);
+ await dataProvider.getInvoice(etkeccAdmin, transactionId);
+ notify("Invoice download started", { type: "info" });
+ } catch (error) {
+ // Use the specific error message from the dataProvider
+ const errorMessage = error instanceof Error ? error.message : "Error downloading invoice";
+ notify(errorMessage, { type: "error" });
+ console.error("Error downloading invoice:", error);
+ } finally {
+ setDownloadingInvoice(null);
+ }
+ };
+
+ const header = (
+
+
+ Billing
+
+
+ View payments and generate invoices from here. More details about billing can be found{" "}
+
+ here
+
+ .
+
+
+ );
+
+ if (loading) {
+ return (
+
+ {header}
+
+ Loading billing information...
+
+
+ );
+ }
+
+ if (failure) {
+ return (
+
+ {header}
+
+
+ There was a problem loading your billing information.
+
+ This might be a temporary issue - please try again in a few minutes.
+
+ If it persists, contact{" "}
+
+ etke.cc support team
+ {" "}
+ with the following error message:
+
+
+ {failure}
+
+
+
+ );
+ }
+
+ return (
+
+ {header}
+
+ Payment Summary
+
+ Total Payments:
+
+
+
+
+
+
+ Payment History
+
+ {paymentsData.length === 0 ? (
+
+ No payments found. If you believe that's an error, please{" "}
+
+ contact etke.cc support
+
+ .
+
+ ) : (
+
+
+
+
+ Transaction ID
+ Email
+ Type
+ Amount
+ Paid At
+ Download Invoice
+
+
+
+ {paymentsData.map(payment => (
+
+
+
+
+ {payment.email}
+ {payment.is_subscription ? "Subscription" : "One-time"}
+ ${payment.amount.toFixed(2)}
+ {new Date(payment.paid_at).toLocaleDateString()}
+
+ }
+ onClick={() => handleInvoiceDownload(payment.transaction_id)}
+ disabled={downloadingInvoice === payment.transaction_id}
+ >
+ {downloadingInvoice === payment.transaction_id ? "Downloading..." : "Invoice"}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+};
+
+export default BillingPage;
diff --git a/src/components/etke.cc/README.md b/src/components/etke.cc/README.md
index 88bc0b7..2319add 100644
--- a/src/components/etke.cc/README.md
+++ b/src/components/etke.cc/README.md
@@ -65,3 +65,10 @@ On this page you can do the following:
When you open [Server Actions page](#server-status-page), you will see the Server Commands panel.
This panel contains all [the commands](https://etke.cc/help/extras/scheduler/#commands) you can run on your server in 1 click.
Once command is finished, you will get a notification about the result.
+
+### Billing Page
+
+
+
+When you click on the `Billing` sidebar menu item, you will be see the Billing page.
+On this page you can see the list of successful payments and invoices.
diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts
index 2dad72c..7f84771 100644
--- a/src/synapse/dataProvider.ts
+++ b/src/synapse/dataProvider.ts
@@ -334,6 +334,19 @@ export interface RecurringCommand {
time: string;
}
+export interface Payment {
+ amount: number;
+ email: string;
+ is_subscription: boolean;
+ paid_at: string;
+ transaction_id: string;
+}
+
+export interface PaymentsResponse {
+ payments: Payment[];
+ total: number;
+}
+
export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise;
purgeRemoteMedia: (params: DeleteMediaParams) => Promise;
@@ -362,6 +375,8 @@ export interface SynapseDataProvider extends DataProvider {
createRecurringCommand: (etkeAdminUrl: string, command: Partial) => Promise;
updateRecurringCommand: (etkeAdminUrl: string, command: RecurringCommand) => Promise;
deleteRecurringCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
+ getPayments: (etkeAdminUrl: string) => Promise;
+ getInvoice: (etkeAdminUrl: string, transactionId: string) => Promise;
}
const resourceMap = {
@@ -1452,6 +1467,92 @@ const baseDataProvider: SynapseDataProvider = {
return { success: false };
}
},
+ getPayments: async (etkeAdminUrl: string) => {
+ const response = await fetch(`${etkeAdminUrl}/payments`, {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem("access_token")}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch payments: ${response.status} ${response.statusText}`);
+ }
+
+ const status = response.status;
+
+ if (status === 200) {
+ const json = await response.json();
+ return json as PaymentsResponse;
+ }
+
+ if (status === 204) {
+ return { payments: [], total: 0 };
+ }
+
+ throw new Error(`${response.status} ${response.statusText}`); // Handle unexpected status codes
+ },
+ getInvoice: async (etkeAdminUrl: string, transactionId: string) => {
+ try {
+ const response = await fetch(`${etkeAdminUrl}/payments/${transactionId}/invoice`, {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem("access_token")}`,
+ },
+ });
+
+ if (!response.ok) {
+ let errorMessage = `Error fetching invoice: ${response.status} ${response.statusText}`;
+
+ // Handle specific error codes
+ switch (response.status) {
+ case 404:
+ errorMessage = "Invoice not found for this transaction";
+ break;
+ case 500:
+ errorMessage = "Server error while generating invoice. Please try again later";
+ break;
+ case 401:
+ errorMessage = "Unauthorized access. Please check your permissions";
+ break;
+ case 403:
+ errorMessage = "Access forbidden. You don't have permission to download this invoice";
+ break;
+ default:
+ errorMessage = `Failed to fetch invoice (${response.status}): ${response.statusText}`;
+ }
+
+ console.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ // Get the file as a blob
+ const blob = await response.blob();
+
+ // Create a download link
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+
+ // Try to get filename from response headers
+ const contentDisposition = response.headers.get("Content-Disposition");
+ let filename = `invoice_${transactionId}.pdf`;
+
+ if (contentDisposition) {
+ const filenameMatch = contentDisposition.match(/filename="(.+)"/);
+ if (filenameMatch) {
+ filename = filenameMatch[1];
+ }
+ }
+
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error("Error downloading invoice:", error);
+ throw error; // Re-throw to let the UI handle the error
+ }
+ },
};
const dataProvider = withLifecycleCallbacks(baseDataProvider, [