diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5bd560fd4..e83ef879a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,14 +1,13 @@ name: Tests on: + push: + branches: ['**'] pull_request: - branches: - - main - - dev - - develop + branches: [main, dev, develop] workflow_dispatch: -# Cancel in-progress runs on the same PR when a newer commit lands. +# Cancel in-progress runs on the same PR / branch when a newer commit lands. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index aa741a920..ee1132ce3 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -66,5 +66,6 @@ jobs: -f "sha=$COMMIT_SHA" gh pr create \ --head "$BRANCH" \ + --base dev \ --title "Update content submodule" \ --body "Auto-generated: updates content submodule to latest peanut-content main." diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index e3cfe1f98..191a3a788 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -109,7 +109,7 @@ export default function QRPayPage() { return paymentProcessor ? maintenanceConfig.disabledPaymentProviders.includes(paymentProcessor) : false }, [paymentProcessor]) - const { shouldBlockPay, kycGateState } = useQrKycGate() + const { shouldBlockPay, kycGateState } = useQrKycGate(paymentProcessor) const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const sumsubFlow = useMultiPhaseKycFlow({}) const queryClient = useQueryClient() @@ -400,9 +400,15 @@ export default function QRPayPage() { if (error.message.includes('PAYMENT_DESTINATION_MISSING_AMOUNT')) { setWaitingForMerchantAmount(true) } else if (error.message.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { + // Pix has no fallback rail in Brazil — ask the merchant to regenerate. + // For Argentina (MERCADO_PAGO and ARGENTINA_QR3), MP is the dominant + // rail, so suggesting an MP QR is the most useful fallback. setErrorInitiatingPayment( - 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' + qrType === EQrType.PIX + ? 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' + : 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' ) + posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) } else { // Network/timeout errors after all retries exhausted diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 243c58e06..51b99d253 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -110,6 +110,7 @@ export const ANALYTICS_EVENTS = { // ── QR ── QR_SCANNED: 'qr_scanned', QR_NOTIFY_ME_CLICKED: 'qr_notify_me_clicked', + QR_DECODING_ERROR_SHOWN: 'qr_decoding_error_shown', // ── Home ── BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled', diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts index 8f8ad8aaa..1924eaeaf 100644 --- a/src/constants/payment.consts.ts +++ b/src/constants/payment.consts.ts @@ -23,8 +23,10 @@ export const MAX_QR_PAYMENT_AMOUNT_FOREIGN = 2000 // max per transaction for for export const MIN_PIX_AMOUNT_BRL = 1 // Bridge developer fee applied to cross-currency (non-USD) transfers. -// Must match backend BRIDGE_DEVELOPER_FEE_PERCENT in peanut-api-ts/src/bridge/consts.ts -export const BRIDGE_DEVELOPER_FEE_RATE = 0.005 +// Must match backend BRIDGE_DEVELOPER_FEE_PERCENT in peanut-api-ts/src/bridge/consts.ts. +// Currently 0 — fee was undisclosed in-app while charged via Bridge's email +// receipt. Will re-enable as an FX-rate spread once we ship the quote endpoint. +export const BRIDGE_DEVELOPER_FEE_RATE = 0 /** * validate if amount meets minimum requirement for a payment method diff --git a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts index f72f361e8..f78ab8564 100644 --- a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts +++ b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts @@ -17,15 +17,16 @@ jest.mock('../useKycStatus', () => ({ import { useBridgeTosStatus } from '../useBridgeTosStatus' import useProviderRejectionStatus from '../useProviderRejectionStatus' +import type { ProviderRejectionState } from '../useProviderRejectionStatus' import useKycStatus from '../useKycStatus' const mockTosStatus = useBridgeTosStatus as jest.MockedFunction const mockRejectionStatus = useProviderRejectionStatus as jest.MockedFunction const mockKycStatus = useKycStatus as jest.MockedFunction -const defaultRejection: Record = { +const defaultRejection = { provider: 'BRIDGE' as const, - state: 'happy', + state: 'happy' as ProviderRejectionState, userMessage: null, rejectedRails: [], kycVerification: null, @@ -35,7 +36,7 @@ const defaultRejection: Record = { function setup({ needsBridgeTos = false, - bridgeState = 'happy' as any, + bridgeState = 'happy' as ProviderRejectionState, bridgeUserMessage = null as string | null, isSumsubApproved = false, isBridgeApproved = false, @@ -53,7 +54,7 @@ function setup({ hasBlockedRejection: bridgeState === 'blocked', hasAnyRejection: bridgeState === 'fixable' || bridgeState === 'blocked', primaryRejection: null, - } as any) + }) mockKycStatus.mockReturnValue({ isUserSumsubKycApproved: isSumsubApproved, isUserBridgeKycApproved: isBridgeApproved, diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 3b6e46c0a..2b1cea8c6 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -23,10 +23,11 @@ export interface QrKycGateResult { /** * This hook determines the KYC gate state for the QR pay page. - * It checks the user's KYC status to determine the appropriate action. + * It checks the user's KYC status and the payment processor to determine the appropriate action. + * @param paymentProcessor - The payment processor type ('MANTECA' | null) * @returns {QrKycGateResult} An object with the KYC gate state and a boolean indicating if the user should be blocked from paying. */ -export function useQrKycGate(): QrKycGateResult { +export function useQrKycGate(paymentProcessor?: 'MANTECA' | null): QrKycGateResult { const { user, isFetchingUser, fetchUser } = useAuth() const [kycGateState, setKycGateState] = useState(QrKycState.LOADING) const hasRequestedUserFetchRef = useRef(false) @@ -74,7 +75,6 @@ export function useQrKycGate(): QrKycGateResult { const isFixable = railMeta.selfHealable === true && mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && - mantecaKyc?.rejectType !== 'FINAL' && ((kycMeta.selfHealAttempt as number) || 0) < MAX_SELF_HEAL_ATTEMPTS setKycGateState( isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED @@ -124,7 +124,7 @@ export function useQrKycGate(): QrKycGateResult { } setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - }, [user?.user, user?.rails, isFetchingUser, fetchUser]) + }, [user?.user, user?.rails, isFetchingUser, paymentProcessor, fetchUser]) useEffect(() => { determineKycGateState() diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 7fd727cf5..148cf945f 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -1,3 +1,4 @@ +import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts' import { applyBridgeCrossCurrencyFee, getCurrencyConfig, @@ -7,6 +8,10 @@ import { reverseBridgeCrossCurrencyFee, } from '../bridge.utils' +// Tests track the constant so they remain correct whether the fee is 0 +// (current state — disabled until FX-spread followup) or non-zero. +const NET_OF_100 = 100 * (1 - BRIDGE_DEVELOPER_FEE_RATE) + describe('bridge.utils', () => { describe('getCurrencyConfig', () => { it('should return USD with correct payment rails for US', () => { @@ -208,24 +213,24 @@ describe('bridge.utils', () => { // is the USDC stablecoin (not the 'USD' fiat display code). Callers must // pass 'USDC' so the fee helper matches backend `getBridgeDeveloperFeeParams`. - it('applies 0.5% fee for EUR → USDC (onramp EUR deposit)', () => { - expect(applyBridgeCrossCurrencyFee(100, 'EUR', 'USDC')).toBeCloseTo(99.5, 10) + it('applies fee for EUR → USDC (onramp EUR deposit)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'EUR', 'USDC')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for USDC → EUR (offramp to EUR bank)', () => { - expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'EUR')).toBeCloseTo(99.5, 10) + it('applies fee for USDC → EUR (offramp to EUR bank)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'EUR')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for GBP → USDC', () => { - expect(applyBridgeCrossCurrencyFee(100, 'GBP', 'USDC')).toBeCloseTo(99.5, 10) + it('applies fee for GBP → USDC', () => { + expect(applyBridgeCrossCurrencyFee(100, 'GBP', 'USDC')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for MXN → USDC', () => { - expect(applyBridgeCrossCurrencyFee(100, 'MXN', 'USDC')).toBeCloseTo(99.5, 10) + it('applies fee for MXN → USDC', () => { + expect(applyBridgeCrossCurrencyFee(100, 'MXN', 'USDC')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for USDC → MXN (offramp to Mexican bank)', () => { - expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'MXN')).toBeCloseTo(99.5, 10) + it('applies fee for USDC → MXN (offramp to Mexican bank)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'MXN')).toBeCloseTo(NET_OF_100, 10) }) it('does not apply fee for USD → USDC (fiat rail ↔ stablecoin is fee-free)', () => { @@ -241,21 +246,19 @@ describe('bridge.utils', () => { }) it('is case-insensitive', () => { - expect(applyBridgeCrossCurrencyFee(100, 'eur', 'usdc')).toBeCloseTo(99.5, 10) + expect(applyBridgeCrossCurrencyFee(100, 'eur', 'usdc')).toBeCloseTo(NET_OF_100, 10) expect(applyBridgeCrossCurrencyFee(100, 'Usd', 'Usdc')).toBe(100) }) it('matches the real onramp display-quote math (EUR 500 @ 1.167)', () => { - // 500 EUR × 1.167 rate = 583.50 gross USDC - // after 0.5% Bridge fee = 580.5825 USDC delivered const gross = 500 * 1.167 const net = applyBridgeCrossCurrencyFee(gross, 'EUR', 'USDC') - expect(net).toBeCloseTo(580.5825, 4) + expect(net).toBeCloseTo(gross * (1 - BRIDGE_DEVELOPER_FEE_RATE), 4) }) it('handles zero and negative amounts without surprises', () => { expect(applyBridgeCrossCurrencyFee(0, 'EUR', 'USDC')).toBe(0) - expect(applyBridgeCrossCurrencyFee(-100, 'EUR', 'USDC')).toBeCloseTo(-99.5, 10) + expect(applyBridgeCrossCurrencyFee(-100, 'EUR', 'USDC')).toBeCloseTo(-NET_OF_100, 10) }) }) @@ -264,10 +267,11 @@ describe('bridge.utils', () => { // Guards against the classic algebra bug of using `net * (1 + rate)` // instead of `net / (1 - rate)` — those differ by rate² (~0.0025%). - it('reverse(99.5) for EUR → USDC yields exactly 100 (not 99.9975)', () => { - // The canonical sanity check: the naive `net * (1 + rate)` = 99.9975 - // would under-shoot. Correct inverse `net / (1 - rate)` lands on 100. - expect(reverseBridgeCrossCurrencyFee(99.5, 'EUR', 'USDC')).toBeCloseTo(100, 10) + it('reverse(net) yields exactly the gross input (not net * (1 + rate))', () => { + // The canonical sanity check: the naive `net * (1 + rate)` would + // under-shoot by rate². Correct inverse `net / (1 - rate)` lands + // on the original gross. Holds for any rate including 0. + expect(reverseBridgeCrossCurrencyFee(NET_OF_100, 'EUR', 'USDC')).toBeCloseTo(100, 10) }) it.each([0.01, 1, 100, 999.99, 1_000_000])('apply(reverse(%f)) round-trips for EUR → USDC', (amount) => { @@ -293,7 +297,7 @@ describe('bridge.utils', () => { }) it('is case-insensitive', () => { - expect(reverseBridgeCrossCurrencyFee(99.5, 'eur', 'usdc')).toBeCloseTo(100, 10) + expect(reverseBridgeCrossCurrencyFee(NET_OF_100, 'eur', 'usdc')).toBeCloseTo(100, 10) expect(reverseBridgeCrossCurrencyFee(100, 'Usd', 'Usdc')).toBe(100) }) })