Skip to content

Commit e6d0631

Browse files
authored
Feat: ecommerce sales and user's receipts (#155)
1 parent 744e1e0 commit e6d0631

File tree

11 files changed

+670
-6
lines changed

11 files changed

+670
-6
lines changed

drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@
7171
"when": 1759323380579,
7272
"tag": "0009_slippery_penance",
7373
"breakpoints": true
74+
},
75+
{
76+
"idx": 10,
77+
"version": "7",
78+
"when": 1759491911320,
79+
"tag": "0010_minor_roulette",
80+
"breakpoints": true
7481
}
7582
]
7683
}

src/app/api/webhook/route.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { generateInvoiceNumber } from "@/lib/helpers/client";
44
import { getInvoiceCount } from "@/lib/helpers/invoice";
55
import { db } from "@/server/db";
66
import {
7+
clientPaymentTable,
8+
ecommerceClientTable,
79
paymentDetailsPayersTable,
810
recurringPaymentTable,
911
type requestStatusEnum,
@@ -14,6 +16,52 @@ import { and, eq, not } from "drizzle-orm";
1416
import { NextResponse } from "next/server";
1517
import { ulid } from "ulid";
1618

19+
async function addClientPayment(webhookBody: any) {
20+
await db.transaction(async (tx) => {
21+
const ecommerceClient = await tx
22+
.select()
23+
.from(ecommerceClientTable)
24+
.where(eq(ecommerceClientTable.rnClientId, webhookBody.clientId))
25+
.limit(1);
26+
27+
if (!ecommerceClient.length) {
28+
throw new ResourceNotFoundError(
29+
`No ecommerce client found with client ID: ${webhookBody.clientId}`,
30+
);
31+
}
32+
33+
const client = ecommerceClient[0];
34+
35+
const inserted = await tx
36+
.insert(clientPaymentTable)
37+
.values({
38+
id: ulid(),
39+
userId: client.userId,
40+
ecommerceClientId: client.id,
41+
requestId: webhookBody.requestId,
42+
invoiceCurrency: webhookBody.currency,
43+
paymentCurrency: webhookBody.paymentCurrency,
44+
txHash: webhookBody.txHash,
45+
network: webhookBody.network,
46+
amount: webhookBody.amount,
47+
customerInfo: webhookBody.customerInfo || null,
48+
reference: webhookBody.reference || null,
49+
origin: webhookBody.origin,
50+
})
51+
.onConflictDoNothing({
52+
target: [clientPaymentTable.requestId, clientPaymentTable.txHash],
53+
})
54+
.returning({ id: clientPaymentTable.id });
55+
56+
if (!inserted.length) {
57+
console.warn(
58+
`Duplicate client payment detected for requestId: ${webhookBody.requestId} and txHash: ${webhookBody.txHash}`,
59+
);
60+
return;
61+
}
62+
});
63+
}
64+
1765
/**
1866
* Updates the request status in the database
1967
*/
@@ -132,6 +180,8 @@ export async function POST(req: Request) {
132180
txHash: body.txHash,
133181
requestScanUrl: body.explorer,
134182
});
183+
} else if (body.clientId) {
184+
await addClientPayment(body);
135185
} else {
136186
await updateRequestStatus(
137187
requestId,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { DashboardReceipts } from "@/components/dashboard/receipts";
2+
import { getCurrentSession } from "@/server/auth";
3+
import { api } from "@/trpc/server";
4+
import { redirect } from "next/navigation";
5+
6+
export default async function ReceiptsPage() {
7+
const { user } = await getCurrentSession();
8+
9+
if (!user) {
10+
redirect("/");
11+
}
12+
13+
const clientPayments = await api.ecommerce.getAllUserReceipts.query();
14+
15+
return <DashboardReceipts initialClientPayments={clientPayments} />;
16+
}

src/app/ecommerce/sales/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { EcommerceSales } from "@/components/ecommerce/sales";
12
import { getCurrentSession } from "@/server/auth";
2-
//import { api } from "@/trpc/server";
3+
import { api } from "@/trpc/server";
34
import { redirect } from "next/navigation";
45

56
export default async function SalesPage() {
@@ -9,7 +10,7 @@ export default async function SalesPage() {
910
redirect("/");
1011
}
1112

12-
// TODO fetch sales data
13+
const clientPayments = await api.ecommerce.getAllClientPayments.query();
1314

14-
return <div>Sales Page - to be implemented</div>;
15+
return <EcommerceSales initialClientPayments={clientPayments} />;
1516
}

src/components/dashboard-navigation.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ export function DashboardNavigation() {
1414
setActiveTab("pay");
1515
} else if (pathname.includes("/subscriptions")) {
1616
setActiveTab("subscriptions");
17+
} else if (pathname.includes("/receipts")) {
18+
setActiveTab("receipts");
1719
} else {
1820
setActiveTab("get-paid");
1921
}
2022
}, [pathname]);
2123

