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
This commit is contained in:
parent
8c427e2988
commit
e0c880fb43
@ -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 Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240)
|
||||||
* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365)
|
* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365)
|
||||||
* 🚀 [Server Actions page](https://github.com/etkecc/synapse-admin/pull/457)
|
* 🚀 [Server Actions page](https://github.com/etkecc/synapse-admin/pull/457)
|
||||||
|
* 💳 [Billing page](https://github.com/etkecc/synapse-admin/pull/691)
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
|
BIN
screenshots/etke.cc/billing/page.webp
Normal file
BIN
screenshots/etke.cc/billing/page.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
@ -5,6 +5,7 @@ import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin
|
|||||||
import { Route } from "react-router-dom";
|
import { Route } from "react-router-dom";
|
||||||
|
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AdminLayout from "./components/AdminLayout";
|
||||||
|
import BillingPage from "./components/etke.cc/BillingPage";
|
||||||
import ServerActionsPage from "./components/etke.cc/ServerActionsPage";
|
import ServerActionsPage from "./components/etke.cc/ServerActionsPage";
|
||||||
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
|
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
|
||||||
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
|
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
|
||||||
@ -79,6 +80,7 @@ export const App = () => (
|
|||||||
<Route path="/server_actions/recurring/:id" element={<RecurringCommandEdit />} />
|
<Route path="/server_actions/recurring/:id" element={<RecurringCommandEdit />} />
|
||||||
<Route path="/server_actions/recurring/create" element={<RecurringCommandEdit />} />
|
<Route path="/server_actions/recurring/create" element={<RecurringCommandEdit />} />
|
||||||
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||||
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
</CustomRoutes>
|
</CustomRoutes>
|
||||||
<Resource {...users} />
|
<Resource {...users} />
|
||||||
<Resource {...rooms} />
|
<Resource {...rooms} />
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
|
import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
|
||||||
|
import PaymentIcon from "@mui/icons-material/Payment";
|
||||||
import { useEffect, useState, Suspense } from "react";
|
import { useEffect, useState, Suspense } from "react";
|
||||||
import {
|
import {
|
||||||
CheckForApplicationUpdate,
|
CheckForApplicationUpdate,
|
||||||
@ -120,6 +121,7 @@ const AdminMenu = props => {
|
|||||||
primaryText="Server Actions"
|
primaryText="Server Actions"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{etkeRoutesEnabled && <Menu.Item key="billing" to="/billing" leftIcon={<PaymentIcon />} primaryText="Billing" />}
|
||||||
<Menu.ResourceItems />
|
<Menu.ResourceItems />
|
||||||
{menu &&
|
{menu &&
|
||||||
menu.map((item, index) => {
|
menu.map((item, index) => {
|
||||||
|
208
src/components/etke.cc/BillingPage.tsx
Normal file
208
src/components/etke.cc/BillingPage.tsx
Normal file
@ -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 (
|
||||||
|
<Tooltip title={uuid}>
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center" }}>
|
||||||
|
{short}
|
||||||
|
<IconButton size="small" onClick={copyToClipboard}>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingPage = () => {
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider() as SynapseDataProvider;
|
||||||
|
const notify = useNotify();
|
||||||
|
const [paymentsData, setPaymentsData] = useState<Payment[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failure, setFailure] = useState<string | null>(null);
|
||||||
|
const [downloadingInvoice, setDownloadingInvoice] = useState<string | null>(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 = (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4">
|
||||||
|
<PaymentIcon sx={{ verticalAlign: "middle", mr: 1 }} /> Billing
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
View payments and generate invoices from here. More details about billing can be found{" "}
|
||||||
|
<Link href="https://etke.cc/help/extras/scheduler/#payments" target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3}>
|
||||||
|
{header}
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography>Loading billing information...</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3}>
|
||||||
|
{header}
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography>
|
||||||
|
There was a problem loading your billing information.
|
||||||
|
<br />
|
||||||
|
This might be a temporary issue - please try again in a few minutes.
|
||||||
|
<br />
|
||||||
|
If it persists, contact{" "}
|
||||||
|
<Link href="https://etke.cc/contacts/" target="_blank">
|
||||||
|
etke.cc support team
|
||||||
|
</Link>{" "}
|
||||||
|
with the following error message:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
|
||||||
|
{failure}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3}>
|
||||||
|
{header}
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h5">Payment Summary</Typography>
|
||||||
|
<Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography variant="body1">Total Payments:</Typography>
|
||||||
|
<Chip label={total} color="primary" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||||
|
Payment History
|
||||||
|
</Typography>
|
||||||
|
{paymentsData.length === 0 ? (
|
||||||
|
<Typography variant="body1">
|
||||||
|
No payments found. If you believe that's an error, please{" "}
|
||||||
|
<Link href="https://etke.cc/contacts/" target="_blank">
|
||||||
|
contact etke.cc support
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Transaction ID</TableCell>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Amount</TableCell>
|
||||||
|
<TableCell>Paid At</TableCell>
|
||||||
|
<TableCell>Download Invoice</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paymentsData.map(payment => (
|
||||||
|
<TableRow key={payment.transaction_id}>
|
||||||
|
<TableCell>
|
||||||
|
<TruncatedUUID uuid={payment.transaction_id} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{payment.email}</TableCell>
|
||||||
|
<TableCell>{payment.is_subscription ? "Subscription" : "One-time"}</TableCell>
|
||||||
|
<TableCell>${payment.amount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell>{new Date(payment.paid_at).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={() => handleInvoiceDownload(payment.transaction_id)}
|
||||||
|
disabled={downloadingInvoice === payment.transaction_id}
|
||||||
|
>
|
||||||
|
{downloadingInvoice === payment.transaction_id ? "Downloading..." : "Invoice"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingPage;
|
@ -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.
|
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.
|
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.
|
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.
|
||||||
|
@ -334,6 +334,19 @@ export interface RecurringCommand {
|
|||||||
time: string;
|
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 {
|
export interface SynapseDataProvider extends DataProvider {
|
||||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||||
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||||
@ -362,6 +375,8 @@ export interface SynapseDataProvider extends DataProvider {
|
|||||||
createRecurringCommand: (etkeAdminUrl: string, command: Partial<RecurringCommand>) => Promise<RecurringCommand>;
|
createRecurringCommand: (etkeAdminUrl: string, command: Partial<RecurringCommand>) => Promise<RecurringCommand>;
|
||||||
updateRecurringCommand: (etkeAdminUrl: string, command: RecurringCommand) => Promise<RecurringCommand>;
|
updateRecurringCommand: (etkeAdminUrl: string, command: RecurringCommand) => Promise<RecurringCommand>;
|
||||||
deleteRecurringCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
deleteRecurringCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
||||||
|
getPayments: (etkeAdminUrl: string) => Promise<PaymentsResponse>;
|
||||||
|
getInvoice: (etkeAdminUrl: string, transactionId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceMap = {
|
const resourceMap = {
|
||||||
@ -1452,6 +1467,92 @@ const baseDataProvider: SynapseDataProvider = {
|
|||||||
return { success: false };
|
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, [
|
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||||
|
Loading…
x
Reference in New Issue
Block a user