OpenAPI type codegen with CI drift gate (plan 3.4, ARCH-16, FE-14)

Every dict-returning route the SPA consumes now declares a response
model (auth, bulk, sync-status, notifications). api-types.gen.ts is
generated from app.openapi() via openapi-typescript, and the 22 hand-
written read interfaces in api.ts are now aliases onto the generated
schemas — backend schema drift becomes a typecheck failure, and CI
regenerates the file and fails the deploy if it's stale. The aliasing
immediately caught two real drifts: RecurringItemType was missing
SAVINGS, and raw_charges (untyped JSON column) is now explicitly
shaped at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-12 07:53:00 -06:00
parent 3ea1ef0fa0
commit 7dfe2da2a9
11 changed files with 4347 additions and 251 deletions

View File

@@ -18,11 +18,17 @@ jobs:
/tmp/test-venv/bin/pip install --quiet -r requirements.txt -r requirements-dev.txt
/tmp/test-venv/bin/python -m pytest tests/ -q
- name: Frontend typecheck
- name: Frontend typecheck + API type drift gate
run: |
cd frontend
cd backend
DATABASE_URL='sqlite://' SECRET_KEY='ci-only-0123456789abcdef0123456789abcdef0123456789abcdef' \
ADMIN_USERNAME=ci ADMIN_PASSWORD=ci-not-admin OPENAI_API_KEY=dummy \
/tmp/test-venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
cd ../frontend
corepack enable
pnpm install --frozen-lockfile
pnpm exec openapi-typescript openapi.json -o src/lib/api-types.gen.ts
git diff --exit-code src/lib/api-types.gen.ts
pnpm typecheck
deploy:

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ tech_docs/
# Claude Code local state
.claude/
.venv/
frontend/openapi.json

View File

