{"openapi":"3.1.0","info":{"title":"FinO$ API","version":"1.0.0","description":"API sobre los datos financieros de un workspace: consulta transacciones, cuentas, saldos y resúmenes, y sube estados de cuenta para procesar.\n\n**Autenticación:** `Authorization: Bearer fin_sk_…`. La llave se crea en Configuración → API y se muestra una sola vez.\n\n**Workspace:** cada llave está atada al workspace donde se creó; no hace falta (ni sirve) mandar `X-Workspace-Id`.\n\n**Montos:** negativo es salida, positivo es entrada. Cada transacción trae su `status`: solo las `validated` las confirmó el usuario."},"servers":[{"url":"https://api.getfinos.com"}],"security":[{"apiKey":[]}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"Llave `fin_sk_…` con scope `read`."}},"schemas":{"Transaction":{"type":"object","properties":{"id":{"type":"string","description":"Identificador estable de la transacción."},"statement_id":{"type":"string","description":"Estado de cuenta del que salió."},"date":{"type":["string","null"],"format":"date","description":"Fecha del movimiento."},"amount":{"type":["number","null"],"description":"Negativo = salida, positivo = entrada."},"currency":{"type":["string","null"],"description":"Código ISO-4217 (MXN, USD…)."},"kind":{"type":"string","description":"`expense`, `income`, `internal_transfer` o `refund`."},"status":{"type":["string","null"],"description":"`validated` (confirmada por el usuario), `pending` o `dismissed`."},"concept":{"type":["string","null"],"description":"Descripción tal como la imprime el banco."},"counterparty":{"type":["string","null"],"description":"Contraparte, cuando el banco la reporta."},"merchant":{"type":["string","null"],"description":"Comercio canónico."},"category":{"type":["string","null"],"description":"Clave de categoría (ver /categories)."},"bank":{"type":["string","null"],"description":"Banco emisor."},"account_id":{"type":["string","null"]},"account_type":{"type":["string","null"],"description":"`debit` o `credit`."},"original_amount":{"type":["number","null"],"description":"Monto en la divisa original, si el banco la reporta distinta."},"original_currency":{"type":["string","null"]},"fx_rate":{"type":["number","null"]},"categorization_source":{"type":"string","description":"Quién decidió `category`/`kind`: `human` (una persona), `rule` (una regla), `ai` (el categorizador), `auto` (autovalidación por score) o `extraction` — nadie ha decidido: es la SUPOSICIÓN del extractor y puede cambiar al validarse."}},"required":["id","statement_id","date","amount","currency","kind","status","concept","counterparty","merchant","category","bank","account_id","account_type","original_amount","original_currency","fx_rate","categorization_source"]},"Account":{"type":"object","properties":{"id":{"type":"string","description":"Identificador de la cuenta."},"bank":{"type":["string","null"],"description":"Banco emisor."},"account_number":{"type":["string","null"],"description":"Enmascarado por el banco."},"account_type":{"type":["string","null"],"description":"`debit` o `credit`."},"currency":{"type":["string","null"],"description":"Código ISO-4217 (MXN, USD…)."},"balance":{"type":["number","null"],"description":"Saldo al último corte, incluyendo holdings registrados a mano."},"first_period_start":{"type":["string","null"],"format":"date"},"last_period_end":{"type":["string","null"],"format":"date"},"statement_count":{"type":"integer"},"transaction_count":{"type":"integer","description":"Cuántas transacciones se han extraído de esta cuenta en total."}},"required":["id","bank","account_number","account_type","currency","balance","first_period_start","last_period_end","statement_count","transaction_count"]},"Category":{"type":"object","properties":{"key":{"type":"string","description":"Clave estable; es lo que trae `category`."},"label":{"type":"string","description":"Nombre legible."},"kind":{"type":"string","description":"`expense`, `income`, `internal_transfer` o `refund`."},"archived":{"type":"boolean"}},"required":["key","label","kind","archived"]},"Statement":{"type":"object","properties":{"id":{"type":"string","description":"Identificador del estado de cuenta."},"filename":{"type":"string"},"bank":{"type":["string","null"],"description":"Banco emisor."},"period_start":{"type":["string","null"],"format":"date"},"period_end":{"type":["string","null"],"format":"date"},"status":{"type":["string","null"],"description":"`pending` (subido, aún sin procesar), `processing`, `completed` o `error`. Es como sabes si ya puedes leer sus transacciones."},"page_count":{"type":["integer","null"]},"uploaded_at":{"type":"string","format":"date-time"},"possible_duplicates_count":{"type":"integer","description":"Anomalías de duplicado ABIERTAS que señalan a este estado (duplicate + cross_account_duplicate). Si > 0, revisa /anomalies: los duplicate salen con ?statement_id=…; los cross_account_duplicate nacen sin statement_id — búscalos en la lista sin ese filtro, por su transaction_id."}},"required":["id","filename","bank","period_start","period_end","status","page_count","uploaded_at","possible_duplicates_count"]},"Summary":{"type":"object","properties":{"month":{"type":"string","description":"Mes en formato `YYYY-MM`."},"currency":{"type":["string","null"],"description":"Código ISO-4217 (MXN, USD…)."},"net_worth":{"type":"number","description":"Activos menos deuda AL CIERRE del mes pedido, no el de hoy. Los saldos son de arrastre: una cuenta sin estado ese mes conserva el último conocido. Un mes anterior a tu primer estado de cuenta da 0."},"assets":{"type":"number","description":"Saldos de cuentas de débito, incluyendo holdings registrados a mano."},"debt":{"type":"number","description":"Deuda de tarjetas, positiva cuando se debe."},"net_worth_change":{"type":["number","null"],"description":"Cambio contra el mes anterior. `null` si no hay mes previo con qué comparar."},"income":{"type":"number","description":"Ingreso del periodo. SOLO transacciones validadas."},"expense":{"type":"number","description":"Gasto del periodo. SOLO transacciones validadas."},"net":{"type":"number","description":"`income - expense`."},"transaction_count":{"type":"integer","description":"Cuántas transacciones validadas entraron en estas cifras."},"top_expense_categories":{"type":"array","description":"Las 5 categorías con más gasto del mes."},"msi_committed":{"type":["number","null"],"description":"Mensualidad comprometida en compras a meses sin intereses PARA ESE MES. Es decreciente: cada plan deja de aportar cuando termina de pagarse, así que un mes lejano puede ser 0. `null` para un mes que ya pasó — no guardamos el estado histórico de los planes, así que cualquier cifra sería inventada."},"pending_count":{"type":"integer","description":"Transacciones del mes AÚN SIN VALIDAR. `income`/`expense` no las cuentan: un mes con `income: 0` y `pending_count` alto no está vacío — está sin validar."},"pending_income":{"type":"number","description":"Estimación del ingreso pendiente de validar (misma conversión FX)."},"pending_expense":{"type":"number","description":"Estimación del gasto pendiente de validar (misma conversión FX)."}},"required":["month","currency","net_worth","assets","debt","net_worth_change","income","expense","net","transaction_count","top_expense_categories","msi_committed","pending_count","pending_income","pending_expense"]},"Period":{"type":"object","properties":{"period":{"type":"string","description":"Mes del periodo, `YYYY-MM`."},"income":{"type":"number","description":"Ingreso del periodo. SOLO transacciones validadas."},"expense":{"type":"number","description":"Gasto del periodo. SOLO transacciones validadas."},"net":{"type":"number","description":"`income - expense`."},"transaction_count":{"type":"integer","description":"Cuántas transacciones validadas entraron en las cifras de este mes."},"assets":{"type":"number","description":"Saldos de débito al cierre, con arrastre si no hubo estado de cuenta ese mes."},"debt":{"type":"number","description":"Deuda de tarjetas al cierre del mes."},"net_worth":{"type":"number","description":"Activos menos deuda al cierre del mes."},"msi_committed":{"type":["number","null"],"description":"Compromiso de meses sin intereses de ESE mes. Se reporta cuando ningún estado de cuenta cubre el mes todavía — incluso si el mes ya pasó, porque el compromiso es real aunque no esté extraído. `null` cuando el mes ya está cubierto: ahí el cargo real ya viene en `expense` y reportarlo aparte sería contarlo dos veces."},"pending_count":{"type":"integer","description":"Transacciones del mes AÚN SIN VALIDAR. `income`/`expense` no las cuentan: un mes con `income: 0` y `pending_count` alto no está vacío — está sin validar."},"pending_income":{"type":"number","description":"Estimación del ingreso pendiente de validar (misma conversión FX)."},"pending_expense":{"type":"number","description":"Estimación del gasto pendiente de validar (misma conversión FX)."}},"required":["period","income","expense","net","transaction_count","assets","debt","net_worth","msi_committed","pending_count","pending_income","pending_expense"]},"Counterparty":{"type":"object","properties":{"match_field":{"type":"string","description":"`merchant`, `counterparty` o `concept` — contra qué campo comparar. Cópialo tal cual al crear una regla: es la señal con la que FinO$ reconoce a esta contraparte."},"pattern":{"type":"string","description":"El texto a buscar. Cópialo del grupo en vez de reescribirlo."},"display_name":{"type":"string","description":"Nombre legible para hablar de esta contraparte."},"pending_count":{"type":"integer","description":"Transacciones sin validar. Es donde hay trabajo por hacer."},"validated_count":{"type":"integer","description":"Transacciones ya validadas. Son el contexto histórico, no trabajo pendiente."},"total_amount":{"type":["number","null"],"description":"Suma de los montos, en valor absoluto. `null` cuando hay más de una divisa: sumarlas en crudo daría una cifra falsa."},"currency":{"type":["string","null"],"description":"Divisa del grupo. `null` cuando mezcla varias — ver `mixed_currency`."},"mixed_currency":{"type":"boolean","description":"El grupo mezcla divisas."},"direction":{"type":["string","null"],"description":"`in` (te pagan), `out` (pagas) o `both`. Distingue un cliente de un proveedor."},"first_date":{"type":["string","null"],"format":"date"},"last_date":{"type":["string","null"],"format":"date"},"months_seen":{"type":"integer","description":"En cuántos meses distintos aparece."},"cadence":{"type":"string","description":"`monthly`, `irregular` o `one_off`. Un movimiento mensual sugiere una relación estable."},"amount_stable":{"type":"boolean","description":"Los montos varían poco entre sí — típico de una renta o una suscripción."},"typical_day_of_month":{"type":["integer","null"],"description":"Día del mes habitual (mediana). Es lo que permite decir \"siempre a fin de mes\"."},"current_categories":{"type":"array","description":"Qué categorías tienen HOY estas transacciones. `value: null` = sin categoría.","items":{"$ref":"#/components/schemas/ValueCount"}},"current_kinds":{"type":"array","description":"Qué tipos tienen HOY estas transacciones.","items":{"$ref":"#/components/schemas/ValueCount"}},"decided_categories":{"type":"array","items":{"$ref":"#/components/schemas/ValueCount"},"description":"Categorías YA DECIDIDAS (por persona o regla) en este grupo. Compáralo con `current_categories`: lo que aparece allá pero no acá es SUPOSICIÓN del extractor, no una decisión de nadie."},"decided_kinds":{"type":"array","items":{"$ref":"#/components/schemas/ValueCount"},"description":"Tipos ya decididos en este grupo, leídos del override y no de la suposición."},"undecided_count":{"type":"integer","description":"Transacciones del grupo SIN categoría decidida. `undecided_count` + Σ `decided_categories` = `pending_count` + `validated_count`. Si es alto mientras `current_categories` se ve seguro, ese acuerdo lo puso el extractor y es justo lo que conviene preguntar."},"sample_transactions":{"type":"array","description":"Algunos movimientos, para reconocer de qué se está hablando.","items":{"$ref":"#/components/schemas/TransactionSample"}}},"required":["match_field","pattern","display_name","pending_count","validated_count","total_amount","currency","mixed_currency","direction","first_date","last_date","months_seen","cadence","amount_stable","typical_day_of_month","current_categories","current_kinds","decided_categories","decided_kinds","undecided_count","sample_transactions"]},"TransactionSample":{"type":"object","properties":{"date":{"type":["string","null"],"format":"date","description":"Fecha del movimiento."},"amount":{"type":["number","null"],"description":"Monto del movimiento, en valor absoluto."},"currency":{"type":["string","null"],"description":"Código ISO-4217 (MXN, USD…)."},"concept":{"type":["string","null"],"description":"Descripción tal como la imprime el banco."},"category":{"type":["string","null"],"description":"Clave de categoría (ver /categories)."},"category_override":{"type":["string","null"],"description":"La categoría DECIDIDA de la fila; null cuando solo existe la suposición de la extracción (que es lo que muestra `category`). Equivale a `categorization_source: 'extraction'` en /transactions: `null` aquí = nadie ha decidido."},"status":{"type":["string","null"],"description":"`validated` (ya confirmada por el usuario) o `pending` (sin validar)."}},"required":["date","amount","currency","concept","category","category_override","status"]},"ValueCount":{"type":"object","properties":{"value":{"type":["string","null"],"description":"El valor; `null` significa \"sin asignar\"."},"count":{"type":"integer","description":"Cuántas transacciones lo tienen."}},"required":["value","count"]},"CategorizationRule":{"type":"object","properties":{"id":{"type":"string","description":"Identificador de la regla."},"match_field":{"type":"string","description":"`merchant`, `counterparty` o `concept` — contra qué campo comparar. Cópialo tal cual al crear una regla: es la señal con la que FinO$ reconoce a esta contraparte."},"match_type":{"type":"string","description":"`contains` (subcadena) o `equals` (exacto)."},"pattern":{"type":"string","description":"El texto a buscar. Cópialo del grupo en vez de reescribirlo."},"kind":{"type":["string","null"],"description":"`expense`, `income`, `internal_transfer` o `refund`. `null` si la regla solo asigna categoría."},"category":{"type":["string","null"],"description":"Clave de categoría que asigna (ver /categories). `null` si solo asigna tipo."},"created_at":{"type":"string","format":"date-time"}},"required":["id","match_field","match_type","pattern","kind","category","created_at"]},"RulePreview":{"type":"object","properties":{"would_change_count":{"type":"integer","description":"Transacciones cuya clasificación cambiaría si confirmas."},"already_matching_count":{"type":"integer","description":"Coinciden con la regla pero ya están clasificadas: no cambiarían."},"would_autovalidate_count":{"type":"integer","description":"Transacciones que quedarían VALIDADAS automáticamente, y por lo tanto dejarían de aparecer en la bandeja de revisión del usuario. Es el número que hay que decirle antes de confirmar. Es una cota superior: algunas pueden quedar retenidas por otras guardas."},"blocked_by_direction_count":{"type":"integer","description":"Coinciden por texto pero la regla NO se les aplicaría porque el dinero va en sentido contrario (una regla de gasto no toca una devolución). Se reporta para que los conteos cuadren."},"current_categories":{"type":"array","description":"Qué categorías tienen HOY estas transacciones. `value: null` = sin categoría.","items":{"$ref":"#/components/schemas/ValueCount"}},"current_kinds":{"type":"array","description":"Qué tipos tienen HOY estas transacciones.","items":{"$ref":"#/components/schemas/ValueCount"}},"decided_categories":{"type":"array","items":{"$ref":"#/components/schemas/ValueCount"},"description":"Categorías YA DECIDIDAS (por persona o regla) entre las filas afectadas. Compáralo con current_categories: lo que está ahí pero no aquí es SUPOSICIÓN del extractor, y es lo que la regla convertirá en decisión."},"decided_kinds":{"type":"array","items":{"$ref":"#/components/schemas/ValueCount"},"description":"Tipos ya decididos entre las filas afectadas."},"undecided_count":{"type":"integer","description":"Filas afectadas SIN categoría decidida. No es el complemento de would_change_count (ese también cuenta filas a las que la regla solo aporta el tipo). undecided_count + Σ decided_categories = filas afectadas."},"sample_transactions":{"type":"array","description":"Algunos movimientos, para reconocer de qué se está hablando.","items":{"$ref":"#/components/schemas/TransactionSample"}},"conflicts":{"type":"array","description":"Reglas activas con el mismo patrón. Si hay alguna, probablemente ya existe.","items":{"$ref":"#/components/schemas/CategorizationRule"}}},"required":["would_change_count","already_matching_count","would_autovalidate_count","blocked_by_direction_count","current_categories","current_kinds","decided_categories","decided_kinds","undecided_count","sample_transactions","conflicts"]},"Anomaly":{"type":"object","properties":{"id":{"type":"string","description":"Identificador estable."},"kind":{"type":"string","description":"`duplicate` (dos veces dentro del mismo estado — casi seguro error de extracción), `cross_account_duplicate` (mismo día y monto en dos cuentas), `balance_mismatch` (los movimientos no reconcilian con los saldos impresos), `amount_outlier_history`, `amount_vs_balance`, `sign_mismatch`, `credit_inflow_unclassified`."},"severity":{"type":"string","description":"`high` · `medium` · `low`."},"status":{"type":"string","description":"`open` · `dismissed` · `resolved`."},"statement_id":{"type":["string","null"],"description":"Estado de cuenta al que pertenece la anomalía; null si es transversal."},"transaction_id":{"type":["string","null"],"description":"Transacción señalada (casa con `id` de /transactions); null si la anomalía es del estado de cuenta completo."},"hint":{"type":["string","null"],"description":"Explicación corta generada por el detector."},"expected_value":{"type":["number","null"],"description":"Lo que el detector esperaba (p. ej. el saldo impreso)."},"actual_value":{"type":["number","null"],"description":"Lo que encontró."},"created_at":{"type":"string","format":"date-time"}},"required":["id","kind","severity","status","statement_id","transaction_id","hint","expected_value","actual_value","created_at"]},"Workspace":{"type":"object","properties":{"id":{"type":"string","description":"Identificador del workspace."},"name":{"type":"string","description":"Nombre del workspace."},"parent_workspace_id":{"type":["string","null"],"description":"`null` significa que este workspace es una RAÍZ: es el acreditado — quien paga la suscripción y contra quien se cobra el consumo de toda su rama. Un valor no-null apunta al `id` de esa raíz (hoy la jerarquía es de UN nivel, así que es también el padre directo). Un integrador que liste los workspaces de un cliente y no repare en este campo puede terminar cobrándole, o atribuyéndole gasto, a la cuenta equivocada."},"created_at":{"type":"string","format":"date-time"}},"required":["id","name","parent_workspace_id","created_at"]}}},"paths":{"/api/v1/transactions":{"get":{"operationId":"listTransactions","summary":"Lista transacciones","description":"Devuelve validadas y pendientes, de más reciente a más antigua. Filtra por fecha, banco, categoría, cuenta, tipo o texto libre. Usa categorization_source para distinguir una categoría DECIDIDA de la suposición del extractor.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200},"description":"Máximo 200. Un valor mayor se acota en vez de fallar."},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"from","in":"query","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","schema":{"type":"string","format":"date"}},{"name":"bank","in":"query","schema":{"type":"string"}},{"name":"category","in":"query","schema":{"type":"string"}},{"name":"account_id","in":"query","schema":{"type":"string"}},{"name":"statement_id","in":"query","schema":{"type":"string"},"description":"Solo las transacciones de ese estado de cuenta. Es como lees lo que acabas de subir: tras el POST, espera a que su `status` sea `completed` y pide sus transacciones con este filtro."},{"name":"kind","in":"query","schema":{"type":"string","enum":["expense","income","internal_transfer","refund"]}},{"name":"search","in":"query","schema":{"type":"string"},"description":"Busca en concepto y contraparte, sin distinguir acentos."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Transaction"}},"pagination":{"type":"object","description":"Usa `has_more` para saber si falta pedir más; no hace falta hacer cuentas.","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"},"has_more":{"type":"boolean"}},"required":["limit","offset","total","has_more"]}}}}}}}}},"/api/v1/accounts":{"get":{"operationId":"listAccounts","summary":"Lista cuentas con su saldo","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Account"}}}}}}}}}},"/api/v1/categories":{"get":{"operationId":"listCategories","summary":"Lista las categorías activas del workspace","description":"Claves y nombres de las categorías activas del workspace. Úsalas tal cual en reglas y filtros.\n\nEn workspaces de negocio hay tres categorías de gasto que se parecen y NO son intercambiables: `cost_of_sales` es lo que escala con las ventas (por ejemplo, datos o insumos que se consumen al vender); `suppliers` es el gasto recurrente de operación con proveedores que no escala con ventas; `professional_services` es contador, legal y consultoría. Si dudas entre ellas, pregunta al usuario si el gasto crece cuando vende más.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Category"}}}}}}}}}},"/api/v1/summary":{"get":{"operationId":"getSummary","summary":"Resumen del mes: patrimonio, flujo y top de gasto","description":"La respuesta a \"¿cómo voy?\". Es un SNAPSHOT del mes pedido: el patrimonio es el del cierre de ese mes, no el de hoy, y coincide con el que devuelve /periods para el mismo mes. IMPORTANTE: estas cifras cuentan SOLO transacciones validadas, mientras que /transactions devuelve validadas y pendientes. Si sumas las transacciones y comparas contra este resumen, no van a coincidir — y no es un error.\n\nSi `pending_count` > 0, el mes tiene movimientos sin validar que estos totales NO incluyen — repórtalo antes de concluir que un mes está vacío.","parameters":[{"name":"month","in":"query","schema":{"type":"string"},"description":"`YYYY-MM`. Por default, el mes en curso."},{"name":"display_currency","in":"query","schema":{"type":"string","default":"MXN"},"description":"Divisa de salida. Todo se convierte a la tasa de su fecha."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Summary"}}}}}}}}},"/api/v1/periods":{"get":{"operationId":"listPeriods","summary":"Serie mes a mes de ingreso, gasto y patrimonio","description":"Una fila por mes. Como en /summary, cuenta SOLO transacciones validadas. Los saldos son de arrastre: una cuenta sin estado de cuenta ese mes conserva su último saldo conocido.\n\nSi `pending_count` > 0, el mes tiene movimientos sin validar que estos totales NO incluyen — repórtalo antes de concluir que un mes está vacío.","parameters":[{"name":"from","in":"query","schema":{"type":"string"},"description":"`YYYY-MM`."},{"name":"to","in":"query","schema":{"type":"string"},"description":"`YYYY-MM`. Default: mes en curso."},{"name":"display_currency","in":"query","schema":{"type":"string","default":"MXN"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Period"}}}}}}}}}},"/api/v1/statements/{id}":{"get":{"operationId":"getStatement","summary":"Estado de un estado de cuenta","description":"Consúltalo después de subir, hasta que `status` sea `completed`: ahí sus transacciones ya aparecen en /transactions.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Statement"}}}}}},"404":{"description":"No existe, o no pertenece al workspace de tu llave."}}}},"/api/v1/statements":{"post":{"operationId":"uploadStatement","summary":"Sube un PDF para procesar","description":"Requiere el scope `statements:write`. Procesar consume saldo del wallet (se cobra por hoja), por eso el permiso es aparte del de lectura. Si el saldo alcanza, responde 202: el procesamiento es asíncrono — consulta GET /api/v1/statements/{id} hasta que `status` sea `completed`. Si el saldo NO alcanza, responde SIEMPRE 402 (nunca 202) — el saldo insuficiente nunca es un \"aceptado, ya verás\". Si ya tenías al menos un estado procesado antes, el archivo queda encolado (`details.status: pending`, `details.statement_id` presente) y se procesa solo cuando recargues; en tu primer upload nunca queda archivo alguno: se descarta y `details.statement_id`/`details.status` no vienen en la respuesta, porque no hay nada que consultar.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"El PDF. Máximo 10 MB."},"filename":{"type":"string","description":"Opcional; por default el del archivo."}},"required":["file"]}}}},"responses":{"202":{"description":"Aceptado y en proceso — `data.status` es `processing`."},"400":{"description":"Falta el archivo, está vacío, excede 10 MB o no es PDF."},"402":{"description":"Saldo insuficiente. Trátalo distinto de un fallo: reintentar NO ayuda, hay que recargar. `code` es `INSUFFICIENT_BALANCE` y `details` trae `statement_id` (ausente si era tu primer upload — el archivo no se guardó), `status` (`pending` si quedó encolado, ausente si no), `required_cents`, `available_cents` y `plan_monthly_pages`.\n\n⚠️ **CAMBIO INCOMPATIBLE — el cuerpo del 402 se reestructuró.** Si tu integración es anterior a esta versión, son TRES cambios, no uno:\n1. **Status.** Un archivo encolado antes salía con **202** y `data.status: \"pending\"`. Ahora el saldo insuficiente responde SIEMPRE 402.\n2. **`required_cents` y `available_cents` SE MOVIERON del nivel raíz del cuerpo a `details`.** Antes: `body.required_cents`. Ahora: `body.details.required_cents`. Leer la ruta vieja ya no da error — da `undefined`, que se renderiza como $0 en la pantalla donde el usuario decide cuánto recargar. Es el modo de falla más caro posible y hay que migrarlo a mano.\n3. **`error` cambió de texto**: `\"insufficient balance to process this statement\"` → `\"insufficient balance\"`. Si hacías match por ese string, usa `code` (`INSUFFICIENT_BALANCE`), que es el campo estable.\n\nLos campos planos NO se duplicaron por compatibilidad: con `error` y el status cambiando igual, mantener dos de cuatro daría una falsa sensación de que la integración vieja sigue sirviendo, y crearía un segundo contrato que habría que romper otra vez más adelante."},"403":{"description":"La suscripción del workspace está en pausa. `code` es `SUBSCRIPTION_PAUSED`. No se encola nada — recargar no la levanta, así que reintentar aquí tampoco ayuda."},"502":{"description":"El procesador de PDFs falló. Reintentar puede servir."}}},"get":{"operationId":"listStatements","summary":"Lista estados de cuenta y su estado de procesamiento","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200},"description":"Máximo 200. Un valor mayor se acota en vez de fallar."},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Statement"}},"pagination":{"type":"object","description":"Usa `has_more` para saber si falta pedir más; no hace falta hacer cuentas.","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"},"has_more":{"type":"boolean"}},"required":["limit","offset","total","has_more"]}}}}}}}}},"/api/v1/anomalies":{"get":{"operationId":"listAnomalies","summary":"Anomalías detectadas: duplicados, descuadres, outliers","description":"Lo que los detectores marcan automáticamente al procesar cada estado de cuenta. Un `duplicate` con el mismo statement_id es casi con certeza un error de extracción, no dos compras. Solo lectura: resolverlas vive en la app.","parameters":[{"name":"status","in":"query","schema":{"type":"string","default":"open","enum":["open","dismissed","resolved","all"]}},{"name":"statement_id","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Anomaly"}},"pagination":{"type":"object","description":"Usa `has_more` para saber si falta pedir más; no hace falta hacer cuentas.","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"},"has_more":{"type":"boolean"}},"required":["limit","offset","total","has_more"]}}}}}}}}},"/api/v1/counterparties":{"get":{"operationId":"listCounterparties","summary":"Agrupa transacciones por contraparte, para saber qué preguntar","description":"Agrupa los movimientos por con QUIÉN son, no por concepto, y devuelve por grupo lo necesario para hacer una buena pregunta: cuántos hay sin clasificar, cada cuánto ocurren, si el monto es estable, qué día del mes suelen caer y en qué sentido va el dinero.\n\nEstá pensado para conversar: en vez de pedirle al usuario que clasifique transacción por transacción, mira los grupos con más pendientes y pregúntale por la relación (\"veo 5 pagos de X a fin de mes, ¿es tu cliente?\"). Su respuesta se convierte en una regla con `createCategorizationRule`, que clasifica todas de una vez.\n\nCada grupo trae `match_field` y `pattern`: cópialos tal cual al crear la regla en vez de reescribir el nombre, para que el patrón case con el dato real.\n\nIncluye validadas además de pendientes, porque el historial es lo que permite afirmar una cadencia; los conteos van separados.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100},"description":"Máximo 100. Un valor mayor se acota en vez de fallar."},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"from","in":"query","schema":{"type":"string","format":"date"},"description":"Default: hace 12 meses."},{"name":"to","in":"query","schema":{"type":"string","format":"date"}},{"name":"include_resolved","in":"query","schema":{"type":"boolean","default":false},"description":"Incluir también grupos sin nada pendiente. Por default se omiten: no hay nada que preguntar sobre ellos."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Counterparty"}},"pagination":{"type":"object","description":"Usa `has_more` para saber si falta pedir más; no hace falta hacer cuentas.","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"},"has_more":{"type":"boolean"}},"required":["limit","offset","total","has_more"]}}}}}}}}},"/api/v1/categorization-rules":{"get":{"operationId":"listCategorizationRules","summary":"Lista las reglas de categorización activas","description":"Revísalas antes de crear una regla nueva para no duplicar una que ya existe.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CategorizationRule"}}}}}}}}},"post":{"operationId":"createCategorizationRule","summary":"Crea una regla de categorización (con vista previa obligatoria)","description":"**Sin `confirm: true` esta llamada NO escribe nada**: devuelve 200 con la vista previa de lo que pasaría. Para escribir de verdad hay que llamarla otra vez con `confirm: true`, y entonces responde 201.\n\nEse ida y vuelta no es un trámite. Una regla se aplica a TODO lo que coincida, incluso a movimientos ya registrados, y las que clasifica quedan validadas automáticamente: **dejan de aparecerle al usuario en su bandeja de revisión**. Por eso, antes de confirmar, dile en concreto qué va a pasar usando `would_change_count` y sobre todo `would_autovalidate_count`, y pídele confirmación explícita.\n\n`would_change_count` cuenta también filas cuya categoría EFECTIVA ya coincide: ahí la regla convierte la suposición del extractor en decisión (y autovalida). Usa decided_categories/undecided_count para explicarlo.\n\nSi `conflicts` trae algo, ya existe una regla con ese mismo patrón: díselo en vez de crear una duplicada.\n\nManda `expected_match_count` con el `would_change_count` que te dio la vista previa: si los datos cambiaron mientras tanto, recibirás 409 y podrás mostrarle la vista previa nueva en vez de escribir algo que ya no es lo que se acordó.\n\nCopia `match_field` y `pattern` de `listCounterparties` en vez de escribirlos a mano. Requiere el scope `categorization:write`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"match_field":{"type":"string","enum":["concept","merchant","counterparty"],"description":"Contra qué campo comparar. Cópialo del grupo de contraparte."},"match_type":{"type":"string","enum":["contains","equals"],"description":"`contains` es lo habitual; `equals` exige coincidencia exacta."},"pattern":{"type":"string","description":"El texto a buscar."},"kind":{"type":["string","null"],"enum":["expense","income","internal_transfer","refund",null],"description":"Tipo a asignar. Puede ir `null` si solo asignas categoría."},"category":{"type":["string","null"],"description":"Clave de categoría a asignar (de /categories). Tiene que ser del mismo tipo que `kind`, o la llamada falla."},"confirm":{"type":"boolean","default":false,"description":"Déjalo fuera (o en `false`) para ver la vista previa. Ponlo en `true` SOLO después de que el usuario haya visto el impacto y lo haya aprobado."},"expected_match_count":{"type":"integer","description":"El `would_change_count` de la vista previa que le mostraste. Si ya no coincide, la llamada falla con 409 en vez de escribir."}},"required":["match_field","match_type","pattern"]}}}},"responses":{"200":{"description":"Vista previa. **No se creó nada.** Muestra el impacto y pide confirmación.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"created":{"type":"boolean"},"preview":{"$ref":"#/components/schemas/RulePreview"}}}}}}}},"201":{"description":"Regla creada y aplicada."},"400":{"description":"La regla no se sostiene: falta un campo, o el tipo y la categoría se contradicen. El mensaje dice cuál."},"403":{"description":"La llave no tiene el scope `categorization:write`."},"409":{"description":"Los datos cambiaron desde la vista previa. Trae la nueva; muéstrasela al usuario antes de reintentar."}}}},"/api/v1/workspaces":{"get":{"operationId":"listWorkspaces","summary":"Lista el subárbol del workspace de la llave (raíz + hijos directos)","description":"Devuelve la RAÍZ de la llave y sus hijos directos (un solo nivel — no hay nietos), sin importar si la llave está atada a la raíz o a uno de sus hijos: el subárbol es una propiedad de la raíz, no de con cuál workspace llames. Usa `parent_workspace_id` para distinguir la raíz (`null`) de un hijo.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200},"description":"Máximo 200. Un valor mayor se acota en vez de fallar."},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"q","in":"query","schema":{"type":"string"},"description":"Filtra por nombre, sin distinguir mayúsculas/minúsculas."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Workspace"}},"has_more":{"type":"boolean","description":"True si hay más filas después de esta página (pide la siguiente con `offset`)."}}}}}}}},"post":{"operationId":"createWorkspace","summary":"Crea un workspace hijo bajo la raíz de la llave","description":"Requiere el scope `workspaces:write`. Crea un hijo bajo la RAÍZ de la llave (igual que en `GET`, si la llave está atada a un hijo el nuevo workspace nace hermano suyo, no nieto — la jerarquía es de un solo nivel). El hijo nace SIN wallet ni suscripción propios: hereda la de la raíz, que es quien paga.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Nombre del workspace nuevo."}},"required":["name"]}}}},"responses":{"201":{"description":"Workspace creado.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Workspace"}}}}}},"400":{"description":"`code: CHILD_WORKSPACES_INVALID_NAME` — falta `name` o viene vacío."},"403":{"description":"O la llave no tiene el scope `workspaces:write`, o la raíz no puede tener hijos: `code: CHILD_WORKSPACES_NOT_ROOT` (la raíz de la llave ya es hija de otra — la jerarquía es de un solo nivel) o `CHILD_WORKSPACES_NOT_ALLOWED` (el plan efectivo de la raíz no permite hijos)."}}}}}}