diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index da3c5f142..40a82c3bb 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -8,8 +8,10 @@ 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 { simplefiApi } from '@/services/simplefi' +import type { SimpleFiQrPaymentResponse } from '@/services/simplefi' import NavHeader from '@/components/Global/NavHeader' -import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' +import { MERCADO_PAGO, PIX, SIMPLEFI } from '@/assets/payment-apps' import Image from 'next/image' import PeanutLoading from '@/components/Global/PeanutLoading' import TokenAmountInput from '@/components/Global/TokenAmountInput' @@ -27,16 +29,21 @@ 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' +import { isPaymentProcessorQR, parseSimpleFiQr, EQrType } from '@/components/Global/DirectSendQR/utils' +import type { SimpleFiQrData } from '@/components/Global/DirectSendQR/utils' import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate' import ActionModal from '@/components/Global/ActionModal' import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { EQrType } from '@/components/Global/DirectSendQR/utils' import { SoundPlayer } from '@/components/Global/SoundPlayer' import { useQueryClient } from '@tanstack/react-query' +import { useAuth } from '@/context/authContext' +import { useWebSocket } from '@/hooks/useWebSocket' +import type { HistoryEntry } from '@/hooks/useTransactionHistory' const MAX_QR_PAYMENT_AMOUNT = '200' +type PaymentProcessor = 'MANTECA' | 'SIMPLEFI' + export default function QRPayPage() { const searchParams = useSearchParams() const router = useRouter() @@ -49,6 +56,9 @@ export default function QRPayPage() { const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const [errorInitiatingPayment, setErrorInitiatingPayment] = useState(null) const [paymentLock, setPaymentLock] = useState(null) + const [simpleFiPayment, setSimpleFiPayment] = useState(null) + const [simpleFiQrData, setSimpleFiQrData] = useState(null) + const [showOrderNotReadyModal, setShowOrderNotReadyModal] = useState(false) const [isFirstLoad, setIsFirstLoad] = useState(true) const [amount, setAmount] = useState(undefined) const [currencyAmount, setCurrencyAmount] = useState(undefined) @@ -59,6 +69,24 @@ export default function QRPayPage() { const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) const { shouldBlockPay, kycGateState } = useQrKycGate() const queryClient = useQueryClient() + const { user } = useAuth() + const [pendingSimpleFiPaymentId, setPendingSimpleFiPaymentId] = useState(null) + const [isWaitingForWebSocket, setIsWaitingForWebSocket] = useState(false) + + const paymentProcessor: PaymentProcessor | null = useMemo(() => { + switch (qrType) { + case EQrType.SIMPLEFI_STATIC: + case EQrType.SIMPLEFI_DYNAMIC: + case EQrType.SIMPLEFI_USER_SPECIFIED: + return 'SIMPLEFI' + case EQrType.MERCADO_PAGO: + case EQrType.ARGENTINA_QR3: + case EQrType.PIX: + return 'MANTECA' + default: + return null + } + }, [qrType]) const resetState = () => { setIsSuccess(false) @@ -66,14 +94,63 @@ export default function QRPayPage() { setBalanceErrorMessage(null) setErrorInitiatingPayment(null) setPaymentLock(null) + setSimpleFiPayment(null) + setSimpleFiQrData(null) + setShowOrderNotReadyModal(false) setIsFirstLoad(true) setAmount(undefined) setCurrencyAmount(undefined) setQrPayment(null) setCurrency(undefined) setLoadingState('Idle') + setPendingSimpleFiPaymentId(null) + setIsWaitingForWebSocket(false) } + const handleSimpleFiStatusUpdate = useCallback( + (entry: HistoryEntry) => { + if (!pendingSimpleFiPaymentId || entry.uuid !== pendingSimpleFiPaymentId) { + return + } + + if (entry.type !== EHistoryEntryType.SIMPLEFI_QR_PAYMENT) { + return + } + + console.log('[SimpleFi WebSocket] Received status update:', entry.status) + + setIsWaitingForWebSocket(false) + setPendingSimpleFiPaymentId(null) + + switch (entry.status) { + case 'approved': + setSimpleFiPayment({ + id: entry.uuid, + usdAmount: entry.amount, + currency: entry.currency!.code, + currencyAmount: entry.currency!.amount, + price: simpleFiPayment!.price, + address: simpleFiPayment!.address, + }) + setIsSuccess(true) + setLoadingState('Idle') + break + + case 'expired': + case 'canceled': + case 'refunded': + setErrorMessage('Payment failed or expired. Please try again.') + setIsSuccess(false) + setLoadingState('Idle') + break + + default: + console.log('[SimpleFi WebSocket] Unknown status:', entry.status) + } + }, + [pendingSimpleFiPaymentId, simpleFiPayment, setLoadingState] + ) + // First fetch for qrcode info — only after KYC gating allows proceeding useEffect(() => { resetState() @@ -83,21 +160,49 @@ export default function QRPayPage() { return } - // defer until gating computed later in component + if (paymentProcessor === 'SIMPLEFI') { + const parsed = parseSimpleFiQr(qrCode) + setSimpleFiQrData(parsed) + } + setIsFirstLoad(false) - // Trigger on rescan - }, [timestamp]) + }, [timestamp, paymentProcessor, qrCode]) + + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: true, + onHistoryEntry: handleSimpleFiStatusUpdate, + }) + + useEffect(() => { + if (!isWaitingForWebSocket || !pendingSimpleFiPaymentId) return + + const timeout = setTimeout( + () => { + console.log('[SimpleFi WebSocket] Timeout after 5 minutes') + setIsWaitingForWebSocket(false) + setPendingSimpleFiPaymentId(null) + setErrorMessage('Payment is taking longer than expected. Please check your transaction history.') + setLoadingState('Idle') + }, + 5 * 60 * 1000 + ) + + return () => clearTimeout(timeout) + }, [isWaitingForWebSocket, pendingSimpleFiPaymentId, setLoadingState]) - // Get amount from payment lock + // Get amount from payment lock (Manteca) useEffect(() => { + if (paymentProcessor !== 'MANTECA') return if (!paymentLock) return if (paymentLock.code !== '') { setAmount(paymentLock.paymentAssetAmount) } - }, [paymentLock?.code]) + }, [paymentLock?.code, paymentProcessor]) - // Get currency object from payment lock + // Get currency object from payment lock (Manteca) useEffect(() => { + if (paymentProcessor !== 'MANTECA') return if (!paymentLock) return const getCurrencyObject = async () => { let currencyCode: string @@ -115,20 +220,23 @@ export default function QRPayPage() { } } getCurrencyObject().then(setCurrency) - }, [paymentLock?.code]) + }, [paymentLock?.code, paymentProcessor]) const isBlockingError = useMemo(() => { return !!errorMessage && errorMessage !== 'Please confirm the transaction.' }, [errorMessage]) const usdAmount = useMemo(() => { + if (paymentProcessor === 'SIMPLEFI') { + return simpleFiPayment?.usdAmount || amount + } if (!paymentLock) return null if (paymentLock.code === '') { return amount } else { return paymentLock.paymentAgainstAmount } - }, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) + }, [paymentProcessor, simpleFiPayment, paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) const methodIcon = useMemo(() => { switch (qrType) { @@ -138,13 +246,67 @@ export default function QRPayPage() { return 'https://flagcdn.com/w160/ar.png' case EQrType.PIX: return PIX + case EQrType.SIMPLEFI_STATIC: + case EQrType.SIMPLEFI_DYNAMIC: + case EQrType.SIMPLEFI_USER_SPECIFIED: + return SIMPLEFI default: return null } }, [qrType]) - // fetch payment lock only when gating allows proceeding and we don't yet have a lock + // Fetch SimpleFi payment details useEffect(() => { + if (paymentProcessor !== 'SIMPLEFI' || !simpleFiQrData) return + if (!!simpleFiPayment) return + if (kycGateState !== QrKycState.PROCEED_TO_PAY) return + + const fetchSimpleFiPayment = async () => { + setLoadingState('Fetching details') + try { + let response: SimpleFiQrPaymentResponse + + if (simpleFiQrData.type === 'SIMPLEFI_STATIC') { + response = await simplefiApi.initiateQrPayment({ + type: 'STATIC', + merchantSlug: simpleFiQrData.merchantSlug, + }) + } else if (simpleFiQrData.type === 'SIMPLEFI_DYNAMIC') { + response = await simplefiApi.initiateQrPayment({ + type: 'DYNAMIC', + simplefiRequestId: simpleFiQrData.paymentId, + }) + } else { + setLoadingState('Idle') + return + } + + setSimpleFiPayment(response) + setAmount(response.currencyAmount) + setCurrencyAmount(response.currencyAmount) + setCurrency({ + code: 'ARS', + symbol: 'ARS', + price: Number(response.price), + }) + } catch (error) { + const errorMsg = (error as Error).message + if (errorMsg.includes('ready to pay')) { + setShowOrderNotReadyModal(true) + } else { + setErrorInitiatingPayment(errorMsg) + } + } finally { + setLoadingState('Idle') + } + } + + fetchSimpleFiPayment() + }, [kycGateState, simpleFiPayment, simpleFiQrData, paymentProcessor, setLoadingState]) + + // fetch payment lock only when gating allows proceeding and we don't yet have a lock (Manteca) + useEffect(() => { + if (paymentProcessor !== 'MANTECA') return if (!qrCode || !isPaymentProcessorQR(qrCode)) return if (!!paymentLock) return if (kycGateState !== QrKycState.PROCEED_TO_PAY) return @@ -155,14 +317,83 @@ export default function QRPayPage() { .then((pl) => setPaymentLock(pl)) .catch((error) => setErrorInitiatingPayment(error.message)) .finally(() => setLoadingState('Idle')) - }, [kycGateState, paymentLock, qrCode, setLoadingState]) + }, [kycGateState, paymentLock, qrCode, setLoadingState, paymentProcessor]) const merchantName = useMemo(() => { + if (paymentProcessor === 'SIMPLEFI') { + if (simpleFiQrData?.type === 'SIMPLEFI_STATIC' || simpleFiQrData?.type === 'SIMPLEFI_USER_SPECIFIED') { + return simpleFiQrData.merchantSlug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + } + return 'SimpleFi Merchant' + } if (!paymentLock) return null return paymentLock.paymentRecipientName - }, [paymentLock]) + }, [paymentProcessor, simpleFiQrData, paymentLock]) - const payQR = useCallback(async () => { + const handleSimpleFiPayment = useCallback(async () => { + if (!simpleFiPayment && !simpleFiQrData) return + + let finalPayment = simpleFiPayment + + if (simpleFiQrData?.type === 'SIMPLEFI_USER_SPECIFIED' && !simpleFiPayment && currencyAmount) { + setLoadingState('Fetching details') + try { + finalPayment = await simplefiApi.initiateQrPayment({ + type: 'USER_SPECIFIED', + merchantSlug: simpleFiQrData.merchantSlug, + currencyAmount: currencyAmount, + currency: 'ARS', + }) + setSimpleFiPayment(finalPayment) + } catch (error) { + captureException(error) + setErrorMessage('Unable to process payment. Please try again') + setIsSuccess(false) + setLoadingState('Idle') + return + } + } + + if (!finalPayment) { + setErrorMessage('Unable to fetch payment details') + setIsSuccess(false) + setLoadingState('Idle') + return + } + + setLoadingState('Preparing transaction') + let userOpHash: Hash + let receipt: TransactionReceipt | null + try { + const result = await sendMoney(finalPayment.address, finalPayment.usdAmount) + userOpHash = result.userOpHash + receipt = result.receipt + } catch (error) { + if ((error as Error).toString().includes('not allowed')) { + setErrorMessage('Please confirm the transaction in your wallet') + } else { + captureException(error) + setErrorMessage('Could not complete the transaction') + setIsSuccess(false) + } + setLoadingState('Idle') + return + } + + if (receipt !== null && isTxReverted(receipt)) { + setErrorMessage('Transaction was rejected by the network') + setLoadingState('Idle') + setIsSuccess(false) + return + } + + console.log('[SimpleFi] Transaction sent, waiting for WebSocket confirmation...') + setLoadingState('Paying') + setIsWaitingForWebSocket(true) + setPendingSimpleFiPaymentId(finalPayment.id) + }, [simpleFiPayment, simpleFiQrData, currencyAmount, sendMoney, setLoadingState]) + + const handleMantecaPayment = useCallback(async () => { if (!paymentLock || !qrCode || !currencyAmount) return let finalPaymentLock = paymentLock if (finalPaymentLock.code === '') { @@ -179,7 +410,6 @@ export default function QRPayPage() { } } if (finalPaymentLock.code === '') { - finalPaymentLock setErrorMessage('Could not fetch qr payment details') setIsSuccess(false) setLoadingState('Idle') @@ -222,7 +452,15 @@ export default function QRPayPage() { } finally { setLoadingState('Idle') } - }, [paymentLock?.code, sendMoney, usdAmount, qrCode, currencyAmount]) + }, [paymentLock?.code, sendMoney, qrCode, currencyAmount, setLoadingState]) + + const payQR = useCallback(async () => { + if (paymentProcessor === 'SIMPLEFI') { + await handleSimpleFiPayment() + } else if (paymentProcessor === 'MANTECA') { + await handleMantecaPayment() + } + }, [paymentProcessor, handleSimpleFiPayment, handleMantecaPayment]) // Check user balance useEffect(() => { @@ -246,6 +484,36 @@ export default function QRPayPage() { } }, [isSuccess]) + const handleOrderNotReadyRetry = useCallback(async () => { + setShowOrderNotReadyModal(false) + if (!simpleFiQrData || simpleFiQrData.type !== 'SIMPLEFI_STATIC') return + + setLoadingState('Fetching details') + try { + const response = await simplefiApi.initiateQrPayment({ + type: 'STATIC', + merchantSlug: simpleFiQrData.merchantSlug, + }) + setSimpleFiPayment(response) + setAmount(response.currencyAmount) + setCurrencyAmount(response.currencyAmount) + setCurrency({ + code: 'ARS', + symbol: 'ARS', + price: Number(response.price), + }) + } catch (error) { + const errorMsg = (error as Error).message + if (errorMsg.includes('ready to pay')) { + setShowOrderNotReadyModal(true) + } else { + setErrorInitiatingPayment(errorMsg) + } + } finally { + setLoadingState('Idle') + } + }, [simpleFiQrData, setLoadingState]) + if (!!errorInitiatingPayment) { return (
@@ -293,7 +561,11 @@ export default function QRPayPage() { onClose={() => router.back()} title="Verify your identity to continue" description="You'll need to verify your identity before paying with a QR code. Don't worry it usually just takes a few minutes." - icon={Mercado Pago} + icon={ + methodIcon ? ( + Payment method + ) : undefined + } ctas={[ { text: 'Verify now', @@ -329,15 +601,42 @@ export default function QRPayPage() { ) } - if (isFirstLoad || !paymentLock || !currency) { + if (showOrderNotReadyModal) { + return ( +
+ +
+

Order Not Ready

+

Please notify the cashier that you're ready to pay, then tap Retry.

+
+
+ +
+ + +
+
+
+ ) + } + + if ( + isFirstLoad || + (paymentProcessor === 'MANTECA' && !paymentLock) || + (paymentProcessor === 'SIMPLEFI' && simpleFiQrData?.type !== 'SIMPLEFI_USER_SPECIFIED' && !simpleFiPayment) || + !currency + ) { return } //Success - if (isSuccess && !qrPayment) { - // This should never happen, if this happens there is dev error + if (isSuccess && paymentProcessor === 'MANTECA' && !qrPayment) { return null - } else if (isSuccess) { + } else if (isSuccess && paymentProcessor === 'MANTECA' && qrPayment) { return (
@@ -420,6 +719,85 @@ export default function QRPayPage() { />
) + } else if (isSuccess && paymentProcessor === 'SIMPLEFI') { + return ( +
+ + +
+ +
+
+ +
+
+ +
+

You paid {merchantName}

+
+ ARS{' '} + {formatNumberForDisplay(simpleFiPayment?.currencyAmount ?? currencyAmount ?? '0', { + maxDecimals: 2, + })} +
+
+ ≈ {formatNumberForDisplay(usdAmount ?? undefined, { maxDecimals: 2 })} USD +
+
+
+
+ + +
+
+ +
+ ) } return ( @@ -434,7 +812,7 @@ export default function QRPayPage() {
Mercado Pago - {isLoading ? loadingState : 'Pay'} + {isLoading || isWaitingForWebSocket + ? isWaitingForWebSocket + ? 'Processing Payment...' + : loadingState + : 'Pay'} {/* Error State */} diff --git a/src/assets/payment-apps/index.ts b/src/assets/payment-apps/index.ts index cdd89cab2..2dd28453f 100644 --- a/src/assets/payment-apps/index.ts +++ b/src/assets/payment-apps/index.ts @@ -4,4 +4,5 @@ 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' +export { default as SIMPLEFI } from './simplefi-logo.svg' diff --git a/src/assets/payment-apps/simplefi-logo.svg b/src/assets/payment-apps/simplefi-logo.svg new file mode 100644 index 000000000..0743161c1 --- /dev/null +++ b/src/assets/payment-apps/simplefi-logo.svg @@ -0,0 +1 @@ + 资源 359 资源 359 资源 359 资源 359 \ No newline at end of file diff --git a/src/components/Global/DirectSendQR/__tests__/recognizeQr.test.ts b/src/components/Global/DirectSendQR/__tests__/recognizeQr.test.ts index fcc10968c..b586a0914 100644 --- a/src/components/Global/DirectSendQR/__tests__/recognizeQr.test.ts +++ b/src/components/Global/DirectSendQR/__tests__/recognizeQr.test.ts @@ -37,6 +37,10 @@ describe('recognizeQr', () => { ['rPEPPER7kfTD9w2To4CQk6UCfuHM9c6GDY', EQrType.XRP_ADDRESS], ['https://example.com', EQrType.URL], ['http://domain.co.uk/path', EQrType.URL], + ['https://pagar.simplefi.tech/peanut-test/static', EQrType.SIMPLEFI_STATIC], + ['https://pagar.simplefi.tech/peanut-test?static=true', EQrType.SIMPLEFI_STATIC], + ['https://pagar.simplefi.tech/peanut-test', EQrType.SIMPLEFI_USER_SPECIFIED], + ['https://pagar.simplefi.tech/1234/payment/5678', EQrType.SIMPLEFI_DYNAMIC], ['random text without any pattern', null], ['123456', null], ['', null], diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index f0295b164..b1014d2ca 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -293,6 +293,9 @@ export default function DirectSendQr({ case EQrType.MERCADO_PAGO: case EQrType.ARGENTINA_QR3: case EQrType.PIX: + case EQrType.SIMPLEFI_STATIC: + case EQrType.SIMPLEFI_DYNAMIC: + case EQrType.SIMPLEFI_USER_SPECIFIED: { const timestamp = Date.now() // Casing matters, so send original instead of normalized diff --git a/src/components/Global/DirectSendQR/utils.ts b/src/components/Global/DirectSendQR/utils.ts index a45a8e8a1..bcd0d34f2 100644 --- a/src/components/Global/DirectSendQR/utils.ts +++ b/src/components/Global/DirectSendQR/utils.ts @@ -15,6 +15,9 @@ export enum EQrType { TRON_ADDRESS = 'TRON_ADDRESS', SOLANA_ADDRESS = 'SOLANA_ADDRESS', XRP_ADDRESS = 'XRP_ADDRESS', + SIMPLEFI_STATIC = 'SIMPLEFI_STATIC', + SIMPLEFI_DYNAMIC = 'SIMPLEFI_DYNAMIC', + SIMPLEFI_USER_SPECIFIED = 'SIMPLEFI_USER_SPECIFIED', } export const NAME_BY_QR_TYPE: { [key in QrType]?: string } = { @@ -26,6 +29,9 @@ export const NAME_BY_QR_TYPE: { [key in QrType]?: string } = { [EQrType.TRON_ADDRESS]: 'Tron', [EQrType.SOLANA_ADDRESS]: 'Solana', [EQrType.XRP_ADDRESS]: 'Ripple', + [EQrType.SIMPLEFI_STATIC]: 'SimpleFi', + [EQrType.SIMPLEFI_DYNAMIC]: 'SimpleFi', + [EQrType.SIMPLEFI_USER_SPECIFIED]: 'SimpleFi', } export type QrType = `${EQrType}` @@ -57,10 +63,24 @@ const ARGENTINA_QR3_REGEX = /^(?=.*00020101021[12])(?=.*5303032)(?=.*5802AR)/i /* PIX is also a emvco qr code */ const PIX_REGEX = /^.*000201.*0014br\.gov\.bcb\.pix.*5303986.*5802BR.*$/i +/** Simplefi QR codes are urls, depending on the route and params we can + * infer the flow type and merchant slug. + * + * The flow type is static, dynamic or user_specified. + */ +export const SIMPLEFI_STATIC_REGEX = + /^https:\/\/pagar\.simplefi\.tech\/(?[^\/]*)(\/static$|\?static\=true)/ +export const SIMPLEFI_USER_SPECIFIED_REGEX = /^https:\/\/pagar\.simplefi\.tech\/(?[^\/]*)$/ +export const SIMPLEFI_DYNAMIC_REGEX = + /^https:\/\/pagar\.simplefi\.tech\/(?[^\/]*)\/payment\/(?[^\/]*)$/ + export const PAYMENT_PROCESSOR_REGEXES: { [key in QrType]?: RegExp } = { [EQrType.MERCADO_PAGO]: MP_AR_REGEX, [EQrType.PIX]: PIX_REGEX, [EQrType.ARGENTINA_QR3]: ARGENTINA_QR3_REGEX, + [EQrType.SIMPLEFI_STATIC]: SIMPLEFI_STATIC_REGEX, + [EQrType.SIMPLEFI_DYNAMIC]: SIMPLEFI_DYNAMIC_REGEX, + [EQrType.SIMPLEFI_USER_SPECIFIED]: SIMPLEFI_USER_SPECIFIED_REGEX, } const EIP_681_REGEX = /^ethereum:(?:pay-)?([^@/?]+)(?:@([^/?]+))?(?:\/([^?]+))?(?:\?(.*))?$/i @@ -70,6 +90,9 @@ const REGEXES_BY_TYPE: { [key in QrType]?: RegExp } = { //this order is important, first mercadipago, then argentina qr3 [EQrType.MERCADO_PAGO]: MP_AR_REGEX, [EQrType.ARGENTINA_QR3]: ARGENTINA_QR3_REGEX, + [EQrType.SIMPLEFI_STATIC]: SIMPLEFI_STATIC_REGEX, + [EQrType.SIMPLEFI_DYNAMIC]: SIMPLEFI_DYNAMIC_REGEX, + [EQrType.SIMPLEFI_USER_SPECIFIED]: SIMPLEFI_USER_SPECIFIED_REGEX, [EQrType.BITCOIN_ONCHAIN]: /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/, [EQrType.BITCOIN_INVOICE]: /^ln(bc|tb|bcrt)([0-9]{1,}[a-z0-9]+){1}$/, [EQrType.PIX]: PIX_REGEX, @@ -172,3 +195,50 @@ export const parseEip681 = ( return { address } } + +export interface SimpleFiStaticQrData { + type: 'SIMPLEFI_STATIC' + merchantSlug: string +} + +export interface SimpleFiDynamicQrData { + type: 'SIMPLEFI_DYNAMIC' + merchantId: string + paymentId: string +} + +export interface SimpleFiUserSpecifiedQrData { + type: 'SIMPLEFI_USER_SPECIFIED' + merchantSlug: string +} + +export type SimpleFiQrData = SimpleFiStaticQrData | SimpleFiDynamicQrData | SimpleFiUserSpecifiedQrData + +export const parseSimpleFiQr = (data: string): SimpleFiQrData | null => { + const staticMatch = data.match(SIMPLEFI_STATIC_REGEX) + if (staticMatch?.groups?.merchantSlug) { + return { + type: 'SIMPLEFI_STATIC', + merchantSlug: staticMatch.groups.merchantSlug, + } + } + + const dynamicMatch = data.match(SIMPLEFI_DYNAMIC_REGEX) + if (dynamicMatch?.groups?.merchantId && dynamicMatch?.groups?.paymentId) { + return { + type: 'SIMPLEFI_DYNAMIC', + merchantId: dynamicMatch.groups.merchantId, + paymentId: dynamicMatch.groups.paymentId, + } + } + + const userSpecifiedMatch = data.match(SIMPLEFI_USER_SPECIFIED_REGEX) + if (userSpecifiedMatch?.groups?.merchantSlug) { + return { + type: 'SIMPLEFI_USER_SPECIFIED', + merchantSlug: userSpecifiedMatch.groups.merchantSlug, + } + } + + return null +} diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 85f51d2c5..a5917910f 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -1,6 +1,6 @@ import { PEANUT_WALLET_TOKEN_DECIMALS, STABLE_COINS } from '@/constants' import { tokenSelectorContext } from '@/context' -import { formatAmountWithoutComma, formatTokenAmount } from '@/utils' +import { formatAmountWithoutComma, formatTokenAmount, formatCurrency } from '@/utils' import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import Icon from '../Icon' import { twMerge } from 'tailwind-merge' @@ -240,7 +240,8 @@ const TokenAmountInput = ({ {/* Conversion */} {showConversion && ( )} diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index c0211aee1..a2cfd9d1d 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -214,6 +214,7 @@ export const TransactionDetailsReceipt = ({ if ( [ EHistoryEntryType.MANTECA_QR_PAYMENT, + EHistoryEntryType.SIMPLEFI_QR_PAYMENT, EHistoryEntryType.MANTECA_OFFRAMP, EHistoryEntryType.MANTECA_ONRAMP, ].includes(transaction.extraDataForDrawer!.originalType) @@ -261,48 +262,26 @@ export const TransactionDetailsReceipt = ({ if (!transaction) return null - // format data for display with proper handling for different transaction types - let amountDisplay = '' + let usdAmount: number | bigint = 0 if (transactionAmount) { - // if transactionAmount is provided (from TransactionCard), use it - amountDisplay = transactionAmount.replace(/[+-]/g, '').replace(/\$/, '$ ') - } else if ( - (transaction.direction === 'bank_deposit' || - transaction.direction === 'bank_withdraw' || - transaction.direction === 'bank_request_fulfillment') && - transaction.currency?.code && - transaction.currency.code.toUpperCase() !== 'USD' - ) { - // handle bank deposits/withdrawals with non-USD currency - const isCompleted = transaction.status === 'completed' - - if (isCompleted) { - // for completed transactions: show USD amount (amount is already in USD) - const amount = transaction.amount || 0 - const numericAmount = typeof amount === 'bigint' ? Number(amount) : Number(amount) - amountDisplay = `$ ${formatAmount(isNaN(numericAmount) ? 0 : numericAmount)}` - } else { - // for non-completed transactions: show original currency - const currencyAmount = transaction.currency?.amount || transaction.amount.toString() - const numericAmount = Number(currencyAmount) - const currencySymbol = getDisplayCurrencySymbol(transaction.currency.code) - amountDisplay = `${currencySymbol} ${formatAmount(isNaN(numericAmount) ? 0 : numericAmount)}` - } - } else { - // default: use currency amount if provided, otherwise fallback to raw amount - never show token value, only USD - if (transaction.currency?.amount && transaction.currency?.code) { - const numericAmount = Number(transaction.currency.amount) - const amount = isNaN(numericAmount) ? 0 : numericAmount - const currencySymbol = getDisplayCurrencySymbol(transaction.currency.code) - amountDisplay = `${currencySymbol} ${formatAmount(amount)}` - } else { - const amount = transaction.amount || 0 - const numericAmount = typeof amount === 'bigint' ? Number(amount) : Number(amount) - amountDisplay = `$ ${formatAmount(isNaN(numericAmount) ? 0 : numericAmount)}` - } + // if transactionAmount is provided as a string, parse it + const parsed = parseFloat(transactionAmount.replace(/[\+\-\$]/g, '')) + usdAmount = isNaN(parsed) ? 0 : parsed + } else if (transaction.amount !== undefined && transaction.amount !== null) { + // fallback to transaction.amount + usdAmount = transaction.amount + } else if (transaction.currency?.amount) { + // last fallback to currency amount + const parsed = parseFloat(String(transaction.currency.amount)) + usdAmount = isNaN(parsed) ? 0 : parsed } + // ensure we have a valid number for display + const numericAmount = typeof usdAmount === 'bigint' ? Number(usdAmount) : usdAmount + const safeAmount = isNaN(numericAmount) || numericAmount === null || numericAmount === undefined ? 0 : numericAmount + const amountDisplay = `$ ${formatCurrency(Math.abs(safeAmount).toString())}` + const feeDisplay = transaction.fee !== undefined ? formatAmount(transaction.fee as number) : 'N/A' // determine if the qr code and sharing section should be shown diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index e8a1e6d07..cf807116c 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -309,6 +309,15 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact nameForDetails = entry.recipientAccount?.identifier || 'Merchant' isPeerActuallyUser = false break + case EHistoryEntryType.SIMPLEFI_QR_PAYMENT: + direction = 'qr_payment' + transactionCardType = 'pay' + nameForDetails = entry.recipientAccount?.identifier || 'Merchant' + // We dont have merchant name so we try to prettify the slug, + // replacing dashws with speaces and making the first letter uppercase + nameForDetails = nameForDetails.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + isPeerActuallyUser = false + break default: direction = 'send' transactionCardType = 'send' @@ -370,10 +379,13 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact case 'SUCCESSFUL': case 'CLAIMED': case 'PAID': + case 'APPROVED': uiStatus = 'completed' break case 'FAILED': case 'ERROR': + case 'CANCELED': + case 'EXPIRED': uiStatus = 'failed' break case 'CANCELLED': diff --git a/src/services/simplefi.ts b/src/services/simplefi.ts new file mode 100644 index 000000000..7057171db --- /dev/null +++ b/src/services/simplefi.ts @@ -0,0 +1,73 @@ +import { PEANUT_API_URL } from '@/constants' +import { fetchWithSentry } from '@/utils' +import Cookies from 'js-cookie' +import type { Address } from 'viem' + +export type QrPaymentType = 'STATIC' | 'DYNAMIC' | 'USER_SPECIFIED' + +export interface SimpleFiQrPaymentRequest { + type: QrPaymentType + merchantSlug?: string + currencyAmount?: string + currency?: string + simplefiRequestId?: string +} + +export interface SimpleFiQrPaymentResponse { + id: string + usdAmount: string + currency: string + currencyAmount: string + price: string + address: Address +} + +export type SimpleFiErrorCode = + | 'ORDER_NOT_READY' + | 'PAYMENT_NOT_PENDING' + | 'NO_ARBITRUM_TRANSACTION' + | 'MISSING_MERCHANT_SLUG' + | 'MISSING_REQUEST_ID' + | 'MISSING_PARAMETERS' + | 'NO_MERCHANT_FOUND' + | 'INVALID_TYPE' + | 'UNEXPECTED_ERROR' + +export interface SimpleFiQrPaymentError { + error: SimpleFiErrorCode + message: string +} + +const ERROR_MESSAGES: Record = { + ORDER_NOT_READY: "Please notify the cashier that you're ready to pay", + PAYMENT_NOT_PENDING: 'This payment has expired or been completed', + NO_ARBITRUM_TRANSACTION: 'Payment method not supported', + MISSING_MERCHANT_SLUG: 'Invalid merchant QR code', + MISSING_REQUEST_ID: 'Invalid payment request', + MISSING_PARAMETERS: 'Missing required payment information', + NO_MERCHANT_FOUND: 'Merchant not found. Please use the standard payment option', + INVALID_TYPE: 'Invalid payment type', + UNEXPECTED_ERROR: 'Unable to process payment. Please try again', +} + +export const simplefiApi = { + initiateQrPayment: async (data: SimpleFiQrPaymentRequest): Promise => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/simplefi/qr-pay`, { + 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(() => ({}))) as SimpleFiQrPaymentError + const errorCode = errorData.error || 'UNEXPECTED_ERROR' + const userMessage = ERROR_MESSAGES[errorCode] || ERROR_MESSAGES.UNEXPECTED_ERROR + throw new Error(userMessage) + } + + return response.json() + }, +} diff --git a/src/utils/history.utils.ts b/src/utils/history.utils.ts index c2d0d8527..967669302 100644 --- a/src/utils/history.utils.ts +++ b/src/utils/history.utils.ts @@ -1,4 +1,4 @@ -import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' +import { MERCADO_PAGO, PIX, SIMPLEFI } from '@/assets/payment-apps' import { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { getFromLocalStorage } from '@/utils' import { PEANUT_WALLET_TOKEN_DECIMALS, BASE_URL } from '@/constants' @@ -18,6 +18,7 @@ export enum EHistoryEntryType { BRIDGE_ONRAMP = 'BRIDGE_ONRAMP', BANK_SEND_LINK_CLAIM = 'BANK_SEND_LINK_CLAIM', MANTECA_QR_PAYMENT = 'MANTECA_QR_PAYMENT', + SIMPLEFI_QR_PAYMENT = 'SIMPLEFI_QR_PAYMENT', MANTECA_OFFRAMP = 'MANTECA_OFFRAMP', MANTECA_ONRAMP = 'MANTECA_ONRAMP', BRIDGE_GUEST_OFFRAMP = 'BRIDGE_GUEST_OFFRAMP', @@ -64,6 +65,11 @@ export enum EHistoryStatus { REFUNDED = 'REFUNDED', CANCELED = 'CANCELED', ERROR = 'ERROR', + approved = 'approved', + pending = 'pending', + refunded = 'refunded', + canceled = 'canceled', // from simplefi, canceled with only one l + expired = 'expired', } export const FINAL_STATES: HistoryStatus[] = [ @@ -132,6 +138,7 @@ export function getReceiptUrl(transaction: TransactionDetails): string | undefin transaction.extraDataForDrawer?.originalType && [ EHistoryEntryType.MANTECA_QR_PAYMENT, + EHistoryEntryType.SIMPLEFI_QR_PAYMENT, EHistoryEntryType.MANTECA_OFFRAMP, EHistoryEntryType.MANTECA_ONRAMP, EHistoryEntryType.SEND_LINK, @@ -160,6 +167,9 @@ export function getAvatarUrl(transaction: TransactionDetails): string | undefine return undefined } } + if (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SIMPLEFI_QR_PAYMENT) { + return SIMPLEFI + } } /** Returns the sign of the transaction, based on the direction and status of the transaction. */