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 d85a3406..778b9301 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,52 @@ import { and, eq, not } from "drizzle-orm";
import { NextResponse } from "next/server";
import { ulid } from "ulid";
+async function addClientPayment(webhookBody: any) {
+ await db.transaction(async (tx) => {
+ const ecommerceClient = await tx
+ .select()
+ .from(ecommerceClientTable)
+ .where(eq(ecommerceClientTable.rnClientId, webhookBody.clientId))
+ .limit(1);
+
+ if (!ecommerceClient.length) {
+ throw new ResourceNotFoundError(
+ `No ecommerce client found with client ID: ${webhookBody.clientId}`,
+ );
+ }
+
+ const client = ecommerceClient[0];
+
+ const inserted = await tx
+ .insert(clientPaymentTable)
+ .values({
+ id: ulid(),
+ userId: client.userId,
+ ecommerceClientId: client.id,
+ requestId: webhookBody.requestId,
+ invoiceCurrency: webhookBody.currency,
+ paymentCurrency: webhookBody.paymentCurrency,
+ txHash: webhookBody.txHash,
+ network: webhookBody.network,
+ amount: webhookBody.amount,
+ customerInfo: webhookBody.customerInfo || null,
+ reference: webhookBody.reference || null,
+ origin: webhookBody.origin,
+ })
+ .onConflictDoNothing({
+ target: [clientPaymentTable.requestId, clientPaymentTable.txHash],
+ })
+ .returning({ id: clientPaymentTable.id });
+
+ if (!inserted.length) {
+ console.warn(
+ `Duplicate client payment detected for requestId: ${webhookBody.requestId} and txHash: ${webhookBody.txHash}`,
+ );
+ return;
+ }
+ });
+}
+
/**
* Updates the request status in the database
*/
@@ -132,6 +180,8 @@ export async function POST(req: Request) {
txHash: body.txHash,
requestScanUrl: body.explorer,
});
+ } else if (body.clientId) {
+ await addClientPayment(body);
} else {
await updateRequestStatus(
requestId,
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/app/ecommerce/sales/page.tsx b/src/app/ecommerce/sales/page.tsx
index 3381301c..3a675a37 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,7 @@ export default async function SalesPage() {
redirect("/");
}
- // TODO fetch sales data
+ const clientPayments = await api.ecommerce.getAllClientPayments.query();
- return
Sales Page - to be implemented
;
+ 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..709254c3
--- /dev/null
+++ b/src/components/dashboard/receipts.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { EmptyState } from "@/components/ui/table/empty-state";
+import { Pagination } from "@/components/ui/table/pagination";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table/table";
+import { TableHeadCell } from "@/components/ui/table/table-head-cell";
+import type { ClientPaymentWithEcommerceClient } from "@/lib/types";
+import { api } from "@/trpc/react";
+import { format } from "date-fns";
+import { Filter, Receipt } from "lucide-react";
+import { useState } from "react";
+import { ErrorState } from "../ui/table/error-state";
+
+interface DashboardReceiptsProps {
+ initialClientPayments: ClientPaymentWithEcommerceClient[];
+}
+
+const ReceiptTableColumns = () => (
+
+ 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,
+ refetchInterval: 10000,
+ });
+
+ 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 (
+
+
+ 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 && (
+
+ )}
+
+ );
+}
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..a748fa77
--- /dev/null
+++ b/src/components/ecommerce/sales/blocks/client-payments-table.tsx
@@ -0,0 +1,267 @@
+"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 { ClientPaymentWithEcommerceClient } from "@/lib/types";
+import { format } from "date-fns";
+import {
+ ChevronDown,
+ ChevronUp,
+ CreditCard,
+ ExternalLink,
+ Filter,
+} from "lucide-react";
+import { useState } from "react";
+
+interface ClientPaymentsTableProps {
+ clientPayments: ClientPaymentWithEcommerceClient[];
+}
+
+interface CustomerInfoDisplayProps {
+ customerInfo: ClientPaymentWithEcommerceClient["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
+ Origin
+ Request Scan URL
+
+);
+
+const ClientPaymentRow = ({
+ clientPayment,
+}: { clientPayment: ClientPaymentWithEcommerceClient }) => {
+ 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.ecommerceClient.label}
+
+
+
+ {clientPayment.origin || -}
+
+
+
+ View Request
+
+
+
+
+ );
+};
+
+const ITEMS_PER_PAGE = 10;
+export function ClientPaymentsTable({
+ clientPayments,
+}: ClientPaymentsTableProps) {
+ const [activeClientId, setActiveClientId] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+
+ const filteredPayments = activeClientId
+ ? clientPayments.filter(
+ (payment) => payment.ecommerceClientId === 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 ecommerceClients = clientPayments.reduce(
+ (acc, payment) => {
+ if (acc[payment.ecommerceClient.id]) return acc;
+ acc[payment.ecommerceClient.id] = payment.ecommerceClient;
+ return acc;
+ },
+ {} as Record,
+ );
+
+ 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..78ea793c
--- /dev/null
+++ b/src/components/ecommerce/sales/index.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { ErrorState } from "@/components/ui/table/error-state";
+import type { ClientPaymentWithEcommerceClient } from "@/lib/types";
+import { api } from "@/trpc/react";
+import { ClientPaymentsTable } from "./blocks/client-payments-table";
+
+interface EcommerceSalesProps {
+ initialClientPayments: ClientPaymentWithEcommerceClient[];
+}
+
+export function EcommerceSales({ initialClientPayments }: EcommerceSalesProps) {
+ const { data, error, refetch, isRefetching } =
+ api.ecommerce.getAllClientPayments.useQuery(undefined, {
+ initialData: initialClientPayments,
+ refetchOnMount: true,
+ refetchInterval: 10000,
+ });
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
Client Payments
+
+ 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 522fe99f..bc358332 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -322,9 +322,47 @@ export const ecommerceClientTable = createTable(
table.userId,
table.domain,
),
+ clientIdIndex: uniqueIndex("ecommerce_client_user_id_client_id_unique").on(
+ table.rnClientId,
+ ),
}),
);
+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 }) => ({
@@ -332,6 +370,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 +433,20 @@ export const ecommerceClientRelations = relations(
}),
);
+export const clientPaymentRelations = relations(
+ clientPaymentTable,
+ ({ one }) => ({
+ user: one(userTable, {
+ fields: [clientPaymentTable.userId],
+ references: [userTable.id],
+ }),
+ ecommerceClient: one(ecommerceClientTable, {
+ fields: [clientPaymentTable.ecommerceClientId],
+ references: [ecommerceClientTable.id],
+ }),
+ }),
+);
+
export const paymentDetailsRelations = relations(
paymentDetailsTable,
({ one, many }) => ({
@@ -430,3 +483,4 @@ export type PaymentDetailsPayers = InferSelectModel<
>;
export type RecurringPayment = InferSelectModel;
export type EcommerceClient = InferSelectModel;
+export type ClientPayment = InferSelectModel;
diff --git a/src/server/routers/ecommerce.ts b/src/server/routers/ecommerce.ts
index 6cd240ba..78f75b25 100644
--- a/src/server/routers/ecommerce.ts
+++ b/src/server/routers/ecommerce.ts
@@ -5,10 +5,10 @@ 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 { ecommerceClientTable } from "../db/schema";
+import { clientPaymentTable, ecommerceClientTable } from "../db/schema";
import { protectedProcedure, router } from "../trpc";
export const ecommerceRouter = router({
@@ -149,4 +149,34 @@ 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),
+ with: {
+ ecommerceClient: true,
+ },
+ });
+
+ return clientPayments;
+ } catch (error) {
+ throw toTRPCError(error);
+ }
+ }),
+ getAllUserReceipts: protectedProcedure.query(async ({ ctx }) => {
+ const { db, user } = ctx;
+ try {
+ const receipts = await db.query.clientPaymentTable.findMany({
+ where: sql`${clientPaymentTable.customerInfo}->>'email' = ${user.email}`,
+ with: {
+ ecommerceClient: true,
+ },
+ });
+
+ return receipts;
+ } catch (error) {
+ throw toTRPCError(error);
+ }
+ }),
});