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 }) => ({