Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
55 changes: 55 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,57 @@ 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 existingPayment = await tx
.select()
.from(clientPaymentTable)
.where(
and(
eq(clientPaymentTable.txHash, webhookBody.txHash),
eq(clientPaymentTable.requestId, webhookBody.requestId),
),
)
.limit(1);

if (existingPayment.length > 0) {
console.warn(
`Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`,
);
return;
}

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];

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,
});
});
Comment on lines 19 to 62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make duplicate protection atomic

The “select then insert” guard still lets two concurrent webhook deliveries race past the duplicate check and both insert, so we can double-count the same payment (Request webhooks routinely retry on network hiccups). We need the database to enforce idempotency. Please rely on an atomic insert with ON CONFLICT DO NOTHING (or a unique constraint + error handling) instead of the manual pre-check.

-    const existingPayment = await tx
-      .select()
-      .from(clientPaymentTable)
-      .where(
-        and(
-          eq(clientPaymentTable.txHash, webhookBody.txHash),
-          eq(clientPaymentTable.requestId, webhookBody.requestId),
-        ),
-      )
-      .limit(1);
-
-    if (existingPayment.length > 0) {
-      console.warn(
-        `Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`,
-      );
-      return;
-    }
-
-    const ecommerceClient = await tx
+    const ecommerceClient = await tx
       .select()
       .from(ecommerceClientTable)
       .where(eq(ecommerceClientTable.rnClientId, webhookBody.clientId))
       .limit(1);
 
@@
-    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,
-    });
+    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.txHash, clientPaymentTable.requestId],
+      })
+      .returning({ id: clientPaymentTable.id });
+
+    if (!inserted.length) {
+      console.warn(
+        `Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`,
+      );
+      return;
+    }

}

/**
* Updates the request status in the database
*/
Expand Down Expand Up @@ -132,6 +185,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
186 changes: 186 additions & 0 deletions src/components/dashboard/receipts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"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,
});

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