diff --git a/frontend/src/components/InvoiceBackupSettings.jsx b/frontend/src/components/InvoiceBackupSettings.jsx
new file mode 100644
index 00000000..0d0c253e
--- /dev/null
+++ b/frontend/src/components/InvoiceBackupSettings.jsx
@@ -0,0 +1,106 @@
+import { useRef } from "react";
+import { Download, Upload, Loader2, DatabaseBackup } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useInvoiceStorage } from "@/hooks/useInvoiceStorage";
+import toast from "react-hot-toast";
+
+export default function InvoiceBackupSettings() {
+ const fileInputRef = useRef(null);
+ const { exportBackup, importBackup, isExporting, isImporting } =
+ useInvoiceStorage();
+
+ const handleExport = async () => {
+ try {
+ await exportBackup();
+ toast.success("Invoice backup exported successfully.");
+ } catch (error) {
+ console.error("Failed to export invoice backup:", error);
+ toast.error("Failed to export invoice backup.");
+ }
+ };
+
+ const handleImportClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = async (event) => {
+ const file = event.target.files?.[0];
+
+ if (!file) return;
+
+ try {
+ await importBackup(file);
+ toast.success("Invoice backup imported successfully.");
+ } catch (error) {
+ console.error("Failed to import invoice backup:", error);
+ toast.error(error?.message || "Failed to import invoice backup.");
+ } finally {
+ event.target.value = "";
+ }
+ };
+
+ return (
+
+
+
+
+ Invoice Backup
+
+
+
+
+ Back up your locally stored invoices or restore them on another
+ browser or device.
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/page/ReceivedInvoice.jsx b/frontend/src/page/ReceivedInvoice.jsx
index 09f2da81..93934c62 100644
--- a/frontend/src/page/ReceivedInvoice.jsx
+++ b/frontend/src/page/ReceivedInvoice.jsx
@@ -93,6 +93,24 @@ function ReceivedInvoice() {
const [paymentLoading, setPaymentLoading] = useState({});
const [showWalletAlert, setShowWalletAlert] = useState(!isConnected);
const [refreshTrigger, setRefreshTrigger] = useState(0);
+
+ useEffect(() => {
+ const handleStorageUpdate = () => {
+ setRefreshTrigger((previous) => previous + 1);
+ };
+
+ window.addEventListener(
+ "chainvoice:invoice-storage-updated",
+ handleStorageUpdate
+ );
+
+ return () => {
+ window.removeEventListener(
+ "chainvoice:invoice-storage-updated",
+ handleStorageUpdate
+ );
+ };
+ }, []);
const {
keys,
isRegistered,
diff --git a/frontend/src/page/SentInvoice.jsx b/frontend/src/page/SentInvoice.jsx
index cdd31134..7b67a8fd 100644
--- a/frontend/src/page/SentInvoice.jsx
+++ b/frontend/src/page/SentInvoice.jsx
@@ -85,6 +85,23 @@ function SentInvoice() {
const [invoiceToCancel, setInvoiceToCancel] = useState(null);
const [showWalletAlert, setShowWalletAlert] = useState(!isConnected);
const [refreshTrigger, setRefreshTrigger] = useState(0);
+ useEffect(() => {
+ const handleStorageUpdate = () => {
+ setRefreshTrigger((previous) => previous + 1);
+ };
+
+ window.addEventListener(
+ "chainvoice:invoice-storage-updated",
+ handleStorageUpdate
+ );
+
+ return () => {
+ window.removeEventListener(
+ "chainvoice:invoice-storage-updated",
+ handleStorageUpdate
+ );
+ };
+}, []);
const [resending, setResending] = useState({});
// Get tokens from the hook
diff --git a/frontend/src/page/Settings.jsx b/frontend/src/page/Settings.jsx
index 26d04064..cce5503f 100644
--- a/frontend/src/page/Settings.jsx
+++ b/frontend/src/page/Settings.jsx
@@ -1,3 +1,4 @@
+import InvoiceBackupSettings from "../components/InvoiceBackupSettings";
import ProductCatalogImport from "../components/ProductCatalogImport";
import UserProfileSettings from "../components/UserProfileSettings";
@@ -16,6 +17,13 @@ const SETTINGS_SECTIONS = [
"Manage your products for quick access when creating invoices.",
Content: ProductCatalogImport,
},
+ {
+ id: "invoice-backup",
+ title: "Invoice Backup",
+ description:
+ "Export your locally stored invoices or restore them from a backup file.",
+ Content: InvoiceBackupSettings,
+ },
];
function Settings() {
diff --git a/frontend/src/services/invoiceStorage/invoiceBackup.js b/frontend/src/services/invoiceStorage/invoiceBackup.js
index 24d32d8e..c5a8d377 100644
--- a/frontend/src/services/invoiceStorage/invoiceBackup.js
+++ b/frontend/src/services/invoiceStorage/invoiceBackup.js
@@ -1,49 +1,269 @@
import {
+ BACKUP_VERSION,
exportDB,
importDB,
downloadJSON,
readFileAsJSON,
-} from '@aossie-org/idb-backup';
-import { DB_NAME, getAllInvoices } from './invoiceDB.js';
+} from "@aossie-org/idb-backup";
+import {
+ DB_NAME,
+ STORE_NAME,
+ getAllInvoices,
+} from "./invoiceDB.js";
+
+const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/;
+
+function isObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function isSerializedInteger(value) {
+ if (typeof value === "number") {
+ return Number.isSafeInteger(value) && value >= 0;
+ }
+
+ if (typeof value === "string") {
+ return /^(0|[1-9]\d*)$/.test(value);
+ }
+
+ return (
+ isObject(value) &&
+ value.__type === "bigint" &&
+ typeof value.value === "string" &&
+ /^(0|[1-9]\d*)$/.test(value.value)
+ );
+}
+
+function validateInvoiceRecord(record, index) {
+ if (!isObject(record)) {
+ throw new Error(`Invoice record ${index + 1} is not a valid object.`);
+ }
+
+ if (!isSerializedInteger(record.invoiceId)) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid invoiceId.`
+ );
+ }
+
+ if (!isSerializedInteger(record.chainId)) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid chainId.`
+ );
+ }
+
+ if (typeof record.from !== "string" || !ADDRESS_PATTERN.test(record.from)) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid sender address.`
+ );
+ }
+
+ if (typeof record.to !== "string" || !ADDRESS_PATTERN.test(record.to)) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid receiver address.`
+ );
+ }
+
+ if (!isObject(record.data)) {
+ throw new Error(
+ `Invoice record ${index + 1} is missing its invoice payload.`
+ );
+}
+
+const payload = record.data;
+
+if (
+ typeof payload.amountDue !== "string" ||
+ payload.amountDue.trim() === ""
+) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid invoice payload amount.`
+ );
+}
+
+if (
+ !isObject(payload.paymentToken) ||
+ typeof payload.paymentToken.address !== "string"
+) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid payment token.`
+ );
+}
+
+if (!isObject(payload.user) || typeof payload.user.address !== "string") {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid sender information.`
+ );
+}
+
+if (
+ !isObject(payload.client) ||
+ typeof payload.client.address !== "string"
+) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid client information.`
+ );
+}
+
+if (!Array.isArray(payload.items)) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid invoice items list.`
+ );
+}
+
+ if (
+ record.compositeKey !== undefined &&
+ typeof record.compositeKey !== "string"
+ ) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid composite key.`
+ );
+ }
+
+ if (
+ record.isPaid !== undefined &&
+ typeof record.isPaid !== "boolean"
+ ) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid payment status.`
+ );
+ }
+
+ if (
+ record.isCancelled !== undefined &&
+ typeof record.isCancelled !== "boolean"
+ ) {
+ throw new Error(
+ `Invoice record ${index + 1} has an invalid cancellation status.`
+ );
+ }
+}
+
+function validateBackup(backupData) {
+ if (!isObject(backupData)) {
+ throw new Error(
+ "Invalid backup file. The selected file is not a Chainvoice backup."
+ );
+ }
+
+ if (backupData.backupVersion !== BACKUP_VERSION) {
+ throw new Error(
+ `Unsupported backup version. Expected version ${BACKUP_VERSION}.`
+ );
+ }
+
+ if (backupData.databaseName !== DB_NAME) {
+ throw new Error(
+ "Invalid backup file. This backup does not belong to Chainvoice."
+ );
+ }
+
+ if (
+ !Number.isInteger(backupData.databaseVersion) ||
+ backupData.databaseVersion < 1
+ ) {
+ throw new Error("Invalid backup database version.");
+ }
+
+ if (
+ typeof backupData.exportedAt !== "string" ||
+ Number.isNaN(Date.parse(backupData.exportedAt))
+ ) {
+ throw new Error("Invalid backup export timestamp.");
+ }
+
+ if (!isObject(backupData.schema) || !isObject(backupData.stores)) {
+ throw new Error("Invalid backup structure.");
+ }
+
+ const invoiceSchema = backupData.schema[STORE_NAME];
+ const invoiceEntries = backupData.stores[STORE_NAME];
+
+ if (!isObject(invoiceSchema) || !Array.isArray(invoiceEntries)) {
+ throw new Error(
+ "Invalid Chainvoice backup. The invoices store is missing."
+ );
+ }
+
+ if (
+ invoiceSchema.keyPath !== "compositeKey" ||
+ invoiceSchema.autoIncrement !== false
+ ) {
+ throw new Error(
+ "Invalid Chainvoice backup. The invoice store schema is incompatible."
+ );
+ }
+
+ invoiceEntries.forEach((entry, index) => {
+ if (!isObject(entry) || !("value" in entry)) {
+ throw new Error(`Backup entry ${index + 1} is malformed.`);
+ }
+
+ validateInvoiceRecord(entry.value, index);
+ });
+
+ return backupData;
+}
/**
- * Export all local invoice data as a downloadable JSON backup file.
- * Uses @aossie-org/idb-backup for type-safe IndexedDB export.
- * @returns {Promise