2224
return (
2325
<Tabs value={activeTab} className="w-full mb-8">
24-
<TabsList className="grid w-full grid-cols-3">
26+
<TabsList className="grid w-full grid-cols-4">
2527
<TabsTrigger value="get-paid" asChild>
2628
<Link href="/dashboard/get-paid">Get Paid</Link>
2729
</TabsTrigger>
@@ -31,6 +33,9 @@ export function DashboardNavigation() {
3133
<TabsTrigger value="subscriptions" asChild>
3234
<Link href="/dashboard/subscriptions">Subscriptions</Link>
3335
</TabsTrigger>
36+
<TabsTrigger value="receipts" asChild>
37+
<Link href="/dashboard/receipts">Receipts</Link>
38+
</TabsTrigger>
3439
</TabsList>
3540
</Tabs>
3641
);
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"use client";
2+
3+
import { Card, CardContent } from "@/components/ui/card";
4+
import {
5+
Select,
6+
SelectContent,
7+
SelectItem,
8+
SelectTrigger,
9+
SelectValue,
10+
} from "@/components/ui/select";
11+
import { EmptyState } from "@/components/ui/table/empty-state";
12+
import { Pagination } from "@/components/ui/table/pagination";
13+
import {
14+
Table,
15+
TableBody,
16+
TableCell,
17+
TableHeader,
18+
TableRow,
19+
} from "@/components/ui/table/table";
20+
import { TableHeadCell } from "@/components/ui/table/table-head-cell";
21+
import type { ClientPaymentWithEcommerceClient } from "@/lib/types";
22+
import { api } from "@/trpc/react";
23+
import { format } from "date-fns";
24+
import { Filter, Receipt } from "lucide-react";
25+
import { useState } from "react";
26+
import { ErrorState } from "../ui/table/error-state";
27+
28+
interface DashboardReceiptsProps {
29+
initialClientPayments: ClientPaymentWithEcommerceClient[];
30+
}
31+
32+
const ReceiptTableColumns = () => (
33+
<TableRow className="hover:bg-transparent border-none">
34+
<TableHeadCell>Date</TableHeadCell>
35+
<TableHeadCell>Reference</TableHeadCell>
36+
<TableHeadCell>Amount</TableHeadCell>
37+
<TableHeadCell>Payment Currency</TableHeadCell>
38+
<TableHeadCell>Network</TableHeadCell>
39+
<TableHeadCell>Merchant</TableHeadCell>
40+
</TableRow>
41+
);
42+
43+
const ReceiptRow = ({
44+
receipt,
45+
}: { receipt: ClientPaymentWithEcommerceClient }) => {
46+
return (
47+
<TableRow className="hover:bg-zinc-50/50">
48+
<TableCell>
49+
{receipt.createdAt
50+
? format(new Date(receipt.createdAt), "do MMM yyyy")
51+
: "N/A"}
52+
</TableCell>
53+
<TableCell>
54+
{receipt.reference || <span className="text-zinc-500">-</span>}
55+
</TableCell>
56+
<TableCell className="font-medium">{receipt.amount}</TableCell>
57+
<TableCell>{receipt.paymentCurrency}</TableCell>
58+
<TableCell>{receipt.network}</TableCell>
59+
<TableCell>{receipt.ecommerceClient.label}</TableCell>
60+
</TableRow>
61+
);
62+
};
63+
64+
const ITEMS_PER_PAGE = 10;
65+
66+
export function DashboardReceipts({
67+
initialClientPayments,
68+
}: DashboardReceiptsProps) {
69+
const [activeClientId, setActiveClientId] = useState<string | null>(null);
70+
const [currentPage, setCurrentPage] = useState(1);
71+
72+
const { data, error, refetch, isRefetching } =
73+
api.ecommerce.getAllUserReceipts.useQuery(undefined, {
74+
initialData: initialClientPayments,
75+
refetchOnMount: true,
76+
refetchInterval: 10000,
77+
});
78+
79+
if (error) {
80+
return (
81+
<ErrorState
82+
onRetry={refetch}
83+
isRetrying={isRefetching}
84+
explanation="We couldn't load the receipts data. Please try again."
85+
/>
86+
);
87+
}
88+
89+
const receipts = data || [];
90+
91+
const filteredReceipts = activeClientId
92+
? receipts.filter((receipt) => receipt.ecommerceClientId === activeClientId)
93+
: receipts;
94+
95+
const totalPages = Math.ceil(filteredReceipts.length / ITEMS_PER_PAGE);
96+
const paginatedReceipts = filteredReceipts.slice(
97+
(currentPage - 1) * ITEMS_PER_PAGE,
98+
currentPage * ITEMS_PER_PAGE,
99+
);
100+
101+
const handleClientFilterChange = (value: string) => {
102+
setActiveClientId(value === "all" ? null : value);
103+
setCurrentPage(1);
104+
};
105+
106+
const ecommerceClients = receipts.reduce(
107+
(acc, receipt) => {
108+
if (acc[receipt.ecommerceClient.id]) return acc;
109+
acc[receipt.ecommerceClient.id] = receipt.ecommerceClient;
110+
return acc;
111+
},
112+
{} as Record<string, ClientPaymentWithEcommerceClient["ecommerceClient"]>,
113+
);
114+
115+
return (
116+
<div className="space-y-6">
117+
<p className="text-sm text-muted-foreground">
118+
View all your payment receipts from ecommerce transactions
119+
</p>
120+
<div className="flex items-center justify-between">
121+
<div className="flex items-center gap-4">
122+
<div className="flex items-center gap-2">
123+
<Filter className="h-4 w-4 text-zinc-600" />
124+
<span className="text-sm font-medium text-zinc-700">
125+
Filter by merchant:
126+
</span>
127+
</div>
128+
<Select
129+
value={activeClientId || "all"}
130+
onValueChange={handleClientFilterChange}
131+
>
132+
<SelectTrigger className="w-[200px]">
133+
<SelectValue placeholder="All Merchants" />
134+
</SelectTrigger>
135+
<SelectContent>
136+
<SelectItem value="all">All Merchants</SelectItem>
137+
{Object.entries(ecommerceClients).map(([clientId, client]) => (
138+
<SelectItem key={clientId} value={clientId}>
139+
{client.label}
140+
</SelectItem>
141+
))}
142+
</SelectContent>
143+
</Select>
144+
</div>
145+
</div>
146+
<Card className="border border-zinc-100">
147+
<CardContent className="p-0">
148+
<Table>
149+
<TableHeader>
150+
<ReceiptTableColumns />
151+
</TableHeader>
152+
<TableBody>
153+
{paginatedReceipts.length === 0 ? (
154+
<TableRow>
155+
<TableCell colSpan={6} className="p-0">
156+
<EmptyState
157+
icon={<Receipt className="h-6 w-6 text-zinc-600" />}
158+
title="No receipts"
159+
subtitle={
160+
activeClientId
161+
? "No receipts found for the selected merchant"
162+
: "You haven't received any payments yet"
163+
}
164+
/>
165+
</TableCell>
166+
</TableRow>
167+
) : (
168+
paginatedReceipts.map((receipt) => (
169+
<ReceiptRow key={receipt.id} receipt={receipt} />
170+
))
171+
)}
172+
</TableBody>
173+
</Table>
174+
</CardContent>
175+
</Card>
176+
177+
{totalPages > 1 && (
178+
<Pagination
179+
page={currentPage}
180+
totalItems={filteredReceipts.length}
181+
itemsPerPage={ITEMS_PER_PAGE}
182+
setPage={setCurrentPage}
183+
/>
184+
)}
185+
</div>
186+
);
187+
}

0 commit comments

Comments
 (0)