From 3b2fdc8db064e726e4afa344e673b1164bd4a9cb Mon Sep 17 00:00:00 2001 From: Ekong Jemimah Date: Mon, 31 Aug 2026 08:47:43 +0100 Subject: [PATCH 1/3] feat: add author payouts summary to library admin (#258) --- __tests__/admin/BookPayoutPanel.test.jsx | 207 ++++++++++ .../admin/admin-book-payouts.service.test.js | 147 ++++++++ app/[locale]/admin/books/page.jsx | 26 ++ components/admin/BookPayoutPanel.jsx | 354 ++++++++++++++++++ lib/actions/admin-book-payouts.ts | 312 +++++++++++++++ 5 files changed, 1046 insertions(+) create mode 100644 __tests__/admin/BookPayoutPanel.test.jsx create mode 100644 __tests__/admin/admin-book-payouts.service.test.js create mode 100644 components/admin/BookPayoutPanel.jsx create mode 100644 lib/actions/admin-book-payouts.ts diff --git a/__tests__/admin/BookPayoutPanel.test.jsx b/__tests__/admin/BookPayoutPanel.test.jsx new file mode 100644 index 00000000..be706eba --- /dev/null +++ b/__tests__/admin/BookPayoutPanel.test.jsx @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import BookPayoutPanel from "@/components/admin/BookPayoutPanel"; + +vi.mock("@/lib/config/font.config", () => ({ + poppins_400: { className: "" }, + poppins_500: { className: "" }, + poppins_600: { className: "" }, +})); + +const toastMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: toastMock })); + +vi.mock("@/lib/config/env", () => ({ + config: { + stellarNetwork: "testnet", + }, +})); + +const explorerMock = vi.hoisted(() => ({ + isValidStellarAddress: vi.fn(), + getExplorerTransactionUrl: vi.fn( + (hash) => `https://stellar.expert/explorer/testnet/tx/${hash}` + ), + getExplorerUrl: vi.fn( + (pubkey) => `https://stellar.expert/explorer/testnet/account/${pubkey}` + ), +})); +vi.mock("@/lib/utils/stellarExplorer", () => explorerMock); + +const serviceMocks = vi.hoisted(() => ({ + fetchBookPayouts: vi.fn(), +})); +vi.mock("@/lib/actions/admin-book-payouts", () => ({ + fetchBookPayouts: serviceMocks.fetchBookPayouts, +})); + +const BOOK = { + _id: "bk_002", + title: "Understanding Hadith Sciences", + author: { name: "Dr. Fatima" }, + price: 12.99, +}; + +const WALLET = "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567"; + +const MOCK_SUMMARY = { + bookId: "bk_002", + title: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + creatorWallet: WALLET, + unitsSold: 3, + grossUsdc: 38.97, + settlements: [ + { + _id: "pt_201", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + amount: 12.99, + currency: "USDC", + status: "confirmed", + buyerName: "Amina Yusuf", + createdAt: "2026-08-05T10:00:00.000Z", + }, + { + _id: "pt_202", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + amount: 12.99, + currency: "USDC", + status: "confirmed", + buyerName: "Umar Farouk", + createdAt: "2026-07-19T14:30:00.000Z", + }, + { + _id: "pt_203", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + amount: 12.99, + currency: "USDC", + status: "pending", + buyerName: "Zaynab Idris", + createdAt: "2026-07-02T08:15:00.000Z", + }, + { + _id: "pt_204", + txHash: "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a", + amount: 12.99, + currency: "USDC", + status: "pending", + buyerName: "Hassan Ibrahim", + createdAt: "2026-06-21T09:45:00.000Z", + }, + ], +}; + +function renderPanel() { + return render( + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + explorerMock.isValidStellarAddress.mockReturnValue(true); + serviceMocks.fetchBookPayouts.mockResolvedValue({ + success: true, + summary: MOCK_SUMMARY, + }); + + if (!navigator.clipboard) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + writable: true, + }); + } else { + vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + } +}); + +describe("BookPayoutPanel", () => { + it("renders the payout summary for a book", async () => { + renderPanel(); + + expect(screen.getByRole("heading", { name: "Author Payouts" })).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + expect(screen.getByText("Units Sold")).toBeInTheDocument(); + expect(screen.getByText("$38.97")).toBeInTheDocument(); + expect(screen.getByText("Gross USDC")).toBeInTheDocument(); + + expect(screen.getByText(WALLET)).toBeInTheDocument(); + expect(screen.getByText("Validated")).toBeInTheDocument(); + + const accountLink = screen.getByRole("link", { name: "View on explorer" }); + expect(accountLink).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/account/${WALLET}` + ); + + const txLink = screen.getByRole("link", { + name: "View settlement transaction on explorer for Amina Yusuf", + }); + expect(txLink).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/tx/${MOCK_SUMMARY.settlements[0].txHash}` + ); + }); + + it("requests payouts with the book identity", async () => { + renderPanel(); + + await waitFor(() => { + expect(serviceMocks.fetchBookPayouts).toHaveBeenCalledWith( + expect.objectContaining({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + }) + ); + }); + }); + + it("shows an error and recovers on Retry", async () => { + serviceMocks.fetchBookPayouts + .mockResolvedValueOnce({ success: false, error: "Server exploded" }) + .mockResolvedValueOnce({ success: true, summary: MOCK_SUMMARY }); + + renderPanel(); + + expect(await screen.findByText("Server exploded")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("$38.97")).toBeInTheDocument(); + expect(screen.getByText("Validated")).toBeInTheDocument(); + }); + + it("flags an unverifiable creator wallet format", async () => { + explorerMock.isValidStellarAddress.mockReturnValue(false); + serviceMocks.fetchBookPayouts.mockResolvedValue({ + success: true, + summary: { ...MOCK_SUMMARY, creatorWallet: "not-an-address" }, + }); + + renderPanel(); + + expect(await screen.findByText("Unverified format")).toBeInTheDocument(); + expect(screen.getByText("not-an-address")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "View on explorer" })).not.toBeInTheDocument(); + }); + + it("copies the creator wallet address", async () => { + renderPanel(); + + const copyButton = await screen.findByRole("button", { + name: "Copy wallet address", + }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(WALLET); + }); + expect(toastMock.success).toHaveBeenCalledWith( + "Creator wallet copied to clipboard!" + ); + }); +}); \ No newline at end of file diff --git a/__tests__/admin/admin-book-payouts.service.test.js b/__tests__/admin/admin-book-payouts.service.test.js new file mode 100644 index 00000000..94e86656 --- /dev/null +++ b/__tests__/admin/admin-book-payouts.service.test.js @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchBookPayouts } from "@/lib/actions/admin-book-payouts"; +import axiosInstance from "@/lib/config/axios.config"; + +vi.mock("@/lib/config/axios.config", () => ({ + default: { + get: vi.fn(), + }, +})); + +describe("admin-book-payouts service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("queries book transactions with date params and aggregates matched title", async () => { + const confirmed = { + _id: "tx_a", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + itemType: "book", + itemTitle: "Understanding Hadith Sciences", + amount: 12.99, + status: "confirmed", + buyer: { name: "Amina Yusuf" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-05T10:00:00.000Z", + }; + const pending = { + _id: "tx_b", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + itemType: "book", + itemTitle: "Understanding Hadith Sciences", + amount: 12.99, + status: "pending", + buyer: { name: "Umar Farouk" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-01T12:00:00.000Z", + }; + const otherItem = { + _id: "tx_c", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + itemType: "course", + itemTitle: "Arabic Grammar Essentials", + amount: 35, + status: "confirmed", + creator: { name: "Dr. Bilal Karim" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-04T12:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [confirmed, pending, otherItem] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + dateFrom: "2026-08-01", + dateTo: "2026-08-31", + }); + + expect(axiosInstance.get).toHaveBeenCalledWith( + "/api/admin/transactions", + expect.objectContaining({ + params: expect.objectContaining({ + itemType: "book", + limit: 200, + dateFrom: "2026-08-01", + dateTo: "2026-08-31", + }), + }) + ); + + expect(result.success).toBe(true); + expect(result.summary.unitsSold).toBe(1); + expect(result.summary.grossUsdc).toBe(12.99); + expect(result.summary.settlements.length).toBe(2); + expect(result.summary.creatorWallet).toBe( + "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567" + ); + expect(result.summary.creatorName).toBe("Dr. Fatima"); + }); + + it("falls back to the per-book mock dataset on network errors", async () => { + axiosInstance.get.mockRejectedValueOnce({ code: "ERR_NETWORK" }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + }); + + expect(result.success).toBe(true); + expect(result.summary.bookId).toBe("bk_002"); + expect(result.summary.unitsSold).toBe(3); + expect(result.summary.grossUsdc).toBe(38.97); + expect(result.summary.settlements.length).toBe(4); + expect(result.summary.settlements[0].createdAt).toBe("2026-08-05T10:00:00.000Z"); + }); + + it("respects the date range when filtering the mock dataset", async () => { + axiosInstance.get.mockRejectedValueOnce({ response: { status: 404 } }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + dateFrom: "2026-07-15", + dateTo: "2026-07-31", + }); + + expect(result.success).toBe(true); + expect(result.summary.settlements.length).toBe(1); + expect(result.summary.unitsSold).toBe(1); + expect(result.summary.grossUsdc).toBe(12.99); + expect(result.summary.settlements[0].createdAt).toBe("2026-07-19T14:30:00.000Z"); + }); + + it("returns an empty summary for free books with no sales", async () => { + axiosInstance.get.mockRejectedValueOnce({ code: "ERR_NETWORK" }); + + const result = await fetchBookPayouts({ + bookId: "bk_001", + bookTitle: "Introduction to Fiqh", + creatorName: "Sheikh Ahmad", + }); + + expect(result.success).toBe(true); + expect(result.summary.unitsSold).toBe(0); + expect(result.summary.grossUsdc).toBe(0); + expect(result.summary.settlements).toEqual([]); + expect(result.summary.creatorWallet).toBe(""); + }); + + it("returns an error result for non-fallback failures", async () => { + axiosInstance.get.mockRejectedValueOnce({ + response: { status: 500, data: { message: "Server exploded" } }, + }); + + const result = await fetchBookPayouts({ bookId: "bk_002" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Server exploded"); + }); +}); \ No newline at end of file diff --git a/app/[locale]/admin/books/page.jsx b/app/[locale]/admin/books/page.jsx index 6a1906ea..f10a5c3a 100644 --- a/app/[locale]/admin/books/page.jsx +++ b/app/[locale]/admin/books/page.jsx @@ -8,6 +8,7 @@ * - Take-down action opens a reason-category confirmation dialog * - Restore action returns the book to its prior active state * - Status changes reflected immediately in the list + * - Payouts action opens a read-only author payout summary panel (#258) */ import { useState, useCallback, useEffect, useMemo } from "react"; @@ -56,6 +57,7 @@ import { CheckCircle, Ban, Search, + Wallet, } from "lucide-react"; import { cn } from "@/lib/utils"; import { poppins_400, poppins_500 } from "@/lib/config/font.config"; @@ -63,6 +65,7 @@ import TakeDownBookDialog from "@/components/admin/TakeDownBookDialog"; import RestoreBookDialog from "@/components/admin/RestoreBookDialog"; import BlurImage from "@/components/ui/blur-image"; import MediaBlurToggle from "@/components/admin/MediaBlurToggle"; +import BookPayoutPanel from "@/components/admin/BookPayoutPanel"; // Mock books data — replace with real API call const generateMockBooks = () => [ @@ -156,6 +159,8 @@ export default function AdminBooksPage() { const [takedownTarget, setTakedownTarget] = useState(null); const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); const [restoreTarget, setRestoreTarget] = useState(null); + const [payoutDialogOpen, setPayoutDialogOpen] = useState(false); + const [payoutTarget, setPayoutTarget] = useState(null); const fetchBooks = useCallback(async () => { setLoading(true); @@ -395,6 +400,16 @@ export default function AdminBooksPage() { + { + setPayoutTarget(book); + setPayoutDialogOpen(true); + }} + > + + Payouts + + {book.status === "active" ? ( { @@ -453,6 +468,17 @@ export default function AdminBooksPage() { onRestored={handleRestored} /> )} + + {payoutTarget && ( + { + setPayoutDialogOpen(open); + if (!open) setPayoutTarget(null); + }} + book={payoutTarget} + /> + )} ); } diff --git a/components/admin/BookPayoutPanel.jsx b/components/admin/BookPayoutPanel.jsx new file mode 100644 index 00000000..d1a02c68 --- /dev/null +++ b/components/admin/BookPayoutPanel.jsx @@ -0,0 +1,354 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { RangeFilter } from "@/components/admin/range-filter"; +import { fetchBookPayouts } from "@/lib/actions/admin-book-payouts"; +import { + isValidStellarAddress, + getExplorerTransactionUrl, + getExplorerUrl, +} from "@/lib/utils/stellarExplorer"; +import { config } from "@/lib/config/env"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config"; +import { + Loader2, + Wallet, + ExternalLink, + Copy, + Check, + ShieldCheck, + AlertTriangle, + CircleDollarSign, + BookOpen, + Receipt, + RefreshCw, + PackageX, +} from "lucide-react"; + +function StatChip({ icon: Icon, label, value }) { + return ( + + +
+
+

+ {value} +

+
+
+ ); +} + +function formatDate(iso) { + const date = new Date(iso); + if (isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export default function BookPayoutPanel({ book, open, onOpenChange }) { + const [range, setRange] = useState({ from: null, to: null }); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copiedWallet, setCopiedWallet] = useState(false); + + const network = config.stellarNetwork; + + const loadPayouts = useCallback(async () => { + if (!book) return; + setLoading(true); + setError(null); + try { + const res = await fetchBookPayouts({ + bookId: book._id, + bookTitle: book.title, + creatorName: book.author?.name, + dateFrom: range.from ? range.from.toISOString() : undefined, + dateTo: range.to ? range.to.toISOString() : undefined, + }); + if (res.success && res.summary) { + setSummary(res.summary); + } else { + setSummary(null); + setError(res.error || "Unable to load payout summary."); + } + } catch (err) { + setSummary(null); + setError(err?.message || "Unable to load payout summary."); + } finally { + setLoading(false); + } + }, [book, range.from, range.to]); + + useEffect(() => { + if (open && book) loadPayouts(); + }, [open, book, loadPayouts]); + + useEffect(() => { + if (!open) { + setRange({ from: null, to: null }); + setSummary(null); + setError(null); + setCopiedWallet(false); + } + }, [open]); + + if (!book) return null; + + const wallet = summary?.creatorWallet || ""; + const walletValid = wallet ? isValidStellarAddress(wallet) : false; + const accountUrl = walletValid ? getExplorerUrl(wallet, network) : null; + + const handleCopyWallet = async () => { + if (!wallet) return; + try { + await navigator.clipboard.writeText(wallet); + setCopiedWallet(true); + toast.success("Creator wallet copied to clipboard!"); + setTimeout(() => setCopiedWallet(false), 2000); + } catch { + toast.error("Could not copy wallet address."); + } + }; + + return ( + + + + + + + Payout summary for{" "} + {book.title} by{" "} + {book.author?.name || "Unknown author"}. Read-only quick glance — + reconciliation lives in the payments section. + + + +
+
+ + Read-only + + {book.price === 0 && ( + + Free + + )} +
+ +
+ + {loading ? ( +
+
+ ) : error ? ( +
+
+ ) : summary ? ( +
+
+ + + +
+ + + +
+ + Creator Wallet + + {wallet ? ( + walletValid ? ( + + + ) : ( + + + ) + ) : ( + Not set + )} +
+ + {wallet ? ( +
+ + {wallet} + + + {accountUrl && ( + + View on explorer + + )} +
+ ) : ( +

+ Creator has no registered payout address yet. +

+ )} +
+
+ + {summary.settlements.length === 0 ? ( +
+
+ ) : ( +
+ + + + Buyer + Amount + Status + Date + On-chain + + + + {summary.settlements.map((st) => { + const txUrl = st.txHash + ? getExplorerTransactionUrl(st.txHash, network) + : null; + return ( + + + {st.buyerName || "—"} + + + + ${Number(st.amount).toFixed(2)} + {" "} + + USDC + + + + + {st.status} + + + + {formatDate(st.createdAt)} + + + {txUrl ? ( + + View + + ) : ( + "—" + )} + + + ); + })} + +
+
+ )} +
+ ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/lib/actions/admin-book-payouts.ts b/lib/actions/admin-book-payouts.ts new file mode 100644 index 00000000..f6ea5e52 --- /dev/null +++ b/lib/actions/admin-book-payouts.ts @@ -0,0 +1,312 @@ +import axiosInstance from "@/lib/config/axios.config"; + +export interface PayoutSettlement { + _id: string; + txHash: string; + amount: number; + currency: string; + status: string; + buyerName?: string; + createdAt: string; +} + +export interface BookPayoutSummary { + bookId: string; + title: string; + creatorName: string; + creatorWallet: string; + unitsSold: number; + grossUsdc: number; + settlements: PayoutSettlement[]; +} + +export interface FetchBookPayoutsParams { + bookId: string; + bookTitle?: string; + creatorName?: string; + dateFrom?: string; + dateTo?: string; +} + +export interface FetchBookPayoutsResult { + success: boolean; + summary?: BookPayoutSummary; + error?: string; +} + +interface MockPayoutSeed { + creatorWallet: string; + creatorName: string; + settlements: Array<{ + _id: string; + txHash: string; + amount: number; + status: string; + buyerName: string; + createdAt: string; + }>; +} + +const MOCK_PAYOUT_SEEDS: Record = { + bk_002: { + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + creatorName: "Dr. Fatima", + settlements: [ + { + _id: "pt_201", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + amount: 12.99, + status: "confirmed", + buyerName: "Amina Yusuf", + createdAt: "2026-08-05T10:00:00.000Z", + }, + { + _id: "pt_202", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + amount: 12.99, + status: "confirmed", + buyerName: "Umar Farouk", + createdAt: "2026-07-19T14:30:00.000Z", + }, + { + _id: "pt_203", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + amount: 12.99, + status: "pending", + buyerName: "Zaynab Idris", + createdAt: "2026-07-02T08:15:00.000Z", + }, + { + _id: "pt_204", + txHash: "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a", + amount: 12.99, + status: "confirmed", + buyerName: "Fatima Ali", + createdAt: "2026-06-21T09:45:00.000Z", + }, + ], + }, + bk_003: { + creatorWallet: "GDFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W9999", + creatorName: "Sheikh Omar", + settlements: [ + { + _id: "pt_301", + txHash: "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d", + amount: 9.99, + status: "confirmed", + buyerName: "Hassan Ibrahim", + createdAt: "2026-08-12T11:20:00.000Z", + }, + { + _id: "pt_302", + txHash: "5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c", + amount: 9.99, + status: "confirmed", + buyerName: "Tariq Mansoor", + createdAt: "2026-07-28T16:40:00.000Z", + }, + { + _id: "pt_303", + txHash: "6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8", + amount: 9.99, + status: "expired", + buyerName: "Khadija Bello", + createdAt: "2026-06-09T13:05:00.000Z", + }, + ], + }, + bk_004: { + creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", + creatorName: "Ustadh Ibrahim", + settlements: [ + { + _id: "pt_401", + txHash: "7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9", + amount: 5.0, + status: "confirmed", + buyerName: "Umar Farouk", + createdAt: "2026-08-03T09:10:00.000Z", + }, + { + _id: "pt_402", + txHash: "8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0", + amount: 5.0, + status: "confirmed", + buyerName: "Amina Yusuf", + createdAt: "2026-07-11T15:25:00.000Z", + }, + ], + }, + bk_006: { + creatorWallet: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", + creatorName: "Sister Maryam", + settlements: [ + { + _id: "pt_601", + txHash: "9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", + amount: 7.5, + status: "confirmed", + buyerName: "Zaynab Idris", + createdAt: "2026-08-15T12:00:00.000Z", + }, + { + _id: "pt_602", + txHash: "0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + amount: 7.5, + status: "confirmed", + buyerName: "Fatima Ali", + createdAt: "2026-07-22T10:35:00.000Z", + }, + { + _id: "pt_603", + txHash: "1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c", + amount: 7.5, + status: "submitted", + buyerName: "Tariq Mansoor", + createdAt: "2026-06-27T18:50:00.000Z", + }, + ], + }, +}; + +const FREE_BOOKS_WITHOUT_SALES = new Set(["bk_001", "bk_005"]); + +function isWithinRange(iso: string, dateFrom?: string, dateTo?: string): boolean { + const date = new Date(iso); + if (isNaN(date.getTime())) return false; + if (dateFrom) { + const from = new Date(dateFrom); + if (!isNaN(from.getTime()) && date < from) return false; + } + if (dateTo) { + const to = new Date(dateTo); + if (!isNaN(to.getTime())) { + to.setHours(23, 59, 59, 999); + if (date > to) return false; + } + } + return true; +} + +function filterMockSettlements(seed: MockPayoutSeed, params: FetchBookPayoutsParams): PayoutSettlement[] { + return seed.settlements + .filter((s) => isWithinRange(s.createdAt, params.dateFrom, params.dateTo)) + .map((s) => ({ ...s, currency: "USDC" })) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); +} + +function buildSummary( + params: FetchBookPayoutsParams, + settlements: PayoutSettlement[], + creatorWallet: string, + creatorName: string +): BookPayoutSummary { + const confirmed = settlements.filter((s) => s.status.toLowerCase() === "confirmed"); + return { + bookId: params.bookId, + title: params.bookTitle || "Book", + creatorName: creatorName || params.creatorName || "Unknown author", + creatorWallet, + unitsSold: confirmed.length, + grossUsdc: Math.round(confirmed.reduce((sum, s) => sum + (Number(s.amount) || 0), 0) * 100) / 100, + settlements, + }; +} + +function buildMockSummary(params: FetchBookPayoutsParams): FetchBookPayoutsResult { + const seed = + FREE_BOOKS_WITHOUT_SALES.has(params.bookId) || + !Object.prototype.hasOwnProperty.call(MOCK_PAYOUT_SEEDS, params.bookId) + ? { + creatorWallet: "", + creatorName: params.creatorName || "Unknown author", + settlements: [] as MockPayoutSeed["settlements"], + } + : MOCK_PAYOUT_SEEDS[params.bookId]; + + const settlements = filterMockSettlements(seed, params); + return { + success: true, + summary: buildSummary(params, settlements, seed.creatorWallet, seed.creatorName), + }; +} + +export async function fetchBookPayouts( + params: FetchBookPayoutsParams +): Promise { + if (!params.bookId) { + return { success: false, error: "Missing bookId" }; + } + + const queryParams: Record = { + itemType: "book", + page: 1, + limit: 200, + }; + if (params.dateFrom) queryParams.dateFrom = params.dateFrom; + if (params.dateTo) queryParams.dateTo = params.dateTo; + + try { + const res = await axiosInstance.get("/api/admin/transactions", { + params: queryParams, + }); + + if (res.data && res.data.success) { + const txList: Array<{ + itemType?: string; + itemTitle?: string; + amount?: number; + status?: string; + txHash?: string; + _id?: string; + buyer?: { name?: string }; + creator?: { name?: string }; + creatorWallet?: string; + createdAt?: string; + }> = res.data.transactions || []; + + const bookTitle = params.bookTitle?.toLowerCase(); + const matched = txList.filter( + (tx) => + tx.itemType?.toLowerCase() === "book" && + (!bookTitle || !tx.itemTitle || tx.itemTitle.toLowerCase() === bookTitle) + ); + + if (matched.length > 0) { + const settlements: PayoutSettlement[] = matched + .map((tx) => ({ + _id: tx._id || "", + txHash: tx.txHash || "", + amount: Number(tx.amount) || 0, + currency: "USDC", + status: tx.status || "unknown", + buyerName: tx.buyer?.name, + createdAt: tx.createdAt || "", + })) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return { + success: true, + summary: buildSummary( + params, + settlements, + matched[0].creatorWallet || "", + matched[0].creator?.name || "" + ), + }; + } + } + + return buildMockSummary(params); + } catch (error: any) { // TODO(types): Axios error on admin book payouts + if (error.response?.status === 404 || error.code === "ERR_NETWORK" || !error.response) { + return buildMockSummary(params); + } + console.error("Failed to fetch book payouts:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "Failed to fetch payout summary", + }; + } +} \ No newline at end of file From c9789ef72e3fdf1e5945b542a77475329b95e0b8 Mon Sep 17 00:00:00 2001 From: Ekong Jemimah Date: Mon, 31 Aug 2026 09:24:37 +0100 Subject: [PATCH 2/3] fix: author payouts book-id matching, race guard, and missing cloudinary dep - Prefer immutable book ID over title matching for real API aggregation - Filter out book transactions that cannot be tied to the requested book - Align free-book set with catalog (bk_001, bk_003, bk_005) - Add request race guard to BookPayoutPanel to prevent stale overwrites - Add cloudinary dependency to fix pre-existing CI build failure (#442) - Expand test coverage: 13 payout tests passing --- __tests__/admin/BookPayoutPanel.test.jsx | 51 +++++++++ .../admin/admin-book-payouts.service.test.js | 101 ++++++++++++++++-- components/admin/BookPayoutPanel.jsx | 9 +- lib/actions/admin-book-payouts.ts | 46 ++------ package-lock.json | 17 ++- package.json | 1 + 6 files changed, 179 insertions(+), 46 deletions(-) diff --git a/__tests__/admin/BookPayoutPanel.test.jsx b/__tests__/admin/BookPayoutPanel.test.jsx index be706eba..331cce2f 100644 --- a/__tests__/admin/BookPayoutPanel.test.jsx +++ b/__tests__/admin/BookPayoutPanel.test.jsx @@ -100,6 +100,16 @@ function renderPanel() { ); } +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + beforeEach(() => { vi.clearAllMocks(); explorerMock.isValidStellarAddress.mockReturnValue(true); @@ -160,6 +170,47 @@ describe("BookPayoutPanel", () => { }); }); + it("ignores a stale response from a superseded request", async () => { + const stale = deferred(); + const fresh = deferred(); + serviceMocks.fetchBookPayouts + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise); + + const otherBook = { + _id: "bk_004", + title: "Tajweed Made Simple", + author: { name: "Ustadh Ibrahim" }, + price: 5.0, + }; + + const { rerender } = render( + + ); + rerender( + + ); + + fresh.resolve({ + success: true, + summary: { + ...MOCK_SUMMARY, + bookId: "bk_004", + title: "Tajweed Made Simple", + creatorName: "Ustadh Ibrahim", + creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", + }, + }); + expect( + await screen.findByText("GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0") + ).toBeInTheDocument(); + + stale.resolve({ success: true, summary: MOCK_SUMMARY }); + await waitFor(() => { + expect(screen.queryByText(WALLET)).not.toBeInTheDocument(); + }); + }); + it("shows an error and recovers on Retry", async () => { serviceMocks.fetchBookPayouts .mockResolvedValueOnce({ success: false, error: "Server exploded" }) diff --git a/__tests__/admin/admin-book-payouts.service.test.js b/__tests__/admin/admin-book-payouts.service.test.js index 94e86656..f088d21e 100644 --- a/__tests__/admin/admin-book-payouts.service.test.js +++ b/__tests__/admin/admin-book-payouts.service.test.js @@ -118,20 +118,107 @@ describe("admin-book-payouts service", () => { expect(result.summary.settlements[0].createdAt).toBe("2026-07-19T14:30:00.000Z"); }); - it("returns an empty summary for free books with no sales", async () => { - axiosInstance.get.mockRejectedValueOnce({ code: "ERR_NETWORK" }); + it("prefers the immutable book identifier over title matching", async () => { + const matchingId = { + _id: "tx_a", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + itemType: "book", + itemId: "bk_002", + itemTitle: "Some Other Title", + amount: 12.99, + status: "confirmed", + buyer: { name: "Amina Yusuf" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-05T10:00:00.000Z", + }; + const wrongId = { + _id: "tx_b", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + itemType: "book", + itemId: "bk_999", + itemTitle: "Understanding Hadith Sciences", + amount: 12.99, + status: "confirmed", + buyer: { name: "Umar Farouk" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", + createdAt: "2026-08-04T12:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [matchingId, wrongId] }, + }); const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + }); + + expect(result.summary.creatorWallet).toBe( + "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567" + ); + expect(result.summary.settlements.length).toBe(1); + expect(result.summary.unitsSold).toBe(1); + }); + + it("excludes transactions that cannot be tied to the requested book", async () => { + const orphan = { + _id: "tx_c", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + itemType: "book", + amount: 9.99, + status: "confirmed", + buyer: { name: "Fatima Ali" }, + creator: { name: "Unknown" }, + creatorWallet: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", + createdAt: "2026-08-05T10:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [orphan] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + }); + + expect(result.success).toBe(true); + expect(result.summary.bookId).toBe("bk_002"); + expect(result.summary.settlements.length).toBe(4); + expect(result.summary.creatorWallet).toBe( + "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567" + ); + }); + + it("returns an empty summary for free books with no sales", async () => { + axiosInstance.get + .mockRejectedValueOnce({ code: "ERR_NETWORK" }) + .mockRejectedValueOnce({ code: "ERR_NETWORK" }); + + const resultBk1 = await fetchBookPayouts({ bookId: "bk_001", bookTitle: "Introduction to Fiqh", creatorName: "Sheikh Ahmad", }); + const resultBk3 = await fetchBookPayouts({ + bookId: "bk_003", + bookTitle: "Seerah of the Prophet", + creatorName: "Sheikh Omar", + }); - expect(result.success).toBe(true); - expect(result.summary.unitsSold).toBe(0); - expect(result.summary.grossUsdc).toBe(0); - expect(result.summary.settlements).toEqual([]); - expect(result.summary.creatorWallet).toBe(""); + expect(resultBk1.success).toBe(true); + expect(resultBk1.summary.unitsSold).toBe(0); + expect(resultBk1.summary.grossUsdc).toBe(0); + expect(resultBk1.summary.settlements).toEqual([]); + expect(resultBk1.summary.creatorWallet).toBe(""); + + expect(resultBk3.success).toBe(true); + expect(resultBk3.summary.unitsSold).toBe(0); + expect(resultBk3.summary.grossUsdc).toBe(0); + expect(resultBk3.summary.settlements).toEqual([]); + expect(resultBk3.summary.creatorWallet).toBe(""); }); it("returns an error result for non-fallback failures", async () => { diff --git a/components/admin/BookPayoutPanel.jsx b/components/admin/BookPayoutPanel.jsx index d1a02c68..8b372eda 100644 --- a/components/admin/BookPayoutPanel.jsx +++ b/components/admin/BookPayoutPanel.jsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Dialog, DialogContent, @@ -84,11 +84,13 @@ export default function BookPayoutPanel({ book, open, onOpenChange }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [copiedWallet, setCopiedWallet] = useState(false); + const requestIdRef = useRef(0); const network = config.stellarNetwork; const loadPayouts = useCallback(async () => { if (!book) return; + const requestId = ++requestIdRef.current; setLoading(true); setError(null); try { @@ -99,6 +101,7 @@ export default function BookPayoutPanel({ book, open, onOpenChange }) { dateFrom: range.from ? range.from.toISOString() : undefined, dateTo: range.to ? range.to.toISOString() : undefined, }); + if (requestId !== requestIdRef.current) return; if (res.success && res.summary) { setSummary(res.summary); } else { @@ -106,10 +109,11 @@ export default function BookPayoutPanel({ book, open, onOpenChange }) { setError(res.error || "Unable to load payout summary."); } } catch (err) { + if (requestId !== requestIdRef.current) return; setSummary(null); setError(err?.message || "Unable to load payout summary."); } finally { - setLoading(false); + if (requestId === requestIdRef.current) setLoading(false); } }, [book, range.from, range.to]); @@ -119,6 +123,7 @@ export default function BookPayoutPanel({ book, open, onOpenChange }) { useEffect(() => { if (!open) { + requestIdRef.current += 1; setRange({ from: null, to: null }); setSummary(null); setError(null); diff --git a/lib/actions/admin-book-payouts.ts b/lib/actions/admin-book-payouts.ts index f6ea5e52..c66058d3 100644 --- a/lib/actions/admin-book-payouts.ts +++ b/lib/actions/admin-book-payouts.ts @@ -86,36 +86,6 @@ const MOCK_PAYOUT_SEEDS: Record = { }, ], }, - bk_003: { - creatorWallet: "GDFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W9999", - creatorName: "Sheikh Omar", - settlements: [ - { - _id: "pt_301", - txHash: "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d", - amount: 9.99, - status: "confirmed", - buyerName: "Hassan Ibrahim", - createdAt: "2026-08-12T11:20:00.000Z", - }, - { - _id: "pt_302", - txHash: "5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c", - amount: 9.99, - status: "confirmed", - buyerName: "Tariq Mansoor", - createdAt: "2026-07-28T16:40:00.000Z", - }, - { - _id: "pt_303", - txHash: "6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8", - amount: 9.99, - status: "expired", - buyerName: "Khadija Bello", - createdAt: "2026-06-09T13:05:00.000Z", - }, - ], - }, bk_004: { creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", creatorName: "Ustadh Ibrahim", @@ -170,7 +140,7 @@ const MOCK_PAYOUT_SEEDS: Record = { }, }; -const FREE_BOOKS_WITHOUT_SALES = new Set(["bk_001", "bk_005"]); +const FREE_BOOKS_WITHOUT_SALES = new Set(["bk_001", "bk_003", "bk_005"]); function isWithinRange(iso: string, dateFrom?: string, dateTo?: string): boolean { const date = new Date(iso); @@ -255,6 +225,7 @@ export async function fetchBookPayouts( if (res.data && res.data.success) { const txList: Array<{ itemType?: string; + itemId?: string; itemTitle?: string; amount?: number; status?: string; @@ -267,11 +238,14 @@ export async function fetchBookPayouts( }> = res.data.transactions || []; const bookTitle = params.bookTitle?.toLowerCase(); - const matched = txList.filter( - (tx) => - tx.itemType?.toLowerCase() === "book" && - (!bookTitle || !tx.itemTitle || tx.itemTitle.toLowerCase() === bookTitle) - ); + const matched = txList.filter((tx) => { + if (tx.itemType?.toLowerCase() !== "book") return false; + if (tx.itemId) return tx.itemId === params.bookId; + if (bookTitle) { + return !!tx.itemTitle && tx.itemTitle.toLowerCase() === bookTitle; + } + return false; + }); if (matched.length > 0) { const settlements: PayoutSettlement[] = matched diff --git a/package-lock.json b/package-lock.json index 8969cabc..536bd0e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@vidstack/react": "^1.12.13", "axios": "^1.9.0", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.11.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", @@ -10972,6 +10973,18 @@ "node": ">=12" } }, + "node_modules/cloudinary": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.11.0.tgz", + "integrity": "sha512-o2pAkDDqrzPFE/mn9GCfFjBIGQmPs++Ts6rtkFl4vY528DNHiMovKWjH16HBjeGa9iwI7eAad24aWN2emT0aww==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/clsx": { "version": "2.1.1", "license": "MIT", @@ -14451,7 +14464,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { diff --git a/package.json b/package.json index 2ebe9def..cbcfd270 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@vidstack/react": "^1.12.13", "axios": "^1.9.0", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.11.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", From 951474367d71569d48166368e3fbd67ee7673ac1 Mon Sep 17 00:00:00 2001 From: Ekong Jemimah Date: Mon, 31 Aug 2026 09:38:24 +0100 Subject: [PATCH 3/3] fix: remove seeded mock fallback from book payouts service Per CodeRabbit: seeded payout fallback could show fake creator wallets and settlements to admins when the API is unavailable. The service now returns an empty verified summary only for an authoritative empty response and propagates a real error for unavailable data. Seeded mock records moved to tests only. --- .../admin/admin-book-payouts.service.test.js | 144 +++++----- lib/actions/admin-book-payouts.ts | 246 +++++------------- 2 files changed, 139 insertions(+), 251 deletions(-) diff --git a/__tests__/admin/admin-book-payouts.service.test.js b/__tests__/admin/admin-book-payouts.service.test.js index f088d21e..f91f463f 100644 --- a/__tests__/admin/admin-book-payouts.service.test.js +++ b/__tests__/admin/admin-book-payouts.service.test.js @@ -13,11 +13,12 @@ describe("admin-book-payouts service", () => { vi.clearAllMocks(); }); - it("queries book transactions with date params and aggregates matched title", async () => { + it("queries book transactions with date params and aggregates matched book", async () => { const confirmed = { _id: "tx_a", txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", itemType: "book", + itemId: "bk_002", itemTitle: "Understanding Hadith Sciences", amount: 12.99, status: "confirmed", @@ -30,6 +31,7 @@ describe("admin-book-payouts service", () => { _id: "tx_b", txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", itemType: "book", + itemId: "bk_002", itemTitle: "Understanding Hadith Sciences", amount: 12.99, status: "pending", @@ -84,40 +86,6 @@ describe("admin-book-payouts service", () => { expect(result.summary.creatorName).toBe("Dr. Fatima"); }); - it("falls back to the per-book mock dataset on network errors", async () => { - axiosInstance.get.mockRejectedValueOnce({ code: "ERR_NETWORK" }); - - const result = await fetchBookPayouts({ - bookId: "bk_002", - bookTitle: "Understanding Hadith Sciences", - creatorName: "Dr. Fatima", - }); - - expect(result.success).toBe(true); - expect(result.summary.bookId).toBe("bk_002"); - expect(result.summary.unitsSold).toBe(3); - expect(result.summary.grossUsdc).toBe(38.97); - expect(result.summary.settlements.length).toBe(4); - expect(result.summary.settlements[0].createdAt).toBe("2026-08-05T10:00:00.000Z"); - }); - - it("respects the date range when filtering the mock dataset", async () => { - axiosInstance.get.mockRejectedValueOnce({ response: { status: 404 } }); - - const result = await fetchBookPayouts({ - bookId: "bk_002", - bookTitle: "Understanding Hadith Sciences", - dateFrom: "2026-07-15", - dateTo: "2026-07-31", - }); - - expect(result.success).toBe(true); - expect(result.summary.settlements.length).toBe(1); - expect(result.summary.unitsSold).toBe(1); - expect(result.summary.grossUsdc).toBe(12.99); - expect(result.summary.settlements[0].createdAt).toBe("2026-07-19T14:30:00.000Z"); - }); - it("prefers the immutable book identifier over title matching", async () => { const matchingId = { _id: "tx_a", @@ -162,7 +130,27 @@ describe("admin-book-payouts service", () => { expect(result.summary.unitsSold).toBe(1); }); - it("excludes transactions that cannot be tied to the requested book", async () => { + it("returns an empty verified summary when the API has no transactions for the book", async () => { + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_001", + bookTitle: "Introduction to Fiqh", + creatorName: "Sheikh Ahmad", + }); + + expect(result.success).toBe(true); + expect(result.summary.bookId).toBe("bk_001"); + expect(result.summary.title).toBe("Introduction to Fiqh"); + expect(result.summary.unitsSold).toBe(0); + expect(result.summary.grossUsdc).toBe(0); + expect(result.summary.settlements).toEqual([]); + expect(result.summary.creatorWallet).toBe(""); + }); + + it("returns an empty verified summary when no transaction matches the book id", async () => { const orphan = { _id: "tx_c", txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", @@ -186,42 +174,56 @@ describe("admin-book-payouts service", () => { expect(result.success).toBe(true); expect(result.summary.bookId).toBe("bk_002"); - expect(result.summary.settlements.length).toBe(4); - expect(result.summary.creatorWallet).toBe( - "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567" - ); + expect(result.summary.settlements).toEqual([]); + expect(result.summary.unitsSold).toBe(0); + expect(result.summary.creatorWallet).toBe(""); }); - it("returns an empty summary for free books with no sales", async () => { - axiosInstance.get - .mockRejectedValueOnce({ code: "ERR_NETWORK" }) - .mockRejectedValueOnce({ code: "ERR_NETWORK" }); + it("filters settlements to the requested date range", async () => { + const inRange = { + _id: "tx_a", + txHash: "1a1b1c1d1e1f1a1b1c1d1e1f1a1b1c1d1e1f1a1b1c1d1e1f1a1b1c1d1e1f1a", + itemType: "book", + itemId: "bk_002", + amount: 12.99, + status: "confirmed", + buyer: { name: "Umar Farouk" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-07-19T14:30:00.000Z", + }; + const outOfRange = { + _id: "tx_b", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + itemType: "book", + itemId: "bk_002", + amount: 12.99, + status: "confirmed", + buyer: { name: "Amina Yusuf" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-05T10:00:00.000Z", + }; - const resultBk1 = await fetchBookPayouts({ - bookId: "bk_001", - bookTitle: "Introduction to Fiqh", - creatorName: "Sheikh Ahmad", + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [inRange, outOfRange] }, }); - const resultBk3 = await fetchBookPayouts({ - bookId: "bk_003", - bookTitle: "Seerah of the Prophet", - creatorName: "Sheikh Omar", + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + dateFrom: "2026-07-15", + dateTo: "2026-07-31", }); - expect(resultBk1.success).toBe(true); - expect(resultBk1.summary.unitsSold).toBe(0); - expect(resultBk1.summary.grossUsdc).toBe(0); - expect(resultBk1.summary.settlements).toEqual([]); - expect(resultBk1.summary.creatorWallet).toBe(""); - - expect(resultBk3.success).toBe(true); - expect(resultBk3.summary.unitsSold).toBe(0); - expect(resultBk3.summary.grossUsdc).toBe(0); - expect(resultBk3.summary.settlements).toEqual([]); - expect(resultBk3.summary.creatorWallet).toBe(""); + expect(result.success).toBe(true); + expect(result.summary.settlements.length).toBe(1); + expect(result.summary.settlements[0].createdAt).toBe("2026-07-19T14:30:00.000Z"); + expect(result.summary.unitsSold).toBe(1); + expect(result.summary.grossUsdc).toBe(12.99); }); - it("returns an error result for non-fallback failures", async () => { + it("propagates the server error message when the API is unavailable", async () => { axiosInstance.get.mockRejectedValueOnce({ response: { status: 500, data: { message: "Server exploded" } }, }); @@ -231,4 +233,16 @@ describe("admin-book-payouts service", () => { expect(result.success).toBe(false); expect(result.error).toBe("Server exploded"); }); -}); \ No newline at end of file + + it("propagates a fallback error message when the API errors without a message", async () => { + axiosInstance.get.mockRejectedValueOnce({ + code: "ERR_NETWORK", + message: "Network Error", + }); + + const result = await fetchBookPayouts({ bookId: "bk_002" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Network Error"); + }); +}); diff --git a/lib/actions/admin-book-payouts.ts b/lib/actions/admin-book-payouts.ts index c66058d3..7369d8d2 100644 --- a/lib/actions/admin-book-payouts.ts +++ b/lib/actions/admin-book-payouts.ts @@ -34,114 +34,20 @@ export interface FetchBookPayoutsResult { error?: string; } -interface MockPayoutSeed { - creatorWallet: string; - creatorName: string; - settlements: Array<{ - _id: string; - txHash: string; - amount: number; - status: string; - buyerName: string; - createdAt: string; - }>; +interface AdminTransactionRecord { + itemType?: string; + itemId?: string; + itemTitle?: string; + amount?: number; + status?: string; + txHash?: string; + _id?: string; + buyer?: { name?: string }; + creator?: { name?: string }; + creatorWallet?: string; + createdAt?: string; } -const MOCK_PAYOUT_SEEDS: Record = { - bk_002: { - creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", - creatorName: "Dr. Fatima", - settlements: [ - { - _id: "pt_201", - txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", - amount: 12.99, - status: "confirmed", - buyerName: "Amina Yusuf", - createdAt: "2026-08-05T10:00:00.000Z", - }, - { - _id: "pt_202", - txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", - amount: 12.99, - status: "confirmed", - buyerName: "Umar Farouk", - createdAt: "2026-07-19T14:30:00.000Z", - }, - { - _id: "pt_203", - txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", - amount: 12.99, - status: "pending", - buyerName: "Zaynab Idris", - createdAt: "2026-07-02T08:15:00.000Z", - }, - { - _id: "pt_204", - txHash: "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a", - amount: 12.99, - status: "confirmed", - buyerName: "Fatima Ali", - createdAt: "2026-06-21T09:45:00.000Z", - }, - ], - }, - bk_004: { - creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", - creatorName: "Ustadh Ibrahim", - settlements: [ - { - _id: "pt_401", - txHash: "7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9", - amount: 5.0, - status: "confirmed", - buyerName: "Umar Farouk", - createdAt: "2026-08-03T09:10:00.000Z", - }, - { - _id: "pt_402", - txHash: "8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0", - amount: 5.0, - status: "confirmed", - buyerName: "Amina Yusuf", - createdAt: "2026-07-11T15:25:00.000Z", - }, - ], - }, - bk_006: { - creatorWallet: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", - creatorName: "Sister Maryam", - settlements: [ - { - _id: "pt_601", - txHash: "9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", - amount: 7.5, - status: "confirmed", - buyerName: "Zaynab Idris", - createdAt: "2026-08-15T12:00:00.000Z", - }, - { - _id: "pt_602", - txHash: "0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - amount: 7.5, - status: "confirmed", - buyerName: "Fatima Ali", - createdAt: "2026-07-22T10:35:00.000Z", - }, - { - _id: "pt_603", - txHash: "1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c", - amount: 7.5, - status: "submitted", - buyerName: "Tariq Mansoor", - createdAt: "2026-06-27T18:50:00.000Z", - }, - ], - }, -}; - -const FREE_BOOKS_WITHOUT_SALES = new Set(["bk_001", "bk_003", "bk_005"]); - function isWithinRange(iso: string, dateFrom?: string, dateTo?: string): boolean { const date = new Date(iso); if (isNaN(date.getTime())) return false; @@ -159,20 +65,15 @@ function isWithinRange(iso: string, dateFrom?: string, dateTo?: string): boolean return true; } -function filterMockSettlements(seed: MockPayoutSeed, params: FetchBookPayoutsParams): PayoutSettlement[] { - return seed.settlements - .filter((s) => isWithinRange(s.createdAt, params.dateFrom, params.dateTo)) - .map((s) => ({ ...s, currency: "USDC" })) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); -} - function buildSummary( params: FetchBookPayoutsParams, settlements: PayoutSettlement[], creatorWallet: string, creatorName: string ): BookPayoutSummary { - const confirmed = settlements.filter((s) => s.status.toLowerCase() === "confirmed"); + const confirmed = settlements.filter( + (s) => s.status && s.status.toLowerCase() === "confirmed" + ); return { bookId: params.bookId, title: params.bookTitle || "Book", @@ -184,21 +85,15 @@ function buildSummary( }; } -function buildMockSummary(params: FetchBookPayoutsParams): FetchBookPayoutsResult { - const seed = - FREE_BOOKS_WITHOUT_SALES.has(params.bookId) || - !Object.prototype.hasOwnProperty.call(MOCK_PAYOUT_SEEDS, params.bookId) - ? { - creatorWallet: "", - creatorName: params.creatorName || "Unknown author", - settlements: [] as MockPayoutSeed["settlements"], - } - : MOCK_PAYOUT_SEEDS[params.bookId]; - - const settlements = filterMockSettlements(seed, params); +function toSettlement(tx: AdminTransactionRecord): PayoutSettlement { return { - success: true, - summary: buildSummary(params, settlements, seed.creatorWallet, seed.creatorName), + _id: tx._id || "", + txHash: tx.txHash || "", + amount: Number(tx.amount) || 0, + currency: "USDC", + status: tx.status || "unknown", + buyerName: tx.buyer?.name, + createdAt: tx.createdAt || "", }; } @@ -217,70 +112,49 @@ export async function fetchBookPayouts( if (params.dateFrom) queryParams.dateFrom = params.dateFrom; if (params.dateTo) queryParams.dateTo = params.dateTo; + let res; try { - const res = await axiosInstance.get("/api/admin/transactions", { + res = await axiosInstance.get("/api/admin/transactions", { params: queryParams, }); + } catch (error: any) { + console.error("Failed to fetch book payouts:", error); + return { + success: false, + error: + error?.response?.data?.message || + error?.message || + "Failed to fetch payout summary", + }; + } - if (res.data && res.data.success) { - const txList: Array<{ - itemType?: string; - itemId?: string; - itemTitle?: string; - amount?: number; - status?: string; - txHash?: string; - _id?: string; - buyer?: { name?: string }; - creator?: { name?: string }; - creatorWallet?: string; - createdAt?: string; - }> = res.data.transactions || []; - - const bookTitle = params.bookTitle?.toLowerCase(); - const matched = txList.filter((tx) => { - if (tx.itemType?.toLowerCase() !== "book") return false; - if (tx.itemId) return tx.itemId === params.bookId; - if (bookTitle) { - return !!tx.itemTitle && tx.itemTitle.toLowerCase() === bookTitle; - } - return false; - }); - - if (matched.length > 0) { - const settlements: PayoutSettlement[] = matched - .map((tx) => ({ - _id: tx._id || "", - txHash: tx.txHash || "", - amount: Number(tx.amount) || 0, - currency: "USDC", - status: tx.status || "unknown", - buyerName: tx.buyer?.name, - createdAt: tx.createdAt || "", - })) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const txList: AdminTransactionRecord[] = + (res.data && res.data.transactions) || []; - return { - success: true, - summary: buildSummary( - params, - settlements, - matched[0].creatorWallet || "", - matched[0].creator?.name || "" - ), - }; - } - } + const matched = txList.filter((tx) => { + if (tx.itemType?.toLowerCase() !== "book") return false; + return !!tx.itemId && tx.itemId === params.bookId; + }); - return buildMockSummary(params); - } catch (error: any) { // TODO(types): Axios error on admin book payouts - if (error.response?.status === 404 || error.code === "ERR_NETWORK" || !error.response) { - return buildMockSummary(params); - } - console.error("Failed to fetch book payouts:", error); + if (matched.length === 0) { return { - success: false, - error: error.response?.data?.message || error.message || "Failed to fetch payout summary", + success: true, + summary: buildSummary(params, [], "", ""), }; } -} \ No newline at end of file + + const settlements = matched + .map(toSettlement) + .filter((s) => isWithinRange(s.createdAt, params.dateFrom, params.dateTo)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return { + success: true, + summary: buildSummary( + params, + settlements, + matched[0].creatorWallet || "", + matched[0].creator?.name || "" + ), + }; +}