Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/invoice-creator.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
116 changes: 116 additions & 0 deletions src/components/invoice-form/blocks/payment-currency-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 { 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,
refetch,
} = api.currency.getConversionCurrencies.useQuery({
targetCurrency,
network,
});

if (isLoading) {
return (
<div className="space-y-2">
<Label htmlFor="paymentCurrency">Payment Currency</Label>
<Select disabled>
<SelectTrigger>
<SelectValue>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading currencies...</span>
</div>
</SelectValue>
</SelectTrigger>
</Select>
</div>
);
}

if (error) {
return (
<div className="space-y-2">
<Label htmlFor="paymentCurrency">Payment Currency</Label>
<Select disabled>
<SelectTrigger>
<SelectValue placeholder="Error loading currencies" />
</SelectTrigger>
</Select>
<p className="text-sm text-red-500">
Failed to load payment currencies: {error.message}
</p>
<p className="text-sm text-red-500">
<button
type="button"
onClick={() => refetch()}
className="text-red-500 underline"
>
Retry
</button>{" "}
or refresh the page.
</p>
</div>
);
}

const conversionRoutes = conversionData?.conversionRoutes || [];

if (conversionRoutes.length === 0) {
return (
<div className="space-y-2">
<Label htmlFor="paymentCurrency">Payment Currency</Label>
<Select disabled>
<SelectTrigger>
<SelectValue placeholder="No payment currencies available" />
</SelectTrigger>
</Select>
<p className="text-sm text-amber-600">
No payment currencies are available for {targetCurrency} on {network}
</p>
</div>
);
}

return (
<div className="space-y-2">
<Label htmlFor="paymentCurrency">Payment Currency</Label>
<Select onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select payment currency" />
</SelectTrigger>
<SelectContent>
{conversionRoutes.map((currency) => (
<SelectItem key={currency.id} value={currency.id}>
{formatCurrencyLabel(currency.id)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
MAINNET_CURRENCIES,
type MainnetCurrency,
formatCurrencyLabel,
getPaymentCurrenciesForInvoice,
} from "@/lib/constants/currencies";
import type { InvoiceFormValues } from "@/lib/schemas/invoice";
import type {
Expand All @@ -40,11 +39,13 @@ 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
const BANK_ACCOUNT_APPROVAL_TIMEOUT = 60000; // 1 minute timeout for bank account approval
const DEFAULT_NETWORK = "sepolia";

type RecurringFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";

Expand Down Expand Up @@ -896,22 +897,11 @@ export function InvoiceForm({
{/* Only show payment currency selector for USD invoices */}
{form.watch("invoiceCurrency") === "USD" && (
<div className="space-y-2">
<Label htmlFor="paymentCurrency">Payment Currency</Label>
<Select
onValueChange={(value) => form.setValue("paymentCurrency", value)}
defaultValue={form.getValues("paymentCurrency")}
>
<SelectTrigger>
<SelectValue placeholder="Select payment currency" />
</SelectTrigger>
<SelectContent>
{getPaymentCurrenciesForInvoice("USD").map((currency) => (
<SelectItem key={currency} value={currency}>
{formatCurrencyLabel(currency)}
</SelectItem>
))}
</SelectContent>
</Select>
<PaymentCurrencySelector
onChange={(value) => form.setValue("paymentCurrency", value)}
targetCurrency="USD"
network={DEFAULT_NETWORK}
/>
{form.formState.errors.paymentCurrency && (
<p className="text-sm text-red-500">
{form.formState.errors.paymentCurrency.message}
Expand Down
2 changes: 2 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,7 @@ export const appRouter = router({
compliance: complianceRouter,
recurringPayment: recurringPaymentRouter,
subscriptionPlan: subscriptionPlanRouter,
currency: currencyRouter,
});

export type AppRouter = typeof appRouter;
64 changes: 64 additions & 0 deletions src/server/routers/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { apiClient } from "@/lib/axios";
import { TRPCError } from "@trpc/server";
import type { AxiosResponse } from "axios";
import axios 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<GetConversionCurrenciesResponse> => {
const { targetCurrency, network } = input;

try {
const response: AxiosResponse<GetConversionCurrenciesResponse> =
await apiClient.get(
`v2/currencies/${targetCurrency}/conversion-routes?network=${network}`,
);

return response.data;
} catch (error) {
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,
message: error.response?.data?.message || error.message,
cause: error,
});
}

throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch conversion currencies",
});
}
}),
});