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..a93a7ebb0 --- /dev/null +++ b/src/app/(mobile-ui)/qr-pay/layout.tsx @@ -0,0 +1,12 @@ +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: 'Use Peanut to pay Argentinian MercadoPago and Brazilian Pix QR codes', +}) + +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..c8f6c2df4 --- /dev/null +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -0,0 +1,375 @@ +'use client' + +import { useSearchParams, useRouter } from 'next/navigation' +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' +import { mantecaApi } 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 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 { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' +import { formatUnits, parseUnits } from 'viem' +import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' +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 { isPaymentProcessorQR } from '@/components/Global/DirectSendQR/utils' + +const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' +const MAX_QR_PAYMENT_AMOUNT = '200' + +export default function QRPayPage() { + const searchParams = useSearchParams() + const router = useRouter() + const qrCode = searchParams.get('qrCode') + const timestamp = searchParams.get('t') + const { balance, sendMoney } = useWallet() + const [isSuccess, setIsSuccess] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) + 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 [currency, setCurrency] = useState<{ code: string; symbol: string; price: number } | undefined>(undefined) + const { openTransactionDetails, selectedTransaction, isDrawerOpen, closeTransactionDetails } = + useTransactionDetailsDrawer() + const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) + + const resetState = () => { + setIsSuccess(false) + setErrorMessage(null) + setBalanceErrorMessage(null) + setErrorInitiatingPayment(null) + setPaymentLock(null) + setIsFirstLoad(true) + setAmount(undefined) + setCurrencyAmount(undefined) + setQrPayment(null) + setCurrency(undefined) + setLoadingState('Idle') + } + + // First fetch for qrcode info + useEffect(() => { + resetState() + + if (!qrCode || !isPaymentProcessorQR(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 + currencyCode = paymentLock.paymentAsset + if (paymentLock.code === '') { + price = (await getCurrencyPrice(currencyCode)).sell + } else { + price = Number(paymentLock.paymentPrice) + } + return { + code: currencyCode, + symbol: currencyCode, + price, + } + } + getCurrencyObject().then(setCurrency) + }, [paymentLock?.code]) + + const usdAmount = useMemo(() => { + if (!paymentLock) return null + if (paymentLock.code === '') { + return amount + } else { + return paymentLock.paymentAgainstAmount + } + }, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) + + const methodIcon = useMemo(() => { + if (!paymentLock) return null + switch (paymentLock.type) { + case 'QR3_PAYMENT': + case 'QR3': + return MERCADO_PAGO + case 'PIX': + return PIX + default: + return null + } + }, [paymentLock]) + + const merchantName = useMemo(() => { + if (!paymentLock) return null + return paymentLock.paymentRecipientName + }, [paymentLock]) + + const payQR = useCallback(async () => { + 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 + 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) { + setBalanceErrorMessage(null) + return + } + 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) { + return ( +
+ + + Unable to get QR details + + {errorInitiatingPayment || 'An error occurred while getting the QR details.'} + + + + + + +
+ ) + } + + if (isFirstLoad || !paymentLock || !currency) { + return + } + + //Success + if (isSuccess && !qrPayment) { + // This should never happen, if this happens there is dev error + return null + } else if (isSuccess) { + return ( +
+ +
+ +
+
+ +
+
+ +
+

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

