Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
"when": 1759323380579,
"tag": "0009_slippery_penance",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1759491911320,
"tag": "0010_minor_roulette",
"breakpoints": true
}
]
}
50 changes: 50 additions & 0 deletions src/app/api/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { generateInvoiceNumber } from "@/lib/helpers/client";
import { getInvoiceCount } from "@/lib/helpers/invoice";
import { db } from "@/server/db";
import {
clientPaymentTable,
ecommerceClientTable,
paymentDetailsPayersTable,
recurringPaymentTable,
type requestStatusEnum,
Expand All @@ -14,6 +16,52 @@ import { and, eq, not } from "drizzle-orm";
import { NextResponse } from "next/server";
import { ulid } from "ulid";

async function addClientPayment(webhookBody: any) {
await db.transaction(async (tx) => {
const ecommerceClient = await tx
.select()
.from(ecommerceClientTable)
.where(eq(ecommerceClientTable.rnClientId, webhookBody.clientId))
.limit(1);

if (!ecommerceClient.length) {
throw new ResourceNotFoundError(
`No ecommerce client found with client ID: ${webhookBody.clientId}`,
);
}

const client = ecommerceClient[0];

const inserted = await tx
.insert(clientPaymentTable)
.values({
id: ulid(),
userId: client.userId,
ecommerceClientId: client.id,
requestId: webhookBody.requestId,
invoiceCurrency: webhookBody.currency,
paymentCurrency: webhookBody.paymentCurrency,
txHash: webhookBody.txHash,
network: webhookBody.network,
amount: webhookBody.amount,
customerInfo: webhookBody.customerInfo || null,
reference: webhookBody.reference || null,
origin: webhookBody.origin,
})
.onConflictDoNothing({
target: [clientPaymentTable.requestId, clientPaymentTable.txHash],
})
.returning({ id: clientPaymentTable.id });

if (!inserted.length) {
console.warn(
`Duplicate client payment detected for requestId: ${webhookBody.requestId} and txHash: ${webhookBody.txHash}`,
);
return;
}
});
}

/**
* Updates the request status in the database
*/
Expand Down Expand Up @@ -132,6 +180,8 @@ export async function POST(req: Request) {
txHash: body.txHash,
requestScanUrl: body.explorer,
});
} else if (body.clientId) {
await addClientPayment(body);
} else {
await updateRequestStatus(
requestId,
Expand Down
16 changes: 16 additions & 0 deletions src/app/dashboard/receipts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { DashboardReceipts } from "@/components/dashboard/receipts";
import { getCurrentSession } from "@/server/auth";
import { api } from "@/trpc/server";
import { redirect } from "next/navigation";

export default async function ReceiptsPage() {
const { user } = await getCurrentSession();

if (!user) {
redirect("/");
}

const clientPayments = await api.ecommerce.getAllUserReceipts.query();

return <DashboardReceipts initialClientPayments={clientPayments} />;
}
7 changes: 4 additions & 3 deletions src/app/ecommerce/sales/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EcommerceSales } from "@/components/ecommerce/sales";
import { getCurrentSession } from "@/server/auth";
//import { api } from "@/trpc/server";
import { api } from "@/trpc/server";
import { redirect } from "next/navigation";

export default async function SalesPage() {
Expand All @@ -9,7 +10,7 @@ export default async function SalesPage() {
redirect("/");
}

// TODO fetch sales data
const clientPayments = await api.ecommerce.getAllClientPayments.query();

return <div>Sales Page - to be implemented</div>;
return <EcommerceSales initialClientPayments={clientPayments} />;
}
7 changes: 6 additions & 1 deletion src/components/dashboard-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ export function DashboardNavigation() {
setActiveTab("pay");
} else if (pathname.includes("/subscriptions")) {
setActiveTab("subscriptions");
} else if (pathname.includes("/receipts")) {
setActiveTab("receipts");
} else {
setActiveTab("get-paid");
}
}, [pathname]);

return (
<Tabs value={activeTab} className="w-full mb-8">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="get-paid" asChild>
<Link href="/dashboard/get-paid">Get Paid</Link>
</TabsTrigger>
Expand All @@ -31,6 +33,9 @@ export function DashboardNavigation() {
<TabsTrigger value="subscriptions" asChild>
<Link href="/dashboard/subscriptions">Subscriptions</Link>
</TabsTrigger>
<TabsTrigger value="receipts" asChild>
<Link href="/dashboard/receipts">Receipts</Link>
</TabsTrigger>
</TabsList>
</Tabs>
);
Expand Down
187 changes: 187 additions & 0 deletions src/components/dashboard/receipts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"use client";

import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EmptyState } from "@/components/ui/table/empty-state";
import { Pagination } from "@/components/ui/table/pagination";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "@/components/ui/table/table";
import { TableHeadCell } from "@/components/ui/table/table-head-cell";
import type { ClientPaymentWithEcommerceClient } from "@/lib/types";
import { api } from "@/trpc/react";
import { format } from "date-fns";
import { Filter, Receipt } from "lucide-react";
import { useState } from "react";
import { ErrorState } from "../ui/table/error-state";

