Urza’s Wallet / Engineering

Architecture

Costa Rica personal finance comparison platform.

built vs planned

Built vs planned

Accepted

Public credit-card comparison

Visitors browse, filter, compare, calculate, read articles and guides, and request a product through a consent-based lead form. Editors maintain issuers, credit cards, reviews, offers and content through the CMS and triage leads.

Draft

Deferred capabilities

Pension funds and home loans have schema and API foundations but no public vertical pages or CMS maintenance path. Accounts, saved comparisons, alerts, Clerk, Redis and document storage are deferred.

No automated ingestion or bank scraping exists. Editors are the only source of canonical product updates. Product request CTAs open an in-page form rather than redirecting to an issuer.

principles

Principles

PrincipleConsequence
SEO firstPublic content arrives as server-rendered HTML with metadata and structured data.
Low operational overheadTwo deployable application services and one shared PostgreSQL database on Railway.
Extensible domainsShared product fields plus vertical-specific tables and typed interfaces.
Transparent freshnessShow the last edit time and derive staleness from age; retain visibly stale products.
Privacy and consentPublic exploration is anonymous; lead collection requires explicit consent.
Proportionate securityEditor Basic auth, a private API, validated boundaries and parameterized SQL.
Measurable fitnessPerformance targets include LCP <2.5 s, CLS <0.1 and INP <200 ms; targets are not asserted production measurements.
tech stack

Technology stack

LayerImplementationRationale
Web and BFFNext.js 14.2.35, React 18, TypeScriptServer-rendered public pages and same-origin route handlers (ADR-002).
APIPython ≥3.12, FastAPI, Pydantic, UvicornAsync typed HTTP boundaries (ADR-012); replaces Express.
Application persistenceSQLAlchemy async + asyncpg, PostgreSQL 16Parameterized repositories and a relational source of truth (ADR-006).
Schema managementAlembic wrappers over shared SQL migrationsThe Python service and @urza/db mirror the same schema.
TypeScript packages@urza/types, @urza/db using pg, @urza/configShared contracts, SQL repository mirror and configuration. No Drizzle dependency is installed.
NotificationsSendGridBest-effort internal notifications when a visitor submits a lead.
OperationsRailway; Terraform for GitHub resources onlyManual service configuration, staging and human promotion.
DocumentationPlain HTML/CSS, Mermaid CLI 12.0.0, Cloudflare Pages + AccessCommitted output, no browser JavaScript; hosting setup remains manual.
context

System context

Browse, compare, request

Maintain catalog and leads

Crawl public HTML

Manual research

Internal lead notification

Visitor

Urza's Wallet

Editor

Search engines

Issuer publications

SendGrid

Clerk · Draft

C4 context — people and external systems
containers

Containers

HTTPS

HTTP Basic over HTTPS

Private HTTP /api/v1

Server component fetch

SQLAlchemy / asyncpg

Lead notification

Visitor browser

Next.js 14 web · Railway

Editor browser

Admin UI · inside Next.js

Next.js same-origin route handlers

FastAPI · Railway

PostgreSQL 16

SendGrid

Redis · Draft

Documents volume / R2 · Draft

Clerk · Draft

C4 containers — deployable services and data

Admin UI and proxy handlers are modules of the web service, not separate deployments. Dashed connections indicate Draft capabilities. No ingestion service exists.

api components

API components

HTTP requests

Request ID · Basic auth · rate limit

Public and admin routers

Pydantic boundary models

ComparisonService

Domain repositories

PostgreSQL

Age-based freshness on read

Lead notification service

SendGrid

Scheduled anonymization command

LeadRepository

FastAPI modules

The app factory wires stub repositories for contract tests and SQLAlchemy repositories when a database URL is supplied. Admin writes record audit entries; freshness is computed on read. The retention command is a separately scheduled process, with its schedule configured manually.

web components

Web components

Browser

App Router pages and layouts

React components and client interactions

Server fetch helpers in lib

fetchXClient helpers

Same-origin route handlers in app/api

Private FastAPI

Next.js layers
cms and leads

CMS and leads modules

Editor

Next.js admin pages

Admin route handlers

admin_* routers

Issuer/product/review/offer/article/guide write repositories

PostgreSQL

admin_audit_log

Visitor lead form

Public lead router

LeadRepository and consent insert

Best-effort team notification

Admin leads and deletion requests

CMS and lead ownership
rendering and seo

Rendering and SEO

API-backed pages use force-dynamic: Railway build containers cannot reach the private API. Data is fetched on each request rather than baked into an error page. Static educational and policy pages can render at build time; interactive calculators run client-side within their rendered page shell.