+
+ {currency.symbol} {qrPayment!.details.paymentAssetAmount} +
+
≈ {usdAmount} USD
+
+
+
+ + +
+
+ +
+ ) + } + + return ( +
+ + + {/* Payment Content */} +
+ {/* Merchant Card */} + +
+
+ Mercado Pago +
+
+

+ You're paying +

+

{merchantName}

+
+
+
+ + {/* Amount Card */} + {currency && ( + + )} + {balanceErrorMessage && } + + {/* Information Card */} + + + + + + {/* Send Button */} + + + {/* Error State */} + {errorMessage && } +
+
+ ) +} diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index 30ce3486b..2aa748abb 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -497,7 +497,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..8019ede28 100644 --- a/src/app/actions/currency.ts +++ b/src/app/actions/currency.ts @@ -1,55 +1,45 @@ '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' + +const MANTECA_CURRENCIES = ['ARS', 'BRL', 'COP', 'CRC', 'PUSD', 'GTQ', 'PHP', 'BOB'] 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 - 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') - } - price = parseFloat(data.buy_rate) - } - break - case 'ARS': - { - 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 - } - 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 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/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 }:
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 0098213f0..c1105b656 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -294,9 +294,15 @@ export default function DirectSendQr({ } break case EQrType.MERCADO_PAGO: + case EQrType.PIX: + { + const timestamp = Date.now() + // Casing matters, so send original instead of normalized + redirectUrl = `/qr-pay?qrCode=${originalData}&t=${timestamp}` + } + break case EQrType.BITCOIN_ONCHAIN: case EQrType.BITCOIN_INVOICE: - case EQrType.PIX: case EQrType.TRON_ADDRESS: case EQrType.SOLANA_ADDRESS: case EQrType.XRP_ADDRESS: { diff --git a/src/components/Global/DirectSendQR/utils.ts b/src/components/Global/DirectSendQR/utils.ts index 302a00071..b20cab84b 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 PAYMENT_PROCESSOR_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,19 @@ export function recognizeQr(data: string): QrType | null { return null } +/** + * 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 + } + } + 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 bc3833434..5a3e6c96a 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -125,6 +125,10 @@ const TokenAmountInput = ({ [displayMode, currency?.price, selectedTokenData?.price, calculateAlternativeValue] ) + const showConversion = useMemo(() => { + 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('USD') setAlternativeDisplaySymbol(currency?.symbol || '') } else { setDisplaySymbol(currency?.symbol || '') - setAlternativeDisplaySymbol('$') + setAlternativeDisplaySymbol('USD') } break } @@ -196,48 +200,62 @@ 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} +
+
+ + + {/* Input */} + { + 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') && ( + + {/* Conversion */} + {showConversion && ( + + )} + + {/* Balance */} + {walletBalance && !hideBalance && ( +
+ Balance: {displayMode === 'FIAT' && currency ? 'USD ' : '$ '} + {walletBalance} +
+ )} +
+ + {/* Conversion toggle */} + {showConversion && (
{ e.preventDefault() const currentValue = displayValue @@ -246,10 +264,7 @@ const TokenAmountInput = ({ setIsInputUsd(!isInputUsd) }} > - - +
)} diff --git a/src/components/Request/views/ReqFulfillBankFlowManager.tsx b/src/components/Request/views/ReqFulfillBankFlowManager.tsx index 4ba9cdd2b..cd57fd0a3 100644 --- a/src/components/Request/views/ReqFulfillBankFlowManager.tsx +++ b/src/components/Request/views/ReqFulfillBankFlowManager.tsx @@ -62,7 +62,7 @@ export const ReqFulfillBankFlowManager = ({ parsedPaymentData }: { parsedPayment const usdAmount = chargeDetails.tokenAmount const minAmount = getMinimumAmount(selectedCountry.id) getCurrencyPrice(currency).then((price) => { - 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 0a34e7688..3652956f8 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' @@ -27,6 +27,7 @@ export type TransactionType = | 'bank_request_fulfillment' | 'claim_external' | 'bank_claim' + | 'pay' interface TransactionCardProps { type: TransactionType @@ -66,7 +67,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 @@ -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) { @@ -149,15 +152,11 @@ const TransactionCard: React.FC = ({
{/* txn avatar component handles icon/initials/colors */} {avatarUrl ? ( -
+
Icon @@ -208,6 +207,7 @@ const TransactionCard: React.FC = ({ onClose={closeTransactionDetails} transaction={selectedTransaction} transactionAmount={finalDisplayAmount} + avatarUrl={avatarUrl} /> ) @@ -237,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/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..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': @@ -165,8 +176,8 @@ export const TransactionDetailsHeaderCard: React.FC
{avatarUrl ? ( -
- Icon +
+ Icon
) : ( void @@ -49,6 +50,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() @@ -115,7 +117,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 +253,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 +297,7 @@ export const TransactionDetailsReceipt = ({ isVerified={transaction.isVerified} isLinkTransaction={transaction.extraDataForDrawer?.isLinkTransaction} transactionType={transaction.extraDataForDrawer?.transactionCardType} - avatarUrl={transaction.extraDataForDrawer?.rewardData?.avatarUrl} + avatarUrl={avatarUrl ?? transaction.extraDataForDrawer?.avatarUrl} haveSentMoneyToUser={transaction.haveSentMoneyToUser} /> @@ -440,15 +442,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 349e2c5d1..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 SIMBOLS_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,13 +31,13 @@ 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 } - 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 +46,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 new file mode 100644 index 000000000..4b18f254c --- /dev/null +++ b/src/services/manteca.ts @@ -0,0 +1,145 @@ +import { PEANUT_API_URL, PEANUT_API_KEY } from '@/constants' +import { fetchWithSentry } from '@/utils' +import Cookies from 'js-cookie' +import type { Address, Hash } from 'viem' + +export interface QrPaymentRequest { + qrCode: string + amount?: 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 + } + } +} + +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 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/init`, { + 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() + }, + 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/services/services.types.ts b/src/services/services.types.ts index 87e55ab63..85e81c969 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 +} 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/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..ca8c0031e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,12 +5,12 @@ 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 { getCurrencyConfig as getBridgeCurrencyConfig, getOfframpCurrencyConfig, - getCurrencySymbol, getPaymentRailDisplayName, getMinimumAmount, } from './bridge.utils'