An accounting app for freelancers (autónomos) in Spain: invoices, expenses, quarterly VAT (IVA, Modelo 303 / 390) and income-tax instalments (IRPF, Modelo 130). It is published as an online example at marieobelleiro.com/demo/app_DEMO.html: a single HTML file loaded with sample data, where visitors can add invoices and expenses and see how every figure reacts. Nothing is stored — reloading the page brings back the example data.
Two assistants sit on top of the app: Acceso fácil, a button-driven window built for keyboard and screen-reader users, and the Asistente Inteligente, a conversational assistant whose understanding is provided by Jev (TypeSafe).
The online example is a demo sandbox: it opens with sample data for 2025 and 2026, and every change a visitor makes — new invoices or expenses, deletions, paid/pending, 303 checkboxes, payments, notes, settings, conversations with the Asistente Inteligente — lives only in the browser's memory. Nothing is stored, and no accounting data leaves the page (only the text of a question typed to the Asistente Inteligente is sent, for Jev to understand it). Reloading the page restores the sample data, which is also the way to start again. The welcome dialog and the DEMO tooltip present it this way, and both assistants remind the user after saving.
Whoever builds the API — you, a hired freelancer, or a future team — plus anyone reviewing or approving the scope beforehand. No prior knowledge of this codebase is assumed.
It also serves as durable context for an AI coding agent asked to implement or modify the API later: since the app is a single HTML file with no separate spec, an agent working from the code alone would have to infer intent from function names and DOM logic. This document states that intent explicitly, so a change made months from now — by a person or an agent — stays consistent with the design instead of drifting from it.
A build-ready specification. It replaces the need to reverse-engineer requirements from the HTML/JS, so an implementer knows exactly which actions to build, what each one takes in and returns, and what's explicitly excluded — without having to ask.
The application keeps all data in a JSON object (__appData) inside the page, with no server and no callable actions — everything happens through direct DOM manipulation in the browser, and the data lives only in memory. Defining a proper action and read API is the foundation for reliable, testable and maintainable operation of the product, whatever triggers a given action: a screen, an assistant, a script or a test.
This document uses English terms for readability. Below is the mapping to the exact names shown in the app. The app itself keeps the official Spanish names on purpose: they are the names on the Hacienda forms freelancers have to file.
| App label (Spanish) | This document (English) |
|---|---|
| Facturación | Invoices screen |
| Gastos | Expenses screen |
| IVA | VAT screen |
| IRPF | Income tax screen |
| Resumen Anual | Annual view |
| 1T·Ene–Mar / 2T·Abr–Jun / 3T·Jul–Sep / 4T·Oct–Dic | Q1 / Q2 / Q3 / Q4 |
| Factura / Gasto | Invoice / Expense |
| Factura nacional / Factura exenta de impuestos | Domestic / Tax-exempt invoice |
| Cobrada / Pendiente | Paid / Pending |
| Acceso fácil | Accessibility assistant |
| Asistente Inteligente | Conversational assistant |
The API is organized into four functional layers.
| Layer | Role | Applied to this product |
|---|---|---|
| Action API | Exposes each user capability as a discrete, callable operation with typed parameters | create_invoice, update_invoice, delete_invoice, set_invoice_paid, create_expense, update_expense, delete_expense, set_expense_in_303, set_iva_paid, set_irpf_paid, add_year, delete_year |
| Search / Retrieval API | Queries app state in a form a caller can consume directly | Dashboard totals, invoice/expense lists filtered by year or quarter, IVA/IRPF quarterly and annual figures |
| Identity & Permissions API | Confirms who is calling and what they're allowed to do | Ties every action to the ledger the caller is allowed to use; blocks actions the caller isn't allowed to run |
| Notification / State-Change API | Reports events back to the caller | Confirms an action succeeded, carries warnings (e.g. date outside the quarter) and errors — replacing the demo's in-page messages and the remaining native prompt() |
Design rules that apply across all four layers: actions must be atomic and composable, permission checks must live server-side (not only in UI logic), every action returns a structured success/failure result, and every state-changing action is logged consistently regardless of what triggered it.
addFact(), deleteFact(), updateFact(), toggleCobrada(), addGasto(), deleteGasto(), updateGasto(), the mobile forms (mSaveInv(), mSaveGasto(), mDelete()) and the assistants must have a corresponding action with a name, a typed parameter list and a defined return shape.set_invoice_paid(invoice_id, paid) replaces toggleCobrada() and set_expense_in_303(expense_id, included) replaces the 303 checkbox toggle, so repeating a call (a retry, a double tap, an undo) can't flip the status the wrong way.set_salary_clients(year, clients), set_iva_compensation(quarter, on, amount), set_iva_note, set_irpf_note.restore_invoice / restore_expense action, or a soft delete with an expiry.status (success / error), a machine-readable code on error, a short human-readable message, and a list of warnings (e.g. date_outside_quarter) that never block the action.The invoice functions map to one action family. Below is create_invoice — any caller (a screen, an assistant, a script) uses this shape.
{
"name": "create_invoice",
"description": "Creates an invoice (factura) in the given ledger year.",
"parameters": {
"type": "object",
"properties": {
"year": { "type": "integer", "description": "The year selected in the app. The date's year is always set to it." },
"number": { "type": "string", "description": "Invoice number (Núm.)" },
"type": { "type": "string", "enum": ["esp", "ext"], "description": "esp = domestic with IVA; ext = tax-exempt, foreign client" },
"client_name": { "type": "string" },
"description": { "type": "string", "description": "Concepto" },
"date": { "type": "string", "format": "date", "description": "An impossible day is moved to the month's last day (31/06 → 30/06)" },
"quarter": { "type": "integer", "enum": [1, 2, 3, 4], "description": "Quarter it is declared in. Defaults to the date's quarter; may differ (warning only)" },
"amount": { "type": "number", "description": "Taxable base (base imponible), before VAT and withholding" },
"vat_rate": { "type": "number", "enum": [0, 4, 5, 10, 21], "description": "Forced to 0 when type is ext" },
"withholding": { "type": "boolean", "default": false, "description": "Flat 15% IRPF retention; only for type esp" },
"paid": { "type": "boolean", "default": false }
},
"required": ["year", "number", "type", "client_name", "description", "date", "amount"]
},
"returns": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["success", "error"] },
"mode": { "type": "string", "enum": ["demo", "persisted"] },
"invoice_id": { "type": "string" },
"warnings": { "type": "array", "items": { "type": "string" } },
"code": { "type": "string" },
"message": { "type": "string" }
}
}
}
The corresponding read action, get_dashboard_summary, returns the same totals renderDashboard() computes — as structured data instead of rendered HTML.
Mapping from app_DEMO.html's in-browser functions to the API actions this design proposes.
| Screen | Function(s) in the demo | Proposed API action |
|---|---|---|
| Facturación | addFact(), updateFact(), deleteFact(), toggleCobrada(), saveSalarioClientes(); mobile: mSaveInv(), mDelete('f') | create_invoice, update_invoice, delete_invoice (+ restore), set_invoice_paid, set_salary_clients |
| Gastos | addGasto(), addGastoIva(), addIvaRate(), updateGasto(), deleteGasto(); mobile: mSaveGasto(), mDelete('g') | create_expense, update_expense, delete_expense (+ restore), set_expense_in_303 |
| IVA | renderIva(), renderIvaAnual(), saveIvaNota(), saveIvaPagado(), saveIvaCompensarOn(), saveIvaCompensarAmt() | get_iva_summary, set_iva_note, set_iva_paid, set_iva_compensation |
| IRPF | renderIrpf(), renderIrpfAnual(), saveIrpfNota(), saveIrpfPagado() | get_irpf_summary, set_irpf_note, set_irpf_paid |
| Dashboard | renderDashboard(), getIngresosGastosPorMes(), getFacturadoPorMes(), getSalarioMainClientMonthly() | get_dashboard_summary, get_monthly_series |
| Year management | setYear(); addYear(), deleteYearConfirm() (not wired to the UI) | list_years, add_year, delete_year |
| Acceso fácil | Reads by rendering the screens and reading the figures back (window.AF.read); writes through getState()/saveState() and the save functions above | The same read actions as each screen plus create_invoice, create_expense, set_invoice_paid, set_salary_clients, set_iva_paid, set_irpf_paid |
| Asistente Inteligente | Answers through window.AF.answer() and the monthly helpers; writes through cfSave() (forms) and saveFlow() (step flows) | Same as Acceso fácil, plus classify_question — the call to Jev (Section 8) |
The demo has no backend: it reads and writes the __appData object in memory. Every function above needs to move behind the typed action interface before any external caller can use it.
Example: a caller marks an invoice as paid. This shows how the four layers from Section 2 work together for a real action.
sequenceDiagram participant Caller participant IdentityAPI as Identity & Permissions API participant ActionAPI as Action API participant StateAPI as Notification / State-Change API Caller->>IdentityAPI: Confirm caller can use this ledger IdentityAPI-->>Caller: Allowed (mode: demo) Caller->>ActionAPI: set_invoice_paid(invoice_id, true) ActionAPI->>ActionAPI: Validate invoice exists in the ledger ActionAPI-->>StateAPI: Emit state-change event StateAPI-->>Caller: status: success, mode: demo
This flow works identically for any caller — a screen, an assistant, a script or a test harness — because the API layers don't change based on who is calling them.
Organized the way the app itself is laid out — top to bottom, screen by screen — so it reads alongside the UI.
| Width | Layout |
|---|---|
| Up to 1023 px | Mobile layout: one column, burger navigation, period dropdowns, summary boxes and full-screen lists and forms. From 768 px the column is limited to 768 px and centred, so boxes and charts keep their proportions instead of stretching. |
| 1024–1231 px | Desktop layout that adapts to the available width; wide tables scroll inside their box. |
| 1232 px and wider | Desktop layout at its full width (1108 px content). |
The welcome dialog and the two assistant windows switch to their small-screen form (full screen / bottom panel) below 768 px. Text is kept at 14 px or larger on mobile, except the uppercase mono labels (12 px, as on desktop) and the chart axes.
Assistant buttons. On desktop they sit at the bottom of the left navigation bar. In the mobile layout both sit bottom-right inside a semi-transparent white box (12 px padding, 27 px gap, 12 px radius, 1 px #E9E9E9 border), each with a light shadow. The button of an open window gets an orange ring.
User info area. Account name and a "DEMO" badge with an (i) tooltip: the demo is meant to organise the accounting of freelancers in Spain; visitors are invited to try it by adding expenses or invoices; the two assistants show how far a design can be taken in collaboration with AI; and a contact email for anyone who wants to use it for their own accounting.
Mobile header. First row: the green "CONTABILIDAD" chip and the user info. Second row: the burger button and a chip with the current section in its colour.
On desktop it always renders exactly 6 year slots from the earliest year with data. States: active (selected), has-data (grey, turns green on hover), future (disabled, greyed), or plain/empty. In the mobile layout only the years with data are shown. Clicking a year calls setYear(yr).
dead code addYear(), deleteYearConfirm() and pedirAnoInicial() exist in the code but aren't wired to any button: there is no way to add or delete a year through the UI. If rebuilt, this is a gap to close deliberately.
prompt() asking for the rate. The new expense starts in category 8 (Otros consumos de explotación) and can be changed in its row. The only native dialogue reachable from the UI; a rebuilt version should route it through a form.Every (i) button behaves the same way: hover on desktop (the pointer can move into the tooltip, e.g. to click a link), tap on touch screens (tap again or tap outside to close), and focus / Enter with the keyboard (Escape closes). The tooltip is light blue with a blue left border.
Salario neto tooltip ends: "El salario neto es calculado con esas facturas, restando los gastos del mes anterior, independientemente de si los pagos se hayan realizado."
Every invoice and expense stores its quarter (trim) separately from its date. IVA, IRPF and the Facturación/Gastos boxes group by the stored quarter; the dashboard charts and the salary estimate group by the date. The same rules apply in every place where data is entered — desktop tables, mobile forms, Acceso fácil and the Asistente Inteligente:
Header: today's date ("1 OCT 2026"); for past years, the year's last day.
getSalarioMainClientMonthly()).Mobile order: date, Comparativa Ingresos · Gastos, Últimas facturas, Salario neto, Previsión de ahorro, IVA a pagar, IRPF a pagar.
| Type | IVA | Retention | Total |
|---|---|---|---|
| ext (tax-exempt, foreign client) | None | None | base |
| esp (domestic) | 0/4/5/10/21% | Optional flat 15% | base + IVA − retention |
1 Seguridad Social / RETA
2 Arrendamientos y cánones
3 Suministros (30% cap)
4 Otros suministros
5 Otros servicios exteriores
6 Transporte y viajes
7 Gastos de manutención (€26.67 / €48.08 día)
8 Otros consumos de explotación
9 Otros gastos deducibles (5% / €2,000)
10 Otros desgravables (with "Objeto")
The number appears everywhere a category is named (box titles, lists, forms, both assistants) except the distribution chart. In the "Por % IVA" table the category column shows only the number; it opens a dropdown with the full numbered names, and the full name is available on hover and to screen readers.
Expenses can be grouped by the deductible categories the IRPF return defines, or by the VAT rate applied. The expenses don't change, only how they're grouped. Both views show a Total € column per expense and BASE · IVA · TOTAL per box.
Deadlines: 20 Apr · 20 Jul · 20 Oct · 30 Jan (year+1).
| Box | Meaning | Formula (that quarter) |
|---|---|---|
| c.01 | Base imponible nacional | Σ base of esp invoices |
| c.03 | IVA repercutido | Σ base × iva% (esp invoices) |
| c.28 | Base gastos deducibles | Σ base of qualifying expenses |
| c.29 | IVA soportado | Σ base × iva% (qualifying expenses) |
| c.120 | Servicios extranjero | Σ base of ext invoices |
Result: subtotal = c.03 − c.29; resultado = subtotal − compensar (if "Aplicar compensación" is on). Positive → "A ingresar"; negative → "A compensar próximo trimestre"; zero → "Sin actividad". The payment block only appears when positive. The 303 is disabled for RETA and 0% expenses (see Gastos).
One rule on every screen. The quarterly 303, the annual summary (390) and the dashboard box use the same result: IVA repercutido − IVA soportado − IVA compensado. With no domestic invoices the 303 shows a warning, but IVA soportado is still deducted.
Annual summary (Modelo 390): IVA repercutido · IVA deducido · Operaciones no sujetas · IVA compensado · Resultado Mod. 303, per quarter; "—" for quarters not yet due. The footer "Resultado anual → Mod. 390" adds repercutido − deducido for the quarters already due; offsets are left out because they only move amounts between quarters and would otherwise be counted twice.
Mobile: the annual table becomes one block per quarter (label on the left, amount on the right, result in bold); quarters not yet due read "Sin resultado hasta que termine el plazo de declaración".
Same deadlines as IVA. Figures are cumulative from Q1, not quarter-only.
| Box | Meaning | Formula (cumulative) |
|---|---|---|
| c.01 | Ingresos íntegros | Σ base, all invoices, Q1→now |
| c.02 | Gastos deducibles | Σ base, all expenses, Q1→now |
| c.03 | Rendimiento neto | c.01 − c.02 |
| c.05 | 20% s/ rendimiento | c.03 × 20% |
| c.07 | Retenciones | Σ base × 15% (esp, ret=true) |
| c.08 | Pagos anteriores | Σ paid in prior quarters |
Result: cuota = max(0, c.05 − c.07 − c.08). Floored — no negative state, unlike IVA.
Quarter boxes: "IRPF abonado (1T)", "IRPF acumulado (2T)", "IRPF acumulado (3T)", "IRPF acumulado (4T)" — the amount paid through each quarter. In the mobile layout only the selected quarter and the one before it are shown, since what matters there is what has already been paid. The annual summary follows the same one-column rule as IVA on mobile.
A round accessibility button opens a window, "ACCESO FÁCIL", that lets the user consult and enter data through a short sequence of buttons instead of navigating the screens. Its goal is accessibility: a linear, button-only path that is fast with a keyboard and with VoiceOver or any other screen reader. It runs entirely inside the page — no external service, no network calls.
The data is structured and the vocabulary is fixed by the Spanish tax forms, so every question maps to a finite set of choices. Buttons are the most reliable input for screen-reader users: each one is a labelled, reachable stop, with no ambiguity about whether the input was understood. Free-text questions are the job of the Asistente Inteligente (Section 8).
aria-expanded. Closing the window (close button or Escape) returns focus to it.Three numbered steps, then the answer. After the answer, a "Cambiar periodo" row (Resumen anual · 1T–4T · year) and a "Ver en la app" link that opens the matching screen, year and quarter. By default every answer shows the current quarter of the active year; past years default to the annual summary.
| 1. Action | 2. Category | 3. Options |
|---|---|---|
| Consultar datos | Datos clave | Pending invoices, Salario neto del mes, Previsión de ahorro mensual, IVA a pagar, IRPF a pagar. Follow-ups: income/expenses per month (last 12 months), salary vs last month and same month last year, annual distribution of expenses. |
| Consultar datos | Facturación | The four summary boxes. Follow-ups: table totals (domestic / exempt / net), list of invoices. |
| Consultar datos | Gastos | The four summary boxes. Follow-ups: by IRPF category (numbered), by % IVA. |
| Consultar datos | Impuestos | IVA · Modelo 303 or IRPF · Modelo 130: quarterly boxes and result, or the annual summary (Modelo 390 / Mod. 100). |
| Introducir datos | Facturación | New invoice (form), mark an invoice as paid (list of pending invoices, with undo), salary clients. |
| Introducir datos | Gastos | New expense form, with the numbered IRPF category chosen from buttons and "Incluir en 303". |
| Introducir datos | Impuestos | Confirm a payment (IVA, IRPF or both) for a chosen quarter, prefilled with the quarter's result. IVA can only be recorded when the result is "A ingresar". |
| Consultar definiciones | 18 terms | Base imponible, IVA repercutido/soportado, Modelo 303/390/130, c.120, IRPF, rendimiento neto, retención 15 %, RETA, gastos deducibles, amortización, IVA a compensar, cobrada/pendiente, salario neto, previsión de ahorro, trimestre. |
Bottom of the window: "Volver" (undo the last step) and "Empezar de nuevo" (reset).
The ESP/ENG switch translates the whole window. English keeps the official Spanish terms in brackets — "VAT payable (IVA a pagar)", "Form 130 (Modelo 130)", "Q3 (3T)" — so a freelancer can match them against the real forms. The window's lang attribute switches too, so screen readers change voice. Amounts keep the Spanish format (1.234,56 €).
role="group" labelled by its question; option buttons use aria-pressed.aria-describedby / aria-invalid; focus moves to the first invalid field. Confirmations use role="status".A chat window, opened from the robot button, where the user writes questions in their own words — in Spanish or English — about their figures, the tax terms, or what they want to record. Jev understands what is being asked; the app produces the answer from its own data and functions. Answers are never invented: every figure comes from the same code as the screens.
jev-latest, TypeSafe) as a single "choice" question: which of the app's 46 requests does this text make? Jev returns the chosen request, the probabilities and a confidence value.| Group | Requests Jev chooses from |
|---|---|
| Figures | Key figures, net salary, month by month, distribution of expenses, invoicing, table totals, list of invoices, expenses, expenses by IRPF category, expenses by % IVA, IVA, IRPF, profit for a month/quarter/year, next month's forecast |
| Entering data | New invoice, mark an invoice as paid, salary clients, new expense, IVA paid, IRPF paid |
| Definitions | 19 terms (the 18 of Acceso fácil plus "beneficio") |
| Conversation | Greeting, thanks, compliment, complaint, goodbye, help, and "outside the app" (weather, jokes, legal advice…), which gets a polite explanation of what the assistant can do |
The page never holds the TypeSafe key. It sends the question to a small connector on the same server, jev.php, which adds the key, calls Jev and returns the result.
jev.php (holds the key; needs PHP with cURL) and jev-intents.json (the 46 requests and their descriptions — updating the assistant's vocabulary only means replacing this file).window.AF.answer()) for the period understood, so both assistants always say the same thing.Sections 1–8 describe an API that could sit behind the single-file app. Hosting it for real — a backend other devices could call — introduces problems that don't exist in the online example.
The online example keeps everything in memory and resets on reload. A real product needs persistent storage per user, which turns the "demo / persisted" mode of Section 3 into a real distinction.
"The ledger the caller is allowed to use" needs a real credential — an account, a session or an API key. Whether the product stays single-user is a decision to state explicitly, not leave implied.
Two open sessions editing the same data would overwrite each other. A host needs either optimistic concurrency control (reject a write if the data changed since it was read) or real-time sync — or at minimum, a way to tell a caller "this changed elsewhere" instead of losing data silently.
Only create_invoice has a full JSON schema here. Every other action in the Demo Mapping table needs the same treatment — parameters, types, required fields, return shape — before this is truly build-ready.
Actions return a machine-readable code on failure and warnings on success, but no enumerated list exists yet. The messages catalogued in Section 7 are the starting list (e.g. the date warning → date_outside_quarter, a date that can't be read → invalid_date).
The ledger is one JSON object (__appData), structured by year — the natural input for a hosted store. A host holding real tax records needs automatic backups and a version history, plus a user-controlled export.
In a hosted product classify_question becomes a server action like any other: the key stays server-side, limits are applied per account rather than per IP address, and the request list (jev-intents.json) grows together with the actions it points to.
This functional design lives apart from the app, with nothing keeping the two in sync automatically. Worth deciding which one is canonical before a real build starts, and updating this document whenever scope actually changes.