01Pendientes: el monto que falta, al frente
pending-payments-list.tsx · payments/me/pendingLa pantalla /payments ya existe como placeholder de MF-03 y se abre desde Más, desde «Ver todos» del home y desde las notificaciones. Es una pantalla de stack (fuera de (tabs)) con el StackHeader de MF-04, y lleva los dos tabs de la web como Tabs pill a lo ancho. La card es la de la página web, campo a campo, en una columna: tipo de cobro, ruta, código, cotización, estatus, «Total | Pendiente», vencimiento y «Pagar ahora».
La banda «Pendiente total»
payments/me/pendingya devuelvesummary.totalPendingycount, y la web no los pinta. En el teléfono, donde la lista no cabe entera, la banda responde «¿cuánto debo?» antes del primer scroll, y baja en cuanto un pago se confirma. No hace falta tocar el backend. El chip «1 vencido» se calcula en el cliente a partir de los ítems.La card, campo a campo
Badge neutro con el icono de tipo (
payment-entity-types.ts),CorridorBadgesolo para fletes (almacén, cotizaciones y ventas no traen ruta),entityCodeen display 18,quotationNumberen muted, y a la derecha el estatus. El bloque «Total | Pendiente» va en un plate gris de radio 10, concéntrico con la card de 14.Un solo mapa de estatus
La web tiene dos: la página pinta Pendiente en ámbar y Parcial en azul, y el widget del home, al revés. La app usa el del widget, que ya adoptaron MF-03 y MF-06: Vencido en rojo, Parcial en ámbar y Pendiente en brand. El mismo pago no puede cambiar de color entre el home y Pagos.
Vencimiento con texto, no solo color
«Vence 22 sept 2026» con reloj, o «Vencido · 3 días» en rojo. El estado se lee en palabras y además en color. Sin
dueDate, la fila no se muestra (nunca «Sin fecha»).«Pagar ahora» abre el sheet
Mientras
POST payment-gateway/linksresponde, el botón muestra el spinner con «Procesando...» y los demás botones quedan deshabilitados. Así un segundo toque no crea un segundo enlace. Si el POST falla, sale un toast de error y no se abre ningún sheet.Pull-to-refresh y la ruta del shell
RefreshControlinvalida['payments','pending']y['users','sidebar-counts']. El chip de ruta del top bar no filtra esta lista: la web tampoco lo hace, y un pago de almacén no tiene corredor.
02Historial: por mes, plegable, con su comprobante
payment-history-list.tsx · payment-history-row.tsx · payments/me/historyParidad con la web: grupos por mes con «N pagos · total» a la derecha y filas que se despliegan. Cambian tres cosas porque el soporte es un teléfono: el scroll infinito reemplaza el botón «Cargar más» (FlashList con onEndReached, página de 20), el comprobante se abre en el PdfViewer de MF-04 con compartir nativo, y el enlace a la entidad dice qué abre según su tipo.
La fila cerrada
Plate de 36 con el icono y el tono del tipo, el código sin truncar (la ruta pasa al detalle: a 390 px el badge se comía el código) y una sublínea «fecha · método · referencia (o número de comprobante)». A la derecha, el monto tabular con la moneda en versalitas. La fila completa es el área táctil de 64 px y el chevron indica el estado.
Tonos del sistema, no de Tailwind
La web pinta los tipos con
purpleysky, que están fuera de la escala. La app usa flete en brand, almacén en ámbar (esa escala sí existe en el producto), cotización en lima-soft con marino y venta en celeste-soft con marino.Desplegada: los datos y dos acciones
Método, número de comprobante, cotización y notas (si vienen). Las acciones son «Ver flete» o «Ver cotización» según el tipo (la web dice «Ver envío» para todo), que se omite en almacén y ventas porque la app no tiene esas rutas, y «Comprobante», deshabilitado con «Comprobante aún no disponible» si falta
receiptPdfUrl.El equivalente en Bs
La API ya manda
equivalentAmount,equivalentRateyequivalentRateCapturedAtpara los métodos en bolívares, y el esquema Zod de la web los descarta. En Venezuela esa línea responde «¿cuánto salió de mi cuenta?». Va en un plate gris dentro de la fila desplegada y solo aparece cuando los datos vienen.Agrupar por fecha de pago
La API ordena por
paidAty la web agrupa porcreatedAt, porque su esquema descartapaidAt. Un pago creado el 31 y verificado el 1 puede caer en el mes equivocado. La app agrupa porpaidAty usacreatedAtcomo respaldo.Lo que no aparece
El historial solo lista pagos verificados y no anulados. Un Zelle en verificación no aparece en el historial y su pendiente sigue igual, y por eso la pantalla «Estamos verificando tu Zelle» (§08) lo dice antes de cerrar.
03Estados de las listas
Empty · Skeleton · connection-pill.tsx (MF-03)Se copian los seis estados de la web. Los skeletons son anatómicos: repiten la forma de la card y de la fila, no barras genéricas. Un error nunca tira una lista que ya cargó: si falla la página siguiente del historial, aparece una línea roja al pie con reintento. Sin conexión, las listas muestran lo último que cargaron y el aviso es el ConnectionAlert global de MF-03, que ya cubre todo el stack (app). No hay un banner propio de pagos.
No tienes pagos pendientes
Cuando tengas pagos pendientes aparecerán aquí.
Error al cargar pagos
No pudimos cargar tus pagos pendientes. Por favor intenta de nuevo.
Aún no tienes pagos registrados
Cuando realices un pago aparecerá aquí con su comprobante.
Mientras carga, el tab «Pendientes» no muestra contador: nunca un 0 falso (la misma regla de los chips de MF-06). En el vacío de pendientes, «Ver historial» es la siguiente acción natural. Mientras se refresca, la lista queda al 60 % sin skeleton (isPlaceholderData), como en la web.
04Por dónde se entra y el badge
pending-payments.tsx (MF-03) · freight-payment-alert.tsx (MF-06) · (tabs)/mas.tsxTres puertas, y ninguna obliga a pasar por la lista. En la web, «Pagar» del home y «Pagar ahora» de la alerta del flete abren el portal directamente. Hoy la app navega a /payments (home) o muestra el toast «Funcionalidad de pago próximamente» (alerta). MF-07 conecta las dos al mismo PaymentPortalSheet. El contador vive en la fila Pagos de Más y en el punto rojo del tab, ambos alimentados por sidebar-counts.pendingPayments.
Cuenta
La alerta del flete paga pendingAmount neto: los fees de servicio viajan con su pago, no con el flete (lib/freights/service-fees.ts, portado en MF-06). Por eso «Fees de servicio: $21,70» corresponde al pago anterior con tarjeta, y el fee de este pago lo calcula el portal cuando se elige el método (§06).
05El portal: ¿Contexto A o shell móvil?
DECISIONS #15 · apps/payments-portal · proposal/mogos-pagos.htmlEl enfoque ya está decidido (WebView en v1). Queda la forma. El portal habla otro sistema: marino y papel, Neue Haas, cifras tabulares, el filo de --grad-marca. La app habla el producto. Hay que decidir quién pone la cabecera y qué se ve mientras la web no está lista. Se evaluaron tres formas:
Cabecera blanca + portal completo
Es la traducción literal del sheet web («Pago seguro · Realizar pago»). Si carga el portal completo, suma su propio membrete debajo y quedan dos cabeceras. Si carga el embed, el cuerpo marino/papel cuelga de una barra blanca que no le pertenece.
Descartada: dos sistemas apiladosEl membrete es nativo, el cuerpo es el portal
La cabecera noche del sheet (la de MF-03) se dibuja con el membrete del portal: --grad-hero, wordmark vectorial, «Pago seguro» y el filo de marca. Debajo, el portal en embed=true, que ya existe sin cabecera. Carga, error y éxito se pintan en el mismo papel.
expo-web-browser con el portal completo
Es la opción más barata y la que más confianza da a Apple, pero no tiene puente: no llega onMessage, la vuelta depende de un deep link y no hay actualización inmediata de pendientes y badges, que es justo el DoD. Queda como salida de emergencia («Abrir en el navegador», §07).
Cerrado por Fabrizio: B. La marca ya separa las dos cosas: Contexto A es para el dinero y Contexto B para operar. Poner el membrete nativo le dice al cliente «aquí se paga» antes de que cargue un byte, y hace que la espera tenga la forma del portal y no la de un sheet genérico. Además resuelve el único punto frágil de la WebView, porque la app, que conoce su origen, puede mostrar en el membrete a qué host está conectada (§07). La cabecera noche ya es un patrón de MF-01/MF-03 (sheet de crear, Próximamente), así que no se introduce nada nuevo en el shell.
06PaymentPortalSheet: membrete de noche, papel de día
payment-portal-sheet.tsx · payment-portal-embed.tsx · react-native-webviewSe presenta como page sheet a pantalla casi completa, sobre la pantalla desde la que se abrió, que queda con velo marino. No se cierra arrastrando: el gesto choca con el scroll de la WebView y un deslizamiento accidental perdería un formulario de Stripe a medio llenar. Se cierra con la ✕ del membrete o con el botón atrás de Android. Debajo del membrete corre el portal real en embed, la misma pasarela que la web: resumen, montos rápidos, los cuatro métodos (Stripe, PayPal, Binance Pay y Zelle) y el desglose del fee de servicio. No hay SDK de pago nativo.
Resumen del pago
FleteEl membrete, pieza por pieza
Primera fila: wordmark vectorial blanco con el punto rojo, «Pago seguro» con escudo celeste y la ✕ de 44. Segunda fila, separada por una línea fina: Referencia (
entityCode) y Conexión con candado y el host deEXPO_PUBLIC_PAYMENTS_PORTAL_URL. Abajo cierra el filo de 3 px de--grad-marca.El filo mide la carga
La regla del portal dice que el degradado marca movimiento. Aquí el filo es la barra de progreso: crece con
onLoadProgressy se completa enPAYMENT_READY(no enonLoad, que llega antes de validar el token).El desglose es del portal, no de la app
«Abono a tu flete», «Fee de servicio Mogos» (solo el monto, nunca el porcentaje) y «Total a cobrar» (o «Total a transferir» en Zelle) los calcula y los pinta el portal, y el servidor los recalcula. La app no duplica esa lógica: las listas muestran montos netos y el fee aparece cuando se elige el método.
Idioma y tema
El enlace se abre con
?embed=true&lang=es|en|zhdesde i18next. El idioma solo se cambia en Más, así que con el sheet abierto no puede cambiar ySET_LANGno hace falta en v1. El portal es light-only, igual que la nota de entrega de MF-06: la pantalla es la del cobro.
La ortografía del portal tiene deuda visible («Resumen del Pago», «Opciones Rapidas», «Monto Especifico», «Descripcion», «encriptacion»). La maqueta ya muestra la versión corregida en sentence case, y el arreglo va como fase chica en apps/payments-portal con spanish-i18n-review.
07Estados del sheet: nativos y en papel
NetInfo · onError · onHttpError · PAYMENT_ERROR · onShouldStartLoadWithRequestTodo lo que no es el portal lo pinta la app, con las piezas del portal: papel --hueso, placa circular, título en display, CTA marino con filo y botón fantasma. El membrete no cambia, salvo la fila de Conexión, que dice la verdad: host seguro, «Sin señal» o el host del banco. El «no se cobró nada» solo se dice cuando es cierto (antes de PAYMENT_READY o en un error de carga).
Sin conexión
No se envió nada al portal. Cuando vuelvas a tener señal, reintenta: tu enlace de pago sigue activo.
No pudimos abrir el portal
El portal de pagos no respondió. No se cobró nada.
Enlace inválido
El enlace de pago no es válido o ya venció. Generamos uno nuevo por el mismo monto.
Confirma tu compra
Enviamos un código a tu teléfono terminado en 4821 para autorizar $1.293,75 a MOGOLAIN LLC.
Resumen del pago
FleteEl skeleton tiene la forma del portal
Resumen con borde marino, barra, bloque de monto, cuatro montos rápidos y CTA, en
--line-softsobre papel. Cuando llegaPAYMENT_READY, la WebView (montada con opacidad 0 desde el principio) hace fundido sobre el skeleton. No hay flash blanco.Sin conexión no destruye nada
Si se cae la red con el portal ya cargado, la WebView se queda como está (Stripe reintenta solo) y solo la fila Conexión pasa a «Sin señal» con punto rojo. El estado de pantalla completa aparece únicamente si no hay nada que mostrar. «Reintentar» recarga la misma URL, porque el enlace dura 24 h.
Error con salida de emergencia
«Abrir en el navegador» usa
expo-web-browsercon la URL sinembed. Es la opción C como fallback, equivalente al «Abrir en pestaña nueva» de la web. Al volver (AppStateactivo) se invalidan igual pendientes, historial y contadores.Enlace vencido: uno nuevo, no un callejón
PAYMENT_ERRORpor token inválido lleva a este estado. «Generar un enlace nuevo» repite elPOST payment-gateway/linkscon el mismo ítem y recarga. Los errores de tarjeta (rechazada, sin saldo) los muestra el portal dentro de su formulario, y la app no los tapa.El membrete dice dónde estás
La verificación 3DS de Stripe navega fuera del portal. La fila pasa a «Tu banco · hooks.stripe.com» con el icono de banco en celeste. Es la única señal de host que tiene el cliente dentro de una WebView, y por eso solo aparecen hosts de la lista permitida (§09).
Salir con confirmación, solo cuando importa
Antes de
PAYMENT_READY, la ✕ cierra directamente. Después pide confirmación con «Seguir pagando» como acción principal. El texto no promete lo que la app no sabe: si el cliente ya envió un Zelle, se refleja cuando se verifique.
08La vuelta: éxito nativo, Zelle honesto
PAYMENT_SUCCESS · status · queryClient.invalidateQueriesEn modo embed, el portal no redirige al terminar: avisa con PAYMENT_SUCCESS. La app, en ese instante, invalida primero (['payments','pending'], ['payments','history'], ['users','sidebar-counts'] y, si es un flete, ['freights', id]) y después reemplaza el cuerpo del sheet por una pantalla final nativa en papel. Hay dos finales según el status del payload: pagado (tarjeta, PayPal, Binance) o en verificación (Zelle, status: "pending_verification"). Las dos se animan al entrar en vista.
Recibimos tu pago
Ya lo abonamos a tu flete. Te enviamos el comprobante por correo.
Estamos verificando tu Zelle
Tu pago será verificado automáticamente en 5-10 minutos. Hasta que lo confirmemos, el pendiente sigue en tu lista.
El único momento con brillo
El membrete toma
--grad-celeste-glowcon un fundido de 420 ms, el escudo pasa a doble check con «Pago recibido», la placa--grad-freshentra con popease-springde 420 ms, el check se dibuja (260 ms) y los textos suben 12 px con stagger de 40. «Ver el comprobante» aparece solo si vienereceiptUrl. No hay toast: la pantalla ya es la confirmación.La card se va, el resto se acomoda
Al tocar «Listo», la card pagada se pliega y se desvanece (260 ms) y las demás suben. La banda «Pendiente total», el contador del tab, la fila de Más, el punto del tab y el home ya están al día por la invalidación. Si fue un abono parcial, la card se queda con el nuevo pendiente.
Zelle: enviar no es recibir
«Ya envié el pago» llega con
status: "pending_verification". La app no celebra: muestra «Estamos verificando tu Zelle» con el monto enviado y el memo, y la manecilla del reloj da una sola vuelta (640 ms, sin repetir ni pulsar). Membrete «Pago enviado», sin brillo.El pendiente no desaparece hasta que ops verifique
Con Zelle igual se invalida (el portal pudo registrar algo), pero el pendiente sigue en la lista, el badge y el home hasta que operaciones verifique el pago y la API cambie el estatus de la cotización. El texto de la pantalla lo dice antes de cerrar, así la lista sin cambios no parece un error.
09El puente: un contrato, dos cables
lib/embed/messaging.ts · use-embed-mode.ts · payment-portal-sheet.tsxMisma pasarela que la web: apps/payments-portal embebido, con sus cuatro métodos (Stripe, PayPal, Binance Pay y Zelle) y cero SDK nativo. Hay un solo contrato de mensajes y dos cables para transportarlo: el iframe de la web y la WebView de la app. El portal elige el cable según dónde corre. La app no inyecta JavaScript para parchar el portal y no usa polling como señal de éxito.
| Contrato único | Forma |
|---|---|
| Mensaje | { source: "mogos-payments", type, payload } |
type | PAYMENT_READY · PAYMENT_SUCCESS · PAYMENT_ERROR |
PAYMENT_SUCCESS.payload | paymentId, amount, currency, receiptUrl? y status?: "pending_verification" (Zelle; si no viene, el pago está hecho) |
| Momento | Cable web (iframe) | Cable app (WebView) |
|---|---|---|
| Emitir | window.parent.postMessage(msg, origin) solo a orígenes de la allowlist, como hoy | window.ReactNativeWebView.postMessage(JSON.stringify(msg)) cuando ese objeto existe |
| Validar | event.origin === PORTAL_ORIGIN y source | JSON.parse(nativeEvent.data); source === "mogos-payments" y new URL(nativeEvent.url).origin === origen de EXPO_PUBLIC_PAYMENTS_PORTAL_URL; si no, se descarta |
PAYMENT_READY | sin cambios | skeleton → portal, filo al 100 %, activa la confirmación de salida |
PAYMENT_SUCCESS | invalida + cierra | invalida (incluido sidebar-counts) → «Recibimos tu pago», o «Estamos verificando tu Zelle» si status: "pending_verification" |
PAYMENT_ERROR | toast con el mensaje | token → «Enlace inválido»; los errores de tarjeta los muestra el portal |
| Idioma | ?lang + SET_LANG | ?lang al abrir; el sheet es modal y el idioma no cambia con él abierto |
| Navegación | sandbox del iframe | onShouldStartLoadWithRequest: portal, *.stripe.com (3DS), paypal.com; Binance Pay → Linking; el resto se bloquea y se reporta a Sentry |
| PayPal pierde el embed | return_url → PORTAL/success | fallback de navegación: /success se trata como éxito; /error recarga el enlace en embed |
| Señal de éxito | el mensaje | el mensaje (o el fallback de PayPal). Refetch de payments/me/pending solo como red de seguridad al cerrar o volver a primer plano, nunca como señal |
Fase chica obligatoria en el portal. Hoy apps/payments-portal/lib/embed/messaging.ts sale antes de postear si window.parent === window, que es justo el caso de la WebView, así que la app nunca recibiría nada. Hay que emitir también por ReactNativeWebView cuando exista (sin tocar el cable web ni su allowlist) y agregar status: "pending_verification" al éxito de Zelle. Sin endpoints ni migraciones. Plataforma: es pago de un servicio logístico físico, no IAP; react-native-webview es dependencia nativa nueva y requiere dev build por el método warehouse (sin eas build).
10Lo que hay que escribir en Moti
Moti + Reanimated · escala de MF-01 · useReducedMotion| Pieza | Qué anima | Duración · easing | Reduced motion |
|---|---|---|---|
| Tabs Pendientes / Historial | el fondo blanco del trigger se desliza; el contenido entra con fundido + 12 px | 260 ms · ease-out | swap directo |
| Fila de historial | alto 0 → auto y el chevron gira 180° | 260 ms · ease-out | abre ya abierta |
| Sheet del portal | page sheet nativo sube con velo marino al 70 % | 420 ms · ease-in-out | fundido 80 ms |
| El filo carga | width del filo sigue onLoadProgress; se completa en READY | 160 ms por paso · ease-out | filo completo, solo skeleton |
| Skeleton → portal | fundido cruzado skeleton / WebView | 260 ms · ease-out | swap directo |
| Éxito · «Recibimos tu pago» | brillo del membrete (fundido); placa con pop; el check se dibuja a los 420 ms; textos suben 12 px con stagger 40 | 420 ms · ease-spring (placa) · 260 ms (check) | estado final directo |
| Zelle · verificando | placa con pop; la manecilla del reloj da una vuelta; textos suben con stagger 40; sin brillo | 420 ms · ease-spring · 640 ms (manecilla) | estado final directo |
| Card pagada sale | se pliega en alto + fundido; las cards de abajo suben (layout animation) | 260 ms · ease-in-out | desaparece |
| Contadores | el número del tab, de Más y de la banda cambia con fundido vertical de 6 px | 160 ms · ease-out | swap directo |
Los dos finales son los únicos momentos orquestados y los dos se pueden ver en el HTML (§08, botones «Repetir»). El degradado solo aparece donde algo avanza (el filo) o se completa (la placa de éxito). Nada pulsa ni supera los 640 ms. Háptica: success en el éxito, light en Zelle, warning al abrir la confirmación de salida.
11Lo que quedó cerrado
docs/specs/2026-09-16-mobile-pagos.md · cerrado por Fabrizio 2026-09-16- Cerrada · 1
Opción B en todas las bifurcaciones
Membrete nativo (Contexto A) y portal
embed=trueen el cuerpo. Los estados no-portal se pintan en papel. Sin arrastre para cerrar; ✕/atrás con confirmación después de READY. Ante cada bifurcación de forma gana la opción recomendada. - Cerrada · 2
Finales animados
«Recibimos tu pago» y «Estamos verificando tu Zelle» con el motion de la spec: placa 420 ms
ease-spring, check 260, manecilla 640 en una sola vuelta, stagger 40. Reduced motion lleva al estado final. - Cerrada · 3
Misma pasarela que la web
apps/payments-portalembebido con Stripe, PayPal, Binance Pay y Zelle. Cero SDK nativo (ni Stripe RN, ni PayPal nativo, ni Binance). - Cerrada · 4
Un contrato, dos cables
{ source, type: READY | SUCCESS | ERROR, payload }constatus: "pending_verification"para Zelle. Web porwindow.parent.postMessagecon allowlist (nunca"*"); app porReactNativeWebView.postMessage, validandosourcey el origen de la URL. Sin JS inyectado; el polling es solo red de seguridad; PayPal usa el fallback/successy/error. Fase obligatoria enmessaging.ts. - Cerrada · 5a
Banda «Pendiente total»
summary.totalPending+count+ chip de vencidos, arriba de las cards. Se actualiza con la invalidación del éxito. - Cerrada · 5b
Equivalente en Bs en el historial
Línea «Equivalente · tasa · fecha» con
equivalentAmount,equivalentRateyequivalentRateCapturedAt, en la fila desplegada y solo cuando vienen (métodos en bolívares). El esquema de la app no los descarta. - Cerrada · 5c
Zelle «en verificación»
UI nativa con monto y memo. El pendiente no desaparece de lista, badge ni home hasta que ops verifique.
- Cerrada · 6
DoD en iPhone lo hace Fabrizio, en staging
Con el PR montado y cuentas de prueba: Stripe
pk_test+ 4242, PayPal y Binance sandbox, Zelle = el formulario. Nunca producción. El visual QA de los agentes cubre la UI sin dinero; el DoD con dinero no es gate de los agentes. - Diseño · consecuencia
Pantalla de stack, entradas directas
/paymentscon tabs Pendientes (contador) / Historial y?tab=historypara deep links. «Pagar» del home y «Pagar ahora» de la alerta del flete abren el sheet directamente; se retira el toast «próximamente». - Deuda anotada · para goal-phases
Lo que la web debe y la app no repite
Dos mapas de color de estatus (gana el del widget). Tonos
purple/skyfuera de escala. «Ver envío» para todos los tipos. Agrupación porcreatedAty descarte depaidAt/equivalent*. El sheet web no invalidasidebar-counts.CorridorBadgemorado con nombre crudo. Ortografía del portal.PAYMENT_CANCELLEDsale del contrato. Sin eventos PostHog: definirpayment_*.