@@ -68,3 +68,17 @@ Four automated flows on old-vps feed data into WealthySmart:
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
## API Type Generation
Frontend read types are generated from the backend OpenAPI spec. After changing
backend response models:
```bash
cd backend && DATABASE_URL='sqlite://' SECRET_KEY=$(openssl rand -hex 32) \
ADMIN_USERNAME=x ADMIN_PASSWORD=x-x OPENAI_API_KEY=dummy \
.venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
cd ../frontend && pnpm gen:api # regenerates src/lib/api-types.gen.ts
```
CI regenerates and fails the deploy if `api-types.gen.ts` is stale.

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import (
@@ -11,7 +12,16 @@ from app.auth import (
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
class TokenResponse(BaseModel):
access_token: str
token_type: str
class MeResponse(BaseModel):
username: str
@router.post("/login", response_model=TokenResponse)
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
check_login_rate_limit(request)
verify_admin_credentials(form_data.username, form_data.password)
@@ -19,6 +29,6 @@ def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
return {"access_token": token, "token_type": "bearer"}
@router.get("/me")
@router.get("/me", response_model=MeResponse)
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
return {"username": username}

View File

@@ -1,6 +1,7 @@
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
@@ -15,14 +16,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("/vapid-public-key")
class VapidPublicKeyResponse(BaseModel):
publicKey: str
class SubscriptionStatusResponse(BaseModel):
status: str
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
def get_vapid_public_key(_user: str = Depends(get_current_user)):
if not settings.VAPID_PUBLIC_KEY:
raise HTTPException(status_code=503, detail="Push notifications not configured")
return {"publicKey": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe", status_code=201)
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
def subscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
@@ -48,7 +57,7 @@ def subscribe(
return {"status": "subscribed"}
@router.delete("/unsubscribe")
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
def unsubscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session
from app.auth import get_current_user
@@ -8,7 +9,22 @@ from app.services.sync_status import compute_sync_status
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
@router.get("/")
class SyncSourceStatus(BaseModel):
key: str
label: str
description: str
last_received: str | None
age_days: float | None
warn_after_days: float
status: str # 'ok' | 'warning' | 'never'
class SyncStatusResponse(BaseModel):
sources: list[SyncSourceStatus]
warnings: int
@router.get("/", response_model=SyncStatusResponse)
def sync_status(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),

View File

@@ -106,7 +106,11 @@ class BulkActionRequest(BaseModel):
deferred: Optional[bool] = None # for set_deferred
@router.post("/bulk")
class BulkActionResponse(BaseModel):
affected: int
@router.post("/bulk", response_model=BulkActionResponse)
def bulk_action(
req: BulkActionRequest,
session: Session = Depends(get_session),

View File

@@ -8,7 +8,8 @@
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
"build": "vite build",
"preview": "tsx server.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
},
"dependencies": {
"@ag-ui/client": "0.0.52",
@@ -42,6 +43,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react-swc": "^3.9.0",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6.3.5"

191
frontend/pnpm-lock.yaml generated
View File

@@ -96,6 +96,9 @@ importers:
'@vitejs/plugin-react-swc':
specifier: ^3.9.0
version: 3.11.0(@swc/helpers@0.5.21)(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0(typescript@5.9.3)
tailwindcss:
specifier: ^4
version: 4.2.4
@@ -225,6 +228,14 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
@@ -1215,6 +1226,16 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.15':
resolution: {integrity: sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
@@ -1894,6 +1915,10 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -1906,6 +1931,9 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -1923,6 +1951,9 @@ packages:
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -1937,6 +1968,9 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -1966,6 +2000,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@@ -2024,6 +2061,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -2724,6 +2764,10 @@ packages:
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -2808,12 +2852,20 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
@@ -3305,6 +3357,10 @@ packages:
engines: {node: '>=4'}
hasBin: true
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -3393,6 +3449,12 @@ packages:
zod:
optional: true
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
@@ -3426,6 +3488,10 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -3483,6 +3549,10 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -3925,6 +3995,10 @@ packages:
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -3992,6 +4066,10 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type-graphql@2.0.0-rc.1:
resolution: {integrity: sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q==}
engines: {node: '>= 18.12.0'}
@@ -4077,6 +4155,9 @@ packages:
untruncate-json@0.0.1:
resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==}
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
urlpattern-polyfill@10.1.0:
resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
@@ -4275,6 +4356,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -4464,6 +4548,14 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.1
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/runtime@7.29.2': {}
'@base-ui/react@1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
@@ -5430,6 +5522,29 @@ snapshots:
dependencies:
react: 19.2.5
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.15(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.1.1
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -6039,6 +6154,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@@ -6047,6 +6164,8 @@ snapshots:
ansi-styles@5.2.0: {}
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@@ -6060,6 +6179,8 @@ snapshots:
bail@2.0.2: {}
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
bignumber.js@9.3.1: {}
@@ -6085,7 +6206,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -6095,6 +6216,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
brace-expansion@2.1.1:
dependencies:
balanced-match: 1.0.2
buffer-equal-constant-time@1.0.1: {}
buffer@6.0.3:
@@ -6123,6 +6248,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
change-case@5.4.4: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@1.1.4: {}
@@ -6178,6 +6305,8 @@ snapshots:
color-name@1.1.4: {}
colorette@1.4.0: {}
colorette@2.0.20: {}
combined-stream@1.0.8:
@@ -6443,9 +6572,11 @@ snapshots:
dependencies:
ms: 2.0.0
debug@4.4.3:
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {}
@@ -6660,7 +6791,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -6724,7 +6855,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -6771,7 +6902,7 @@ snapshots:
gaxios@7.1.4:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@10.2.2)
node-fetch: 3.3.2
transitivePeerDependencies:
- supports-color
@@ -7032,10 +7163,10 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
https-proxy-agent@7.0.6:
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -7062,6 +7193,8 @@ snapshots:
immer@11.1.4: {}
index-to-position@1.2.0: {}
inherits@2.0.4: {}
inline-style-parser@0.1.1: {}
@@ -7118,12 +7251,18 @@ snapshots:
joycon@3.1.1: {}
js-levenshtein@1.1.6: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
js-tokens@4.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.3.1
@@ -7865,7 +8004,7 @@ snapshots:
micromark@3.2.0:
dependencies:
'@types/debug': 4.1.13
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
decode-named-character-reference: 1.3.0
micromark-core-commonmark: 1.1.0
micromark-factory-space: 1.1.0
@@ -7887,7 +8026,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -7920,6 +8059,10 @@ snapshots:
mime@1.6.0: {}
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.1
minimist@1.2.8: {}
mlly@1.8.2:
@@ -7993,6 +8136,16 @@ snapshots:
- encoding
optional: true
openapi-typescript@7.13.0(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.15(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.9.3
yargs-parser: 21.1.1
p-finally@1.0.0: {}
p-queue@6.6.2:
@@ -8036,6 +8189,12 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.7
index-to-position: 1.2.0
type-fest: 4.41.0
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -8105,6 +8264,8 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
pluralize@8.0.0: {}
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -8520,7 +8681,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -8574,7 +8735,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -8743,6 +8904,8 @@ snapshots:
stylis@4.4.0: {}
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -8795,6 +8958,8 @@ snapshots:
tw-animate-css@1.4.0: {}
type-fest@4.41.0: {}
type-graphql@2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.13.2))(graphql@16.13.2):
dependencies:
'@graphql-yoga/subscription': 5.0.5
@@ -8910,6 +9075,8 @@ snapshots:
untruncate-json@0.0.1: {}
uri-js-replace@1.0.1: {}
urlpattern-polyfill@10.1.0: {}
urql@4.2.2(@urql/core@5.2.0(graphql@16.13.2))(react@19.2.5):
@@ -9070,6 +9237,8 @@ snapshots:
y18n@5.0.8: {}
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,10 @@
import type { components } from "./api-types.gen";
/** Schemas generated from the backend OpenAPI spec (pnpm gen:api). Aliasing
* the app's read types to them makes backend schema drift a typecheck error
* instead of a runtime surprise (review ARCH-16 / FE-14 / plan 3.4). */
type Schema<K extends keyof components["schemas"]> = components["schemas"][K];
const BASE_URL = "/api/v1";
class ApiError extends Error {
@@ -125,91 +132,22 @@ export async function logout() {
// ─── Types ──────────────────────────────────────────────────────────────────
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export type Account = Schema<'AccountRead'>;
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export type Category = Schema<'CategoryRead'>;
export interface SkippedDuplicate {
line: number;
merchant: string;
date: string;
amount: number;
currency: string;
existing_id: number;
existing_merchant: string;
existing_date: string;
existing_amount: number;
existing_source: string;
}
export type SkippedDuplicate = Schema<'SkippedDuplicate'>;
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
skipped: SkippedDuplicate[];
}
export type ImportResult = Schema<'PasteImportResult'>;
export interface Transaction {
id: number;
amount: number;
currency: string;
merchant: string;
city: string | null;
date: string;
card_type: string | null;
card_last4: string | null;
authorization_code: string | null;
reference: string | null;
transaction_type: string;
source: string;
bank: string;
notes: string | null;
category_id: number | null;
category: Category | null;
deferred_to_next_cycle: boolean;
created_at: string;
}
export type Transaction = Schema<'TransactionRead'>;
// --- Budget / Recurring Items ---
export type RecurringItemType = "INCOME" | "EXPENSE";
export type RecurringFrequency =
| "WEEKLY"
| "MONTHLY"
| "QUARTERLY"
| "BIANNUAL"
| "YEARLY";
export type RecurringItemType = Schema<"RecurringItemType">;
export type RecurringFrequency = Schema<"RecurringFrequency">;
export interface RecurringItem {
id: number;
name: string;
amount: number;
currency: string;
item_type: RecurringItemType;
frequency: RecurringFrequency;
day_of_month: number | null;
month_of_year: number | null;
override_amounts: Record<string, number> | null;
category_id: number | null;
is_active: boolean;
notes: string | null;
created_at: string;
category: Category | null;
}
export type RecurringItem = Schema<'RecurringItemRead'>;
export interface RecurringItemCreate {
name: string;
@@ -239,76 +177,19 @@ export interface RecurringItemUpdate {
notes?: string | null;
}
export interface RecurringItemDetail {
id: number;
name: string;
amount: number;
projected_amount: number | null;
used_actual: boolean;
item_type: string;
frequency: string;
category_name: string | null;
category_id: number | null;
}
export type RecurringItemDetail = Schema<'RecurringItemDetail'>;
export interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export type ActualsBySource = Schema<'ActualsBySource'>;
export interface MonthlyProjection {
month: number;
year: number;
projected_income: number;
projected_fixed_expenses: number;
actual_credit_card: number;
actual_cash: number;
actual_transfers: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
carryover_balance: number;
cumulative_balance: number;
balance_overridden: boolean;
}
export type MonthlyProjection = Schema<'MonthlyProjectionResponse'>;
export interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_net: number;
}
export type YearlyProjection = Schema<'YearlyProjectionResponse'>;
export interface MonthlyDetail {
year: number;
month: number;
income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[];
total_projected_income: number;
total_projected_expenses: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
cc_by_category: { category_name: string; amount: number }[];
}
export type MonthlyDetail = Schema<'MonthlyDetailResponse'>;
// --- Savings Accrual ---
export interface SavingsAccrual {
id: number;
year: number;
month: number;
memp_amount: number;
mpat_amount: number;
trigger_transaction_id: number | null;
applied_at: string;
notes: string | null;
}
export type SavingsAccrual = Schema<'SavingsAccrualRead'>;
export interface SavingsAccrualCreate {
year: number;
@@ -361,11 +242,7 @@ export const deleteBalanceOverride = (year: number, month: number) =>
// --- Salarios ---
export interface SalariosSummary {
count: number;
total_amount: number;
latest_date: string | null;
}
export type SalariosSummary = Schema<'SalariosSummary'>;
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>("/salarios/", { params });
@@ -374,32 +251,9 @@ export const getSalariosSummary = () =>
// --- Pensions ---
export interface PensionSnapshot {
id: number;
fund: string;
contract_number: string;
period_start: string;
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
source_filename: string;
created_at: string;
}
export type PensionSnapshot = Schema<'PensionSnapshotRead'>;
export interface PensionUploadResult {
imported: number;
updated: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
export type PensionUploadResult = Schema<'PensionUploadResult'>;
export interface PensionManualEntry {
fund: string;
@@ -436,51 +290,19 @@ export interface MunicipalCharge {
amount: number;
}
export interface WaterMeterReading {
id: number;
meter_id: string;
period: string;
reading_previous: number;
reading_current: number;
consumption_m3: number;
agua_potable: number;
serv_ambientales: number;
alcant_sanitario: number;
iva: number;
is_historical: boolean;
receipt_id: number | null;
created_at: string;
}
export type WaterMeterReading = Schema<'WaterMeterReadingRead'>;
export interface MunicipalReceipt {
id: number;
receipt_date: string;
due_date: string;
period: string;
account: string;
finca: string;
holder_name: string;
holder_cedula: string;
holder_address: string;
subtotal: number;
interests: number;
iva: number;
total: number;
raw_charges: MunicipalCharge[];
source_filename: string;
created_at: string;
}
export type MunicipalReceipt = Omit<
Schema<'MunicipalReceiptRead'>,
'raw_charges'
> & { raw_charges: MunicipalCharge[] };
export interface MunicipalReceiptDetail extends MunicipalReceipt {
water_readings: WaterMeterReading[];
}
export type MunicipalReceiptDetail = Omit<
Schema<'MunicipalReceiptDetailRead'>,
'raw_charges'
> & { raw_charges: MunicipalCharge[] };
export interface MunicipalReceiptUploadResult {
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
export type MunicipalReceiptUploadResult = Schema<'MunicipalReceiptUploadResult'>;
export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData();
@@ -502,20 +324,9 @@ export const getWaterConsumption = (months?: number) =>
// --- Sync Status ---
export interface SyncSource {
key: string;
label: string;
description: string;
last_received: string | null;
age_days: number | null;
warn_after_days: number;
status: 'ok' | 'warning' | 'never';
}
export type SyncSource = Schema<'SyncSourceStatus'>;
export interface SyncStatusResponse {
sources: SyncSource[];
warnings: number;
}
export type SyncStatusResponse = Schema<'SyncStatusResponse'>;
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
@@ -523,10 +334,6 @@ export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
export type UserSettingsData = Record<string, unknown>;
export interface UserSettingsRead {
key: string;
data: UserSettingsData;
updated_at: string;
}
export type UserSettingsRead = Schema<'UserSettingsRead'>;
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');