Hace unas semanas definimos cuál será el rol de la IA y qué autonomía tendrá en el proceso de construcción de una AFP. Lo explicamos en este post.
Allí definimos como principio fundamental: “La documentación es un first-class citizen. Le damos la misma importancia que antes le dábamos al código y es donde enfocamos nuestros esfuerzos”. Estamos apostando a que la capa de abstracción de los devs ya no es el código: es la documentación en markdown.
Y como siguiente paso quedamos en definir una estructura para esta documentación. Teníamos que definir dos cosas:
- Qué queremos documentar.
- Cómo queremos que se estructure esa documentación.
Documentación de procesos
Primero tenemos que entender qué debe hacer una AFP. Esto se traduce en identificar y comprender los procesos involucrados en la operación. Un proceso lo entendemos como una serie de pasos acotados que se ejecutan para lograr un resultado, siguiendo las exigencias de la norma y definiciones internas de producto.
Agrupamos en un dominio los procesos que trabajan sobre los mismos objetos. Cada equipo de la AFP trabaja en uno o más dominios.

Dentro de un proceso, nos interesa documentar:
- Contexto: breve descripción del propósito, para entender rápido de qué se trata.
- Definición: inicio, fin y etapas, detallando el comportamiento y la interacción con otros dominios y servicios.
- Alcance: qué vamos a construir y qué no.
Una buena forma de entender un proceso es a través de un diagrama de secuencia, que nos muestra los distintos pasos y las interacciones entre sistemas y usuarios. Lo incluimos en la documentación.
Queremos que la documentación del proceso se sostenga en la norma y definiciones del equipo. Para esto, utilizamos los siguientes artefactos que siguen una estructura definida:
- Referencias: son citas a normas, oficios, leyes, transcripciones de conversaciones.
- Inferencias: son deducciones que surgen de leer varias referencias.
- Decisiones: cuando las referencias e inferencias no bastan, una persona decide y deja escrita su justificación.
- Intenciones: cosas que queremos hacer por objetivos de producto, aunque la norma no las pida.
Acá un ejemplo de inferencia para el proceso de recaudación de cotizaciones a través de Previred.
Referencia 1, L1 T3 A C5 N3 a)
Las cotizaciones y aportes se determinarán sobre la base de la última remuneración o renta imponible correspondiente al mes anterior a aquel en que se haya iniciado la licencia.
Referencia 2, L1 T3 A C5 N3
Respecto de los días del mes en que el trabajador prestó servicios, las cotizaciones y aportes se calcularán sobre la remuneración del mes, en proporción a los días efectivamente trabajados.
Inferencia
Ninguna de las dos referencias lo dice por sí sola, pero juntas implican que un mes con licencia parcial tiene dos remuneraciones imponibles: la de los días con licencia, que se calcula con el mes anterior, y la de los días trabajados, que se calcula con el mes en curso. Por eso el mismo trabajador puede aparecer dos veces en la planilla de Previred para el mismo período.
Ahora un ejemplo de decisión en el proceso de reiteración de pago del bono amortizable.
Referencia, L3 T19 F C8
Las Administradoras deberán abonar las cuotas del BA a la cuenta de capitalización individual del afiliado […] para posteriormente, cuando se reactive el pago de la pensión, junto a ella, se paguen los cupones que se hubieren depositado en la cuenta individual.
Referencia, L3 T1 I C8 N9
a) Retiros Programados o Renta Temporal.
En ambos casos la Administradora deberá proceder a rebajar de la respectiva cuenta de capitalización individual el mismo número de cuotas abonado a dicha cuenta con ocasión de la caducidad.
La norma dice que se pagan "los cupones que se hubieren depositado", pero no si se pagan los pesos que se depositaron o las cuotas que se compraron con ellos. Si el valor cuota cambió en el intertanto, el resultado es distinto.
Decisión
Pagamos vendiendo las mismas cuotas que se compraron con el cupón, al valor cuota del día del pago. En ese sentido, el pago incluirá la rentabilidad o pérdida del intertanto.
El sustento es que nos basamos en cómo la norma trata una reiteración de pago de la mensualidad de pensión. Si usamos el valor nominal y el valor cuota bajó, pagar el monto nominal pediría vender más cuotas de las que se compraron.
Por último, un ejemplo de intención relacionado con entender las cotizaciones de la cuenta obligatoria.
Intención
Queremos que el afiliado pueda ver cómo se desglosa cada una de las cotizaciones efectuadas por su empleador, indicando el detalle del cálculo. En particular:
- Cuánto se descontó de su sueldo.
- Cuánto aportó el empleador.
- Cuánto le cobramos de comisión.
- Cuánto le costó el seguro de invalidez y sobrevivencia.
Además, mostraremos cómo se llega al valor de la pérdida o rentabilidad por cambio del valor cuota, cuando la plata pasó por el fondo recaudador.
Leyendo la norma, vimos que no se solicita explícitamente mostrar ni explicar estos puntos con tanto detalle, pero queremos hacerlo para que el afiliado pueda entender mejor qué ocurre con su plata.
Alcance
A partir de los procesos definidos, vamos a definir un alcance, es decir, determinar qué vamos a implementar en código y qué no. No implementar algo en código no significa que no abordemos el proceso (estamos normados a hacerlo), pero podría abordarse de manera manual si hay muy pocos casos o no tenemos certeza de cómo ocurre y queremos probarlo con casos reales.
Documentación del diseño de una solución
Ya con el proceso definido y un alcance determinado, tenemos que diseñar software que permita soportar, operar y automatizar partes del proceso.
Nuestro principio fundamental acá es que documentamos los modelos de datos y las interfaces que soportan el proceso.
Modelos de datos
Los modelos de datos son la base de cualquier lógica de negocio. Hay que entender con claridad qué información tenemos que almacenar y cómo fluye. Permite identificar las entidades que participan en el proceso y establecer reglas, estados y relaciones entre estas.
Como explicamos en el artículo del uso de IA, creemos que en el estado actual los humanos que trabajamos en Fintual somos mejores que la IA en la modelación de datos. Por eso, el modelado lo hace una persona, con apoyo de la IA.
Para documentar modelos de datos exigimos un archivo de documentación por modelo indicando:
- Firma del modelo, en Python (que es el lenguaje de programación que estamos usando), incluyendo:
- Campos del modelo, con sus tipos.
- Los campos son todos obligatorios por defecto, excepciones son documentadas y justificadas.
- Las instancias son inmutables por defecto, es decir, se crean y nunca se editan o eliminan. Excepciones son documentadas y justificadas.
- Llaves foráneas a otros modelos.
- Decisiones de diseño.
Acá un ejemplo de un modelo que registra una ejecución de una compra de cuotas en un fondo para un portafolio.
# Ejemplo de modelos de datos
## FundBuyOrder
### Firma
```python
# @/explanations/afp/cuentas/modelos-de-datos-general.md
class FundBuyOrder(BaseModel):
"""Records the executed purchase of shares in a specific pension fund.
Created alongside a ``PortfolioBuyOrder``. It records the shares actually
acquired with the corresponding amount in pesos, share value and relevant dates.
``amount`` is stored explicitly because it may differ from
``share_value_amount * shares`` due to rounding rules. ``share_value_amount``
is the share value applied at execution; it is persisted to keep a record of
the value actually used, since the Fondos domain could later republish it.
"""
portfolio_buy_order = models.ForeignKey(
"accounts.PortfolioBuyOrder",
on_delete=models.PROTECT,
related_name="fund_buy_orders",
)
fund_type = models.CharField(choices=FundTypeEnum)
amount = AmountField(validators=[MinValueValidator(0)])
shares = SharesField(validators=[MinValueValidator(0)])
share_value_amount = AmountField(validators=[MinValueValidator(0)])
share_value_date = models.DateField()
declared_at = models.DateTimeField()
executed_at = models.DateTimeField()
class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(amount__gt=0),
name="fund_buy_order_amount_positive",
),
models.CheckConstraint(
condition=models.Q(shares__gte=0),
name="fund_buy_order_shares_non_neg",
),
models.CheckConstraint(
condition=models.Q(share_value_amount__gt=0),
name="fund_buy_order_share_value_positive",
),
]
```
### Decisiones de diseño
- `amount` se guarda explícito aunque podría calcularse como
`share_value_amount * shares`, porque las reglas de redondeo pueden hacer que
difieran.
- `share_value_amount` guarda el valor cuota aplicado al ejecutar, porque el
dominio de Fondos podría republicarlo después.Interfaces
Una interfaz es el límite del dominio: lo que comunicamos a otros dominios o al exterior del sistema. En términos prácticos son:
- Servicios públicos: funciones (de programación) que exponemos para que otros dominios las usen. Por ejemplo, un servicio para que acreditación le declare a cuentas un depósito de cotización obligatoria.
- Endpoints HTTP: son rutas en la web que exponemos para que clientes en un navegador consuman. Por ejemplo, un endpoint para consultar el saldo de una cuenta.
- Tareas asíncronas: ejecución de subprocesos de varias etapas con esperas entre ellas. Por ejemplo, la ejecución de la declaración de un depósito de cotización obligatoria.
Para las interfaces exigimos documentar:
- Firma del método con el contrato, es decir, la declaración de una función, con los parámetros que recibe, el tipo de respuesta esperada, qué errores puede levantar y un docstring que basta para entender qué hace. Esta firma la exigimos en Python.
- Decisiones de diseño.
- Comportamiento interno y dependencias de otros dominios. A diferencia del docstring de la firma, que se enfoca en el comportamiento de alto nivel, acá especificamos detalles técnicos que son importantes dentro del dominio.
- Side effects:
- Cambios en modelos de datos: qué instancias se crean.
- Notificaciones, como por ejemplo si enviamos un email al cliente.
- Eventos de auditoría.
- Llamados a servicios externos.
- Casos de test: pedimos al menos testear el happy path, los casos borde definidos en el comportamiento interno y un test por cada posible error. Dependiendo del tipo de interfaz pueden haber otros tests obligatorios, por ejemplo de autorización en el caso de endpoints HTTP.
Acá un ejemplo de un endpoint HTTP que no requiere autenticación:
# Ejemplo de endpoint no autenticado
## POST /kba-challenges
### Firma
```python
# The Python file starts with a reference to this document:
# @/explanations/afp/traspasos/.../request-kba-challenge.md
class KbaOptionOut(StrictSchema):
id: str
text: str
class KbaQuestionOut(StrictSchema):
id: str
text: str
options: list[KbaOptionOut]
class RequestKbaChallengeOut(StrictSchema):
kba_verification_request_id: UUID
questions: list[KbaQuestionOut]
@router.post(
"/kba-challenges",
auth=None,
response={
HTTPStatus.CREATED: RequestKbaChallengeOut,
HTTPStatus.UNAUTHORIZED: None,
HTTPStatus.CONFLICT: None,
HTTPStatus.BAD_GATEWAY: None,
},
)
def request_kba_challenge(
request: HttpRequest,
) -> Status[RequestKbaChallengeOut | None]:
"""Opens the identity questions for the transfer bound to the session.
The person answers these questions to prove their identity before the
transfer goes on. The endpoint works only on the transfer request bound to
the browser session, and only while that request has not expired.
Returns:
201: The challenge id and the questions to pose.
401: The session is not bound to a transfer request, or the request
expired.
409: The person's document has not been validated yet.
502: The identity provider is unavailable or answered outside its
contract.
"""
```
### Quién lo llama, por qué no exige autenticación y cómo se protege
Lo llama el formulario web de traspaso entrante, después de que la persona
registró su solicitud.
No exige autenticación porque la persona todavía no es afiliada y no tiene
usuario.
Se protege con la sesión del navegador.
`POST /request-inbound-affiliate-transfer` liga la sesión a la solicitud de
traspaso al registrarla. Este endpoint solo opera sobre esa solicitud, y solo
mientras no venza. Si la sesión no está ligada o la solicitud venció, responde
401.
### Servicio público que utiliza
`cometa.public.inbound_transfers.request_inbound_transfer_kba_challenge`, que
abre las preguntas de verificación de identidad con el número de documento ya
validado.
### Casos de test
#### Test 1: Abre las preguntas de la solicitud de la sesión
La persona registró su solicitud de traspaso con un documento válido, y la
sesión quedó ligada a ella => responde 201 con el id de la verificación y sus
preguntas.
#### Test 2: Sesión sin solicitud
La sesión no está ligada a ninguna solicitud de traspaso => responde 401.
#### Test 3: Solicitud vencida
La sesión está ligada a una solicitud que ya venció => responde 401.Estructura de carpetas
Considerando las definiciones anteriores, un proceso de un dominio queda con una estructura así:
dominio/*/proceso/
├── index.md # tabla de contenidos
├── 00.contexto.md
├── 01.definicion.md
├── 02.alcance.md
├── 03.diagrama-de-secuencia.md
├── 04.modelos-de-datos/
├── 05.servicios-publicos/
├── 06.http/
│ ├── 01.public/
│ └── 02.staff/
└── 07.tareas-asincronas/
└── 01.tarea-1/
├── 01.definicion.md
└── 02.activities/Notar el *. Estos son grupos de procesos opcionales. Por ejemplo, dentro de beneficios queremos agrupar los subprocesos de pensión de vejez bajo la ruta beneficios/pension-vejez/….
La documentación nueva sigue esta estructura, y la existente la adaptamos cuando la modificamos. La estructura es un mínimo, no un límite.
Qué sigue
Nuestro siguiente paso es definir y describir una serie de componentes de código que nuestro framework ofrece para hacer distintas tareas. La idea es que la IA tome estos artefactos como base cuando vaya a desarrollar código a partir de lo que estamos documentando. Así, podemos estandarizar aún más el resultado del código, evitar ambigüedades y errores.
Si quieres compartir tu experiencia estructurando documentación para el desarrollo de software o tienes preguntas sobre esta entrada, escríbenos a proyectoafp@fintual.com.