diff --git a/public/arbitrum-logo.png b/public/arbitrum-logo.png new file mode 100644 index 00000000..a7ee4623 Binary files /dev/null and b/public/arbitrum-logo.png differ diff --git a/public/base-logo.png b/public/base-logo.png new file mode 100644 index 00000000..2b3765ad Binary files /dev/null and b/public/base-logo.png differ diff --git a/public/ethereum-logo.png b/public/ethereum-logo.png new file mode 100644 index 00000000..c8ac5ec4 Binary files /dev/null and b/public/ethereum-logo.png differ diff --git a/public/optimism-logo.png b/public/optimism-logo.png new file mode 100644 index 00000000..264f2672 Binary files /dev/null and b/public/optimism-logo.png differ diff --git a/public/polygon-logo.png b/public/polygon-logo.png new file mode 100644 index 00000000..d719c065 Binary files /dev/null and b/public/polygon-logo.png differ diff --git a/public/sepolia-logo.png b/public/sepolia-logo.png new file mode 100644 index 00000000..c8ac5ec4 Binary files /dev/null and b/public/sepolia-logo.png differ diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index 3081fe6c..d5c04ec7 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -1,4 +1,6 @@ import crypto from "node:crypto"; +import { getInvoiceCount } from "@/lib/invoice"; +import { generateInvoiceNumber } from "@/lib/invoice/client"; import { db } from "@/server/db"; import { requestTable } from "@/server/db/schema"; import { eq } from "drizzle-orm"; @@ -91,9 +93,14 @@ export async function POST(req: Request) { const newDueDate = new Date(now); newDueDate.setDate(now.getDate() + daysDifference); + const invoiceCount = await getInvoiceCount(originalRequest.userId); + + const invoiceNumber = generateInvoiceNumber(invoiceCount); + await tx.insert(requestTable).values({ id: ulid(), ...requestWithoutId, + invoiceNumber, issuedDate: now.toISOString(), dueDate: newDueDate.toISOString(), paymentReference: paymentReference, diff --git a/src/app/invoices/[ID]/page.tsx b/src/app/invoices/[ID]/page.tsx index b724fa81..c6e3d530 100644 --- a/src/app/invoices/[ID]/page.tsx +++ b/src/app/invoices/[ID]/page.tsx @@ -168,10 +168,10 @@ export default async function PaymentPage({ {item.quantity} - {item.price.toFixed(2)} + {item.price.toString()} - {(item.quantity * item.price).toFixed(2)} + {(item.quantity * item.price).toString()} ))} @@ -184,14 +184,14 @@ export default async function PaymentPage({
Subtotal - {Number(invoice.amount).toFixed(2)} + {Number(invoice.amount).toString()}
Total
- {Number(invoice.amount).toFixed(2)} + {Number(invoice.amount).toString()}
{formatCurrencyLabel(invoice.invoiceCurrency)} diff --git a/src/components/app-kit.tsx b/src/components/app-kit.tsx index d7b141bb..5a323256 100644 --- a/src/components/app-kit.tsx +++ b/src/components/app-kit.tsx @@ -1,7 +1,14 @@ "use client"; import { Ethers5Adapter } from "@reown/appkit-adapter-ethers5"; -import { sepolia } from "@reown/appkit/networks"; +import { + arbitrum, + base, + mainnet, + optimism, + polygon, + sepolia, +} from "@reown/appkit/networks"; import { createAppKit } from "@reown/appkit/react"; const metadata = { @@ -20,7 +27,7 @@ if (!projectId) { createAppKit({ adapters: [new Ethers5Adapter()], metadata, - networks: [sepolia], + networks: [sepolia, base, mainnet, arbitrum, optimism, polygon], projectId, features: { analytics: false, diff --git a/src/components/invoice-form.tsx b/src/components/invoice-form.tsx index 67d1286c..71817efd 100644 --- a/src/components/invoice-form.tsx +++ b/src/components/invoice-form.tsx @@ -15,13 +15,16 @@ import { Textarea } from "@/components/ui/textarea"; import { INVOICE_CURRENCIES, type InvoiceCurrency, + MAINNET_CURRENCIES, + type MainnetCurrency, formatCurrencyLabel, getPaymentCurrenciesForInvoice, } from "@/lib/currencies"; import type { InvoiceFormValues } from "@/lib/schemas/invoice"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Terminal, Trash2 } from "lucide-react"; import type { UseFormReturn } from "react-hook-form"; import { useFieldArray } from "react-hook-form"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; type RecurringFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; @@ -223,6 +226,8 @@ export function InvoiceForm({ valueAsNumber: true, })} type="number" + step="any" + min="0" placeholder="Price" /> {form.formState.errors.items?.[index]?.price && ( @@ -294,6 +299,33 @@ export function InvoiceForm({

)}
+ {MAINNET_CURRENCIES.includes( + form.watch("invoiceCurrency") as MainnetCurrency, + ) && ( +
+ + + + Warning: You are creating an invoice with real funds + + +

+ You've selected{" "} + + {formatCurrencyLabel(form.watch("invoiceCurrency"))} + + , which operates on a mainnet blockchain and uses real + cryptocurrency. +

+

+ EasyInvoice is a demonstration app only, designed to showcase + Request Network API functionality. Do not use for real + invoicing. +

+
+
+
+ )} {/* Only show payment currency selector for USD invoices */} {form.watch("invoiceCurrency") === "USD" && ( diff --git a/src/components/invoice-preview.tsx b/src/components/invoice-preview.tsx index 7cf08bf0..96bb202a 100644 --- a/src/components/invoice-preview.tsx +++ b/src/components/invoice-preview.tsx @@ -128,10 +128,10 @@ export function InvoicePreview({ data }: InvoicePreviewProps) { {item.quantity} - {(item.price || 0).toFixed(2)} + {(item.price || 0).toString()} - {((item.quantity || 0) * (item.price || 0)).toFixed(2)} + {((item.quantity || 0) * (item.price || 0)).toString()} ))} @@ -143,13 +143,13 @@ export function InvoicePreview({ data }: InvoicePreviewProps) {
Subtotal - {calculateTotal().toFixed(2)} + {calculateTotal().toString()}
Total
- {calculateTotal().toFixed(2)} + {calculateTotal().toString()}
{formatCurrencyLabel(data.invoiceCurrency || "USD")} diff --git a/src/components/payment-route.tsx b/src/components/payment-route.tsx new file mode 100644 index 00000000..2b9b3a90 --- /dev/null +++ b/src/components/payment-route.tsx @@ -0,0 +1,117 @@ +import type { PaymentRoute as PaymentRouteType } from "@/lib/types"; +import { ArrowRight, Globe, Zap } from "lucide-react"; +import Image from "next/image"; + +interface RouteTypeInfo { + type: "direct" | "same-chain-erc20" | "cross-chain"; + label: string; + description: string; +} + +interface PaymentRouteProps { + route: PaymentRouteType; + isSelected: boolean; + onClick?: () => void; + variant?: "default" | "selected"; + routeType?: RouteTypeInfo; +} + +export function PaymentRoute({ + route, + isSelected, + onClick, + variant = "default", + routeType, +}: PaymentRouteProps) { + const isDirectPayment = + routeType?.type === "direct" || route.id === "REQUEST_NETWORK_PAYMENT"; + const isGasFreePayment = routeType?.type === "same-chain-erc20"; + + const nativeToken = route.chain === "POLYGON" ? "POL" : "ETH"; + + // Get the appropriate badge color and icon based on route type + const getBadgeStyles = () => { + if (isDirectPayment) { + return { + bgColor: "bg-blue-100", + textColor: "text-blue-700", + icon: , + }; + } + + if (isGasFreePayment) { + return { + bgColor: "bg-green-100", + textColor: "text-green-700", + icon: , + }; + } + + return { + bgColor: "bg-purple-100", + textColor: "text-purple-700", + icon: , + }; + }; + + const { bgColor, textColor, icon } = getBadgeStyles(); + + return ( + + ); +} diff --git a/src/components/payment-section.tsx b/src/components/payment-section.tsx index 3a3f3a62..61846de8 100644 --- a/src/components/payment-section.tsx +++ b/src/components/payment-section.tsx @@ -1,18 +1,23 @@ "use client"; +import { PaymentRoute } from "@/components/payment-route"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; -import { formatCurrencyLabel } from "@/lib/currencies"; +import { CHAIN_TO_ID, ID_TO_APPKIT_NETWORK } from "@/lib/chains"; +import { MAINNET_CURRENCIES, formatCurrencyLabel } from "@/lib/currencies"; +import type { PaymentRoute as PaymentRouteType } from "@/lib/types"; import type { Request } from "@/server/db/schema"; import { api } from "@/trpc/react"; import { useAppKit, useAppKitAccount, + useAppKitNetwork, useAppKitProvider, } from "@reown/appkit/react"; import { ethers } from "ethers"; -import { CheckCircle, Clock, Loader2, Wallet } from "lucide-react"; +import { AlertCircle, CheckCircle, Clock, Loader2, Wallet } from "lucide-react"; + import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -20,16 +25,87 @@ interface PaymentSectionProps { invoice: NonNullable; } +const getCurrencyChain = (currency: string) => { + // Extract chain from format like "USDC-base" + const parts = currency.split("-"); + return parts.length > 1 ? parts[1].toLowerCase() : null; +}; + +const REQUEST_NETWORK_CHAIN_TO_PAYMENT_NETWORK = { + matic: "polygon", + base: "base", + "arbitrum-one": "arbitrum", + optimism: "optimism", + mainnet: "ethereum", +}; + +const getRouteType = (route: PaymentRouteType, invoiceChain: string | null) => { + const invoiceChainToUse = + invoiceChain && + REQUEST_NETWORK_CHAIN_TO_PAYMENT_NETWORK[ + invoiceChain as keyof typeof REQUEST_NETWORK_CHAIN_TO_PAYMENT_NETWORK + ]; + + if (route.id === "REQUEST_NETWORK_PAYMENT") { + return { + type: "direct" as const, + label: "Direct Payment", + description: "Pay directly on the same network", + }; + } + + if ( + route.chain && + invoiceChainToUse && + route.chain.toLowerCase() === invoiceChainToUse + ) { + return { + type: "same-chain-erc20" as const, + label: "Same-Chain ERC20", + description: `Pay with ${route.token} (no gas token needed)`, + }; + } + + return { + type: "cross-chain" as const, + label: "Cross-Chain Payment", + description: `Pay from ${route.chain} network using ${route.token}`, + }; +}; + export function PaymentSection({ invoice }: PaymentSectionProps) { + const { open } = useAppKit(); + const { isConnected, address } = useAppKitAccount(); + const { walletProvider } = useAppKitProvider("eip155"); + const { chainId, switchNetwork } = useAppKitNetwork(); + const [showRoutes, setShowRoutes] = useState(false); + const [selectedRoute, setSelectedRoute] = useState( + null, + ); + const [paymentStatus, setPaymentStatus] = useState(invoice.status); const [paymentProgress, setPaymentProgress] = useState("idle"); const [currentStep, setCurrentStep] = useState(1); const [isAppKitReady, setIsAppKitReady] = useState(false); const { mutateAsync: payRequest } = api.invoice.payRequest.useMutation(); + const { mutateAsync: sendPaymentIntent } = + api.invoice.sendPaymentIntent.useMutation(); + const { + data: paymentRoutes, + refetch, + isLoading: isLoadingPaymentRoutes, + } = api.invoice.getPaymentRoutes.useQuery( + { + paymentReference: invoice.paymentReference, + walletAddress: address as string, + }, + { + enabled: !!address, + }, + ); - const { open } = useAppKit(); - const { isConnected, address } = useAppKitAccount(); - const { walletProvider } = useAppKitProvider("eip155"); + // Extract the chain from invoice currency + const invoiceChain = getCurrencyChain(invoice.paymentCurrency); const displayPaymentProgress = () => { switch (paymentProgress) { @@ -57,74 +133,185 @@ export function PaymentSection({ invoice }: PaymentSectionProps) { setCurrentStep(isConnected ? 2 : 1); }, [isConnected]); + useEffect(() => { + if (address) { + refetch(); + } + }, [address, refetch]); + + useEffect(() => { + if (paymentRoutes) { + setSelectedRoute(paymentRoutes[0]); + } + }, [paymentRoutes]); + const handleConnectWallet = () => { if (isAppKitReady) { open(); } }; - const handlePayment = async () => { - if (paymentProgress !== "idle") return; + const handlePaygridPayments = async (paymentData: any, signer: any) => { + const paymentIntent = JSON.parse(paymentData.paymentIntent); + const supportsEIP2612 = paymentData.metadata.supportsEIP2612; + let approvalSignature = undefined; + let approval = undefined; - setPaymentProgress("getting-transactions"); + setPaymentProgress("approving"); - const ethersProvider = new ethers.providers.Web3Provider( - walletProvider as ethers.providers.ExternalProvider, + if (supportsEIP2612) { + approval = JSON.parse(paymentData.approvalPermitPayload); + + approvalSignature = await signer._signTypedData( + approval.domain, + approval.types, + approval.values, + ); + } else { + const tx = await signer.sendTransaction(paymentData.approvalCalldata); + await tx.wait(); + } + + const paymentIntentSignature = await signer._signTypedData( + paymentIntent.domain, + paymentIntent.types, + paymentIntent.values, ); - const signer = await ethersProvider.getSigner(); + const signedPermit = { + signedPaymentIntent: { + signature: paymentIntentSignature, + nonce: paymentIntent.values.nonce.toString(), + deadline: paymentIntent.values.deadline.toString(), + }, + signedApprovalPermit: approvalSignature + ? { + signature: approvalSignature, + nonce: approval.values.nonce.toString(), + deadline: approval?.values?.deadline + ? approval.values.deadline.toString() + : approval.values.expiry.toString(), + } + : undefined, + }; + + setPaymentProgress("paying"); + + await sendPaymentIntent({ + paymentIntent: paymentData.paymentIntentId, + payload: signedPermit, + }); + + setPaymentStatus("processing"); + + toast("Payment is being processed", { + description: "You can safely close this page.", + }); + }; - try { - const paymentData = await payRequest(invoice.paymentReference).then( - (response) => response.data, + const handleDirectPayments = async (paymentData: any, signer: any) => { + const isApprovalNeeded = paymentData.metadata.needsApproval; + + if (isApprovalNeeded) { + setPaymentProgress("approving"); + toast("Payment Approval Required", { + description: "Please approve the payment in your wallet", + }); + + const approvalIndex = paymentData.metadata.approvalTransactionIndex; + + const approvalTransaction = await signer.sendTransaction( + paymentData.transactions[approvalIndex], ); - const isApprovalNeeded = paymentData.metadata.needsApproval; + await approvalTransaction.wait(); + } - if (isApprovalNeeded) { - setPaymentProgress("approving"); - toast("Payment Approval Required", { - description: "Please approve the payment in your wallet", - }); + setPaymentProgress("paying"); - const approvalIndex = paymentData.metadata.approvalTransactionIndex; + toast("Initiating payment", { + description: "Please confirm the payment in your wallet", + }); - const approvalTransaction = await signer.sendTransaction( - paymentData.transactions[approvalIndex], - ); + const paymentTransaction = await signer.sendTransaction( + paymentData.transactions[isApprovalNeeded ? 1 : 0], + ); - await approvalTransaction.wait(); - } + await paymentTransaction.wait(); - setPaymentProgress("paying"); + toast("Payment is being processed", { + description: "You can safely close this page.", + }); - toast("Initiating payment", { - description: "Please confirm the payment in your wallet", - }); + setPaymentStatus("processing"); + }; - const paymentTransaction = await signer.sendTransaction( - paymentData.transactions[isApprovalNeeded ? 1 : 0], - ); + const handlePayment = async () => { + if (paymentProgress !== "idle") return; + + setPaymentProgress("getting-transactions"); + + const targetChain = + CHAIN_TO_ID[selectedRoute?.chain as keyof typeof CHAIN_TO_ID]; - await paymentTransaction.wait(); + if (targetChain !== chainId) { + const targetAppkitNetwork = + ID_TO_APPKIT_NETWORK[targetChain as keyof typeof ID_TO_APPKIT_NETWORK]; - toast("Payment completed", { - description: "Payment completed successfully", + toast("Switching to network", { + description: `Switching to ${targetAppkitNetwork.name} network`, }); - setPaymentStatus("paid"); + try { + await switchNetwork(targetAppkitNetwork); + } catch (_) { + toast("Error switching network"); + return; + } + } + + const ethersProvider = new ethers.providers.Web3Provider( + walletProvider as ethers.providers.ExternalProvider, + ); + + const signer = await ethersProvider.getSigner(); + + try { + const paymentData = await payRequest({ + paymentReference: invoice.paymentReference, + wallet: address, + chain: + selectedRoute?.id === "REQUEST_NETWORK_PAYMENT" + ? undefined + : selectedRoute?.chain, + token: + selectedRoute?.id === "REQUEST_NETWORK_PAYMENT" + ? undefined + : selectedRoute?.token, + }).then((response) => response.data); + + const isPaygrid = paymentData.paymentIntentId; + + if (isPaygrid) { + await handlePaygridPayments(paymentData, signer); + } else { + await handleDirectPayments(paymentData, signer); + } } catch (error) { console.error("Error : ", error); toast("Payment Failed", { description: "Please try again", }); + } finally { + setPaymentProgress("idle"); } - setPaymentProgress("idle"); }; const showCurrencyConversion = invoice.invoiceCurrency !== invoice.paymentCurrency; + const hasRoutes = paymentRoutes && paymentRoutes.length > 0; + return ( @@ -135,7 +322,7 @@ export function PaymentSection({ invoice }: PaymentSectionProps) { paymentStatus === "paid" ? "bg-green-100 text-green-800" : paymentStatus === "processing" - ? "bg-yellow-100 text-yellow-800" + ? "bg-orange-100 text-orange-800" : "bg-blue-100 text-blue-800" }`} > @@ -153,6 +340,7 @@ export function PaymentSection({ invoice }: PaymentSectionProps) { + {/* Secure Payment Section */}

Secure Payment

@@ -161,12 +349,31 @@ export function PaymentSection({ invoice }: PaymentSectionProps) {

+ {MAINNET_CURRENCIES.includes(invoice.invoiceCurrency as any) && ( +
+

+ + Real Cryptocurrency Warning +

+

+ This invoice uses{" "} + + {formatCurrencyLabel(invoice.invoiceCurrency)} + {" "} + on a mainnet blockchain, which means you'll be transferring{" "} + actual value. EasyInvoice is a + demonstration app and all blockchain transactions are + irreversible. +

+
+ )} +
{formatCurrencyLabel(invoice.invoiceCurrency)}{" "} - {Number(invoice.amount).toFixed(2)} + {Number(invoice.amount).toString()}
{showCurrencyConversion && (
@@ -288,15 +495,107 @@ export function PaymentSection({ invoice }: PaymentSectionProps) { invoice.paymentCurrency, )}.`}

+ + {/* Payment Route Selection */} +
+
+ + {paymentRoutes && paymentRoutes.length > 1 && ( + + )} +
+ + {isLoadingPaymentRoutes ? ( +
+
+
+ +
+

+ Loading Payment Routes +

+

+ Finding the best payment routes for your wallet +

+
+
+ ) : !paymentRoutes || paymentRoutes.length === 0 ? ( +
+
+
+ +
+

+ No Payment Routes Available +

+

+ There are currently no available payment routes for + this transaction. +

+
+
+ ) : ( + <> + {/* Selected Route Preview */} + {selectedRoute && ( + + )} + + {/* Route Options - Only show if there are multiple routes */} + {showRoutes && paymentRoutes.length > 1 && ( +
+
+ Available Payment Routes +
+ {paymentRoutes.map((route: PaymentRouteType) => ( + setSelectedRoute(route)} + routeType={getRouteType(route, invoiceChain)} + /> + ))} +
+ )} + + )} +
+ + {/* Payment button should be disabled if there are no routes */}
)} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 00000000..b60dd4af --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,61 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: + "border-orange-500/50 text-orange-500 dark:border-orange-500 [&>svg]:text-orange-500", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/lib/chains.ts b/src/lib/chains.ts new file mode 100644 index 00000000..b36a4f19 --- /dev/null +++ b/src/lib/chains.ts @@ -0,0 +1,26 @@ +import { + arbitrum, + base, + mainnet, + optimism, + polygon, + sepolia, +} from "@reown/appkit/networks"; + +export const CHAIN_TO_ID = { + SEPOLIA: 11155111, + BASE: 8453, + ETHEREUM: 1, + ARBITRUM: 42161, + OPTIMISM: 10, + POLYGON: 137, +}; + +export const ID_TO_APPKIT_NETWORK = { + [sepolia.id]: sepolia, + [base.id]: base, + [mainnet.id]: mainnet, + [arbitrum.id]: arbitrum, + [optimism.id]: optimism, + [polygon.id]: polygon, +}; diff --git a/src/lib/currencies.ts b/src/lib/currencies.ts index 3230d4fe..8babb9d4 100644 --- a/src/lib/currencies.ts +++ b/src/lib/currencies.ts @@ -1,27 +1,49 @@ +export const MAINNET_CURRENCIES = [ + "USDC-base", + "USDC-optimism", + "USDC-matic", + "USDC-mainnet", + "USDT-mainnet", + "USDT-matic", + "USDT-optimism", + "DAI-mainnet", + "DAI-matic", + "DAI-optimism", +] as const; + +export type MainnetCurrency = (typeof MAINNET_CURRENCIES)[number]; + export const INVOICE_CURRENCIES = [ "USD", "ETH-sepolia-sepolia", "FAU-sepolia", "fUSDC-sepolia", "fUSDT-sepolia", + ...MAINNET_CURRENCIES, ] as const; export type InvoiceCurrency = (typeof INVOICE_CURRENCIES)[number]; -export const PAYMENT_CURRENCIES = { +export const PAYMENT_CURRENCIES: Partial<{ + [K in InvoiceCurrency]: readonly string[]; +}> = { USD: ["ETH-sepolia-sepolia", "FAU-sepolia"] as const, "ETH-sepolia-sepolia": ["ETH-sepolia-sepolia"] as const, "FAU-sepolia": ["FAU-sepolia"] as const, "fUSDC-sepolia": ["fUSDC-sepolia"] as const, "fUSDT-sepolia": ["fUSDT-sepolia"] as const, + ...Object.fromEntries( + MAINNET_CURRENCIES.map((currency) => [currency, [currency]]), + ), } as const; -export type PaymentCurrency = - (typeof PAYMENT_CURRENCIES)[InvoiceCurrency][number]; +export type PaymentCurrency = NonNullable< + (typeof PAYMENT_CURRENCIES)[InvoiceCurrency] +>[number]; export function getPaymentCurrenciesForInvoice( invoiceCurrency: InvoiceCurrency, ): PaymentCurrency[] { - return [...PAYMENT_CURRENCIES[invoiceCurrency]]; + return [...(PAYMENT_CURRENCIES[invoiceCurrency] || [])]; } export function formatCurrencyLabel(currency: string): string { @@ -36,6 +58,26 @@ export function formatCurrencyLabel(currency: string): string { return "Sepolia USDT"; case "USD": return "US Dollar"; + case "USDC-base": + return "USDC (Base)"; + case "USDC-optimism": + return "USDC (Optimism)"; + case "USDC-matic": + return "USDC (Polygon)"; + case "USDC-mainnet": + return "USDC (Mainnet)"; + case "USDT-mainnet": + return "USDT (Mainnet)"; + case "USDT-optimism": + return "USDT (Optimism)"; + case "USDT-matic": + return "USDT (Polygon)"; + case "DAI-mainnet": + return "DAI (Mainnet)"; + case "DAI-optimism": + return "DAI (Optimism)"; + case "DAI-matic": + return "DAI (Polygon)"; default: return currency; } diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 00000000..828914b6 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,8 @@ +export interface PaymentRoute { + id: string; + fee: number; + speed: number | "FAST"; + price_impact: number; + chain: string; + token: string; +} diff --git a/src/server/routers/invoice.ts b/src/server/routers/invoice.ts index 41a1a5f3..55409568 100644 --- a/src/server/routers/invoice.ts +++ b/src/server/routers/invoice.ts @@ -4,6 +4,7 @@ import { requestTable, userTable } from "@/server/db/schema"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, isNull, not, or } from "drizzle-orm"; import { ulid } from "ulid"; +import { isEthereumAddress } from "validator"; import { z } from "zod"; import { protectedProcedure, publicProcedure, router } from "../trpc"; @@ -189,20 +190,35 @@ export const invoiceRouter = router({ return invoice; }), payRequest: publicProcedure - .input(z.string()) + .input( + z.object({ + paymentReference: z.string(), + wallet: z.string().optional(), + chain: z.string().optional(), + token: z.string().optional(), + }), + ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const invoice = await db.query.requestTable.findFirst({ - where: eq(requestTable.paymentReference, input), + where: eq(requestTable.paymentReference, input.paymentReference), }); if (!invoice) { return { success: false, message: "Invoice not found" }; } - const response = await apiClient.get( - `/v1/request/${invoice.paymentReference}/pay`, - ); + let paymentEndpoint = `/v1/request/${invoice.paymentReference}/pay?wallet=${input.wallet}`; + + if (input.chain) { + paymentEndpoint += `&chain=${input.chain}`; + } + + if (input.token) { + paymentEndpoint += `&token=${input.token}`; + } + + const response = await apiClient.get(paymentEndpoint); if (response.status !== 200) { return { @@ -249,4 +265,51 @@ export const invoiceRouter = router({ return updatedInvoice[0]; }), + getPaymentRoutes: publicProcedure + .input( + z.object({ + paymentReference: z.string(), + walletAddress: z.string().refine( + (val) => { + return isEthereumAddress(val); + }, + { + message: "Invalid wallet address", + }, + ), + }), + ) + .query(async ({ input }) => { + const { paymentReference, walletAddress } = input; + + const response = await apiClient.get( + `/v1/request/${paymentReference}/routes?wallet=${walletAddress}`, + ); + + if (response.status !== 200) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to get payment routes", + }); + } + + return response.data.routes; + }), + sendPaymentIntent: publicProcedure + .input( + z.object({ + paymentIntent: z.string(), + payload: z.any(), + }), + ) + .mutation(async ({ input }) => { + const { paymentIntent, payload } = input; + + const response = await apiClient.post( + `/v1/request/${paymentIntent}/send`, + payload, + ); + + return response.data; + }), });