interface DashboardReceiptsProps {
initialClientPayments: ClientPaymentWithEcommerceClient[];
}

const ReceiptTableColumns = () => (
<TableRow className="hover:bg-transparent border-none">
<TableHeadCell>Date</TableHeadCell>
<TableHeadCell>Reference</TableHeadCell>
<TableHeadCell>Amount</TableHeadCell>
<TableHeadCell>Payment Currency</TableHeadCell>
<TableHeadCell>Network</TableHeadCell>
<TableHeadCell>Merchant</TableHeadCell>
</TableRow>
);

const ReceiptRow = ({
receipt,
}: { receipt: ClientPaymentWithEcommerceClient }) => {
return (
<TableRow className="hover:bg-zinc-50/50">
<TableCell>
{receipt.createdAt
? format(new Date(receipt.createdAt), "do MMM yyyy")
: "N/A"}
</TableCell>
<TableCell>
{receipt.reference || <span className="text-zinc-500">-</span>}
</TableCell>
<TableCell className="font-medium">{receipt.amount}</TableCell>
<TableCell>{receipt.paymentCurrency}</TableCell>
<TableCell>{receipt.network}</TableCell>
<TableCell>{receipt.ecommerceClient.label}</TableCell>
</TableRow>
);
};

const ITEMS_PER_PAGE = 10;

export function DashboardReceipts({
initialClientPayments,
}: DashboardReceiptsProps) {
const [activeClientId, setActiveClientId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);

const { data, error, refetch, isRefetching } =
api.ecommerce.getAllUserReceipts.useQuery(undefined, {
initialData: initialClientPayments,
refetchOnMount: true,
refetchInterval: 10000,
});

if (error) {
return (
<ErrorState
onRetry={refetch}
isRetrying={isRefetching}
explanation="We couldn't load the receipts data. Please try again."
/>
);
}

const receipts = data || [];

const filteredReceipts = activeClientId
? receipts.filter((receipt) => receipt.ecommerceClientId === activeClientId)
: receipts;

const totalPages = Math.ceil(filteredReceipts.length / ITEMS_PER_PAGE);
const paginatedReceipts = filteredReceipts.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE,
);

const handleClientFilterChange = (value: string) => {
setActiveClientId(value === "all" ? null : value);
setCurrentPage(1);
};

const ecommerceClients = receipts.reduce(
(acc, receipt) => {
if (acc[receipt.ecommerceClient.id]) return acc;
acc[receipt.ecommerceClient.id] = receipt.ecommerceClient;
return acc;
},
{} as Record<string, ClientPaymentWithEcommerceClient["ecommerceClient"]>,
);

return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
View all your payment receipts from ecommerce transactions
</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-zinc-600" />
<span className="text-sm font-medium text-zinc-700">
Filter by merchant:
</span>
</div>
<Select
value={activeClientId || "all"}
onValueChange={handleClientFilterChange}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Merchants" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Merchants</SelectItem>
{Object.entries(ecommerceClients).map(([clientId, client]) => (
<SelectItem key={clientId} value={clientId}>
{client.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Card className="border border-zinc-100">
<CardContent className="p-0">
<Table>
<TableHeader>
<ReceiptTableColumns />
</TableHeader>
<TableBody>
{paginatedReceipts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="p-0">
<EmptyState
icon={<Receipt className="h-6 w-6 text-zinc-600" />}
title="No receipts"
subtitle={
activeClientId
? "No receipts found for the selected merchant"
: "You haven't received any payments yet"
}
/>
</TableCell>
</TableRow>
) : (
paginatedReceipts.map((receipt) => (
<ReceiptRow key={receipt.id} receipt={receipt} />
))
)}
</TableBody>
</Table>
</CardContent>
</Card>

{totalPages > 1 && (
<Pagination
page={currentPage}
totalItems={filteredReceipts.length}
itemsPerPage={ITEMS_PER_PAGE}
setPage={setCurrentPage}
/>
)}
</div>
);
}
Loading