From e0c880fb434350968a61cf5c7e9541cd3c2ab03b Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Sun, 13 Jul 2025 02:21:47 +0300 Subject: [PATCH] Add billing page with payment history and invoice download (#691) * Add billing page with payment history and invoice download - Add new BillingPage component with MUI table for payment display - Add billing menu item to AdminLayout with Payment icon - Add getPayments and getInvoice methods to dataProvider - Implement invoice download functionality with proper error handling - Add routing for /billing path - Support for scheduler/{hash}/payments API endpoint - Enhanced error handling for 500, 404, 401, 403 HTTP errors - Loading states and user feedback for better UX * update readme; add docs; small visual changes; make it fail on payments API errors --- README.md | 1 + screenshots/etke.cc/billing/page.webp | Bin 0 -> 29274 bytes src/App.tsx | 2 + src/components/AdminLayout.tsx | 2 + src/components/etke.cc/BillingPage.tsx | 208 +++++++++++++++++++++++++ src/components/etke.cc/README.md | 7 + src/synapse/dataProvider.ts | 101 ++++++++++++ 7 files changed, 321 insertions(+) create mode 100644 screenshots/etke.cc/billing/page.webp create mode 100644 src/components/etke.cc/BillingPage.tsx 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 0000000000000000000000000000000000000000..2f933d6d70c711ed0fbf4bbe296cf2c018c1ab91 GIT binary patch literal 29274 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 ( } /> } /> } /> + } /> 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, [