From e05fd9677db207e1caffcc399e03bca0fbf2f94e Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Thu, 2 Oct 2025 14:17:49 +0200 Subject: [PATCH 1/6] feat: add client payment schema and webhook handling of payments --- src/app/api/webhook/route.ts | 35 ++++++++++++++++++++++++++++ src/server/db/schema.ts | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index d85a3406..7411b4be 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -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, @@ -14,6 +16,37 @@ 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]; + + await tx.insert(clientPaymentTable).values({ + id: ulid(), + userId: client.userId, + requestId: webhookBody.requestId, + invoiceCurrency: webhookBody.currency, + paymentCurrency: webhookBody.paymentCurrency, + amount: webhookBody.amount, + customerInfo: webhookBody.customerInfo || null, + reference: webhookBody.reference || null, + clientId: webhookBody.clientId, + origin: webhookBody.origin, + }); + }); +} + /** * Updates the request status in the database */ @@ -132,6 +165,8 @@ export async function POST(req: Request) { txHash: body.txHash, requestScanUrl: body.explorer, }); + } else if (body.clientId) { + await addClientPayment(body); } else { await updateRequestStatus( requestId, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 522fe99f..e42857d6 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -177,6 +177,35 @@ export const paymentDetailsPayersTable = createTable("payment_details_payers", { createdAt: timestamp("created_at").defaultNow(), }); +export const clientPaymentTable = createTable("client_payment", { + id: text().primaryKey().notNull(), + userId: text() + .notNull() + .references(() => userTable.id, { + onDelete: "cascade", + }), + requestId: text().notNull(), + invoiceCurrency: text().notNull(), + paymentCurrency: text().notNull(), + amount: text().notNull(), + customerInfo: json().$type<{ + firstName?: string; + lastName?: string; + email?: string; + address?: { + street?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + }>(), + reference: text(), + clientId: text().notNull(), + origin: text(), + createdAt: timestamp("created_at").defaultNow(), +}); + export const requestTable = createTable("request", { id: text().primaryKey().notNull(), type: text().notNull(), @@ -322,6 +351,9 @@ export const ecommerceClientTable = createTable( table.userId, table.domain, ), + clientIdIndex: uniqueIndex("ecommerce_client_user_id_client_id_unique").on( + table.rnClientId, + ), }), ); @@ -332,6 +364,7 @@ export const userRelations = relations(userTable, ({ many }) => ({ session: many(sessionTable), invoiceMe: many(invoiceMeTable), paymentDetailsPayers: many(paymentDetailsPayersTable), + clientPayments: many(clientPaymentTable), })); export const requestRelations = relations(requestTable, ({ one }) => ({ @@ -394,6 +427,16 @@ export const ecommerceClientRelations = relations( }), ); +export const clientPaymentRelations = relations( + clientPaymentTable, + ({ one }) => ({ + user: one(userTable, { + fields: [clientPaymentTable.userId], + references: [userTable.id], + }), + }), +); + export const paymentDetailsRelations = relations( paymentDetailsTable, ({ one, many }) => ({ @@ -430,3 +473,4 @@ export type PaymentDetailsPayers = InferSelectModel< >; export type RecurringPayment = InferSelectModel; export type EcommerceClient = InferSelectModel; +export type ClientPayment = InferSelectModel; From e920d261d7b380ed06d20cb08d0abf733988bac7 Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Thu, 2 Oct 2025 14:55:13 +0200 Subject: [PATCH 2/6] feat: implement ecommerce sales page --- src/app/api/webhook/route.ts | 20 ++ src/app/ecommerce/sales/page.tsx | 15 +- .../sales/blocks/client-payments-table.tsx | 269 ++++++++++++++++++ src/components/ecommerce/sales/index.tsx | 47 +++ src/server/db/schema.ts | 2 + src/server/routers/ecommerce.ts | 14 +- 6 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 src/components/ecommerce/sales/blocks/client-payments-table.tsx create mode 100644 src/components/ecommerce/sales/index.tsx diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index 7411b4be..9e7e3f45 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -18,6 +18,24 @@ 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) @@ -38,6 +56,8 @@ async function addClientPayment(webhookBody: any) { 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, diff --git a/src/app/ecommerce/sales/page.tsx b/src/app/ecommerce/sales/page.tsx index 3381301c..68d28bd1 100644 --- a/src/app/ecommerce/sales/page.tsx +++ b/src/app/ecommerce/sales/page.tsx @@ -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() { @@ -9,7 +10,15 @@ export default async function SalesPage() { redirect("/"); } - // TODO fetch sales data + const [clientPayments, ecommerceClients] = await Promise.all([ + api.ecommerce.getAllClientPayments.query(), + api.ecommerce.getAll.query(), + ]); - return
Sales Page - to be implemented
; + return ( + + ); } diff --git a/src/components/ecommerce/sales/blocks/client-payments-table.tsx b/src/components/ecommerce/sales/blocks/client-payments-table.tsx new file mode 100644 index 00000000..e411664b --- /dev/null +++ b/src/components/ecommerce/sales/blocks/client-payments-table.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { ShortAddress } from "@/components/short-address"; +import { Button } from "@/components/ui/button"; +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 { ClientPayment, EcommerceClient } from "@/server/db/schema"; +import { format } from "date-fns"; +import { + ChevronDown, + ChevronUp, + CreditCard, + ExternalLink, + Filter, +} from "lucide-react"; +import { useState } from "react"; + +interface ClientPaymentsTableProps { + clientPayments: ClientPayment[]; + ecommerceClients: EcommerceClient[]; +} + +interface CustomerInfoDisplayProps { + customerInfo: ClientPayment["customerInfo"]; +} + +function CustomerInfoDisplay({ customerInfo }: CustomerInfoDisplayProps) { + const [isExpanded, setIsExpanded] = useState(false); + + if (!customerInfo) { + return -; + } + + const hasExpandableInfo = + customerInfo.firstName || customerInfo.lastName || customerInfo.address; + + return ( +
+
{customerInfo.email || "No email"}
+ {hasExpandableInfo && ( + <> + {isExpanded && ( +
+ {(customerInfo.firstName || customerInfo.lastName) && ( +
+ {customerInfo.firstName} {customerInfo.lastName} +
+ )} + {customerInfo.address && ( +
+ {customerInfo.address.street && ( +
{customerInfo.address.street}
+ )} +
+ {customerInfo.address.city}, {customerInfo.address.state}{" "} + {customerInfo.address.postalCode} +
+ {customerInfo.address.country && ( +
{customerInfo.address.country}
+ )} +
+ )} +
+ )} + + + )} +
+ ); +} + +const ClientPaymentTableColumns = () => ( + + Date + Amount + Network + Invoice Currency + Payment Currency + Customer Info + Reference + Client ID + Origin + Request Scan URL + +); + +const ClientPaymentRow = ({ + clientPayment, +}: { clientPayment: ClientPayment }) => { + return ( + + + {clientPayment.createdAt + ? format(new Date(clientPayment.createdAt), "do MMM yyyy HH:mm") + : "N/A"} + + {clientPayment.amount} + {clientPayment.network} + {clientPayment.invoiceCurrency} + {clientPayment.paymentCurrency} + + + + + {clientPayment.reference || -} + + + + + + {clientPayment.origin || -} + + + + View Request + + + + + ); +}; + +const ITEMS_PER_PAGE = 10; +export function ClientPaymentsTable({ + clientPayments, + ecommerceClients, +}: ClientPaymentsTableProps) { + const [activeClientId, setActiveClientId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + const filteredPayments = activeClientId + ? clientPayments.filter((payment) => payment.clientId === activeClientId) + : clientPayments; + + const totalPages = Math.ceil(filteredPayments.length / ITEMS_PER_PAGE); + const paginatedPayments = filteredPayments.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE, + ); + + const handleClientFilterChange = (value: string) => { + setActiveClientId(value === "all" ? null : value); + setCurrentPage(1); + }; + + const clientIdToLabel = ecommerceClients.reduce( + (acc, client) => { + acc[client.rnClientId] = client.label; + return acc; + }, + {} as Record, + ); + + const uniqueClientIds = Array.from( + new Set(clientPayments.map((payment) => payment.clientId)), + ); + + return ( +
+
+
+
+ + + Filter by client: + +
+ +
+
+ + + + + + + + + {paginatedPayments.length === 0 ? ( + + + } + title="No client payments" + subtitle={ + activeClientId + ? "No payments found for the selected client" + : "No payments received yet" + } + /> + + + ) : ( + paginatedPayments.map((clientPayment) => ( + + )) + )} + +
+
+
+ + {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/src/components/ecommerce/sales/index.tsx b/src/components/ecommerce/sales/index.tsx new file mode 100644 index 00000000..eb783f9c --- /dev/null +++ b/src/components/ecommerce/sales/index.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { ErrorState } from "@/components/ui/table/error-state"; +import type { ClientPayment, EcommerceClient } from "@/server/db/schema"; +import { api } from "@/trpc/react"; +import { ClientPaymentsTable } from "./blocks/client-payments-table"; + +interface EcommerceSalesProps { + initialClientPayments: ClientPayment[]; + ecommerceClients: EcommerceClient[]; +} + +export function EcommerceSales({ + initialClientPayments, + ecommerceClients, +}: EcommerceSalesProps) { + const { data, error, refetch, isRefetching } = + api.ecommerce.getAllClientPayments.useQuery(undefined, { + initialData: initialClientPayments, + refetchOnMount: true, + }); + + if (error) { + return ( + + ); + } + + return ( +
+
+

Client Payments

+

+ View all payments received through your ecommerce integrations +

+
+ +
+ ); +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e42857d6..5d1a86e8 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -187,6 +187,8 @@ export const clientPaymentTable = createTable("client_payment", { requestId: text().notNull(), invoiceCurrency: text().notNull(), paymentCurrency: text().notNull(), + txHash: text().notNull(), + network: text().notNull(), amount: text().notNull(), customerInfo: json().$type<{ firstName?: string; diff --git a/src/server/routers/ecommerce.ts b/src/server/routers/ecommerce.ts index 6cd240ba..00cb652c 100644 --- a/src/server/routers/ecommerce.ts +++ b/src/server/routers/ecommerce.ts @@ -8,7 +8,7 @@ import { import { and, eq, not } from "drizzle-orm"; import { ulid } from "ulid"; import { z } from "zod"; -import { ecommerceClientTable } from "../db/schema"; +import { clientPaymentTable, ecommerceClientTable } from "../db/schema"; import { protectedProcedure, router } from "../trpc"; export const ecommerceRouter = router({ @@ -149,4 +149,16 @@ export const ecommerceRouter = router({ throw toTRPCError(error); } }), + getAllClientPayments: protectedProcedure.query(async ({ ctx }) => { + const { db, user } = ctx; + try { + const clientPayments = await db.query.clientPaymentTable.findMany({ + where: eq(clientPaymentTable.userId, user.id), + }); + + return clientPayments; + } catch (error) { + throw toTRPCError(error); + } + }), }); From 77a7575c7c780b71a405904cdb2b9a99d03076af Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Fri, 3 Oct 2025 10:41:26 +0200 Subject: [PATCH 3/6] feat: scaffold fetching of user receipts --- src/app/dashboard/receipts/page.tsx | 16 ++++++++++++++ src/components/dashboard-navigation.tsx | 7 ++++++- src/components/dashboard/receipts.tsx | 28 +++++++++++++++++++++++++ src/server/routers/ecommerce.ts | 17 ++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/app/dashboard/receipts/page.tsx create mode 100644 src/components/dashboard/receipts.tsx diff --git a/src/app/dashboard/receipts/page.tsx b/src/app/dashboard/receipts/page.tsx new file mode 100644 index 00000000..9643ec59 --- /dev/null +++ b/src/app/dashboard/receipts/page.tsx @@ -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 ; +} diff --git a/src/components/dashboard-navigation.tsx b/src/components/dashboard-navigation.tsx index 3b42f35e..0ac17820 100644 --- a/src/components/dashboard-navigation.tsx +++ b/src/components/dashboard-navigation.tsx @@ -14,6 +14,8 @@ export function DashboardNavigation() { setActiveTab("pay"); } else if (pathname.includes("/subscriptions")) { setActiveTab("subscriptions"); + } else if (pathname.includes("/receipts")) { + setActiveTab("receipts"); } else { setActiveTab("get-paid"); } @@ -21,7 +23,7 @@ export function DashboardNavigation() { return ( - + Get Paid @@ -31,6 +33,9 @@ export function DashboardNavigation() { Subscriptions + + Receipts + ); diff --git a/src/components/dashboard/receipts.tsx b/src/components/dashboard/receipts.tsx new file mode 100644 index 00000000..b5c58f8c --- /dev/null +++ b/src/components/dashboard/receipts.tsx @@ -0,0 +1,28 @@ +"use client"; + +import type { ClientPayment } from "@/server/db/schema"; + +interface DashboardReceiptsProps { + initialClientPayments: ClientPayment[]; +} + +export function DashboardReceipts({ + initialClientPayments, +}: DashboardReceiptsProps) { + return ( +
+
+

My Receipts

+

+ View all your payment receipts from ecommerce transactions +

+
+
+

Receipts component coming soon...

+

+ Found {initialClientPayments.length} payment receipts +

+
+
+ ); +} diff --git a/src/server/routers/ecommerce.ts b/src/server/routers/ecommerce.ts index 00cb652c..6cb47530 100644 --- a/src/server/routers/ecommerce.ts +++ b/src/server/routers/ecommerce.ts @@ -5,7 +5,7 @@ import { ecommerceClientApiSchema, editecommerceClientApiSchema, } from "@/lib/schemas/ecommerce"; -import { and, eq, not } from "drizzle-orm"; +import { and, eq, not, sql } from "drizzle-orm"; import { ulid } from "ulid"; import { z } from "zod"; import { clientPaymentTable, ecommerceClientTable } from "../db/schema"; @@ -161,4 +161,19 @@ export const ecommerceRouter = router({ throw toTRPCError(error); } }), + getAllUserReceipts: protectedProcedure.query(async ({ ctx }) => { + const { db, user } = ctx; + try { + const receipts = await db + .select() + .from(clientPaymentTable) + .where( + sql`${clientPaymentTable.customerInfo}->>'email' = ${user.email}`, + ); + + return receipts; + } catch (error) { + throw toTRPCError(error); + } + }), }); From 3dd40702022fb451092f7277d4185fea634fa2ea Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Fri, 3 Oct 2025 13:14:00 +0200 Subject: [PATCH 4/6] refactor: use the DB ecommerce client id instead of the API one for client payments --- src/app/api/webhook/route.ts | 2 +- src/app/ecommerce/sales/page.tsx | 12 +----- .../sales/blocks/client-payments-table.tsx | 38 +++++++++---------- src/components/ecommerce/sales/index.tsx | 15 ++------ src/lib/types/index.ts | 6 +++ src/server/db/schema.ts | 10 ++++- src/server/routers/ecommerce.ts | 15 +++++--- 7 files changed, 49 insertions(+), 49 deletions(-) diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index 9e7e3f45..1a301228 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -53,6 +53,7 @@ async function addClientPayment(webhookBody: any) { await tx.insert(clientPaymentTable).values({ id: ulid(), userId: client.userId, + ecommerceClientId: client.id, requestId: webhookBody.requestId, invoiceCurrency: webhookBody.currency, paymentCurrency: webhookBody.paymentCurrency, @@ -61,7 +62,6 @@ async function addClientPayment(webhookBody: any) { amount: webhookBody.amount, customerInfo: webhookBody.customerInfo || null, reference: webhookBody.reference || null, - clientId: webhookBody.clientId, origin: webhookBody.origin, }); }); diff --git a/src/app/ecommerce/sales/page.tsx b/src/app/ecommerce/sales/page.tsx index 68d28bd1..3a675a37 100644 --- a/src/app/ecommerce/sales/page.tsx +++ b/src/app/ecommerce/sales/page.tsx @@ -10,15 +10,7 @@ export default async function SalesPage() { redirect("/"); } - const [clientPayments, ecommerceClients] = await Promise.all([ - api.ecommerce.getAllClientPayments.query(), - api.ecommerce.getAll.query(), - ]); + const clientPayments = await api.ecommerce.getAllClientPayments.query(); - return ( - - ); + return ; } diff --git a/src/components/ecommerce/sales/blocks/client-payments-table.tsx b/src/components/ecommerce/sales/blocks/client-payments-table.tsx index e411664b..2d88f70b 100644 --- a/src/components/ecommerce/sales/blocks/client-payments-table.tsx +++ b/src/components/ecommerce/sales/blocks/client-payments-table.tsx @@ -20,7 +20,7 @@ import { TableRow, } from "@/components/ui/table/table"; import { TableHeadCell } from "@/components/ui/table/table-head-cell"; -import type { ClientPayment, EcommerceClient } from "@/server/db/schema"; +import type { ClientPaymentWithEcommerceClient } from "@/lib/types"; import { format } from "date-fns"; import { ChevronDown, @@ -32,12 +32,11 @@ import { import { useState } from "react"; interface ClientPaymentsTableProps { - clientPayments: ClientPayment[]; - ecommerceClients: EcommerceClient[]; + clientPayments: ClientPaymentWithEcommerceClient[]; } interface CustomerInfoDisplayProps { - customerInfo: ClientPayment["customerInfo"]; + customerInfo: ClientPaymentWithEcommerceClient["customerInfo"]; } function CustomerInfoDisplay({ customerInfo }: CustomerInfoDisplayProps) { @@ -111,7 +110,7 @@ const ClientPaymentTableColumns = () => ( Payment Currency Customer Info Reference - Client ID + Client Origin Request Scan URL @@ -119,7 +118,7 @@ const ClientPaymentTableColumns = () => ( const ClientPaymentRow = ({ clientPayment, -}: { clientPayment: ClientPayment }) => { +}: { clientPayment: ClientPaymentWithEcommerceClient }) => { return ( @@ -137,8 +136,9 @@ const ClientPaymentRow = ({ {clientPayment.reference || -} - - + + {clientPayment.ecommerceClient.label} + {clientPayment.origin || -} @@ -161,13 +161,14 @@ const ClientPaymentRow = ({ const ITEMS_PER_PAGE = 10; export function ClientPaymentsTable({ clientPayments, - ecommerceClients, }: ClientPaymentsTableProps) { const [activeClientId, setActiveClientId] = useState(null); const [currentPage, setCurrentPage] = useState(1); const filteredPayments = activeClientId - ? clientPayments.filter((payment) => payment.clientId === activeClientId) + ? clientPayments.filter( + (payment) => payment.ecommerceClientId === activeClientId, + ) : clientPayments; const totalPages = Math.ceil(filteredPayments.length / ITEMS_PER_PAGE); @@ -181,16 +182,13 @@ export function ClientPaymentsTable({ setCurrentPage(1); }; - const clientIdToLabel = ecommerceClients.reduce( - (acc, client) => { - acc[client.rnClientId] = client.label; + const ecommerceClients = clientPayments.reduce( + (acc, payment) => { + if (acc[payment.ecommerceClient.id]) return acc; + acc[payment.ecommerceClient.id] = payment.ecommerceClient; return acc; }, - {} as Record, - ); - - const uniqueClientIds = Array.from( - new Set(clientPayments.map((payment) => payment.clientId)), + {} as Record, ); return ( @@ -212,9 +210,9 @@ export function ClientPaymentsTable({ All Clients - {uniqueClientIds.map((clientId) => ( + {Object.entries(ecommerceClients).map(([clientId, client]) => ( - {clientIdToLabel[clientId] || `${clientId.slice(0, 8)}...`} + {client.label} ))} diff --git a/src/components/ecommerce/sales/index.tsx b/src/components/ecommerce/sales/index.tsx index eb783f9c..5938f61b 100644 --- a/src/components/ecommerce/sales/index.tsx +++ b/src/components/ecommerce/sales/index.tsx @@ -1,19 +1,15 @@ "use client"; import { ErrorState } from "@/components/ui/table/error-state"; -import type { ClientPayment, EcommerceClient } from "@/server/db/schema"; +import type { ClientPaymentWithEcommerceClient } from "@/lib/types"; import { api } from "@/trpc/react"; import { ClientPaymentsTable } from "./blocks/client-payments-table"; interface EcommerceSalesProps { - initialClientPayments: ClientPayment[]; - ecommerceClients: EcommerceClient[]; + initialClientPayments: ClientPaymentWithEcommerceClient[]; } -export function EcommerceSales({ - initialClientPayments, - ecommerceClients, -}: EcommerceSalesProps) { +export function EcommerceSales({ initialClientPayments }: EcommerceSalesProps) { const { data, error, refetch, isRefetching } = api.ecommerce.getAllClientPayments.useQuery(undefined, { initialData: initialClientPayments, @@ -38,10 +34,7 @@ export function EcommerceSales({ View all payments received through your ecommerce integrations

- + ); } diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 3e3f0354..d3c1191f 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,4 +1,6 @@ import type { RecurringPayment } from "@/server/db/schema"; +import type { ecommerceRouter } from "@/server/routers/ecommerce"; +import type { inferRouterOutputs } from "@trpc/server"; export interface PaymentRoute { id: string; @@ -33,3 +35,7 @@ export type SubscriptionPayment = { totalNumberOfPayments: number; paymentNumber: number; }; + +export type ClientPaymentWithEcommerceClient = inferRouterOutputs< + typeof ecommerceRouter +>["getAllClientPayments"][number]; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 5d1a86e8..7c4d7986 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -185,6 +185,11 @@ export const clientPaymentTable = createTable("client_payment", { onDelete: "cascade", }), requestId: text().notNull(), + ecommerceClientId: text() + .notNull() + .references(() => ecommerceClientTable.id, { + onDelete: "cascade", + }), invoiceCurrency: text().notNull(), paymentCurrency: text().notNull(), txHash: text().notNull(), @@ -203,7 +208,6 @@ export const clientPaymentTable = createTable("client_payment", { }; }>(), reference: text(), - clientId: text().notNull(), origin: text(), createdAt: timestamp("created_at").defaultNow(), }); @@ -436,6 +440,10 @@ export const clientPaymentRelations = relations( fields: [clientPaymentTable.userId], references: [userTable.id], }), + ecommerceClient: one(ecommerceClientTable, { + fields: [clientPaymentTable.ecommerceClientId], + references: [ecommerceClientTable.id], + }), }), ); diff --git a/src/server/routers/ecommerce.ts b/src/server/routers/ecommerce.ts index 6cb47530..78f75b25 100644 --- a/src/server/routers/ecommerce.ts +++ b/src/server/routers/ecommerce.ts @@ -154,6 +154,9 @@ export const ecommerceRouter = router({ try { const clientPayments = await db.query.clientPaymentTable.findMany({ where: eq(clientPaymentTable.userId, user.id), + with: { + ecommerceClient: true, + }, }); return clientPayments; @@ -164,12 +167,12 @@ export const ecommerceRouter = router({ getAllUserReceipts: protectedProcedure.query(async ({ ctx }) => { const { db, user } = ctx; try { - const receipts = await db - .select() - .from(clientPaymentTable) - .where( - sql`${clientPaymentTable.customerInfo}->>'email' = ${user.email}`, - ); + const receipts = await db.query.clientPaymentTable.findMany({ + where: sql`${clientPaymentTable.customerInfo}->>'email' = ${user.email}`, + with: { + ecommerceClient: true, + }, + }); return receipts; } catch (error) { From a41867b15c1ea8c8f2d54b66d333e788d85bfca9 Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Fri, 3 Oct 2025 13:25:17 +0200 Subject: [PATCH 5/6] feat: implement table of receipts --- src/components/dashboard/receipts.tsx | 184 ++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 13 deletions(-) diff --git a/src/components/dashboard/receipts.tsx b/src/components/dashboard/receipts.tsx index b5c58f8c..e1cb1b14 100644 --- a/src/components/dashboard/receipts.tsx +++ b/src/components/dashboard/receipts.tsx @@ -1,28 +1,186 @@ "use client"; -import type { ClientPayment } from "@/server/db/schema"; +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: ClientPayment[]; + initialClientPayments: ClientPaymentWithEcommerceClient[]; } +const ReceiptTableColumns = () => ( + + Date + Reference + Amount + Payment Currency + Network + Merchant + +); + +const ReceiptRow = ({ + receipt, +}: { receipt: ClientPaymentWithEcommerceClient }) => { + return ( + + + {receipt.createdAt + ? format(new Date(receipt.createdAt), "do MMM yyyy") + : "N/A"} + + + {receipt.reference || -} + + {receipt.amount} + {receipt.paymentCurrency} + {receipt.network} + {receipt.ecommerceClient.label} + + ); +}; + +const ITEMS_PER_PAGE = 10; + export function DashboardReceipts({ initialClientPayments, }: DashboardReceiptsProps) { + const [activeClientId, setActiveClientId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + const { data, error, refetch, isRefetching } = + api.ecommerce.getAllUserReceipts.useQuery(undefined, { + initialData: initialClientPayments, + refetchOnMount: true, + }); + + if (error) { + return ( + + ); + } + + 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, + ); + return (
-
-

My Receipts

-

- View all your payment receipts from ecommerce transactions -

-
-
-

Receipts component coming soon...

-

- Found {initialClientPayments.length} payment receipts -

+

+ View all your payment receipts from ecommerce transactions +

+
+
+
+ + + Filter by merchant: + +
+ +
+ + + + + + + + {paginatedReceipts.length === 0 ? ( + + + } + title="No receipts" + subtitle={ + activeClientId + ? "No receipts found for the selected merchant" + : "You haven't received any payments yet" + } + /> + + + ) : ( + paginatedReceipts.map((receipt) => ( + + )) + )} + +
+
+
+ + {totalPages > 1 && ( + + )}
); } From 54d39a3af6ba3c972f91d33cdee6368b97b64cfb Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Fri, 3 Oct 2025 13:45:25 +0200 Subject: [PATCH 6/6] feat: code review align and generate migration --- drizzle/meta/_journal.json | 7 ++ src/app/api/webhook/route.ts | 59 +++++++--------- src/components/dashboard/receipts.tsx | 1 + .../sales/blocks/client-payments-table.tsx | 2 +- src/components/ecommerce/sales/index.tsx | 1 + src/server/db/schema.ts | 70 +++++++++---------- 6 files changed, 72 insertions(+), 68 deletions(-) diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5e74ceec..d985de57 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1759323380579, "tag": "0009_slippery_penance", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1759491911320, + "tag": "0010_minor_roulette", + "breakpoints": true } ] } diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index 1a301228..778b9301 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -18,24 +18,6 @@ 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) @@ -50,20 +32,33 @@ async function addClientPayment(webhookBody: any) { 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, - }); + 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; + } }); } diff --git a/src/components/dashboard/receipts.tsx b/src/components/dashboard/receipts.tsx index e1cb1b14..709254c3 100644 --- a/src/components/dashboard/receipts.tsx +++ b/src/components/dashboard/receipts.tsx @@ -73,6 +73,7 @@ export function DashboardReceipts({ api.ecommerce.getAllUserReceipts.useQuery(undefined, { initialData: initialClientPayments, refetchOnMount: true, + refetchInterval: 10000, }); if (error) { diff --git a/src/components/ecommerce/sales/blocks/client-payments-table.tsx b/src/components/ecommerce/sales/blocks/client-payments-table.tsx index 2d88f70b..a748fa77 100644 --- a/src/components/ecommerce/sales/blocks/client-payments-table.tsx +++ b/src/components/ecommerce/sales/blocks/client-payments-table.tsx @@ -229,7 +229,7 @@ export function ClientPaymentsTable({ {paginatedPayments.length === 0 ? ( - + } title="No client payments" diff --git a/src/components/ecommerce/sales/index.tsx b/src/components/ecommerce/sales/index.tsx index 5938f61b..78ea793c 100644 --- a/src/components/ecommerce/sales/index.tsx +++ b/src/components/ecommerce/sales/index.tsx @@ -14,6 +14,7 @@ export function EcommerceSales({ initialClientPayments }: EcommerceSalesProps) { api.ecommerce.getAllClientPayments.useQuery(undefined, { initialData: initialClientPayments, refetchOnMount: true, + refetchInterval: 10000, }); if (error) { diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 7c4d7986..bc358332 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -177,41 +177,6 @@ export const paymentDetailsPayersTable = createTable("payment_details_payers", { createdAt: timestamp("created_at").defaultNow(), }); -export const clientPaymentTable = createTable("client_payment", { - id: text().primaryKey().notNull(), - userId: text() - .notNull() - .references(() => userTable.id, { - onDelete: "cascade", - }), - requestId: text().notNull(), - ecommerceClientId: text() - .notNull() - .references(() => ecommerceClientTable.id, { - onDelete: "cascade", - }), - invoiceCurrency: text().notNull(), - paymentCurrency: text().notNull(), - txHash: text().notNull(), - network: text().notNull(), - amount: text().notNull(), - customerInfo: json().$type<{ - firstName?: string; - lastName?: string; - email?: string; - address?: { - street?: string; - city?: string; - state?: string; - postalCode?: string; - country?: string; - }; - }>(), - reference: text(), - origin: text(), - createdAt: timestamp("created_at").defaultNow(), -}); - export const requestTable = createTable("request", { id: text().primaryKey().notNull(), type: text().notNull(), @@ -363,6 +328,41 @@ export const ecommerceClientTable = createTable( }), ); +export const clientPaymentTable = createTable("client_payment", { + id: text().primaryKey().notNull(), + userId: text() + .notNull() + .references(() => userTable.id, { + onDelete: "cascade", + }), + requestId: text().notNull(), + ecommerceClientId: text() + .notNull() + .references(() => ecommerceClientTable.id, { + onDelete: "cascade", + }), + invoiceCurrency: text().notNull(), + paymentCurrency: text().notNull(), + txHash: text().notNull(), + network: text().notNull(), + amount: text().notNull(), + customerInfo: json().$type<{ + firstName?: string; + lastName?: string; + email?: string; + address?: { + street?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + }>(), + reference: text(), + origin: text(), + createdAt: timestamp("created_at").defaultNow(), +}); + // Relationships export const userRelations = relations(userTable, ({ many }) => ({