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 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
|
||||
|
||||
|
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 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 = () => (
|
||||
<Route path="/server_actions/recurring/:id" element={<RecurringCommandEdit />} />
|
||||
<Route path="/server_actions/recurring/create" element={<RecurringCommandEdit />} />
|
||||
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
<Resource {...rooms} />
|
||||
|
@ -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 && <Menu.Item key="billing" to="/billing" leftIcon={<PaymentIcon />} primaryText="Billing" />}
|
||||
<Menu.ResourceItems />
|
||||
{menu &&
|
||||
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.
|
||||
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.
|
||||
|
@ -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<DeleteMediaResult>;
|
||||
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
@ -362,6 +375,8 @@ export interface SynapseDataProvider extends DataProvider {
|
||||
createRecurringCommand: (etkeAdminUrl: string, command: Partial<RecurringCommand>) => Promise<RecurringCommand>;
|
||||
updateRecurringCommand: (etkeAdminUrl: string, command: RecurringCommand) => Promise<RecurringCommand>;
|
||||
deleteRecurringCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
||||
getPayments: (etkeAdminUrl: string) => Promise<PaymentsResponse>;
|
||||
getInvoice: (etkeAdminUrl: string, transactionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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, [
|
||||
|
Loading…
x
Reference in New Issue
Block a user