Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2591d32
fix: bridge KYC state machine — unified gate, inline ToS, rejection h…
kushagrasarathe May 6, 2026
7cb7217
chore: format with prettier
kushagrasarathe May 6, 2026
e999024
chore: format manteca page and sumsub action
kushagrasarathe May 6, 2026
9321fcd
fix: address review comments — try/catch in BridgeTosStep, safe metad…
kushagrasarathe May 6, 2026
5868e00
fix: add error for missing bank account id in claim flow
kushagrasarathe May 6, 2026
fa45dff
fix: address hugo's review — prevStatusRef restore, typed silent resu…
kushagrasarathe May 7, 2026
bb91a2b
test: add unit tests for useBridgeTransferReadiness hook
kushagrasarathe May 7, 2026
4bd359e
fix: strengthen fetchCurrentStatus guard with userInitiatedRef to eli…
kushagrasarathe May 7, 2026
aac55c6
fix: revert temp balance check bypasses for withdraw and manteca pages
kushagrasarathe May 7, 2026
5689481
chore: format manteca withdraw page
kushagrasarathe May 7, 2026
e47cd8b
fix(qr-pay): Pix-specific copy on QR decoding error + capture event
jjramirezn May 7, 2026
e952596
fix(qr-pay): rail-specific decoding-error copy for QR3 too
jjramirezn May 7, 2026
f0427ad
revert(qr-pay): keep MP fallback copy for ARGENTINA_QR3
jjramirezn May 7, 2026
3373423
Merge pull request #1943 from peanutprotocol/fix/bridge-kyc-state-mac…
Hugo0 May 7, 2026
5b16daf
fix: format
jjramirezn May 7, 2026
d789048
Merge branch 'main' into hotfix/qr-pix-decoding-error-message
jjramirezn May 7, 2026
4506b76
Merge pull request #1948 from peanutprotocol/hotfix/qr-pix-decoding-e…
Hugo0 May 7, 2026
acfbf75
ci(update-content): target dev (not default branch) for content submo…
Hugo0 May 8, 2026
933617c
ci(tests): trigger on pull_request so API-created PRs run tests
Hugo0 May 8, 2026
24886c5
fix(bridge): zero-out cross-currency dev fee to match backend
Hugo0 May 8, 2026
90894dd
Merge pull request #1957 from peanutprotocol/hotfix/bridge-developer-…
Hugo0 May 8, 2026
7a694ab
Merge pull request #1956 from peanutprotocol/ci/auto-bump-fix
Hugo0 May 9, 2026
881833c
merge: sync main hotfixes into dev
kushagrasarathe May 10, 2026
e1afb44
fix: resolve typecheck errors from merge
kushagrasarathe May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/update-content.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
10 changes: 8 additions & 2 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
kushagrasarathe marked this conversation as resolved.
const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus()
const sumsubFlow = useMultiPhaseKycFlow({})
const queryClient = useQueryClient()
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/constants/analytics.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions src/constants/payment.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/hooks/__tests__/useBridgeTransferReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useBridgeTosStatus>
const mockRejectionStatus = useProviderRejectionStatus as jest.MockedFunction<typeof useProviderRejectionStatus>
const mockKycStatus = useKycStatus as jest.MockedFunction<typeof useKycStatus>

const defaultRejection: Record<string, any> = {
const defaultRejection = {
provider: 'BRIDGE' as const,
state: 'happy',
state: 'happy' as ProviderRejectionState,
userMessage: null,
rejectedRails: [],
kycVerification: null,
Expand All @@ -35,7 +36,7 @@ const defaultRejection: Record<string, any> = {

function setup({
needsBridgeTos = false,
bridgeState = 'happy' as any,
bridgeState = 'happy' as ProviderRejectionState,
bridgeUserMessage = null as string | null,
isSumsubApproved = false,
isBridgeApproved = false,
Expand All @@ -53,7 +54,7 @@ function setup({
hasBlockedRejection: bridgeState === 'blocked',
hasAnyRejection: bridgeState === 'fixable' || bridgeState === 'blocked',
primaryRejection: null,
} as any)
})
mockKycStatus.mockReturnValue({
isUserSumsubKycApproved: isSumsubApproved,
isUserBridgeKycApproved: isBridgeApproved,
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/useQrKycGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>(QrKycState.LOADING)
const hasRequestedUserFetchRef = useRef(false)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
44 changes: 24 additions & 20 deletions src/utils/__tests__/bridge.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts'
import {
applyBridgeCrossCurrencyFee,
getCurrencyConfig,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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)', () => {
Expand All @@ -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)
})
})

Expand All @@ -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) => {
Expand All @@ -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)
})
})
Expand Down
Loading