Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions micopay/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,9 @@ function ExploreRoute() {
// (platform-key-only simulation on mainnet, audit finding B2) — hidden
// until a real user-signed implementation lands. Opt in with
// VITE_ENABLE_DEFI_TRADING=true for internal/demo builds.
// SPEI ramp (KYC + onramp + offramp) moves real funds (device keypair) —
// enabled independently via VITE_ENABLE_SPEI_RAMP=true.
showSpeiRamp={import.meta.env.VITE_ENABLE_SPEI_RAMP === 'true'}
showDefi={import.meta.env.VITE_ENABLE_DEFI_TRADING === 'true'}
/>
);
Expand All @@ -543,6 +546,8 @@ function CetesRoute() {
onBack={() => navigate('/explore')}
onBanco={() => navigate('/deposit')}
userToken={buyerUser?.token}
showDefi={import.meta.env.VITE_ENABLE_DEFI_TRADING === 'true'}
showSpeiRamp={import.meta.env.VITE_ENABLE_SPEI_RAMP === 'true'}
/>
);
}
Expand Down
23 changes: 23 additions & 0 deletions micopay/frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,29 @@
"receiptLock": "Lock",
"receiptRelease": "Release"
},
"kyc": {
"verifyingIdentity": "Verifying identity…",
"verifyingDesc": "This may take a few seconds.",
"identityVerified": "Identity verified",
"identityVerifiedDesc": "Ready! We'll continue with the investment flow.",
"couldNotVerify": "Could not verify",
"couldNotVerifyDesc": "Check the reason and try again.",
"sessionNotAvailable": "Session not available",
"sessionNotAvailableDesc": "We couldn't verify your session. Please log in again and try again.",
"verifyIdentity": "Verify my identity",
"retryVerification": "Retry verification",
"sessionExpires": "Your verification session expires in ~15 minutes.",
"continue": "Continue",
"identityVerification": "Identity verification",
"oneStepWithEtherfuse": "One-step with Etherfuse",
"identityVerifiedWith": "Your identity will be verified with Etherfuse",
"oneTimeProcess": "This is a one-time process done on Etherfuse's hosted page. We do not collect or store sensitive data in this app.",
"reason": "Reason",
"couldNotQuery": "Could not query",
"sessionError": "Session not available. Log in again and try again.",
"openingEtherfuse": "Opening Etherfuse…",
"pollError": "Error checking verification status."
},
"errors": {
"network": {
"offline": { "title": "No connection", "message": "We couldn't connect. Check your signal and try again.", "action": "Try again once you have signal." },
Expand Down
23 changes: 23 additions & 0 deletions micopay/frontend/src/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,29 @@
"receiptLock": "Bloqueo",
"receiptRelease": "Liberación"
},
"kyc": {
"verifyingIdentity": "Verificando identidad…",
"verifyingDesc": "Esto puede tardar unos segundos.",
"identityVerified": "Identidad verificada",
"identityVerifiedDesc": "¡Listo! Continuaremos con el flujo de inversión.",
"couldNotVerify": "No se pudo verificar",
"couldNotVerifyDesc": "Revisa el motivo y vuelve a intentar.",
"sessionNotAvailable": "Sesión no disponible",
"sessionNotAvailableDesc": "No pudimos verificar tu sesión. Vuelve a iniciar sesión e intenta de nuevo.",
"verifyIdentity": "Verificar mi identidad",
"retryVerification": "Reintentar verificación",
"sessionExpires": "Tu sesión de verificación expira en ~15 minutos.",
"continue": "Continuar",
"identityVerification": "Verificación de identidad",
"oneStepWithEtherfuse": "Paso único con Etherfuse",
"identityVerifiedWith": "Tu identidad será verificada con Etherfuse",
"oneTimeProcess": "Este proceso es una sola vez y se hace en la página hospedada de Etherfuse. No recopilamos ni almacenamos datos sensibles en esta app.",
"reason": "Motivo",
"couldNotQuery": "No se pudo consultar",
"sessionError": "Sesión no disponible. Vuelve a iniciar sesión e intenta de nuevo.",
"openingEtherfuse": "Abriendo Etherfuse…",
"pollError": "Error al consultar el estado de verificación."
},
"errors": {
"network": {
"offline": { "title": "Sin conexión", "message": "No pudimos conectarnos. Revisa tu señal e intenta otra vez.", "action": "Vuelve a intentar cuando tengas señal." },
Expand Down
18 changes: 13 additions & 5 deletions micopay/frontend/src/pages/CETESScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface CETESScreenProps {
onBack: () => void;
onBanco?: () => void;
userToken?: string;
showDefi?: boolean;
showSpeiRamp?: boolean;
}

type Tab = 'buy' | 'sell';
Expand All @@ -32,11 +34,15 @@ type ReceiveMethod = 'wallet' | 'spei';
type PayMethod = 'wallet' | 'spei';
type DepositStep = 'quote' | 'instructions' | 'polling';

const CETESScreen = ({ onBack, onBanco, userToken }: CETESScreenProps) => {
const CETESScreen = ({ onBack, onBanco, userToken, showDefi = true, showSpeiRamp = false }: CETESScreenProps) => {
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('buy');
const [receiveMethod, setReceiveMethod] = useState<ReceiveMethod>('wallet');
const [payMethod, setPayMethod] = useState<PayMethod>('wallet');
// When SPEI ramp is enabled but DeFi trading is NOT, hide the simulated
// buy/sell trading UI and force the SPEI onramp/offramp path. This isolates
// real-funds SPEI flows from the platform-key-only DeFi simulation.
const speiOnlyMode = showSpeiRamp && !showDefi;
const [tab, setTab] = useState<Tab>(speiOnlyMode ? 'buy' : 'buy');
const [receiveMethod, setReceiveMethod] = useState<ReceiveMethod>(speiOnlyMode ? 'spei' : 'wallet');
const [payMethod, setPayMethod] = useState<PayMethod>(speiOnlyMode ? 'spei' : 'wallet');
const [amount, setAmount] = useState('');
const [sourceAsset, setSourceAsset] = useState<SourceAsset>('XLM');

Expand Down Expand Up @@ -320,6 +326,7 @@ const CETESScreen = ({ onBack, onBanco, userToken }: CETESScreenProps) => {
</div>

<div className="flex flex-col gap-2 bg-surface-container-low rounded-2xl p-1">
{showDefi && (
<div className="flex gap-2">
{(['buy', 'sell'] as Tab[]).map((tabOption) => (
<button
Expand All @@ -333,8 +340,9 @@ const CETESScreen = ({ onBack, onBanco, userToken }: CETESScreenProps) => {
</button>
))}
</div>
)}

{tab === 'buy' && canDepositSpei && (
{tab === 'buy' && canDepositSpei && showDefi && (
<div className="flex gap-2 mt-1 px-1 pb-1">
<button
onClick={() => { setPayMethod('wallet'); setQuote(null); setTxResult(null); setError(null); setAmount(''); setRampOrderId(null); setOrderState(''); setDepositOrder(null); setDepositStep('quote'); setStellarTxHash(null); }}
Expand Down
8 changes: 5 additions & 3 deletions micopay/frontend/src/pages/Explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ interface ExploreProps {
onNavigate?: (page: string) => void;
/** When true, DeFi features are hidden (backend reported mock/demo mode). */
showDefi?: boolean;
/** When true, SPEI ramp (KYC + onramp + offramp) is reachable independently. */
showSpeiRamp?: boolean;
}

const Explore = ({ onBack, onNavigate, showDefi = true }: ExploreProps) => {
const Explore = ({ onBack, onNavigate, showDefi = true, showSpeiRamp = false }: ExploreProps) => {
const { t } = useTranslation();
return (
<div className="bg-surface text-on-surface font-body min-h-screen flex flex-col pb-32">
Expand All @@ -35,8 +37,8 @@ const Explore = ({ onBack, onNavigate, showDefi = true }: ExploreProps) => {
</section>

<div className="space-y-6">
{/* CETES tokenizados — feature-gated */}
{showDefi && (
{/* CETES tokenizados — shown if either DeFi or SPEI ramp is enabled */}
{(showDefi || showSpeiRamp) && (
<article className="bg-gradient-to-br from-primary/10 to-primary/5 p-6 rounded-[32px] border border-primary/10 shadow-sm relative overflow-hidden group active:scale-[0.98] transition-all">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center shadow-md">
Expand Down
61 changes: 24 additions & 37 deletions micopay/frontend/src/pages/KYCScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { App as CapApp } from '@capacitor/app';


import { startKYC, getKYCStatus, type KYCStatus, type KYCStatusResponse } from '../services/api';
import { readJSON, writeJSON } from '../services/secureStorage';

const SECURE_STORAGE_KEY = 'kyc_status';

function StatusLine({ status }: { status: KYCStatus }) {
const { t } = useTranslation();
if (status === 'pending') {
return (
<div className="flex items-center gap-3 bg-primary/5 border border-primary/10 rounded-2xl px-4 py-3">
<span className="material-symbols-outlined text-primary">hourglass_top</span>
<div>
<p className="font-bold text-on-surface">Verificando identidad…</p>
<p className="text-xs text-on-surface-variant">Esto puede tardar unos segundos.</p>
<p className="font-bold text-on-surface">{t('kyc.verifyingIdentity')}</p>
<p className="text-xs text-on-surface-variant">{t('kyc.verifyingDesc')}</p>
</div>
</div>
);
Expand All @@ -24,8 +25,8 @@ function StatusLine({ status }: { status: KYCStatus }) {
<div className="flex items-center gap-3 bg-[#1D9E75]/10 border border-[#1D9E75]/20 rounded-2xl px-4 py-3">
<span className="material-symbols-outlined text-[#1D9E75]">check_circle</span>
<div>
<p className="font-bold text-on-surface">Identidad verificada</p>
<p className="text-xs text-on-surface-variant">¡Listo! Continuaremos con el flujo de inversión.</p>
<p className="font-bold text-on-surface">{t('kyc.identityVerified')}</p>
<p className="text-xs text-on-surface-variant">{t('kyc.identityVerifiedDesc')}</p>
</div>
</div>
);
Expand All @@ -34,8 +35,8 @@ function StatusLine({ status }: { status: KYCStatus }) {
<div className="flex items-center gap-3 bg-error/10 border border-error/20 rounded-2xl px-4 py-3">
<span className="material-symbols-outlined text-error">error</span>
<div>
<p className="font-bold text-on-surface">No se pudo verificar</p>
<p className="text-xs text-on-surface-variant">Revisa el motivo y vuelve a intentar.</p>
<p className="font-bold text-on-surface">{t('kyc.couldNotVerify')}</p>
<p className="text-xs text-on-surface-variant">{t('kyc.couldNotVerifyDesc')}</p>
</div>
</div>
);
Expand All @@ -47,6 +48,7 @@ type KYCScreenProps = {
};

export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<KYCStatus>('pending');
const [reason, setReason] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
Expand All @@ -72,26 +74,20 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {

const handleOpenHostedFlow = async () => {
if (!token) {
setStatusPollingError('Sesión no disponible. Vuelve a iniciar sesión e intenta de nuevo.');
setStatusPollingError(t('kyc.sessionError'));
return;
}
setStatusPollingError(null);
setReason(null);
setLoading(true);

try {
// IMPORTANT: URL expires in ~15 minutes => generate on touch.
const { onboardingUrl } = await startKYC(token);

startedAtRef.current = Date.now();
setStartingToken(onboardingUrl);
setStatus('pending');

// Open in system browser (not an in-app webview).
if (CapApp && (CapApp as any)) {
// no-op; keeps capacitor import used.
}

// Open in system browser (preferred: Capacitor Browser plugin).
// We lazy-load to avoid hard dependency on TS typings.
try {
Expand All @@ -106,10 +102,6 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
// Fallback for web builds / when plugin is not present.
window.open(onboardingUrl, '_blank', 'noopener,noreferrer');
}




} finally {
setLoading(false);
}
Expand All @@ -134,7 +126,7 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
await applyStatus(res);
return res;
} catch (e: any) {
const message = e?.message ?? 'Error al consultar el estado de verificación.';
const message = e?.message ?? t('kyc.pollError');
setStatusPollingError(message);
return null;
}
Expand All @@ -153,9 +145,7 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
}, 5000);
};

// Always attempt polling when mounted; will return pending/approved from stub.
if (status === 'pending') {
// Kick off immediately
void pollOnce().then(() => {
if (cancelled) return;
startPolling();
Expand All @@ -173,7 +163,6 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
(sub as any)?.remove?.();

};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, token]);
Expand All @@ -182,9 +171,9 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
return (
<div className="bg-surface text-on-surface font-body min-h-screen flex flex-col items-center justify-center px-6 text-center">
<span className="material-symbols-outlined text-error text-4xl mb-3">lock</span>
<h1 className="font-headline font-bold text-lg mb-2">Sesión no disponible</h1>
<h1 className="font-headline font-bold text-lg mb-2">{t('kyc.sessionNotAvailable')}</h1>
<p className="text-sm text-on-surface-variant">
No pudimos verificar tu sesión. Vuelve a iniciar sesión e intenta de nuevo.
{t('kyc.sessionNotAvailableDesc')}
</p>
</div>
);
Expand All @@ -197,39 +186,38 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
<button
onClick={onApproved}
className="p-2 hover:bg-surface-container-low rounded-full transition-colors text-primary"
aria-label="Continuar"
aria-label={t('kyc.continue')}
>
<span className="material-symbols-outlined">verified</span>
</button>
<div>
<h1 className="font-headline font-bold text-lg">Verificación de identidad</h1>
<p className="text-xs text-on-surface-variant">Paso único con Etherfuse</p>
<h1 className="font-headline font-bold text-lg">{t('kyc.identityVerification')}</h1>
<p className="text-xs text-on-surface-variant">{t('kyc.oneStepWithEtherfuse')}</p>
</div>
</div>
</header>

<main className="flex-1 px-6 pb-8 pt-6">
<section className="space-y-4">
<div className="bg-gradient-to-br from-primary/10 to-primary/5 rounded-[24px] p-5 border border-primary/10">
<h2 className="font-headline font-extrabold text-xl">Tu identidad será verificada con Etherfuse</h2>
<h2 className="font-headline font-extrabold text-xl">{t('kyc.identityVerifiedWith')}</h2>
<p className="text-sm text-on-surface-variant mt-2 leading-relaxed">
Este proceso es <span className="font-bold">una sola vez</span> y se hace en la página
hospedada de Etherfuse. No recopilamos ni almacenamos datos sensibles en esta app.
{t('kyc.oneTimeProcess')}
</p>
</div>

<StatusLine status={status} />

{status === 'rejected' && reason && (
<div className="bg-error/10 border border-error/20 rounded-2xl px-4 py-3">
<p className="text-sm font-bold text-error">Motivo</p>
<p className="text-sm font-bold text-error">{t('kyc.reason')}</p>
<p className="text-xs text-on-surface-variant mt-1 leading-relaxed">{reason}</p>
</div>
)}

{statusPollingError && (
<div className="bg-error/10 border border-error/20 rounded-2xl px-4 py-3">
<p className="text-sm font-bold text-error">No se pudo consultar</p>
<p className="text-sm font-bold text-error">{t('kyc.couldNotQuery')}</p>
<p className="text-xs text-on-surface-variant mt-1 leading-relaxed">{statusPollingError}</p>
</div>
)}
Expand All @@ -244,12 +232,12 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
{loading ? (
<>
<span className="material-symbols-outlined animate-spin">progress_activity</span>
Abriendo Etherfuse…
{t('kyc.openingEtherfuse')}
</>
) : (
<>
<span className="material-symbols-outlined">verified_user</span>
Verify my identity
{t('kyc.verifyIdentity')}
</>
)}
</button>
Expand All @@ -261,16 +249,15 @@ export default function KYCScreen({ onApproved, token }: KYCScreenProps) {
className="w-full bg-white border border-error/30 text-error font-bold py-3.5 rounded-2xl flex items-center justify-center gap-2 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
<span className="material-symbols-outlined">refresh</span>
Retry verification
{t('kyc.retryVerification')}
</button>
)}

<p className="text-center text-xs text-outline pt-2">
Tu sesión de verificación expira en ~15 minutos.
{t('kyc.sessionExpires')}
</p>
</div>
</main>
</div>
);
}

12 changes: 0 additions & 12 deletions micopay/frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,18 +717,6 @@ export async function regenerateRampOrderTx(orderId: string, token: string): Pro
return res.data as RampOrder;
}

export async function registerBankAccount(
clabe: string,
token: string,
): Promise<BankAccountResult> {
try {
const res = await http.post('/defi/bank-account', { clabe }, authHeaders(token));
return res.data as BankAccountResult;
} catch (e: unknown) {
throw toApiError(extractApiErrorPayload(e));
}
}

// Global 401 handler: clear the persisted session and bounce to login.
http.interceptors.response.use(
(response) => response,
Expand Down