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()} + + + + + ))} + +
+
+ )} +
+
+ ); +}; + +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 + +![Billing Page](../../../screenshots/etke.cc/billing/page.webp) + +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, [