From 89567624b36ace5cee93c46ba5f6cae8a861dc61 Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Wed, 30 Jul 2025 13:30:21 +0200 Subject: [PATCH 1/3] feat: populate USD payment currencies from the API --- src/components/invoice-creator.tsx | 2 +- .../blocks/payment-currency-selector.tsx | 92 +++++++++++++++++++ .../{ => invoice-form}/invoice-form.tsx | 25 ++--- src/server/index.ts | 2 + src/server/routers/currency.ts | 39 ++++++++ 5 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 src/components/invoice-form/blocks/payment-currency-selector.tsx rename src/components/{ => invoice-form}/invoice-form.tsx (97%) create mode 100644 src/server/routers/currency.ts diff --git a/src/components/invoice-creator.tsx b/src/components/invoice-creator.tsx index 44fb227c..3aae045a 100644 --- a/src/components/invoice-creator.tsx +++ b/src/components/invoice-creator.tsx @@ -1,6 +1,6 @@ "use client"; -import { InvoiceForm } from "@/components/invoice-form"; +import { InvoiceForm } from "@/components/invoice-form/invoice-form"; import { InvoicePreview } from "@/components/invoice-preview"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { generateInvoiceNumber } from "@/lib/helpers/client"; diff --git a/src/components/invoice-form/blocks/payment-currency-selector.tsx b/src/components/invoice-form/blocks/payment-currency-selector.tsx new file mode 100644 index 00000000..3359b9c6 --- /dev/null +++ b/src/components/invoice-form/blocks/payment-currency-selector.tsx @@ -0,0 +1,92 @@ +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + type InvoiceCurrency, + formatCurrencyLabel, +} from "@/lib/constants/currencies"; +import type { GetConversionCurrenciesResponse } from "@/server/routers/currency"; +import { api } from "@/trpc/react"; +import { Loader2 } from "lucide-react"; + +interface PaymentCurrencySelectorProps { + onChange: (value: string) => void; + targetCurrency: InvoiceCurrency; + network: string; +} + +export function PaymentCurrencySelector({ + onChange, + targetCurrency, + network, +}: PaymentCurrencySelectorProps) { + const { + data: conversionData, + isLoading, + error, + } = api.currency.getConversionCurrencies.useQuery( + { + targetCurrency, + network, + }, + ); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ + +

+ Failed to load payment currencies: {error.message} +

+
+ ); + } + + const conversionRoutes = conversionData?.conversionRoutes || []; + + return ( +
+ + +
+ ); +} diff --git a/src/components/invoice-form.tsx b/src/components/invoice-form/invoice-form.tsx similarity index 97% rename from src/components/invoice-form.tsx rename to src/components/invoice-form/invoice-form.tsx index 4385cb18..199f4870 100644 --- a/src/components/invoice-form.tsx +++ b/src/components/invoice-form/invoice-form.tsx @@ -26,7 +26,6 @@ import { MAINNET_CURRENCIES, type MainnetCurrency, formatCurrencyLabel, - getPaymentCurrenciesForInvoice, } from "@/lib/constants/currencies"; import type { InvoiceFormValues } from "@/lib/schemas/invoice"; import type { @@ -40,7 +39,8 @@ import { useCallback, useEffect, useState } from "react"; import type { UseFormReturn } from "react-hook-form"; import { useFieldArray } from "react-hook-form"; import { toast } from "sonner"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { PaymentCurrencySelector } from "./blocks/payment-currency-selector"; // Constants const PAYMENT_DETAILS_POLLING_INTERVAL = 30000; // 30 seconds in milliseconds @@ -896,22 +896,11 @@ export function InvoiceForm({ {/* Only show payment currency selector for USD invoices */} {form.watch("invoiceCurrency") === "USD" && (
- - + form.setValue("paymentCurrency", value)} + targetCurrency="USD" + network="sepolia" + /> {form.formState.errors.paymentCurrency && (

{form.formState.errors.paymentCurrency.message} diff --git a/src/server/index.ts b/src/server/index.ts index 5364df09..64442c73 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ import { authRouter } from "./routers/auth"; import { complianceRouter } from "./routers/compliance"; +import { currencyRouter } from "./routers/currency"; import { invoiceRouter } from "./routers/invoice"; import { invoiceMeRouter } from "./routers/invoice-me"; import { paymentRouter } from "./routers/payment"; @@ -15,6 +16,7 @@ export const appRouter = router({ compliance: complianceRouter, recurringPayment: recurringPaymentRouter, subscriptionPlan: subscriptionPlanRouter, + currency: currencyRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/routers/currency.ts b/src/server/routers/currency.ts new file mode 100644 index 00000000..1565d8d6 --- /dev/null +++ b/src/server/routers/currency.ts @@ -0,0 +1,39 @@ +import { apiClient } from "@/lib/axios"; +import type { AxiosResponse } from "axios"; +import { z } from "zod"; +import { publicProcedure, router } from "../trpc"; + +export type ConversionCurrency = { + id: string; + symbol: string; + decimals: number; + address: string; + type: "ERC20" | "ETH" | "ISO4217"; + network: string; +}; + +export interface GetConversionCurrenciesResponse { + currencyId: string; + network: string; + conversionRoutes: ConversionCurrency[]; +} + +export const currencyRouter = router({ + getConversionCurrencies: publicProcedure + .input( + z.object({ + targetCurrency: z.string(), + network: z.string(), + }), + ) + .query(async ({ input }): Promise => { + const { targetCurrency, network } = input; + + const response: AxiosResponse = + await apiClient.get( + `v2/currencies/${targetCurrency}/conversion-routes?network=${network}`, + ); + + return response.data; + }), +}); From 189a1e1be512711b345878a07319de6a085c3396 Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Wed, 30 Jul 2025 15:01:03 +0200 Subject: [PATCH 2/3] feat: handle empty payment currencies, modify error handling to be in line with what we do in other routers --- .../blocks/payment-currency-selector.tsx | 16 +++++++++++ src/server/routers/currency.ts | 28 +++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/components/invoice-form/blocks/payment-currency-selector.tsx b/src/components/invoice-form/blocks/payment-currency-selector.tsx index 3359b9c6..9fd2263d 100644 --- a/src/components/invoice-form/blocks/payment-currency-selector.tsx +++ b/src/components/invoice-form/blocks/payment-currency-selector.tsx @@ -72,6 +72,22 @@ export function PaymentCurrencySelector({ const conversionRoutes = conversionData?.conversionRoutes || []; + if (conversionRoutes.length === 0) { + return ( +

+ + +

+ No payment currencies are available for {targetCurrency} on {network} +

+
+ ); + } + return (
diff --git a/src/server/routers/currency.ts b/src/server/routers/currency.ts index 1565d8d6..0efffc99 100644 --- a/src/server/routers/currency.ts +++ b/src/server/routers/currency.ts @@ -1,5 +1,6 @@ import { apiClient } from "@/lib/axios"; -import type { AxiosResponse } from "axios"; +import { TRPCError } from "@trpc/server"; +import type { AxiosError, AxiosResponse } from "axios"; import { z } from "zod"; import { publicProcedure, router } from "../trpc"; @@ -29,11 +30,26 @@ export const currencyRouter = router({ .query(async ({ input }): Promise => { const { targetCurrency, network } = input; - const response: AxiosResponse = - await apiClient.get( - `v2/currencies/${targetCurrency}/conversion-routes?network=${network}`, - ); + try { + const response: AxiosResponse = + await apiClient.get( + `v2/currencies/${targetCurrency}/conversion-routes?network=${network}`, + ); - return response.data; + return response.data; + } catch (error) { + if (error && typeof error === "object" && "isAxiosError" in error) { + const axiosError = error as AxiosError; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: axiosError.message, + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch conversion currencies", + }); + } }), }); From b9a7a274aba2785c952a2401bf132a355d93d7f5 Mon Sep 17 00:00:00 2001 From: Bassgeta Date: Mon, 25 Aug 2025 12:37:47 +0200 Subject: [PATCH 3/3] fix: refetch button for error payment currency selector, better error handling in router --- .../blocks/payment-currency-selector.tsx | 22 +++++++++++++------ src/components/invoice-form/invoice-form.tsx | 3 ++- src/server/routers/currency.ts | 19 +++++++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/components/invoice-form/blocks/payment-currency-selector.tsx b/src/components/invoice-form/blocks/payment-currency-selector.tsx index 9fd2263d..921fd344 100644 --- a/src/components/invoice-form/blocks/payment-currency-selector.tsx +++ b/src/components/invoice-form/blocks/payment-currency-selector.tsx @@ -10,7 +10,6 @@ import { type InvoiceCurrency, formatCurrencyLabel, } from "@/lib/constants/currencies"; -import type { GetConversionCurrenciesResponse } from "@/server/routers/currency"; import { api } from "@/trpc/react"; import { Loader2 } from "lucide-react"; @@ -29,12 +28,11 @@ export function PaymentCurrencySelector({ data: conversionData, isLoading, error, - } = api.currency.getConversionCurrencies.useQuery( - { - targetCurrency, - network, - }, - ); + refetch, + } = api.currency.getConversionCurrencies.useQuery({ + targetCurrency, + network, + }); if (isLoading) { return ( @@ -66,6 +64,16 @@ export function PaymentCurrencySelector({

Failed to load payment currencies: {error.message}

+

+ {" "} + or refresh the page. +

); } diff --git a/src/components/invoice-form/invoice-form.tsx b/src/components/invoice-form/invoice-form.tsx index 199f4870..0819a76c 100644 --- a/src/components/invoice-form/invoice-form.tsx +++ b/src/components/invoice-form/invoice-form.tsx @@ -45,6 +45,7 @@ import { PaymentCurrencySelector } from "./blocks/payment-currency-selector"; // Constants const PAYMENT_DETAILS_POLLING_INTERVAL = 30000; // 30 seconds in milliseconds const BANK_ACCOUNT_APPROVAL_TIMEOUT = 60000; // 1 minute timeout for bank account approval +const DEFAULT_NETWORK = "sepolia"; type RecurringFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; @@ -899,7 +900,7 @@ export function InvoiceForm({ form.setValue("paymentCurrency", value)} targetCurrency="USD" - network="sepolia" + network={DEFAULT_NETWORK} /> {form.formState.errors.paymentCurrency && (

diff --git a/src/server/routers/currency.ts b/src/server/routers/currency.ts index 0efffc99..ed6d9cbd 100644 --- a/src/server/routers/currency.ts +++ b/src/server/routers/currency.ts @@ -1,6 +1,7 @@ import { apiClient } from "@/lib/axios"; import { TRPCError } from "@trpc/server"; -import type { AxiosError, AxiosResponse } from "axios"; +import type { AxiosResponse } from "axios"; +import axios from "axios"; import { z } from "zod"; import { publicProcedure, router } from "../trpc"; @@ -38,11 +39,19 @@ export const currencyRouter = router({ return response.data; } catch (error) { - if (error && typeof error === "object" && "isAxiosError" in error) { - const axiosError = error as AxiosError; + if (axios.isAxiosError(error)) { + const statusCode = error.response?.status; + const code = + statusCode === 404 + ? "NOT_FOUND" + : statusCode === 400 + ? "BAD_REQUEST" + : "INTERNAL_SERVER_ERROR"; + throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: axiosError.message, + code, + message: error.response?.data?.message || error.message, + cause: error, }); }