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); + } + }), });