Current URLSurfaceRendering / index policy
/HomepageStatic shell
/tarjetas-de-credito/Credit-card hubDynamic SSR
/tarjetas-de-credito/catalogo/Catalog with filtersDynamic SSR; filtered variants noindex
/tarjetas-de-credito/[issuer]/[product]/Product detail and reviewDynamic SSR; JSON-LD financial product and breadcrumbs
/comparar/tarjetas/?ids=…Anonymous comparisonDynamic SSR; noindex, follow; canonical hub
/tarjetas-de-credito/buscador/Session-only card finderDynamic SSR; answered query variants noindex
/tarjetas-de-credito/articulos/ and /articulos/[slug]/Editorial articlesDynamic SSR; Article structured data
/tarjetas-de-credito/guias/, /guias/[cluster]/, /guias/[cluster]/[slug]/Guide hub, cluster and detailDynamic SSR; breadcrumbs and guide structured data
/tarjetas-de-credito/guias/recorridos/[slug]/Learning journeyDynamic SSR
/tarjetas-de-credito/calculadoras/Calculator hubStatic shell, client inputs
/tarjetas-de-credito/calculadoras/{intereses-y-pago,recompensas,utilizacion}/Three calculatorsStatic educational pages, client calculations
/metodologia/MethodologyStatic; indexable
/privacidad-solicitudes/ and /privacidad-solicitudes/eliminacion/Lead privacy and deletion requestStatic page shell
/admin/**Editor CMS and inboxDynamic; noindex; Basic auth

Metadata and structured data

Public pages use Next.js Metadata or generateMetadata for title, description, canonical and Open Graph data, with es-CR language alternatives where configured. The root document uses lang="es-CR". JSON-LD is rendered in server HTML; helpers serialize it safely. Detail pages expose financial-product and breadcrumb data; hubs, calculators, articles and guides emit their relevant schemas.

Canonical and linking strategy

The implemented canonical paths generally include trailing slashes. Catalog query variants remain anchored to the catalog path; comparison pages point to the credit-card hub and use noindex, follow. Comparison IDs are normalized by the URL codec. Hubs link to catalog, calculators, finder, articles and guides; contextual links return users to products and comparisons. No user-specific ranking or saved-comparison dashboard is shipped.

Sitemap and robots — Draft

Draft The former architecture described next-sitemap, per-vertical sitemaps and robots.txt rules. None is installed or committed. Per-page robots metadata exists, including the admin layout and comparison noindex policy; automated sitemaps remain deferred.

Obsolete URL claims removed: standalone issuer and named filter landing pages, pension/home-loan public pages, /mi-cuenta/**, /acerca-de/, /privacidad/ and the generic /comparar/ guide. Their absence is recorded rather than presented as current behavior.

security and privacy

Security and privacy

ControlImplemented behavior
Admin accessNext.js gates /admin/**; FastAPI independently gates /api/v1/admin/**. Both use Basic credentials from ADMIN_USERNAME and ADMIN_PASSWORD with constant-time comparisons. Missing configuration returns 503; missing or invalid credentials return 401. Lead route handlers forward caller authorization. Several CMS proxies inject configured credentials; see the verified proxy authorization gap.
Transport and boundariesRailway terminates public TLS. Web-to-API requests use the private network; the API is not intended to have a public domain. No Clerk integration or end-user RBAC is installed.
Validation and SQLPydantic and route-level checks validate HTTP inputs. Repositories bind SQL parameters. Dynamic sorting uses explicit allow-lists.
Response headersNext.js sets X-Frame-Options: DENY, X-Content-Type-Options: nosniff, strict-origin-when-cross-origin referrer policy and a Permissions-Policy disabling camera, microphone and geolocation.
Rate limitingFastAPI counts requests per client IP in a process-local sliding window, 60 per minute. Shared Redis counters are deferred.
Consent and minimizationThe lead form requires consent and a policy version. The repository inserts the lead and consent record atomically, storing a SHA-256 IP hash rather than raw IP. Catalog, calculator and comparison inputs are session-only.
Session attributionurza_session_id is random, host-only, SameSite=Lax, expires after 30 days and is Secure in production. It is deliberately not HttpOnly because the client beacon reads it; it grants no authentication authority.
Lead retention and erasureAdmin actions anonymize a lead or all leads matching a deletion-request email. Discarding a lead anonymizes it immediately. The scheduled command anonymizes leads older than LEAD_RETENTION_DAYS (default 365); the cron schedule is manually configured.
Logging and notificationsLead notification logs identify the lead by ID and avoid name/email. SendGrid receives name/email in the internal notification body; failures do not block lead submission.

Verified privacy boundary and limitations

The intended PII boundary is lead capture and its consent records. The existing lead_deletion_requests table also stores the requester’s email and processing notes. Processing a request marks it processed without clearing that email; the retention command only anonymizes leads. This is an existing discrepancy with the stated narrow PII boundary, not a new storage surface introduced by this documentation.

Lead anonymization clears name, email and notes while retaining non-identifying product context. Consent records and session attribution remain. The deletion-request record and SendGrid/team mailbox copies are not removed by that operation. The documentation describes these limits rather than asserting complete erasure across systems.

Draft controls

Draft End-user authentication, per-editor identities and revocation, MFA, RBAC, Redis rate limits, a full Content-Security-Policy/HSTS policy, formal incident procedures and legal/processor agreements are not established by the code inspected here. The old target-state statements about automatic export, 48-hour account erasure and specific regulatory deadlines are not presented as shipped guarantees. CORS middleware is unnecessary for the same-origin browser path and is not configured.

caching

Caching

Current reads use no-store and per-request memoization. Shared caching targets and Redis adoption criteria are Draft.

planned accounts

Planned: accounts

Draft Accounts, Clerk and personalization are deferred.

planned documents

Planned: documents

Draft Document storage, access control, versions and R2 migration are deferred.