From 69153878b50f3e19752a1cf99b64582438aa40f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 3 Sep 2025 10:57:20 -0300 Subject: [PATCH 1/8] wip: manteca qr payment --- src/app/(mobile-ui)/qr-pay/layout.tsx | 13 + src/app/(mobile-ui)/qr-pay/page.tsx | 293 ++++++++++++++++++ src/assets/payment-apps/index.ts | 1 + src/assets/payment-apps/mercado-pago.svg | 9 +- src/assets/payment-apps/pix.svg | 1 + src/components/0_Bruddle/Card.tsx | 2 +- src/components/Global/DirectSendQR/index.tsx | 6 +- .../Global/TokenAmountInput/index.tsx | 93 +++--- src/services/manteca.ts | 64 ++++ src/services/services.types.ts | 28 ++ 10 files changed, 457 insertions(+), 53 deletions(-) create mode 100644 src/app/(mobile-ui)/qr-pay/layout.tsx create mode 100644 src/app/(mobile-ui)/qr-pay/page.tsx create mode 100644 src/assets/payment-apps/pix.svg create mode 100644 src/services/manteca.ts diff --git a/src/app/(mobile-ui)/qr-pay/layout.tsx b/src/app/(mobile-ui)/qr-pay/layout.tsx new file mode 100644 index 000000000..c4e6e8e94 --- /dev/null +++ b/src/app/(mobile-ui)/qr-pay/layout.tsx @@ -0,0 +1,13 @@ +import { generateMetadata } from '@/app/metadata' +import PageContainer from '@/components/0_Bruddle/PageContainer' +import React from 'react' + +export const metadata = generateMetadata({ + title: 'QR Payment | Peanut', + description: + 'Process QR payments with Peanut. Support for PIX, QR 3.0, and CODI payments through Manteca integration.', +}) + +export default function QRPayLayout({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx new file mode 100644 index 000000000..250dbb54b --- /dev/null +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -0,0 +1,293 @@ +'use client' + +import { useSearchParams, useRouter } from 'next/navigation' +import { useState, useCallback, useMemo, useEffect } from 'react' +import { Card } from '@/components/0_Bruddle/Card' +import { Button } from '@/components/0_Bruddle/Button' +import { Icon } from '@/components/Global/Icons/Icon' +import { mantecaApi, type QrPaymentResponse } from '@/services/manteca' +import NavHeader from '@/components/Global/NavHeader' +import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' +import Image from 'next/image' +import { useQuery } from '@tanstack/react-query' +import PeanutLoading from '@/components/Global/PeanutLoading' +import TokenAmountInput from '@/components/Global/TokenAmountInput' +import { useWallet } from '@/hooks/wallet/useWallet' +import { isTxReverted } from '@/utils/general.utils' +import ErrorAlert from '@/components/Global/ErrorAlert' +import { chargesApi } from '@/services/charges' +import { SuccessViewDetailsCard } from '@/components/Global/SuccessViewComponents/SuccessViewDetailsCard' +import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' +import { formatUnits, parseUnits } from 'viem' +import type { Address } from 'viem' + +const paymentData1 = { + qrPayment: { + id: '68a5e5dc80f7cf5cc2fa8812', + numberId: '8565', + userId: '689b92079258d7dd1c6f6a73', + userNumberId: '100011086', + userExternalId: '11ce83b6-e52d-4ecf-bea1-85e58052b688', + status: 'STARTING', + type: 'QR3_PAYMENT', + details: { + depositAddress: '0xB9D77f0A3e954109dDae3C302Ac56C87baD60440' as Address, + paymentAssetAmount: '3268.68', + paymentPrice: '1200.00', + paymentAgainstAmount: '2.72390000', + paymentAsset: 'ARS', + paymentAgainst: 'USDT', + qrType: 'QR3', + priceExpireAt: '2025-08-20T12:17:11.760-03:00', + merchant: { + name: 'Diego Maradona', + legalId: '20415301087', + category: 'AGRICULTURAL_SERVICES', + categoryCode: '0473', + }, + }, + currentStage: 1, + stages: { + '1': { + stageType: 'ORDER', + side: 'SELL', + type: 'MARKET', + asset: 'USDT', + against: 'ARS', + assetAmount: '2.72390000', + price: '1200.00', + priceCode: 'b44d2fd3-77ac-4222-845c-87e425bf231b', + }, + '2': { + stageType: 'WITHDRAW', + network: 'QR3', + asset: 'ARS', + amount: '3268.68', + to: 'ARS-.-3268.68-.-Diego Maradona-.-ARG-.-20415301087-.-00020101021140200010com.yacare02022350150011336972350495204739953030325802AR5910HAVANNA SA6012BUENOS AIRES81220010com.yacare0204Y2156304E401-.-e30', + destination: { + address: + 'ARS-.-3268.68-.-Diego Maradona-.-ARG-.-20415301087-.-00020101021140200010com.yacare02022350150011336972350495204739953030325802AR5910HAVANNA SA6012BUENOS AIRES81220010com.yacare0204Y2156304E401-.-e30', + network: 'QR3', + }, + }, + }, + creationTime: '2025-08-20T12:12:28.077-03:00', + updatedAt: '2025-08-20T12:12:28.077-03:00', + }, + charge: { + id: '68a5e5dc80f7cf5cc2fa8812', + uuid: '68a5e5dc80f7cf5cc2fa8812', + chainId: 'ARS', + hash: '0x0', + tokenAddress: '0xfab98b6f3F4c861fCEBD371cD626b31c7920e6E1', + payerAddress: '0xfab98b6f3F4c861fCEBD371cD626b31c7920e6E1', + amount: '3268.68', + status: 'PENDING', + createdAt: '2025-08-20T12:12:28.077-03:00', + updatedAt: '2025-08-20T12:12:28.077-03:00', + userId: '689b92079258d7dd1c6f6a73', + userExternalId: '11ce83b6-e52d-4ecf-', + }, +} + +export default function QRPayPage() { + const searchParams = useSearchParams() + const router = useRouter() + const qrCode = searchParams.get('qrCode') + const { balance, sendMoney, address } = useWallet() + const [isSuccess, setIsSuccess] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [isUserAmountRequired, setIsUserAmountRequired] = useState(false) + const [amount, setAmount] = useState(undefined) + const [qrPayment, setQrPayment] = useState(null) + + const { + data: paymentData, + isLoading, + isError: isErrorInitiatingPayment, + error: errorInitiatingPayment, + } = useQuery({ + queryKey: ['qr-payment', qrCode], + queryFn: async () => { + if (!qrCode) { + throw new Error('No QR code found') + } + try { + const response = await mantecaApi.initiateQrPayment({ qrCode }) + setAmount(response.qrPayment.details.paymentAssetAmount) + return response + } catch (error: unknown) { + if (error instanceof Error && error.message === 'Missing amount') { + setIsUserAmountRequired(true) + return null + } + throw error + } + }, + enabled: !!qrCode, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }) + + const usdAmount = useMemo(() => { + if (!paymentData?.qrPayment) return null + return paymentData.qrPayment.details.paymentAgainstAmount + }, [paymentData]) + + const methodIcon = useMemo(() => { + if (!paymentData) return null + switch (paymentData.qrPayment.type) { + case 'QR3_PAYMENT': + return MERCADO_PAGO + case 'PIX': + return PIX + default: + return null + } + }, [paymentData]) + + + const payQR = useCallback(async () => { + if (!paymentData || !usdAmount) return + const { userOpHash, receipt } = await sendMoney(paymentData.qrPayment.details.depositAddress, usdAmount) + if (receipt !== null && isTxReverted(receipt)) { + setErrorMessage('Transaction reverted by the network.') + setIsSuccess(false) + return + } + const txHash = receipt?.transactionHash ?? userOpHash + chargesApi.createPayment({ + chargeId: paymentData.charge.uuid, + chainId: paymentData.charge.chainId, + hash: txHash, + tokenAddress: paymentData.charge.tokenAddress, + payerAddress: address, + }) + setIsSuccess(true) + }, [paymentData, sendMoney, setIsSuccess, setErrorMessage, usdAmount]) + + useEffect(() => { + if (!usdAmount || balance === undefined) return + if (parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) > balance) { + setErrorMessage('Insufficient funds') + } + }, [usdAmount, balance, setErrorMessage]) + + if (isErrorInitiatingPayment) { + return ( +
+ + + Unable to get QR details + + {errorInitiatingPayment.message || 'An error occurred while getting the QR details.'} + + + + + + +
+ ) + } + + if (isLoading || (!paymentData && !isUserAmountRequired)) { + return + } + + //Success + if (isSuccess) { + return ( +
+ +
+ +
+ + +
+
+
+ ) + } + + return ( +
+ + + {/* Payment Content */} +
+ {/* Merchant Card */} + {paymentData && ( + +
+
+ Mercado Pago +
+
+

+ You're paying +

+

{paymentData.qrPayment.details.merchant.name}

+
+
+
+ )} + + {/* Amount Card */} + + + {/* Send Button */} + + + {/* Error State */} + {errorMessage && } +
+
+ ) +} diff --git a/src/assets/payment-apps/index.ts b/src/assets/payment-apps/index.ts index 724cf5270..cdd89cab2 100644 --- a/src/assets/payment-apps/index.ts +++ b/src/assets/payment-apps/index.ts @@ -3,4 +3,5 @@ export { default as GOOGLE_PAY } from './google-pay.svg' export { default as MERCADO_PAGO } from './mercado-pago.svg' export { default as PAYPAL } from './paypal.svg' export { default as SATISPAY } from './satispay.svg' +export { default as PIX } from './pix.svg' diff --git a/src/assets/payment-apps/mercado-pago.svg b/src/assets/payment-apps/mercado-pago.svg index dc0402166..de6a31c01 100644 --- a/src/assets/payment-apps/mercado-pago.svg +++ b/src/assets/payment-apps/mercado-pago.svg @@ -1,8 +1 @@ - - - - - - - - + diff --git a/src/assets/payment-apps/pix.svg b/src/assets/payment-apps/pix.svg new file mode 100644 index 000000000..6686e8267 --- /dev/null +++ b/src/assets/payment-apps/pix.svg @@ -0,0 +1 @@ + diff --git a/src/components/0_Bruddle/Card.tsx b/src/components/0_Bruddle/Card.tsx index 1289a9fb5..d42052e6b 100644 --- a/src/components/0_Bruddle/Card.tsx +++ b/src/components/0_Bruddle/Card.tsx @@ -29,7 +29,7 @@ const Card = ({ children, className, shadowSize, color = 'primary', ...props }:
{ + return !hideCurrencyToggle && (displayMode === 'TOKEN' || displayMode === 'FIAT') + }, [hideCurrencyToggle, displayMode]) + // This is needed because if we change the token we selected the value // should change. This only depends on the price on purpose!! we don't want // to change when we change the display mode or the value (we already call @@ -152,11 +156,11 @@ const TokenAmountInput = ({ } case 'FIAT': { if (isInputUsd) { - setDisplaySymbol('$') + setDisplaySymbol('U$D') setAlternativeDisplaySymbol(currency?.symbol || '') } else { setDisplaySymbol(currency?.symbol || '') - setAlternativeDisplaySymbol('$') + setAlternativeDisplaySymbol('U$D') } break } @@ -196,48 +200,54 @@ const TokenAmountInput = ({ return (
-
- - { - const value = formatAmountWithoutComma(e.target.value) - onChange(value, isInputUsd) - }} - ref={inputRef} - inputMode="decimal" - type={inputType} - value={displayValue} - step="any" - min="0" - autoComplete="off" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - if (onSubmit) onSubmit() - } - }} - onBlur={() => { - if (onBlur) onBlur() - }} - disabled={disabled} - /> -
- {walletBalance && !hideBalance && ( -
- Your balance: {displayMode === 'FIAT' && currency ? 'US$' : '$'} - {walletBalance} +
+
+ + { + const value = formatAmountWithoutComma(e.target.value) + onChange(value, isInputUsd) + }} + ref={inputRef} + inputMode="decimal" + type={inputType} + value={displayValue} + step="any" + min="0" + autoComplete="off" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + if (onSubmit) onSubmit() + } + }} + onBlur={() => { + if (onBlur) onBlur() + }} + disabled={disabled} + />
- )} - {/* Show conversion line and toggle */} - {!hideCurrencyToggle && (displayMode === 'TOKEN' || displayMode === 'FIAT') && ( + {showConversion && ( + + )} + {walletBalance && !hideBalance && ( +
+ Balance: {displayMode === 'FIAT' && currency ? 'U$D ' : '$ '} + {walletBalance} +
+ )} +
+ {showConversion && (
{ e.preventDefault() const currentValue = displayValue @@ -246,10 +256,7 @@ const TokenAmountInput = ({ setIsInputUsd(!isInputUsd) }} > - - +
)} diff --git a/src/services/manteca.ts b/src/services/manteca.ts new file mode 100644 index 000000000..8e91dbbc2 --- /dev/null +++ b/src/services/manteca.ts @@ -0,0 +1,64 @@ +import { PEANUT_API_URL } from '@/constants' +import { fetchWithSentry } from '@/utils' +import Cookies from 'js-cookie' +import { Address } from 'viem' + +export interface QrPaymentRequest { + qrCode: string + amount?: string +} + +export interface QrPaymentResponse { + qrPayment: { + id: string + externalId: string + sessionId: string + status: string + currentStage: string + stages: any[] + type: 'QR3_PAYMENT' | 'PIX' + details: { + depositAddress: Address + paymentAsset: string + paymentAgainst: string + paymentAgainstAmount: string + paymentAssetAmount: string + paymentPrice: string + priceExpireAt: string + merchant: { + name: string + } + } + } + charge: { + uuid: string + createdAt: string + link: string + chainId: string + tokenAmount: string + tokenAddress: string + tokenDecimals: number + tokenType: string + tokenSymbol: string + } +} + +export const mantecaApi = { + initiateQrPayment: async (data: QrPaymentRequest): Promise => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Cookies.get('jwt-token')}`, + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || `QR payment failed: ${response.statusText}`) + } + + return response.json() + }, +} diff --git a/src/services/services.types.ts b/src/services/services.types.ts index 56f5c03ac..17172c002 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -306,3 +306,31 @@ export interface TCreateOfframpResponse { deposit_chain_id: number deposit_token_address: string } + +// manteca service types +export interface CreateQrPaymentRequest { + qrCode: string + amount?: string +} + +export interface QrPaymentDetails { + paymentAsset?: string + paymentAssetAmount?: string + paymentPrice?: string + priceExpireAt?: string +} + +export interface QrPaymentResponse { + id: string + externalId: string + sessionId: string + status: string + currentStage: string + details?: QrPaymentDetails + stages?: any[] +} + +export interface CreateQrPaymentResponse { + qrPayment: QrPaymentResponse + charge: TRequestChargeResponse +} From 1b7aad504f4c0aa01eeff03a202566a491eeda98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sun, 7 Sep 2025 23:40:49 -0300 Subject: [PATCH 2/8] feat: pay mercadopago and pix QRs --- src/app/(mobile-ui)/qr-pay/page.tsx | 303 +++++++++--------- src/components/Global/DirectSendQR/index.tsx | 3 +- .../TransactionDetails/TransactionCard.tsx | 13 +- .../TransactionDetailsDrawer.tsx | 3 + .../TransactionDetailsHeaderCard.tsx | 4 +- .../TransactionDetailsReceipt.tsx | 4 +- src/hooks/useCurrency.ts | 6 +- src/hooks/useTransactionHistory.ts | 1 + src/services/manteca.ts | 88 +++-- src/utils/history.utils.ts | 19 ++ src/utils/index.ts | 1 + 11 files changed, 255 insertions(+), 190 deletions(-) create mode 100644 src/utils/history.utils.ts diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 3964d001b..e914c661d 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -5,7 +5,8 @@ import { useState, useCallback, useMemo, useEffect } from 'react' import { Card } from '@/components/0_Bruddle/Card' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' -import { mantecaApi, type QrPaymentResponse } from '@/services/manteca' +import { mantecaApi } from '@/services/manteca' +import type { QrPayment, QrPaymentCharge, QrPaymentLock } from '@/services/manteca' import NavHeader from '@/components/Global/NavHeader' import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' import Image from 'next/image' @@ -16,79 +17,12 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { isTxReverted } from '@/utils/general.utils' import ErrorAlert from '@/components/Global/ErrorAlert' import { chargesApi } from '@/services/charges' -import { SuccessViewDetailsCard } from '@/components/Global/SuccessViewComponents/SuccessViewDetailsCard' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { formatUnits, parseUnits } from 'viem' -import type { Address } from 'viem' - -const paymentData1 = { - qrPayment: { - id: '68a5e5dc80f7cf5cc2fa8812', - numberId: '8565', - userId: '689b92079258d7dd1c6f6a73', - userNumberId: '100011086', - userExternalId: '11ce83b6-e52d-4ecf-bea1-85e58052b688', - status: 'STARTING', - type: 'QR3_PAYMENT', - details: { - depositAddress: '0xB9D77f0A3e954109dDae3C302Ac56C87baD60440' as Address, - paymentAssetAmount: '3268.68', - paymentPrice: '1200.00', - paymentAgainstAmount: '2.72390000', - paymentAsset: 'ARS', - paymentAgainst: 'USDT', - qrType: 'QR3', - priceExpireAt: '2025-08-20T12:17:11.760-03:00', - merchant: { - name: 'Diego Maradona', - legalId: '20415301087', - category: 'AGRICULTURAL_SERVICES', - categoryCode: '0473', - }, - }, - currentStage: 1, - stages: { - '1': { - stageType: 'ORDER', - side: 'SELL', - type: 'MARKET', - asset: 'USDT', - against: 'ARS', - assetAmount: '2.72390000', - price: '1200.00', - priceCode: 'b44d2fd3-77ac-4222-845c-87e425bf231b', - }, - '2': { - stageType: 'WITHDRAW', - network: 'QR3', - asset: 'ARS', - amount: '3268.68', - to: 'ARS-.-3268.68-.-Diego Maradona-.-ARG-.-20415301087-.-00020101021140200010com.yacare02022350150011336972350495204739953030325802AR5910HAVANNA SA6012BUENOS AIRES81220010com.yacare0204Y2156304E401-.-e30', - destination: { - address: - 'ARS-.-3268.68-.-Diego Maradona-.-ARG-.-20415301087-.-00020101021140200010com.yacare02022350150011336972350495204739953030325802AR5910HAVANNA SA6012BUENOS AIRES81220010com.yacare0204Y2156304E401-.-e30', - network: 'QR3', - }, - }, - }, - creationTime: '2025-08-20T12:12:28.077-03:00', - updatedAt: '2025-08-20T12:12:28.077-03:00', - }, - charge: { - id: '68a5e5dc80f7cf5cc2fa8812', - uuid: '68a5e5dc80f7cf5cc2fa8812', - chainId: 'ARS', - hash: '0x0', - tokenAddress: '0xfab98b6f3F4c861fCEBD371cD626b31c7920e6E1', - payerAddress: '0xfab98b6f3F4c861fCEBD371cD626b31c7920e6E1', - amount: '3268.68', - status: 'PENDING', - createdAt: '2025-08-20T12:12:28.077-03:00', - updatedAt: '2025-08-20T12:12:28.077-03:00', - userId: '689b92079258d7dd1c6f6a73', - userExternalId: '11ce83b6-e52d-4ecf-', - }, -} +import { getCurrencyPrice } from '@/app/actions/currency' +import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' +import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsReceipt' +import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' export default function QRPayPage() { const searchParams = useSearchParams() @@ -97,60 +31,104 @@ export default function QRPayPage() { const { balance, sendMoney, address } = useWallet() const [isSuccess, setIsSuccess] = useState(false) const [errorMessage, setErrorMessage] = useState(null) - const [isUserAmountRequired, setIsUserAmountRequired] = useState(false) const [amount, setAmount] = useState(undefined) - const [qrPayment, setQrPayment] = useState(null) + const [currencyAmount, setCurrencyAmount] = useState(undefined) + const [qrPayment, setQrPayment] = useState(null) + const [charge, setCharge] = useState(null) + const [paymentLock, setPaymentLock] = useState(null) + const [currency, setCurrency] = useState<{ code: string; symbol: string; price: number } | undefined>(undefined) + const { openTransactionDetails, selectedTransaction } = useTransactionDetailsDrawer() const { - data: paymentData, - isLoading, + data: paymentResponse, + isFetching, isError: isErrorInitiatingPayment, error: errorInitiatingPayment, + refetch: refetchPayment, + dataUpdatedAt, } = useQuery({ + // DONT add amount to the key, we refetch manually queryKey: ['qr-payment', qrCode], queryFn: async () => { if (!qrCode) { throw new Error('No QR code found') } - try { - const response = await mantecaApi.initiateQrPayment({ qrCode }) - setAmount(response.qrPayment.details.paymentAssetAmount) - return response - } catch (error: unknown) { - if (error instanceof Error && error.message === 'Missing amount') { - setIsUserAmountRequired(true) - return null - } - throw error - } + return await mantecaApi.initiateQrPayment({ qrCode, amount: currencyAmount }) }, enabled: !!qrCode, - staleTime: Infinity, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, + staleTime: 1, + refetchOnMount: 'always', + retry: false, }) + const resetState = () => { + setIsSuccess(false) + setErrorMessage(null) + setAmount(undefined) + setCurrencyAmount(undefined) + setQrPayment(null) + setCharge(null) + setPaymentLock(null) + setCurrency(undefined) + } + + useEffect(() => { + resetState() + if (!paymentResponse) return + const getCurrencyObject = async () => { + let currencyCode: string + let price: number + if ('qrPayment' in paymentResponse) { + currencyCode = paymentResponse.qrPayment.details.paymentAsset + price = Number(paymentResponse.qrPayment.details.paymentPrice) + } else { + currencyCode = paymentResponse.paymentLock.paymentAsset + price = await getCurrencyPrice(currencyCode) + } + return { + code: currencyCode, + symbol: currencyCode, + price, + } + } + if ('qrPayment' in paymentResponse) { + setQrPayment(paymentResponse.qrPayment) + setCharge(paymentResponse.charge) + setAmount(paymentResponse.qrPayment.details.paymentAssetAmount) + } else { + setPaymentLock(paymentResponse.paymentLock) + } + getCurrencyObject().then(setCurrency) + // dataUpdatedAt is added because reference to paymeentResponse + // does not change on refetch or on mount but dataUpdatedAt does + }, [paymentResponse, dataUpdatedAt]) + const usdAmount = useMemo(() => { - if (!paymentData?.qrPayment) return null - return paymentData.qrPayment.details.paymentAgainstAmount - }, [paymentData]) + if (!qrPayment) return null + return qrPayment.details.paymentAgainstAmount + }, [qrPayment]) const methodIcon = useMemo(() => { - if (!paymentData) return null - switch (paymentData.qrPayment.type) { + if (!qrPayment && !paymentLock) return null + switch (qrPayment?.type ?? paymentLock!.type) { case 'QR3_PAYMENT': + case 'QR3': return MERCADO_PAGO case 'PIX': return PIX default: return null } - }, [paymentData]) + }, [qrPayment, paymentLock]) + + const merchantName = useMemo(() => { + if (!qrPayment && !paymentLock) return null + return qrPayment?.details.merchant.name ?? paymentLock!.paymentRecipientName + }, [qrPayment, paymentLock]) const payQR = useCallback(async () => { - if (!paymentData || !usdAmount) return - const { userOpHash, receipt } = await sendMoney(paymentData.qrPayment.details.depositAddress, usdAmount) + if (!qrPayment || !charge || !usdAmount) return + const { userOpHash, receipt } = await sendMoney(qrPayment.details.depositAddress, usdAmount) if (receipt !== null && isTxReverted(receipt)) { setErrorMessage('Transaction reverted by the network.') setIsSuccess(false) @@ -158,15 +136,16 @@ export default function QRPayPage() { } const txHash = receipt?.transactionHash ?? userOpHash chargesApi.createPayment({ - chargeId: paymentData.charge.uuid, - chainId: paymentData.charge.chainId, + chargeId: charge.uuid, + chainId: charge.chainId, hash: txHash, - tokenAddress: paymentData.charge.tokenAddress, + tokenAddress: charge.tokenAddress, payerAddress: address, }) setIsSuccess(true) - }, [paymentData, sendMoney, setIsSuccess, setErrorMessage, usdAmount]) + }, [qrPayment, charge, sendMoney, usdAmount]) + // Check user balance useEffect(() => { if (!usdAmount || balance === undefined) return if (parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) > balance) { @@ -194,21 +173,39 @@ export default function QRPayPage() { ) } - if (isLoading || (!paymentData && !isUserAmountRequired)) { + if (isFetching || !paymentResponse || !currency) { return } //Success - if (isSuccess) { + if (isSuccess && !qrPayment) { + // This should never happen, if this happens there is dev error + throw new Error('Invalid state, successful payment but no QR payment data') + } else if (isSuccess) { return (
- + +
+
+ +
+
+ +
+

+ You paid {qrPayment!.details.merchant.name} +

+

+ {currency.symbol} {amount} +

+
+
@@ -243,50 +255,55 @@ export default function QRPayPage() { {/* Payment Content */}
{/* Merchant Card */} - {paymentData && ( - -
-
- Mercado Pago -
-
-

- You're paying -

-

{paymentData.qrPayment.details.merchant.name}

-
+ +
+
+ Mercado Pago
- - )} +
+

+ You're paying +

+

{merchantName}

+
+
+
{/* Amount Card */} - + {currency && ( + + )} {/* Send Button */} {/* Error State */} {errorMessage && }
+
) } diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index 95893e28c..4fb409e14 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -296,7 +296,8 @@ export default function DirectSendQr({ case EQrType.MERCADO_PAGO: case EQrType.PIX: { - redirectUrl = `/qr-pay?qrCode=${data}` + // Casing matters, so send original instead of normalized + redirectUrl = `/qr-pay?qrCode=${originalData}` } break case EQrType.BITCOIN_ONCHAIN: diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 0a34e7688..8af0972f5 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -6,7 +6,7 @@ import { TransactionDirection } from '@/components/TransactionDetails/Transactio import { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' -import { formatNumberForDisplay, printableAddress } from '@/utils' +import { formatNumberForDisplay, printableAddress, getAvatarUrl } from '@/utils' import { getDisplayCurrencySymbol } from '@/utils/currency' import React from 'react' import { STABLE_COINS } from '@/constants' @@ -66,7 +66,7 @@ const TransactionCard: React.FC = ({ const isLinkTx = transaction.extraDataForDrawer?.isLinkTransaction ?? false const userNameForAvatar = transaction.userName - const avatarUrl = transaction.extraDataForDrawer?.rewardData?.avatarUrl + const avatarUrl = getAvatarUrl(transaction) let finalDisplayAmount = '' const actualCurrencyCode = transaction.currency?.code @@ -149,15 +149,11 @@ const TransactionCard: React.FC = ({
{/* txn avatar component handles icon/initials/colors */} {avatarUrl ? ( -
+
Icon @@ -208,6 +204,7 @@ const TransactionCard: React.FC = ({ onClose={closeTransactionDetails} transaction={selectedTransaction} transactionAmount={finalDisplayAmount} + avatarUrl={avatarUrl} /> ) diff --git a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx index b793961b9..75fc115ac 100644 --- a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx +++ b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx @@ -9,6 +9,7 @@ interface TransactionDetailsDrawerProps { /** the transaction data to display, or null if none selected. */ transaction: TransactionDetails | null transactionAmount?: string // dollarized amount of the transaction + avatarUrl?: string } /** @@ -20,6 +21,7 @@ export const TransactionDetailsDrawer: React.FC = onClose, transaction, transactionAmount, + avatarUrl, }) => { // ref for the main content area to calculate dynamic height const contentRef = useRef(null) @@ -53,6 +55,7 @@ export const TransactionDetailsDrawer: React.FC = transactionAmount={transactionAmount} isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} + avatarUrl={avatarUrl} /> diff --git a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx index 3396f0469..77bb008ab 100644 --- a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx +++ b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx @@ -165,8 +165,8 @@ export const TransactionDetailsHeaderCard: React.FC
{avatarUrl ? ( -
- Icon +
+ Icon
) : ( void @@ -48,6 +49,7 @@ export const TransactionDetailsReceipt = ({ className?: HTMLDivElement['className'] isModalOpen?: boolean setIsModalOpen?: (isModalOpen: boolean) => void + avatarUrl?: string }) => { // ref for the main content area to calculate dynamic height const { user } = useUserStore() @@ -293,7 +295,7 @@ export const TransactionDetailsReceipt = ({ isVerified={transaction.isVerified} isLinkTransaction={transaction.extraDataForDrawer?.isLinkTransaction} transactionType={transaction.extraDataForDrawer?.transactionCardType} - avatarUrl={transaction.extraDataForDrawer?.rewardData?.avatarUrl} + avatarUrl={avatarUrl} haveSentMoneyToUser={transaction.haveSentMoneyToUser} /> diff --git a/src/hooks/useCurrency.ts b/src/hooks/useCurrency.ts index 4a50aad03..5fa69206d 100644 --- a/src/hooks/useCurrency.ts +++ b/src/hooks/useCurrency.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { getCurrencyPrice } from '@/app/actions/currency' -const SIMBOLS_BY_CURRENCY_CODE: Record = { +const SYMBOLS_BY_CURRENCY_CODE: Record = { ARS: 'AR$', USD: '$', EUR: '€', @@ -27,7 +27,7 @@ export const useCurrency = (currencyCode: string | null) => { return } - if (!Object.keys(SIMBOLS_BY_CURRENCY_CODE).includes(code)) { + if (!Object.keys(SYMBOLS_BY_CURRENCY_CODE).includes(code)) { setCode(null) setIsLoading(false) return @@ -36,7 +36,7 @@ export const useCurrency = (currencyCode: string | null) => { setIsLoading(true) getCurrencyPrice(code) .then((price) => { - setSymbol(SIMBOLS_BY_CURRENCY_CODE[code]) + setSymbol(SYMBOLS_BY_CURRENCY_CODE[code]) setPrice(price) setIsLoading(false) }) diff --git a/src/hooks/useTransactionHistory.ts b/src/hooks/useTransactionHistory.ts index b93e25774..5a6229727 100644 --- a/src/hooks/useTransactionHistory.ts +++ b/src/hooks/useTransactionHistory.ts @@ -19,6 +19,7 @@ export enum EHistoryEntryType { BRIDGE_OFFRAMP = 'BRIDGE_OFFRAMP', BRIDGE_ONRAMP = 'BRIDGE_ONRAMP', BANK_SEND_LINK_CLAIM = 'BANK_SEND_LINK_CLAIM', + MANTECA_QR_PAYMENT = 'MANTECA_QR_PAYMENT', } export enum EHistoryUserRole { diff --git a/src/services/manteca.ts b/src/services/manteca.ts index d3e2a13b7..8b2e86588 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -8,43 +8,67 @@ export interface QrPaymentRequest { amount?: string } -export interface QrPaymentResponse { - qrPayment: { - id: string - externalId: string - sessionId: string - status: string - currentStage: string - stages: any[] - type: 'QR3_PAYMENT' | 'PIX' - details: { - depositAddress: Address - paymentAsset: string - paymentAgainst: string - paymentAgainstAmount: string - paymentAssetAmount: string - paymentPrice: string - priceExpireAt: string - merchant: { - name: string - } +export type QrPayment = { + id: string + externalId: string + sessionId: string + status: string + currentStage: string + stages: any[] + type: 'QR3_PAYMENT' | 'PIX' + details: { + depositAddress: Address + paymentAsset: string + paymentAgainst: string + paymentAgainstAmount: string + paymentAssetAmount: string + paymentPrice: string + priceExpireAt: string + merchant: { + name: string } } - charge: { - uuid: string - createdAt: string - link: string - chainId: string - tokenAmount: string - tokenAddress: string - tokenDecimals: number - tokenType: string - tokenSymbol: string - } } +export type QrPaymentCharge = { + uuid: string + createdAt: string + link: string + chainId: string + tokenAmount: string + tokenAddress: string + tokenDecimals: number + tokenType: string + tokenSymbol: string +} + +export type QrPaymentLock = { + code: string + type: string + companyId: string + userId: string + userNumberId: string + userExternalId: string + paymentRecipientName: string + paymentRecipientLegalId: string + paymentAssetAmount: string + paymentAsset: string + paymentPrice: string + paymentAgainstAmount: string + paymentAgainst: string + expireAt: string + creationTime: string +} + +export type QrPaymentResponse = + | { + qrPayment: QrPayment + charge: QrPaymentCharge + } + | { paymentLock: QrPaymentLock } + export const mantecaApi = { - initiateQrPayment: async (data: QrPaymentRequest): Promise => { + initiateQrPayment: async (data: QrPaymentRequest): Promise => { const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment`, { method: 'POST', headers: { diff --git a/src/utils/history.utils.ts b/src/utils/history.utils.ts new file mode 100644 index 000000000..c777e4b9e --- /dev/null +++ b/src/utils/history.utils.ts @@ -0,0 +1,19 @@ +import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' +import { EHistoryEntryType } from '@/hooks/useTransactionHistory' +import { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' + +export function getAvatarUrl(transaction: TransactionDetails): string | undefined { + if (transaction.extraDataForDrawer?.rewardData?.avatarUrl) { + return transaction.extraDataForDrawer.rewardData.avatarUrl + } + if (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_QR_PAYMENT) { + switch (transaction.currency?.code) { + case 'ARS': + return MERCADO_PAGO + case 'BRL': + return PIX + default: + return undefined + } + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index df6d54c2a..c580dff9d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,6 +5,7 @@ export * from './balance.utils' export * from './sentry.utils' export * from './token.utils' export * from './ens.utils' +export * from './history.utils' // Bridge utils - explicit exports to avoid naming conflicts export { From a3e15c0766efc551fcd46939bdd71d12d626dba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 8 Sep 2025 00:16:45 -0300 Subject: [PATCH 3/8] fix: add loading state to qr pay --- src/app/(mobile-ui)/qr-pay/page.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index e914c661d..794b97168 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useSearchParams, useRouter } from 'next/navigation' -import { useState, useCallback, useMemo, useEffect } from 'react' +import { useState, useCallback, useMemo, useEffect, useContext } from 'react' import { Card } from '@/components/0_Bruddle/Card' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' @@ -23,6 +23,7 @@ import { getCurrencyPrice } from '@/app/actions/currency' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsReceipt' import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' +import { loadingStateContext } from '@/context' export default function QRPayPage() { const searchParams = useSearchParams() @@ -38,6 +39,7 @@ export default function QRPayPage() { const [paymentLock, setPaymentLock] = useState(null) const [currency, setCurrency] = useState<{ code: string; symbol: string; price: number } | undefined>(undefined) const { openTransactionDetails, selectedTransaction } = useTransactionDetailsDrawer() + const { isLoading, loadingState } = useContext(loadingStateContext) const { data: paymentResponse, @@ -294,10 +296,10 @@ export default function QRPayPage() { icon="arrow-up-right" iconSize={10} shadowSize="4" - loading={isFetching} - disabled={isErrorInitiatingPayment || !!errorMessage || isFetching || !amount} + loading={isFetching || isLoading} + disabled={isErrorInitiatingPayment || !!errorMessage || isFetching || !amount || isLoading} > - {qrPayment ? 'Send' : 'Confirm Amount'} + {isLoading ? loadingState : qrPayment ? 'Send' : 'Confirm Amount'} {/* Error State */} From 5642079107427d2fb5397c605a13eff5338ce3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 13 Sep 2025 17:01:22 -0300 Subject: [PATCH 4/8] feat(qr-pay): update qr-pay page New payment flow where we have to init the lock and then complete it after the user sends the money to the manteca address. Also history items --- src/app/(mobile-ui)/qr-pay/layout.tsx | 3 +- src/app/(mobile-ui)/qr-pay/page.tsx | 254 +++++++++++------- src/app/[...recipient]/client.tsx | 2 +- src/app/actions/currency.ts | 34 +-- src/app/actions/onramp.ts | 2 +- .../AddMoney/components/InputAmountStep.tsx | 2 +- .../Link/views/Confirm.bank-claim.view.tsx | 9 +- .../Common/ActionListDaimoPayButton.tsx | 2 +- src/components/Global/DirectSendQR/index.tsx | 3 +- src/components/Global/DirectSendQR/utils.ts | 16 +- .../Global/TokenAmountInput/index.tsx | 10 +- .../views/ReqFulfillBankFlowManager.tsx | 2 +- .../TransactionDetails/TransactionCard.tsx | 18 +- .../TransactionDetailsHeaderCard.tsx | 11 + .../TransactionDetailsReceipt.tsx | 37 ++- .../transactionTransformer.ts | 9 +- src/constants/general.consts.ts | 2 + src/constants/loadingStates.consts.ts | 2 + src/hooks/useCreateOnramp.ts | 2 +- src/hooks/useCurrency.ts | 20 +- src/services/manteca.ts | 65 ++++- src/utils/__tests__/bridge.utils.test.ts | 22 -- src/utils/currency.ts | 26 +- src/utils/index.ts | 1 - 24 files changed, 347 insertions(+), 207 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/layout.tsx b/src/app/(mobile-ui)/qr-pay/layout.tsx index c4e6e8e94..a93a7ebb0 100644 --- a/src/app/(mobile-ui)/qr-pay/layout.tsx +++ b/src/app/(mobile-ui)/qr-pay/layout.tsx @@ -4,8 +4,7 @@ import React from 'react' export const metadata = generateMetadata({ title: 'QR Payment | Peanut', - description: - 'Process QR payments with Peanut. Support for PIX, QR 3.0, and CODI payments through Manteca integration.', + description: 'Use Peanut to pay Argentinian MercadoPago and Brazilian Pix QR codes', }) export default function QRPayLayout({ children }: { children: React.ReactNode }) { diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 794b97168..627474a5d 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -6,86 +6,104 @@ import { Card } from '@/components/0_Bruddle/Card' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import { mantecaApi } from '@/services/manteca' -import type { QrPayment, QrPaymentCharge, QrPaymentLock } from '@/services/manteca' +import type { QrPayment, QrPaymentLock } from '@/services/manteca' import NavHeader from '@/components/Global/NavHeader' import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' import Image from 'next/image' -import { useQuery } from '@tanstack/react-query' import PeanutLoading from '@/components/Global/PeanutLoading' import TokenAmountInput from '@/components/Global/TokenAmountInput' import { useWallet } from '@/hooks/wallet/useWallet' import { isTxReverted } from '@/utils/general.utils' import ErrorAlert from '@/components/Global/ErrorAlert' -import { chargesApi } from '@/services/charges' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { formatUnits, parseUnits } from 'viem' -import { getCurrencyPrice } from '@/app/actions/currency' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' -import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsReceipt' +import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer' import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' import { loadingStateContext } from '@/context' +import { getCurrencyPrice } from '@/app/actions/currency' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { captureException } from '@sentry/nextjs' +import { isQRPay } from '@/components/Global/DirectSendQR/utils' + +const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' export default function QRPayPage() { const searchParams = useSearchParams() const router = useRouter() const qrCode = searchParams.get('qrCode') - const { balance, sendMoney, address } = useWallet() + const timestamp = searchParams.get('t') + const { balance, sendMoney } = useWallet() const [isSuccess, setIsSuccess] = useState(false) const [errorMessage, setErrorMessage] = useState(null) + const [isBalanceError, setIsBalanceError] = useState(false) + const [errorInitiatingPayment, setErrorInitiatingPayment] = useState(null) + const [paymentLock, setPaymentLock] = useState(null) + const [isFirstLoad, setIsFirstLoad] = useState(true) const [amount, setAmount] = useState(undefined) const [currencyAmount, setCurrencyAmount] = useState(undefined) const [qrPayment, setQrPayment] = useState(null) - const [charge, setCharge] = useState(null) - const [paymentLock, setPaymentLock] = useState(null) const [currency, setCurrency] = useState<{ code: string; symbol: string; price: number } | undefined>(undefined) - const { openTransactionDetails, selectedTransaction } = useTransactionDetailsDrawer() - const { isLoading, loadingState } = useContext(loadingStateContext) - - const { - data: paymentResponse, - isFetching, - isError: isErrorInitiatingPayment, - error: errorInitiatingPayment, - refetch: refetchPayment, - dataUpdatedAt, - } = useQuery({ - // DONT add amount to the key, we refetch manually - queryKey: ['qr-payment', qrCode], - queryFn: async () => { - if (!qrCode) { - throw new Error('No QR code found') - } - return await mantecaApi.initiateQrPayment({ qrCode, amount: currencyAmount }) - }, - enabled: !!qrCode, - staleTime: 1, - refetchOnMount: 'always', - retry: false, - }) + const { openTransactionDetails, selectedTransaction, isDrawerOpen, closeTransactionDetails } = + useTransactionDetailsDrawer() + const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) const resetState = () => { setIsSuccess(false) setErrorMessage(null) + setIsBalanceError(false) + setErrorInitiatingPayment(null) + setPaymentLock(null) + setIsFirstLoad(true) setAmount(undefined) setCurrencyAmount(undefined) setQrPayment(null) - setCharge(null) - setPaymentLock(null) setCurrency(undefined) + setLoadingState('Idle') } + // First fetch for qrcode info useEffect(() => { resetState() - if (!paymentResponse) return + + if (!qrCode || !isQRPay(qrCode)) { + setErrorInitiatingPayment('Invalid QR code scanned') + return + } + + mantecaApi + .initiateQrPayment({ qrCode }) + .then((paymentLock) => { + setPaymentLock(paymentLock) + }) + .catch((error) => { + setErrorInitiatingPayment(error.message) + }) + .finally(() => { + setIsFirstLoad(false) + }) + // Trigger on rescan + }, [timestamp]) + + // Get amount from payment lock + useEffect(() => { + if (!paymentLock) return + if (paymentLock.code !== '') { + setAmount(paymentLock.paymentAssetAmount) + } + }, [paymentLock?.code]) + + // Get currency object from payment lock + useEffect(() => { + if (!paymentLock) return const getCurrencyObject = async () => { let currencyCode: string let price: number - if ('qrPayment' in paymentResponse) { - currencyCode = paymentResponse.qrPayment.details.paymentAsset - price = Number(paymentResponse.qrPayment.details.paymentPrice) + currencyCode = paymentLock.paymentAsset + if (paymentLock.code === '') { + price = (await getCurrencyPrice(currencyCode)).sell } else { - currencyCode = paymentResponse.paymentLock.paymentAsset - price = await getCurrencyPrice(currencyCode) + price = Number(paymentLock.paymentPrice) } return { code: currencyCode, @@ -93,26 +111,21 @@ export default function QRPayPage() { price, } } - if ('qrPayment' in paymentResponse) { - setQrPayment(paymentResponse.qrPayment) - setCharge(paymentResponse.charge) - setAmount(paymentResponse.qrPayment.details.paymentAssetAmount) - } else { - setPaymentLock(paymentResponse.paymentLock) - } getCurrencyObject().then(setCurrency) - // dataUpdatedAt is added because reference to paymeentResponse - // does not change on refetch or on mount but dataUpdatedAt does - }, [paymentResponse, dataUpdatedAt]) + }, [paymentLock?.code]) const usdAmount = useMemo(() => { - if (!qrPayment) return null - return qrPayment.details.paymentAgainstAmount - }, [qrPayment]) + if (!paymentLock) return null + if (paymentLock.code === '') { + return amount + } else { + return paymentLock.paymentAgainstAmount + } + }, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) const methodIcon = useMemo(() => { - if (!qrPayment && !paymentLock) return null - switch (qrPayment?.type ?? paymentLock!.type) { + if (!paymentLock) return null + switch (paymentLock.type) { case 'QR3_PAYMENT': case 'QR3': return MERCADO_PAGO @@ -121,48 +134,75 @@ export default function QRPayPage() { default: return null } - }, [qrPayment, paymentLock]) + }, [paymentLock]) const merchantName = useMemo(() => { - if (!qrPayment && !paymentLock) return null - return qrPayment?.details.merchant.name ?? paymentLock!.paymentRecipientName - }, [qrPayment, paymentLock]) + if (!paymentLock) return null + return paymentLock.paymentRecipientName + }, [paymentLock]) const payQR = useCallback(async () => { - if (!qrPayment || !charge || !usdAmount) return - const { userOpHash, receipt } = await sendMoney(qrPayment.details.depositAddress, usdAmount) + if (!paymentLock || !qrCode || !currencyAmount) return + let finalPaymentLock = paymentLock + if (finalPaymentLock.code === '') { + setLoadingState('Fetching details') + try { + finalPaymentLock = await mantecaApi.initiateQrPayment({ qrCode, amount: currencyAmount }) + setPaymentLock(finalPaymentLock) + } catch (error) { + captureException(error) + setErrorMessage('Could not initiate payment due to unexpected error. Please contact support') + setIsSuccess(false) + setLoadingState('Idle') + return + } + } + if (finalPaymentLock.code === '') { + finalPaymentLock + setErrorMessage('Could not fetch qr payment details') + setIsSuccess(false) + setLoadingState('Idle') + return + } + setLoadingState('Preparing transaction') + const { userOpHash, receipt } = await sendMoney(MANTECA_DEPOSIT_ADDRESS, finalPaymentLock.paymentAgainstAmount) if (receipt !== null && isTxReverted(receipt)) { setErrorMessage('Transaction reverted by the network.') setIsSuccess(false) return } const txHash = receipt?.transactionHash ?? userOpHash - chargesApi.createPayment({ - chargeId: charge.uuid, - chainId: charge.chainId, - hash: txHash, - tokenAddress: charge.tokenAddress, - payerAddress: address, - }) - setIsSuccess(true) - }, [qrPayment, charge, sendMoney, usdAmount]) + setLoadingState('Paying') + try { + const qrPayment = await mantecaApi.completeQrPayment({ paymentLockCode: finalPaymentLock.code, txHash }) + setQrPayment(qrPayment) + setIsSuccess(true) + } catch (error) { + captureException(error) + setErrorMessage('Could not complete payment due to unexpected error. Please contact support') + setIsSuccess(false) + } finally { + setLoadingState('Idle') + } + }, [paymentLock?.code, sendMoney, usdAmount, qrCode, currencyAmount]) // Check user balance useEffect(() => { - if (!usdAmount || balance === undefined) return - if (parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) > balance) { - setErrorMessage('Insufficient funds') + if (!usdAmount || balance === undefined) { + setIsBalanceError(false) + return } - }, [usdAmount, balance, setErrorMessage]) + setIsBalanceError(parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) > balance) + }, [usdAmount, balance]) - if (isErrorInitiatingPayment) { + if (!!errorInitiatingPayment) { return (
Unable to get QR details - {errorInitiatingPayment.message || 'An error occurred while getting the QR details.'} + {errorInitiatingPayment || 'An error occurred while getting the QR details.'} @@ -175,20 +215,20 @@ export default function QRPayPage() { ) } - if (isFetching || !paymentResponse || !currency) { + if (isFirstLoad || !paymentLock || !currency) { return } //Success if (isSuccess && !qrPayment) { // This should never happen, if this happens there is dev error - throw new Error('Invalid state, successful payment but no QR payment data') + return null } else if (isSuccess) { return (
- +
You paid {qrPayment!.details.merchant.name} -

- {currency.symbol} {amount} -

+
+ {currency.symbol} {qrPayment!.details.paymentAssetAmount} +
+
≈ {usdAmount} U$D
+
) } @@ -274,38 +327,39 @@ export default function QRPayPage() { {/* Amount Card */} {currency && ( )} + {isBalanceError && } + + {/* Information Card */} + + + + {/* Send Button */} {/* Error State */} {errorMessage && }
-
) } diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index 01f612932..150ad9a2d 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -474,7 +474,7 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props) ? { code: currencyCode, symbol: currencySymbol!, - price: currencyPrice!, + price: currencyPrice!.buy, } : undefined } diff --git a/src/app/actions/currency.ts b/src/app/actions/currency.ts index 01db4cc26..f6b8f9da1 100644 --- a/src/app/actions/currency.ts +++ b/src/app/actions/currency.ts @@ -1,16 +1,18 @@ 'use server' import { unstable_cache } from 'next/cache' -import { fetchWithSentry } from '@/utils' import { getExchangeRate } from './exchange-rate' import { AccountType } from '@/interfaces' +import { mantecaApi } from '@/services/manteca' export const getCurrencyPrice = unstable_cache( - async (currencyCode: string): Promise => { - let price: number + async (currencyCode: string): Promise<{ buy: number; sell: number }> => { + let buy: number + let sell: number currencyCode = currencyCode.toUpperCase() switch (currencyCode) { case 'USD': - price = 1 + buy = 1 + sell = 1 break case 'EUR': case 'MXN': @@ -30,26 +32,28 @@ export const getCurrencyPrice = unstable_cache( if (!data) { throw new Error('No data returned from exchange rate API') } - price = parseFloat(data.buy_rate) + buy = parseFloat(data.buy_rate) + sell = parseFloat(data.sell_rate) } break case 'ARS': + case 'BRL': + case 'COP': + case 'CRC': + case 'PUSD': + case 'GTQ': + case 'PHP': + case 'BOB': { - const response = await fetchWithSentry('https://dolarapi.com/v1/dolares/cripto') - const data = await response.json() - - if (!data.compra || !data.venta) { - throw new Error('Invalid response from dolarapi') - } - - // Average between buy and sell price - price = (data.compra + data.venta) / 2 + const response = await mantecaApi.getPrices({ asset: 'USDC', against: currencyCode }) + buy = Number(response.effectiveBuy) + sell = Number(response.effectiveSell) } break default: throw new Error('Unsupported currency') } - return price + return { buy, sell } }, ['getCurrencyPrice'], { diff --git a/src/app/actions/onramp.ts b/src/app/actions/onramp.ts index 5a542494a..ad37f9776 100644 --- a/src/app/actions/onramp.ts +++ b/src/app/actions/onramp.ts @@ -78,7 +78,7 @@ export async function createOnrampForGuest( try { const { currency, paymentRail } = getCurrencyConfig(params.country.id, 'onramp') const price = await getCurrencyPrice(currency) - const amount = (Number(params.amount) * price).toFixed(2) + const amount = (Number(params.amount) * price.buy).toFixed(2) const response = await fetchWithSentry(`${apiUrl}/bridge/onramp/create-for-guest`, { method: 'POST', diff --git a/src/components/AddMoney/components/InputAmountStep.tsx b/src/components/AddMoney/components/InputAmountStep.tsx index d9229c64d..aae6e9ad8 100644 --- a/src/components/AddMoney/components/InputAmountStep.tsx +++ b/src/components/AddMoney/components/InputAmountStep.tsx @@ -53,7 +53,7 @@ const InputAmountStep = ({ ? { code: currencyData.code!, symbol: currencyData.symbol!, - price: currencyData.price!, + price: currencyData.price!.buy, } : undefined } diff --git a/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx index 9c7fddeac..d2d008160 100644 --- a/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx +++ b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx @@ -14,7 +14,6 @@ import { formatUnits } from 'viem' import ExchangeRate from '@/components/ExchangeRate' import { AccountType } from '@/interfaces' import { useCurrency } from '@/hooks/useCurrency' -import { getCurrencySymbol } from '@/utils/bridge.utils' interface ConfirmBankClaimViewProps { onConfirm: () => void @@ -72,15 +71,15 @@ export function ConfirmBankClaimView({ // fallback if conversion fails const failedConversion = useMemo(() => { - return currencyCode !== 'USD' && !isLoadingCurrency && (!price || isNaN(price)) + return currencyCode !== 'USD' && !isLoadingCurrency && (!price?.sell || isNaN(price.sell)) }, [currencyCode, isLoadingCurrency, price]) // display amount in local currency const displayAmount = useMemo(() => { if (currencyCode === 'USD') return usdAmount if (isLoadingCurrency) return '-' - if (!price || isNaN(price)) return usdAmount - const converted = (Number(usdAmount) * price).toFixed(2) + if (!price?.sell || isNaN(price.sell)) return usdAmount + const converted = (Number(usdAmount) * price.sell).toFixed(2) return converted }, [price, usdAmount, currencyCode, isLoadingCurrency]) @@ -88,7 +87,7 @@ export function ConfirmBankClaimView({ if (currencyCode === 'USD') return '$' // fallback to $ if conversion fails if (failedConversion) return '$' - return resolvedSymbol ?? getCurrencySymbol(currencyCode) + return resolvedSymbol ?? currencyCode }, [currencyCode, resolvedSymbol, failedConversion]) return ( diff --git a/src/components/Common/ActionListDaimoPayButton.tsx b/src/components/Common/ActionListDaimoPayButton.tsx index aec0a7ea2..98b4e5774 100644 --- a/src/components/Common/ActionListDaimoPayButton.tsx +++ b/src/components/Common/ActionListDaimoPayButton.tsx @@ -52,7 +52,7 @@ const ActionListDaimoPayButton = () => { ? { code: currencyCode, symbol: currencySymbol || '', - price: currencyPrice || 0, + price: currencyPrice?.buy || 0, } : undefined, currencyAmount: usdAmount, diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index 4fb409e14..c1105b656 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -296,8 +296,9 @@ export default function DirectSendQr({ case EQrType.MERCADO_PAGO: case EQrType.PIX: { + const timestamp = Date.now() // Casing matters, so send original instead of normalized - redirectUrl = `/qr-pay?qrCode=${originalData}` + redirectUrl = `/qr-pay?qrCode=${originalData}&t=${timestamp}` } break case EQrType.BITCOIN_ONCHAIN: diff --git a/src/components/Global/DirectSendQR/utils.ts b/src/components/Global/DirectSendQR/utils.ts index 302a00071..e7625190c 100644 --- a/src/components/Global/DirectSendQR/utils.ts +++ b/src/components/Global/DirectSendQR/utils.ts @@ -50,7 +50,12 @@ const MP_AR_REGEX = /^000201((?!6304).)*(?:(?:26|27|28|29|30|31|35|43)\d{2}(?:0015com\.mercadopago|0016com\.mercadolibre)).*5303032.*5802AR((?!6304).)*6304[0-9A-F]{4}$/i /* PIX is also a emvco qr code */ -const PIX_REGEX = /^.*00020126.*0014br\.gov\.bcb\.pix.*5303986.*5802BR.*$/i +const PIX_REGEX = /^.*000201.*0014br\.gov\.bcb\.pix.*5303986.*5802BR.*$/i + +export const QR_PAY_REGEXES: { [key in QrType]?: RegExp } = { + [EQrType.MERCADO_PAGO]: MP_AR_REGEX, + [EQrType.PIX]: PIX_REGEX, +} const EIP_681_REGEX = /^ethereum:(?:pay-)?([^@/?]+)(?:@([^/?]+))?(?:\/([^?]+))?(?:\?(.*))?$/i @@ -88,6 +93,15 @@ export function recognizeQr(data: string): QrType | null { return null } +export const isQRPay = (data: string): boolean => { + for (const [_type, regex] of Object.entries(QR_PAY_REGEXES)) { + if (regex.test(data)) { + return true + } + } + return false +} + /** * Extracts EIP-681 parameters from an Ethereum URI * @param data The Ethereum URI string (e.g. "ethereum:0x123...?value=1e18") diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 14b56fed2..71a1c792b 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -200,13 +200,15 @@ const TokenAmountInput = ({ return (
+ + {/* Input */}
+ + {/* Conversion */} {showConversion && ( )} + + {/* Balance */} {walletBalance && !hideBalance && (
Balance: {displayMode === 'FIAT' && currency ? 'U$D ' : '$ '} @@ -245,6 +251,8 @@ const TokenAmountInput = ({
)}
+ + {/* Conversion toggle */} {showConversion && (
{ - const currencyAmount = Number(usdAmount) * price + const currencyAmount = Number(usdAmount) * price.buy if (currencyAmount < minAmount) { setErrorMessage(`Minimum amount is ${minAmount.toFixed(2)} ${currency}`) } else { diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 8af0972f5..3652956f8 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -27,6 +27,7 @@ export type TransactionType = | 'bank_request_fulfillment' | 'claim_external' | 'bank_claim' + | 'pay' interface TransactionCardProps { type: TransactionType @@ -89,8 +90,8 @@ const TransactionCard: React.FC = ({ const currencySymbol = getDisplayCurrencySymbol(actualCurrencyCode.toUpperCase()) finalDisplayAmount = `${currencySymbol}${formatNumberForDisplay(currencyAmount, { maxDecimals: defaultDisplayDecimals })}` } - } else if (actualCurrencyCode === 'ARS' && transaction.currency?.amount) { - let arsSign = '' + } else if (transaction.currency?.amount) { + let sign = '' const originalType = transaction.extraDataForDrawer?.originalType as EHistoryEntryType | undefined const originalUserRole = transaction.extraDataForDrawer?.originalUserRole as EHistoryUserRole | undefined @@ -98,18 +99,20 @@ const TransactionCard: React.FC = ({ originalUserRole === EHistoryUserRole.SENDER && (originalType === EHistoryEntryType.SEND_LINK || originalType === EHistoryEntryType.DIRECT_SEND || - originalType === EHistoryEntryType.CASHOUT) + originalType === EHistoryEntryType.CASHOUT || + originalType === EHistoryEntryType.MANTECA_QR_PAYMENT) ) { - arsSign = '-' + sign = '-' } else if ( originalUserRole === EHistoryUserRole.RECIPIENT && (originalType === EHistoryEntryType.DEPOSIT || originalType === EHistoryEntryType.SEND_LINK || - originalType === EHistoryEntryType.DIRECT_SEND) + originalType === EHistoryEntryType.DIRECT_SEND || + originalType === EHistoryEntryType.MANTECA_QR_PAYMENT) ) { - arsSign = '+' + sign = '+' } - finalDisplayAmount = `${arsSign}${getDisplayCurrencySymbol('ARS')}${formatNumberForDisplay(transaction.currency.amount, { maxDecimals: defaultDisplayDecimals })}` + finalDisplayAmount = `${sign}${getDisplayCurrencySymbol(actualCurrencyCode)}${formatNumberForDisplay(transaction.currency.amount, { maxDecimals: defaultDisplayDecimals })}` } // keep currency as $ because we will always receive in USDC else if (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.DEPOSIT) { @@ -234,6 +237,7 @@ function getActionIcon(type: TransactionType, direction: TransactionDirection): case 'cashout': case 'claim_external': case 'bank_claim': + case 'pay': iconName = 'arrow-up' iconSize = 8 break diff --git a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx index 77bb008ab..b52613a3c 100644 --- a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx +++ b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx @@ -23,6 +23,7 @@ export type TransactionDirection = | 'bank_deposit' | 'bank_request_fulfillment' | 'claim_external' + | 'qr_payment' interface TransactionDetailsHeaderCardProps { direction: TransactionDirection @@ -110,6 +111,15 @@ const getTitle = ( titleText = `Claiming to ${displayName}` } break + case 'qr_payment': + if (status === 'completed') { + titleText = `Paid to ${displayName}` + } else if (status === 'failed') { + titleText = `Payment to ${displayName}` + } else { + titleText = `Paying to ${displayName}` + } + break default: titleText = displayName break @@ -127,6 +137,7 @@ const getIcon = (direction: TransactionDirection, isLinkTransaction?: boolean): switch (direction) { case 'send': case 'bank_request_fulfillment': + case 'qr_payment': return 'arrow-up-right' case 'request_sent': case 'receive': diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 315a6c604..593b54751 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -115,7 +115,7 @@ export const TransactionDetailsReceipt = ({ ), fee: transaction.fee !== undefined, exchangeRate: !!( - transaction.direction === 'bank_deposit' && + (transaction.direction === 'bank_deposit' || transaction.direction === 'qr_payment') && transaction.status === 'completed' && transaction.currency?.code && transaction.currency.code.toUpperCase() !== 'USD' @@ -251,7 +251,7 @@ export const TransactionDetailsReceipt = ({ } else { // default: use currency amount if provided, otherwise fallback to raw amount - never show token value, only USD if (transaction.currency?.amount) { - amountDisplay = `$ ${formatAmount(Number(transaction.currency.amount))}` + amountDisplay = `${transaction.currency.code} ${formatAmount(Number(transaction.currency.amount))}` } else { amountDisplay = `$ ${formatAmount(transaction.amount as number)}` } @@ -295,7 +295,7 @@ export const TransactionDetailsReceipt = ({ isVerified={transaction.isVerified} isLinkTransaction={transaction.extraDataForDrawer?.isLinkTransaction} transactionType={transaction.extraDataForDrawer?.transactionCardType} - avatarUrl={avatarUrl} + avatarUrl={avatarUrl ?? transaction.extraDataForDrawer?.avatarUrl} haveSentMoneyToUser={transaction.haveSentMoneyToUser} /> @@ -440,15 +440,26 @@ export const TransactionDetailsReceipt = ({ {/* Exchange rate and original currency for completed bank_deposit transactions */} {rowVisibilityConfig.exchangeRate && ( <> - { - const currencyAmount = transaction.currency?.amount || transaction.amount.toString() - const currencySymbol = getDisplayCurrencySymbol(transaction.currency!.code) - return `${currencySymbol} ${formatAmount(Number(currencyAmount))}` - })()} - hideBottomBorder={false} - /> + {transaction.direction !== 'qr_payment' && ( + { + const currencyAmount = + transaction.currency?.amount || transaction.amount.toString() + const currencySymbol = getDisplayCurrencySymbol(transaction.currency!.code) + return `${currencySymbol} ${formatAmount(Number(currencyAmount))}` + })()} + hideBottomBorder={false} + /> + )} + {transaction.direction === 'qr_payment' && + transaction.extraDataForDrawer?.receipt?.exchange_rate && ( + + )} + {/* TODO: stop using snake_case!!!!! */} {transaction.extraDataForDrawer?.receipt?.exchange_rate && ( )} diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 769bd3746..ed65cd4e9 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -71,6 +71,7 @@ export interface TransactionDetails { rewardData?: RewardData fulfillmentType?: 'bridge' | 'wallet' bridgeTransferId?: string + avatarUrl?: string depositInstructions?: { amount: string currency: string @@ -292,6 +293,12 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact nameForDetails = entry.senderAccount?.identifier || 'Deposit Source' isPeerActuallyUser = false break + case EHistoryEntryType.MANTECA_QR_PAYMENT: + direction = 'qr_payment' + transactionCardType = 'pay' + nameForDetails = entry.recipientAccount?.identifier || 'Merchant' + isPeerActuallyUser = false + break default: direction = 'send' transactionCardType = 'send' @@ -428,7 +435,7 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact id: entry.uuid, direction: direction, userName: nameForDetails, - amount: amount, + amount, currency: rewardData ? undefined : entry.currency, currencySymbol: `${entry.userRole === EHistoryUserRole.SENDER ? '-' : '+'}$`, tokenSymbol: rewardData?.getSymbol(amount) ?? entry.tokenSymbol, diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index 9e2dc58ad..0327e708f 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -46,6 +46,8 @@ export const PEANUT_API_URL = ( 'https://api.peanut.me' ).replace(/\/$/, '') // remove any accidental trailing slash +export const PEANUT_API_KEY = process.env.PEANUT_API_KEY! + export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://peanut.me' export const next_proxy_url = '/api/proxy' diff --git a/src/constants/loadingStates.consts.ts b/src/constants/loadingStates.consts.ts index ea434842e..ee502f0c5 100644 --- a/src/constants/loadingStates.consts.ts +++ b/src/constants/loadingStates.consts.ts @@ -9,6 +9,7 @@ export type LoadingStates = | 'Creating link' | 'Switching network' | 'Fetching route' + | 'Fetching details' | 'Awaiting route fulfillment' | 'Asserting values' | 'Generating details' @@ -25,3 +26,4 @@ export type LoadingStates = | 'Requesting' | 'Logging in' | 'Logging out' + | 'Paying' diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts index 389919634..d2349f7ce 100644 --- a/src/hooks/useCreateOnramp.ts +++ b/src/hooks/useCreateOnramp.ts @@ -45,7 +45,7 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => { if (usdAmount) { // Get currency configuration for the country const price = await getCurrencyPrice(currency) - amount = (Number(usdAmount) * price).toFixed(2) + amount = (Number(usdAmount) * price.buy).toFixed(2) } // Call backend to create onramp via proxy route diff --git a/src/hooks/useCurrency.ts b/src/hooks/useCurrency.ts index 3e369ed68..a103fa8dd 100644 --- a/src/hooks/useCurrency.ts +++ b/src/hooks/useCurrency.ts @@ -1,18 +1,28 @@ import { useState, useEffect } from 'react' import { getCurrencyPrice } from '@/app/actions/currency' -const SYMBOLS_BY_CURRENCY_CODE: Record = { +export const SYMBOLS_BY_CURRENCY_CODE: Record = { ARS: 'AR$', USD: '$', EUR: '€', MXN: 'MX$', + BRL: 'R$', + COP: 'Col$', + CRC: '₡', + BOB: '$b', + PUSD: 'PUSD', + GTQ: 'Q', + PHP: '₱', + GBP: '£', + JPY: '¥', + CAD: 'CA$', } export const useCurrency = (currencyCode: string | null) => { const [code, setCode] = useState(currencyCode?.toUpperCase() ?? null) const [symbol, setSymbol] = useState(null) - const [price, setPrice] = useState(null) - const [isLoading, setIsLoading] = useState(true) + const [price, setPrice] = useState<{ buy: number; sell: number } | null>(null) + const [isLoading, setIsLoading] = useState(false) useEffect(() => { if (!code) { @@ -21,8 +31,8 @@ export const useCurrency = (currencyCode: string | null) => { } if (code === 'USD') { - setSymbol('$') - setPrice(1) + setSymbol(SYMBOLS_BY_CURRENCY_CODE[code]) + setPrice({ buy: 1, sell: 1 }) setIsLoading(false) return } diff --git a/src/services/manteca.ts b/src/services/manteca.ts index 8b2e86588..4b18f254c 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -1,7 +1,7 @@ -import { PEANUT_API_URL } from '@/constants' +import { PEANUT_API_URL, PEANUT_API_KEY } from '@/constants' import { fetchWithSentry } from '@/utils' import Cookies from 'js-cookie' -import { Address } from 'viem' +import type { Address, Hash } from 'viem' export interface QrPaymentRequest { qrCode: string @@ -67,9 +67,28 @@ export type QrPaymentResponse = } | { paymentLock: QrPaymentLock } +export type MantecaPrice = { + ticker: string + buy: string + sell: string + timestamp: string + variation: { + buy: { + realtime: string + daily: string + } + sell: { + realtime: string + daily: string + } + } + effectiveBuy: string + effectiveSell: string +} + export const mantecaApi = { - initiateQrPayment: async (data: QrPaymentRequest): Promise => { - const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment`, { + initiateQrPayment: async (data: QrPaymentRequest): Promise => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment/init`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -83,6 +102,44 @@ export const mantecaApi = { throw new Error(errorData.message || `QR payment failed: ${response.statusText}`) } + return response.json() + }, + completeQrPayment: async ({ + paymentLockCode, + txHash, + }: { + paymentLockCode: string + txHash: Hash + }): Promise => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Cookies.get('jwt-token')}`, + }, + body: JSON.stringify({ paymentLockCode, txHash }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || `QR payment failed: ${response.statusText}`) + } + + return response.json() + }, + getPrices: async ({ asset, against }: { asset: string; against: string }): Promise => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/prices?asset=${asset}&against=${against}`, { + headers: { + 'Content-Type': 'application/json', + 'api-key': PEANUT_API_KEY, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || `Get prices failed: ${response.statusText}`) + } + return response.json() }, } diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 44291affb..b18efe4e3 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -1,7 +1,6 @@ import { getCurrencyConfig, getOfframpCurrencyConfig, - getCurrencySymbol, getPaymentRailDisplayName, getMinimumAmount, } from '../bridge.utils' @@ -150,27 +149,6 @@ describe('bridge.utils', () => { }) }) - describe('getCurrencySymbol', () => { - it('should return correct symbols for supported currencies', () => { - expect(getCurrencySymbol('usd')).toBe('$') - expect(getCurrencySymbol('USD')).toBe('$') - expect(getCurrencySymbol('eur')).toBe('€') - expect(getCurrencySymbol('EUR')).toBe('€') - expect(getCurrencySymbol('mxn')).toBe('MX$') - expect(getCurrencySymbol('MXN')).toBe('MX$') - }) - - it('should return uppercase currency code for unsupported currencies', () => { - expect(getCurrencySymbol('gbp')).toBe('GBP') - expect(getCurrencySymbol('jpy')).toBe('JPY') - expect(getCurrencySymbol('cad')).toBe('CAD') - }) - - it('should handle empty strings', () => { - expect(getCurrencySymbol('')).toBe('') - }) - }) - describe('getPaymentRailDisplayName', () => { it('should return correct display names for supported payment rails', () => { expect(getPaymentRailDisplayName('ach_push')).toBe('ACH Transfer') diff --git a/src/utils/currency.ts b/src/utils/currency.ts index 05bced81d..0ac45a6bf 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -1,35 +1,15 @@ -import { getCurrencySymbol } from './bridge.utils' +import { SYMBOLS_BY_CURRENCY_CODE } from '@/hooks/useCurrency' // Helper function to get currency symbol based on code export const getDisplayCurrencySymbol = (code?: string, fallbackSymbol: string = '$'): string => { if (!code) return fallbackSymbol const upperCode = code.toUpperCase() - - switch (upperCode) { - case 'ARS': - return 'AR$' - case 'USD': - return '$' - case 'EUR': - return '€' - case 'GBP': - return '£' - case 'JPY': - return '¥' - case 'MXN': - return 'MX$' - case 'BRL': - return 'R$' - case 'CAD': - return 'CA$' - default: - return upperCode // Return the currency code itself as fallback (e.g., "CHF") - } + return SYMBOLS_BY_CURRENCY_CODE[upperCode] ?? upperCode } // Simple currency amount formatter export const formatCurrencyAmount = (amount: string | number, currencyCode: string): string => { - const symbol = getCurrencySymbol(currencyCode) + const symbol = getDisplayCurrencySymbol(currencyCode) const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount if (isNaN(numAmount)) return `${symbol}0` diff --git a/src/utils/index.ts b/src/utils/index.ts index c580dff9d..ca8c0031e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,7 +11,6 @@ export * from './history.utils' export { getCurrencyConfig as getBridgeCurrencyConfig, getOfframpCurrencyConfig, - getCurrencySymbol, getPaymentRailDisplayName, getMinimumAmount, } from './bridge.utils' From d9b283d46f31c54e74bad784f8fa998373e5856b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 15 Sep 2025 14:32:04 -0300 Subject: [PATCH 5/8] feat(qr-pay): add max amount validation --- src/app/(mobile-ui)/qr-pay/page.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 627474a5d..971a1dbc2 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -27,6 +27,7 @@ import { captureException } from '@sentry/nextjs' import { isQRPay } from '@/components/Global/DirectSendQR/utils' const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' +const MAX_QR_PAYMENT_AMOUNT = '200' export default function QRPayPage() { const searchParams = useSearchParams() @@ -36,7 +37,7 @@ export default function QRPayPage() { const { balance, sendMoney } = useWallet() const [isSuccess, setIsSuccess] = useState(false) const [errorMessage, setErrorMessage] = useState(null) - const [isBalanceError, setIsBalanceError] = useState(false) + const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const [errorInitiatingPayment, setErrorInitiatingPayment] = useState(null) const [paymentLock, setPaymentLock] = useState(null) const [isFirstLoad, setIsFirstLoad] = useState(true) @@ -51,7 +52,7 @@ export default function QRPayPage() { const resetState = () => { setIsSuccess(false) setErrorMessage(null) - setIsBalanceError(false) + setBalanceErrorMessage(null) setErrorInitiatingPayment(null) setPaymentLock(null) setIsFirstLoad(true) @@ -189,10 +190,17 @@ export default function QRPayPage() { // Check user balance useEffect(() => { if (!usdAmount || balance === undefined) { - setIsBalanceError(false) + setBalanceErrorMessage(null) return } - setIsBalanceError(parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) > balance) + const paymentAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) + if (paymentAmount > parseUnits(MAX_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { + setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) + } else if (paymentAmount > balance) { + setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') + } else { + setBalanceErrorMessage(null) + } }, [usdAmount, balance]) if (!!errorInitiatingPayment) { @@ -336,7 +344,7 @@ export default function QRPayPage() { hideBalance /> )} - {isBalanceError && } + {balanceErrorMessage && } {/* Information Card */} @@ -352,7 +360,9 @@ export default function QRPayPage() { onClick={payQR} shadowSize="4" loading={isLoading} - disabled={!!errorInitiatingPayment || !!errorMessage || !amount || isLoading || isBalanceError} + disabled={ + !!errorInitiatingPayment || !!errorMessage || !amount || isLoading || !!balanceErrorMessage + } > {isLoading ? loadingState : 'Pay'} From 22ac554b44bd6af05730ea19c773872719ed08c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 15 Sep 2025 14:33:15 -0300 Subject: [PATCH 6/8] refactor: rename U$D to USD --- src/app/(mobile-ui)/qr-pay/page.tsx | 2 +- src/components/Global/TokenAmountInput/index.tsx | 6 +++--- .../TransactionDetails/TransactionDetailsReceipt.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 971a1dbc2..4e02ee297 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -254,7 +254,7 @@ export default function QRPayPage() {
{currency.symbol} {qrPayment!.details.paymentAssetAmount}
-
≈ {usdAmount} U$D
+
≈ {usdAmount} USD
diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 71a1c792b..5a3e6c96a 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -156,11 +156,11 @@ const TokenAmountInput = ({ } case 'FIAT': { if (isInputUsd) { - setDisplaySymbol('U$D') + setDisplaySymbol('USD') setAlternativeDisplaySymbol(currency?.symbol || '') } else { setDisplaySymbol(currency?.symbol || '') - setAlternativeDisplaySymbol('U$D') + setAlternativeDisplaySymbol('USD') } break } @@ -246,7 +246,7 @@ const TokenAmountInput = ({ {/* Balance */} {walletBalance && !hideBalance && (
- Balance: {displayMode === 'FIAT' && currency ? 'U$D ' : '$ '} + Balance: {displayMode === 'FIAT' && currency ? 'USD ' : '$ '} {walletBalance}
)} diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 593b54751..f052de0f8 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -456,7 +456,7 @@ export const TransactionDetailsReceipt = ({ transaction.extraDataForDrawer?.receipt?.exchange_rate && ( )} {/* TODO: stop using snake_case!!!!! */} From da60e400884f72b51cb2e59fdedca6733881f91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 15 Sep 2025 14:33:34 -0300 Subject: [PATCH 7/8] refactor(qr-pay): rename isQRPay to isPaymentProcessorQR --- src/app/(mobile-ui)/qr-pay/page.tsx | 4 ++-- src/components/Global/DirectSendQR/utils.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 4e02ee297..c8f6c2df4 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -24,7 +24,7 @@ import { loadingStateContext } from '@/context' import { getCurrencyPrice } from '@/app/actions/currency' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { captureException } from '@sentry/nextjs' -import { isQRPay } from '@/components/Global/DirectSendQR/utils' +import { isPaymentProcessorQR } from '@/components/Global/DirectSendQR/utils' const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' const MAX_QR_PAYMENT_AMOUNT = '200' @@ -67,7 +67,7 @@ export default function QRPayPage() { useEffect(() => { resetState() - if (!qrCode || !isQRPay(qrCode)) { + if (!qrCode || !isPaymentProcessorQR(qrCode)) { setErrorInitiatingPayment('Invalid QR code scanned') return } diff --git a/src/components/Global/DirectSendQR/utils.ts b/src/components/Global/DirectSendQR/utils.ts index e7625190c..b20cab84b 100644 --- a/src/components/Global/DirectSendQR/utils.ts +++ b/src/components/Global/DirectSendQR/utils.ts @@ -52,7 +52,7 @@ const MP_AR_REGEX = /* PIX is also a emvco qr code */ const PIX_REGEX = /^.*000201.*0014br\.gov\.bcb\.pix.*5303986.*5802BR.*$/i -export const QR_PAY_REGEXES: { [key in QrType]?: RegExp } = { +export const PAYMENT_PROCESSOR_REGEXES: { [key in QrType]?: RegExp } = { [EQrType.MERCADO_PAGO]: MP_AR_REGEX, [EQrType.PIX]: PIX_REGEX, } @@ -93,8 +93,12 @@ export function recognizeQr(data: string): QrType | null { return null } -export const isQRPay = (data: string): boolean => { - for (const [_type, regex] of Object.entries(QR_PAY_REGEXES)) { +/** + * Returns true if the given string is a payment processor QR code. + * For example, Mercado Pago, Pix, etc. + */ +export const isPaymentProcessorQR = (data: string): boolean => { + for (const [_type, regex] of Object.entries(PAYMENT_PROCESSOR_REGEXES)) { if (regex.test(data)) { return true } From 4f531080f16c8b47a9dcd7edfb81cfc4ccda3e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 15 Sep 2025 15:15:02 -0300 Subject: [PATCH 8/8] refactor(currency): use ifs instead of switch for exchange rate --- src/app/actions/currency.ts | 72 +++++++++++++++---------------------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/src/app/actions/currency.ts b/src/app/actions/currency.ts index f6b8f9da1..8019ede28 100644 --- a/src/app/actions/currency.ts +++ b/src/app/actions/currency.ts @@ -4,54 +4,40 @@ import { getExchangeRate } from './exchange-rate' import { AccountType } from '@/interfaces' import { mantecaApi } from '@/services/manteca' +const MANTECA_CURRENCIES = ['ARS', 'BRL', 'COP', 'CRC', 'PUSD', 'GTQ', 'PHP', 'BOB'] + export const getCurrencyPrice = unstable_cache( async (currencyCode: string): Promise<{ buy: number; sell: number }> => { let buy: number let sell: number currencyCode = currencyCode.toUpperCase() - switch (currencyCode) { - case 'USD': - buy = 1 - sell = 1 - break - case 'EUR': - case 'MXN': - { - let accountType: AccountType - if (currencyCode === 'EUR') { - accountType = AccountType.IBAN - } else if (currencyCode === 'MXN') { - accountType = AccountType.CLABE - } else { - throw new Error('Invalid currency code') - } - const { data, error } = await getExchangeRate(accountType) - if (error) { - throw new Error('Failed to fetch exchange rate from bridge') - } - if (!data) { - throw new Error('No data returned from exchange rate API') - } - buy = parseFloat(data.buy_rate) - sell = parseFloat(data.sell_rate) - } - break - case 'ARS': - case 'BRL': - case 'COP': - case 'CRC': - case 'PUSD': - case 'GTQ': - case 'PHP': - case 'BOB': - { - const response = await mantecaApi.getPrices({ asset: 'USDC', against: currencyCode }) - buy = Number(response.effectiveBuy) - sell = Number(response.effectiveSell) - } - break - default: - throw new Error('Unsupported currency') + if (currencyCode === 'USD') { + buy = 1 + sell = 1 + } else if (['EUR', 'MXN'].includes(currencyCode)) { + let accountType: AccountType + if (currencyCode === 'EUR') { + accountType = AccountType.IBAN + } else if (currencyCode === 'MXN') { + accountType = AccountType.CLABE + } else { + throw new Error('Invalid currency code') + } + const { data, error } = await getExchangeRate(accountType) + if (error) { + throw new Error('Failed to fetch exchange rate from bridge') + } + if (!data) { + throw new Error('No data returned from exchange rate API') + } + buy = parseFloat(data.buy_rate) + sell = parseFloat(data.sell_rate) + } else if (MANTECA_CURRENCIES.includes(currencyCode)) { + const response = await mantecaApi.getPrices({ asset: 'USDC', against: currencyCode }) + buy = Number(response.effectiveBuy) + sell = Number(response.effectiveSell) + } else { + throw new Error('Invalid currency code') } return { buy, sell } },