The mylana manual

A finance advisor that never guesses your numbers

mylana is a minimal, fully local personal finance advisor. It tracks your liquid money and declared income against your expenses, computes the month's cash flow deterministically, and asks a local LLM for one short recommendation — from numbers it can never change.

Fully local & private One Go binary Exactly one LLM call

Everything is Go and compiles into a single self-contained binary — no Python runtime, no Docker, no cloud. A localhost web UI is the daily front end; the same code runs the analysis and writes one report. This manual is that: one continuous read, from installing it to how every peso is accounted for.

Start here

The four ideas

🧮
The LLM never does math

Every figure is computed by pure, tested Go functions. The LLM only interprets numbers it cannot alter — a financial advisor that miscalculates is worse than none.

One LLM call, not an agent framework

The flow is fixed, so there is nothing for an "agent" to decide. (An earlier CrewAI version spent 6–9 inferences per run doing what one does now.)

🔒
Fully local and private

Ollama runs on your machine. The only network request is to an exchange-rate API, which receives currency codes — never your data.

💬
The UI answers questions instead of raising them

A due day "27" renders as "pay Jul 27 (15d)"; an unknown value says so explicitly. You should never have to compute or guess.

What it produces

One Monthly Financial Report, in seven sections: position today, this month's flow, where the money goes, debt motion, a resolved 30-day calendar, health indicators, and the advisor's take. Read How it works for the full walk-through.

mylana · a minimal, fully local finance advisor. The Markdown docs in docs/*.md remain the working source.
Getting started

Install & configure

Clone the repo, seed your data, and run one command. Everything compiles into a single self-contained Go binary — no Docker, no Python runtime, and a local LLM only if you want the advisory.

Go 1.26+ Runs on localhost No Docker

1Requirements

Go 1.26 or newer is the only hard requirement — it builds and runs the whole app. Everything else is optional.

ComponentNeeded forRequired?
Go 1.26+Building and running mylanaYes
OllamaThe advisory section (any chat model; gemma4:12b is the default)Optional
Python 3.12+ & uvRunning the reference oracle onlyOptional
DockerNever used

Without Ollama, the app still produces the full numeric report; the advice section simply notes that advice is unavailable. Skip the LLM entirely with --no-advice.

2Install & first run

git clone https://github.com/roberjacobo/mylana.git
cd mylana
cp db/expenses.example.json db/expenses.json   # then edit with your real data
go run ./cmd/mylana serve

serve starts a localhost-only server at http://127.0.0.1:8787 and opens your browser. This is the daily front end.

To build a standalone binary once and run it directly:

go build -o mylana.exe ./cmd/mylana
mylana serve
💡
Two entry points, one codebase

mylana serve runs the web UI; plain mylana runs the pipeline once from the CLI and writes outputs/report.md. See the Command reference for the full list.

3Your data

All your data lives in one SQLite file, db/mylana.db — every write is transactional. The expenses.json you copied is a seed: on first run it is imported into the database and renamed to expenses.json.bak.

Back it up or restore it any time:

mylana export   # dump the database to db/*.json
mylana import   # load db/*.json back, replacing what's present
📦
Where it lives, and its shape

See Data & storage for the full schema, how the base directory is resolved, and the document model behind every table.

4Configuration (.env)

An optional .env file in the working directory tunes a few things. Every setting has a default, so you can skip this entirely.

LLM=ollama/gemma4:12b                    # Ollama model for the advice ("ollama/" prefix stripped)
BASE_OLLAMA_URL=http://localhost:11434
LANGUAGE=en                              # UI + report language: en (default), es or fr

5Income

Income is entirely user-declared — there is no AMOUNT setting. The report's projected income is the sum of the income sources you declare in the app (Accounts tab → Income).

A source can be a one-time deposit or a perpetual source (fixed or variable, in MXN or USD). USD sources convert at the day's exchange rate; a variable source counts only what was actually received that month, never an estimate. With no sources declared, projected income is 0.

6Languages

The UI and the report render in English, Español or Français. Change it live from the web Settings menu (the gear at the bottom of the sidebar), which persists your choice to db/settings.json — or set LANGUAGE in .env.

🌐
English is the fallback

English is the source language. Any untranslated string shows in English rather than breaking, so switching languages is always safe.

7Demo / privacy mode

A Settings toggle masks every money amount as $•••••• MXN, so you can show the app without revealing your finances. Names, structure and percentages stay visible, so the app is still fully demonstrable.

🔒
Server-side, not a blur

The masking happens on the server — real figures never reach the page or the report. It is not a CSS effect that could be peeled back in the browser.

8Choosing an LLM

The advisory is the one place a model gets involved. Picking one is easy.

🧠
Any Ollama chat model works

No tool-calling is required. The LLM only writes a short advisory from numbers that are already computed, so a mid-size model is plenty — pick one that fits your GPU's VRAM (e.g. gemma4:12b for 16 GB). Reasoning models are unnecessary here.

Getting started

Command reference

Every command you need, in one place. Each Go command runs from the repo root with no build step for development — go run compiles and runs in one shot. The Python commands are only for the reference oracle, and they need uv sync once before their first use.

Run from repo root No build step to develop Python = oracle only
📦
One binary, not two

There is no separate frontend and backend process to start. serve embeds the HTMX templates and runs the pipeline in-process — nothing to npm install, no Node, no Python runtime to use the app.

1Run (backend + frontend — one binary)

The web UI is the frontend and the pipeline is the backend, but they ship as the same binary and the same command family.

go run ./cmd/mylana serve          # web UI (the frontend) at http://127.0.0.1:8787, opens the browser
go run ./cmd/mylana                # run the pipeline (backend) directly, write outputs/report.md
go run ./cmd/mylana --no-advice    # numeric report only, skip the LLM entirely (< 1s)

2Build & cross-compile

Set GOOS/GOARCH to build for any platform from any platform.

go build -o mylana.exe ./cmd/mylana                              # current OS
GOOS=linux   GOARCH=amd64 go build -o mylana       ./cmd/mylana  # Linux
GOOS=darwin  GOARCH=arm64 go build -o mylana-macos ./cmd/mylana  # macOS (Apple Silicon)
GOOS=windows GOARCH=amd64 go build -o mylana.exe   ./cmd/mylana  # Windows

The result is a ~12 MB self-contained binary (pure Go, no CGO — that's exactly what keeps cross-compilation working). Dev and CLI builds must not pass -H=windowsgui; only the installer build does.

3Data: backup, restore & feed the oracle

Move your data between the SQLite database and the portable JSON shapes.

go run ./cmd/mylana export [dir]   # dump db/mylana.db to db/*.json (default: the data dir)
go run ./cmd/mylana import [dir]   # load db/*.json back, replacing what's present

export is also how you feed the Python oracle — it reads the JSON files, not the database.

4Quality gate (run clean before committing)

Both sides must pass before a commit: the canonical Go implementation and the Python oracle that keeps it honest.

# Go — the canonical side
go vet ./...
go test ./...
gofmt -w assets cmd i18n pipeline store tools web

# Python — the reference oracle
uv run pytest tests/ -q
uv run ruff check src tests
uv run ruff format src tests

5Reference Python pipeline (the oracle)

The Python original produces the same report as the Go pipeline. Sync its isolated venv once, then run it like the CLI.

uv sync                            # once: create the isolated venv
uv run mylana                      # run the oracle (same report as the Go pipeline)
uv run mylana --no-advice          # numbers only

6Verify the Go and Python reports stay identical

If you change the math or the report on either side, prove they still match byte for byte (modulo line endings).

go run ./cmd/mylana export db                  # write db/*.json next to mylana.db
go run ./cmd/mylana --no-advice                # Go report -> outputs/report.md
uv run mylana --no-advice                      # oracle report -> outputs/report.md (overwrites)
# compare the two runs' outputs/report.md (diff, ignoring line endings)

7Icon & Windows resources (regenerate after changing either)

The app icon is generated from code, never hand-edited; the Windows .syso resources are rebuilt from it.

go run ./tools/genicon                                 # regenerate assets/icon-256.png, icon.ico, icon.icns
go-winres make --in cmd/mylana/winres/winres.json --out cmd/mylana/rsrc   # rebuild the embedded Windows .syso

8Packaging (one installer per platform, all from the same binary)

Each platform gets a per-user installer built from the very same binary.

powershell -File packaging/windows/build.ps1   # Windows: dist/mylana-setup.exe (Inno Setup, per-user, no admin)
sh packaging/macos/build-app.sh                # macOS (run on a Mac): dist/mylana.app (unsigned)
sh packaging/linux/build.sh                    # Linux: tar.gz whose install.sh registers a .desktop launcher
Concepts

How it works

mylana reads your local financial data and produces one output: the Monthly Financial Report. Every number is computed deterministically; a local LLM only writes the final advisory — and even that is optional.

Deterministic One HTTP rate call One optional LLM call

1The pipeline at a glance

Five stages turn your data into the report. The one rule to remember: the LLM sees the finished numbers; it can never change them.

Load data store
The expenses document, the spending log, and accounts — all from db/mylana.db (SQLite).
Exchange rate rate.go
One HTTP GET with an automatic fallback source, to convert USD income.
Math math.go
Cash flow, debt motion, resolved calendar, health indicators. Pure functions, no I/O, no AI.
Report report.go
Sections 1–6 rendered as Markdown, translated via i18n.
Advice — optional advisor.go
ONE call to the local LLM becomes section 7. Skipped with --no-advice.

2Two ways to run it

go run ./cmd/mylana serve   # web UI: opens http://127.0.0.1:8787, runs the analysis on demand
go run ./cmd/mylana         # CLI: run the pipeline once, write the report

The web UI runs the pipeline in-process — no subprocess, no Python. Both entry points execute the same Go code (pipeline.Run).

🔁
The pipeline exists twice

The canonical Go implementation (pipeline/) and the reference Python original (src/mylana/, run with uv run mylana) produce the same report, byte for byte. The Python side is an oracle that keeps the math honest — see Architecture.

3Step by step

Step 0 — Income

Income is entirely user-declared; there is no AMOUNT setting. The projected income is the sum of the income_sources (a salary, a rent you collect). USD sources convert at the day's rate; a variable source counts only what was actually received that month, never an estimate. With no sources, projected income is 0.

Step 1 — Load the data

The expenses document (fixed data + income sources), the spending log, and liquid money (accounts, or the legacy single cash number). No path is hardcoded — see Data & storage for where it lives and its schema.

Step 2 — Fetch the exchange rate

One HTTP GET to exchangerate-api.com (free, no API key). If it fails, it retries against frankfurter.app (European Central Bank). If both fail, the run stops — better no report than one with an invented rate.

Step 3 — Compute everything

All arithmetic happens here, in pure functions:

ComputationFunction
Convert USD → MXNConvert(3000, 17.5) → $52,500.00
Monthly outflow (the full picture)MonthlyOutflow(data, entries, ym)
Remaining balanceRemainingBalance(income, outflow)
Debt motion this monthDebtMotion(entries, ym) — paid vs charged
Payment priorityAvalancheOrder(inventory) — highest interest first
Payoff estimatePayoff(...) — amortized months to zero
Resolved calendarUpcomingEvents(data, today, 30) — dates resolved
Health metricsHealthIndicators(...), HeaviestDay(data)

The remaining balance — if positive — becomes the "extra payment" used in payoff estimates.

Step 4 — Render the report

Sections 1–6 are built from those numbers (still zero AI). Every date is resolved ("pay Jul 27 (15d)"), every unknown is explicit. The payment-priority ranking appears only when interest rates exist; otherwise the report shows a missing-data warning instead of a meaningless ranking.

Step 5 — The one LLM call

Skipped entirely with --no-advice. Otherwise one HTTP POST to Ollama (/api/chat, model from LLM in .env, temperature 0.1). The prompt: here is the exact numeric report — do not alter or recompute any number — write only the advisory (2–3 number-specific observations plus one concrete action).

🛡️
Degrades gracefully

If Ollama is down, the report is still written; the advice section says "ADVICE UNAVAILABLE" with the reason. The numbers never depend on the LLM.

4The math, no double counting

The outflow model avoids counting the same peso twice. In short:

total = non-card obligations + cards reserved + variable spending
remaining balance = income − total
  • Items with charged_to (subscriptions billed to a card) are excluded from obligations — they already live inside the card's balance.
  • Each card reserves max(minimum_payment, paid this month); each recurring item reserves max(amount, payments this month) — a real payment replaces the floor, it never stacks on top.

The full set of accounting rules — transfers, income, cancellations, domiciled items — lives in The cash-flow model.

5The report

outputs/report.md is written (the directory is created if missing, gitignored) and printed — or, in the web UI, rendered in the Report tab with one-click copy. Its seven sections: position today, this month's flow, where the money goes, debt motion, the resolved 30-day calendar, health indicators, and the advisor.

⏱️
Timing

--no-advice: under 1 s (one HTTP request + math + file write). With advice: adds one LLM inference (~5–15 s with gemma4:12b on a 16 GB GPU).

Concepts

The cash-flow model

The rules that keep every peso counted exactly once — where money comes from, where it goes, and what never counts as spending.

No double counting Accounts are ground truth Replace, don't stack

1Money in, money out

Every figure in the report is one of two things: money that raises what you can spend, or money that lowers it. The whole discipline of the model is deciding which — and making sure nothing lands in both columns.

Money IN

  • Declared income — the projected sum of your income_sources.
  • Depositsincome entries that credit an account.
  • Transfers in — the receiving leg of a move between your own accounts.

Money OUT

  • Non-card obligations — fixed expenses and streaming not billed to a card.
  • Cards reserved — what each card sets aside this month.
  • Variable spending — the day-to-day diary.
  • Card payments — money leaving an account to lower a card's balance.

The rules below say precisely how each item is classified — and, just as importantly, what is deliberately excluded so it is never counted twice.

2Outflow & remaining

The core of the model is two lines. Everything else is about feeding them the right numbers.

monthly outflow = non-card obligations + cards reserved + variable spending
remaining balance = income − outflow

Obligations billed to a card are absent from the first term — they already live inside the card's balance, which the reserve term accounts for. A positive remaining balance is what the report can offer up as an extra debt payment.

3Cards & domiciled items

🔗
Domiciled subscriptions — charged_to

An item with charged_to: "<card>" is excluded from obligation totals: its cost already lives inside that card's balance, and you pay it by paying the card. It still appears in the views, labeled "On <card>", so nothing disappears — it just isn't summed a second time.

💳
Card payments

A spending-log entry with category: "card-payment" and a card. It lowers the paying account and the card's balance. It is never counted as variable spending — instead it feeds the per-card reserve for the month.

🛒
Card purchases

An entry with a card and any ordinary category. It raises the card's balance, never touches your accounts, and does not count as day-to-day spending — the cash leaves later, when the card gets paid. It stays visible in the diary so the history is complete.

📐
Cards reserved — replace, don't stack

Per card, the month reserves max(minimum_payment, payments made this month). Early in the month that's the minimum; a real payment replaces the floor — it never adds on top of it.

4Accounts & transfers

🏦
Liquid money & accounts

Money lives in accounts (cash / bank). Every expense and card payment debits its account; an empty account field means the first account (the default). Accounts are ground truth — validate them against your bank app; the balance report is the plan, not the ledger.

🔀
Transfers

category: "transfer" with from_account / to_account (an ATM withdrawal is bank → cash). These are diary entries but never spending: excluded from variable totals on both sides. The money just moves — your liquid total is unchanged.

5Recurring payments

🔁
Paying an obligation

category: "recurring-payment" plus recurring (the item's name). It debits the account and moves the item's paid_through forward, but is never variable spending — the obligation was already counted in the outflow. Per item the month reserves max(amount, paid) — the same replace-don't-stack rule as cards. One payment can cover several months.

6Income & deposits

💰
Deposits — the mirror of an expense

category: "income" plus account: money coming in. Where an expense debits, this credits the account. It shows in the diary as a green + credit and is never counted as spending.

⚠️
A deposit does not change projected income

The deposit already lifted the account balance and your net position. Folding it into projected income as well would count the same money twice. So the projection stays the forecast (your declared sources); the deposit is the ground-truth arrival, recorded separately.

7Cancellation

🚫
Cancelling a subscription — ends_on

Cancelling sets ends_on: "YYYY-MM-DD": the last day of paid service the provider states, not the day you clicked cancel. It never deletes the item — the charges already made are real, and the final paid cycle still costs.

📅
Strictly before the end date

A charge on day D renews the service past D, so a charge landing exactly on the end date can never happen. Crunchyroll ending Aug 5 was last charged Jul 5 — an "on or before" rule would invent a phantom August charge. The final paid cycle still costs; renewals stop after it.

A related planned capability is covered on the roadmap: flexible cadence payments.

Concepts

Data & storage

All your data lives in one transactional SQLite file. The JSON shapes below are the seed and exchange format — the document model the code reads and mylana export writes.

One SQLite file Transactional Gitignored

1One SQLite file

Everything you enter lives in db/mylana.db. Every write is transactional — a crash can never corrupt it or half-apply money (both legs of a transfer, or a payment's account debit and card drop, land together or not at all).

The JSON files below are the seed and exchange format, not a second store. If a legacy db/*.json file sits next to a missing database, it is imported on first run and renamed *.json.bak (your pre-migration backup). After that, the database is the single source of truth.

CommandWhat it does
mylana exportDumps the DB back to db/*.json — your backup, and what the Python oracle reads.
mylana importRestores those JSON files back into the database.
🌱
Starting fresh

Copy db/expenses.example.json to db/expenses.json and run once — it imports as your starting data. All real data (the database and every seed file) is gitignored.

2Where the data lives

No path is hardcoded. store.BaseDir() resolves the data directory per call, in this order:

OrderLocationWhen
1MYLANA_DATA_DIR env varTests and portable runs — an explicit override.
2./db next to the working directoryDevelopment: the repo root already has a db/.
3OS user config dirInstalled use: %AppData%\mylana, ~/Library/Application Support/mylana, or ~/.config/mylana — created on first write.

So the same binary runs from the repo checkout or installed anywhere, and always finds its data.

3The expenses document

Your fixed financial data — historically db/expenses.json. It has five sections. Keep the field names exactly as shown; they are what the pipeline expects.

fixed_expenses

Monthly recurring obligations: rent, utilities, memberships.

{
  "name": "Apartment Rent",
  "amount": 8500.00,
  "due_date": "01",
  "category": "housing"
}
FieldTypeMeaning
namestringLabel shown everywhere.
amountnumberMonthly amount in MXN.
due_datestringDay of the month, "01".."31".
categorystring, optionalFree-text grouping.
charged_tostring, optionalA card name. The item is billed to that card, so it is excluded from obligation totals — you pay it by paying the card. Stays visible, labeled "On <card>".
paid_throughstring, optional"YYYY-MM", set by the UI. Counts as paid while >= the current month, so it self-expires; paying ahead keeps it green next month.
ends_onstring, optional"YYYY-MM-DD" — cancellation. The last day of paid service the provider states, not the day you cancelled.

streaming_services

Streaming subscriptions, tracked separately but with the same shape as fixed_expenses (name, amount, due_date, and the optional charged_to, paid_through, ends_on).

{
  "name": "Video Streaming",
  "amount": 299.00,
  "due_date": "10",
  "ends_on": "2026-08-05"
}
🗓️
Cancellation is a date, not a delete

The final paid cycle still costs; renewals stop strictly after ends_on, so a charge never lands exactly on it. Because that date is the renewal anniversary, due_date should be its day of the month. Remove the field to reactivate the item.

credit_cards

Card balances and minimum payments.

{
  "name": "Visa Classic",
  "balance": 12500.00,
  "minimum_payment": 375.00,
  "interest_rate": 0.24,
  "due_date": "25",
  "credit_limit": 30000.00,
  "cut_date": "17/julio/26",
  "statement_balance": 8110.24
}
FieldTypeMeaning
namestringCard label.
balancenumberCurrent balance in MXN (the UI updates it when you register a payment).
minimum_paymentnumberMonthly minimum; the card reserves max(minimum, paid this month).
interest_ratenumberFraction (0.24) or percent (24).
due_datestringPayment day of the month.
credit_limitoptionalInformational only — the math ignores these. Cut dates are snapshots (some banks move them month to month). paid_month ("YYYY-MM") is the PAID / to-pay status, set by the UI, self-expiring like paid_through.
cut_dateoptional
statement_balanceoptional
noteoptional
bankoptional
⚠️
No rate, no ranking

Without interest_rate the report cannot rank debts by priority — it shows an explicit missing-data warning instead of a meaningless order.

debts

Long-term debts and loans.

{
  "name": "Car Loan",
  "total_remaining": 185000.00,
  "monthly_payment": 3850.00,
  "interest_rate": 0.12,
  "due_date": "05",
  "creditor": "Auto Finance SA"
}
FieldTypeMeaning
namestringDebt label.
total_remainingnumberOutstanding balance in MXN (shrinks over time).
monthly_paymentnumberScheduled monthly payment.
interest_ratenumberFraction or percent; drives payoff order and estimates.
due_datestringPayment day of the month.
creditorstring, optionalWho you owe.

income_sources

Perpetual monthly income you expect — a salary, a rent you collect. This is income, never an obligation: it is excluded from every obligation total and instead drives the report's projected income.

{
  "name": "Salary",
  "amount": 3000.00,
  "currency": "USD",
  "due_date": "01"
}
FieldTypeMeaning
namestringSource label.
amountnumberExpected amount. Omit it for a variable source.
currencystring"MXN" (default, omit) or "USD" — USD converts at the day's rate.
due_datestringDay of the month it arrives.
variablebool, optionaltrue for a source whose amount is unknown month to month — the projection then counts only what was actually received that month, never an estimate.

4The spending log

A flat array of day-to-day movements, managed from the web UI (historically db/spending.json) — you rarely touch it by hand.

[
  { "date": "2026-07-12", "description": "Groceries", "amount": 1850.00, "category": "food" },
  { "date": "2026-07-12", "description": "Amazon", "amount": 768.00, "category": "shopping", "card": "Nu" },
  { "date": "2026-07-12", "description": "Card payment: Rappi", "amount": 8110.24, "category": "card-payment", "card": "Rappi" }
]
FieldTypeMeaning
datestring"YYYY-MM-DD".
descriptionstringFree text.
amountnumberWhat was charged, in the default currency (MXN).
categorystring, optionalFree text, except the reserved values below.
cardstring, optionalThe card involved (a purchase or a payment).
accountstring, optionalThe paying account; empty means the default (first) account.
from_account / to_accountstringThe two legs of a transfer.
orig_currency / orig_amountstring / numberHow a foreign movement entered (e.g. "USD" + 3000); the effective rate is amount / orig_amount.
recurringstring, optionalName of the fixed/streaming item a recurring payment settles.
recurring_incomestring, optionalName of the income source a receipt fulfils.

The category field distinguishes six entry kinds:

KindMarkerEffect
Account expenseno cardVariable spending; debits the account.
Card purchasecard setRaises the card balance; never touches accounts, never day-to-day spending.
Card paymentcategory: "card-payment"Debits the account, lowers the card. Not spending.
Transfercategory: "transfer"Moves money between your accounts. Never spending.
Recurring paymentcategory: "recurring-payment"Debits the account, advances the item's paid_through. Not spending.
Incomecategory: "income"A deposit — credits the account. Never spending.

How each kind is counted (and why none double-counts) is spelled out in The cash-flow model.

5Accounts & legacy cash

Liquid money lives in accounts.json — one entry per account.

{
  "accounts": [
    { "name": "Efectivo", "kind": "cash", "balance_mxn": 2000.00 },
    { "name": "BBVA", "kind": "bank", "balance_mxn": 15014.30 }
  ]
}
FieldTypeMeaning
namestringAccount label.
kindstring"cash" or "bank".
balance_mxnnumberCurrent balance in MXN.

The first account is the default — entries without an explicit account debit it. Once accounts exist, "cash on hand" everywhere means their sum.

💵
Legacy cash migrates automatically

Before accounts, liquid money was a single number in cash.json ({"amount_mxn": 17014.30}). Creating your first account migrates it automatically, so no money silently disappears.

6Security

⚠️
Never commit your data

Keep mylana.db (and its -wal/-shm companions), the seed JSON files and the .json.bak backups out of version control. Only expenses.example.json is tracked, as a template.

All amounts are in Mexican Pesos (MXN). Interest rates accept either fractions (0.24) or percents (24) — both normalize to the same value.

The app

The web app

mylana serve starts a localhost-only server and opens your browser. It is the daily front end — server-rendered, embedded in the binary, no JS framework, works offline.

Localhost only Embedded, no CDN Six tabs

1One binary, no toolchain

The UI is built from server-rendered Go templates embedded straight into the binary with go:embed. It listens on localhost only (default port 8787) — nothing leaves your machine. There is no JavaScript framework: HTMX (for hx-boost page swaps) and Lucide icons are vendored, pinned to an exact version, and served locally — never from a CDN. So the app works fully offline.

Every money mutation — logging a transaction, paying a card, a transfer, an income deposit — goes through one shared entry point (store.ApplyEffects), the same code the report reads. The web UI and the report can never disagree.

go run ./cmd/mylana serve   # opens http://127.0.0.1:8787

2The tabs

Six tabs, one job each. They read and write the same SQLite data the pipeline computes over.

🧾

Transactions

The daily diary: log, edit and delete day-to-day movements. Each row says what it was paid with — an account or a card, never a blank cell. Shows the month's variable spending and what's charged to cards.

🔁

Recurring

Your monthly commitments as an actionable dashboard: pay an item, mark it paid, edit it, domicile it to a card, or cancel a subscription. Sectioned tables (fixed / subscriptions / debts / cards' commitment) with status pills, an overdue banner, and a Totals overview. Debts show a resolved payoff line.

💳

Cards

One row per credit card with balance, available credit (color-coded), the live minimum still owed, and a clear settlement status — paid, cut-and-payable in amber, overdue in red. The card detail adds pay + edit, the interest-free installments (MSI), and the subscriptions domiciled to that card, so it mirrors your bank app.

🏦

Accounts

Liquid money split by account (new account, adjust balance, transfer — an ATM withdrawal is bank → cash), plus Income: one-time deposits and perpetual income sources (fixed or variable, MXN or USD).

📊

Statistics

Read-only analytics on the current month: consumption by category, largest movements, and per-card utilization. Charts are pure CSS bars — no chart library.

📄

Report

Run the analysis and read the Monthly Financial Report, with one-click copy of the clean Markdown.

3Settings & theme

A Settings menu — the gear at the bottom of the sidebar — holds the language (English / Español / Français), the demo/privacy mask that hides every money amount so the app can be shown safely, the currency list (the extra currencies you also handle, e.g. USD), and your custom expense categories.

The light/dark theme is remembered in the browser, separate from the language — switching one never touches the other.

4Design notes

🎨
One design system, questions answered

Semantic ok/danger colors, a single indigo accent, and reusable components (metric cards, tables, badges, dialogs) hold the whole UI together. The guiding rule: the UI answers questions instead of raising them — dates are resolved ("pay Jul 27 (15d)") and unknowns are stated explicitly rather than left blank. It also lives in the system tray on Windows/Linux (Open / Quit); on macOS the Dock icon plays that role.

The app

Architecture

Three parts share one data layer over a single SQLite database: the Go analysis pipeline, the Go web UI, and a reference Python oracle that keeps the math honest.

One SQLite file Pure-Go, no CGO Math triplicated on purpose

1The map

Two entry points, one shared data layer, one analysis pipeline. The web UI and the CLI both call the same Go code; nothing reaches the database except through store/.

web/web.go   ──> store/          (read/write db/mylana.db, SQLite)
             ──> pipeline.Run     (the analysis, in-process)

cmd/mylana   ──> web.Serve        (the `serve` subcommand: the localhost server)
             ──> pipeline.Run      (the bare CLI: run the analysis once)

pipeline/run.go ──> rate.go       (network: rate APIs)
                ──> math.go        (pure math, no I/O)
                ──> report.go ──> math.go
                ──> advisor.go     (network: local Ollama)
                ──> store          (shared load/date/format helpers)
                ──> i18n/          (all translations, UI + report)

src/mylana/main.py mirrors pipeline/run.go with the same module split.

Where things live: arithmetic is in pipeline/math.go, report text in pipeline/report.go, web handlers in web/web.go, data access in store/, and translations in i18n/.

2pipeline/ — the Go analysis

The canonical implementation of the pipeline. Pure computation and rendering; the only I/O is the exchange-rate fetch, the optional LLM call, and writing the report.

FileDoes
run.goOrchestration — loads .env and data, fetches the rate, renders the report, optionally appends the LLM advice, and writes outputs/report.md.
rate.goGetRate — exchangerate-api.com, with an automatic fallback to frankfurter.app (ECB).
math.goAll arithmetic: CategoryTotals, MonthlyOutflow, CardsReserved, DebtInventory, the Avalanche/Snowball order, Payoff, DebtMotion, UpcomingEvents, HealthIndicators, and more. Pure functions.
report.goMonthlyReport — sections 1–6, translated via i18n.
advisor.goGetAdvice — one POST to Ollama; never fails, returning an ADVICE UNAVAILABLE placeholder if the server is unreachable.

3store/ — the shared data layer

Reads and writes db/mylana.db and holds every cross-UI rule; the web UI and the pipeline both call it, and there is no private copy anywhere. Connections open per operation and close immediately — never cached — because the data dir changes between calls and a held handle would lock the file on Windows. The first open imports any legacy db/*.json files in one transaction and renames them *.json.bak.

FileDoes
db.goThe SQLite layer — schema, per-operation connections, the legacy-JSON importer, and ExpensesRaw/SetExpensesRaw (the only bridge to the document shape).
export.goExportJSON / ImportJSON, behind mylana export / mylana import.
store.goSpending, obligations, card and recurring mutations, cash, and the pipeline-shared helpers (VariableTotal, CardCharges, CardPayments, CardsReserved, NextDue, MXN/Commas). ApplyEffects is the single money-mutation entry point.
accounts.goLiquid money per account.
income.goPerpetual income sources.
settings.goIn-app preferences.
paths.goBaseDir() and every *Path() helper.

4web/ — the Go web UI

The daily front end. Server-rendered Go templates embedded with go:embed, bound to localhost only; HTMX and Lucide are vendored and pinned (never a CDN). web.go holds the routes and handlers and the per-tab view builders; templates/*.html is the shell plus the tabs; static/ holds the vendored JS. Every money mutation goes through store.ApplyEffects, never a direct balance edit in a handler.

5cmd/mylana/ — the CLI and server entry point

serve starts the web server; a bare invocation runs the pipeline once. A second serve recognizes an already-running instance by the Server: mylana response header and just opens the browser. On Windows and Linux it can put a small system tray icon (Open / Quit); the tray never blocks the server, and a desktop without one degrades to a plain foreground server. The embedded Windows icon and version resources live here too.

6src/mylana/ — the Python oracle

The same pipeline with the same module split — main, exchange_rate, finance_math, report, advisor, i18n — and the same report, byte for byte. It has no UI, no accounts, and no web server: it exists only to keep the math honest. It reads the JSON shape produced by mylana export, never the SQLite schema directly.

7The three-sided math

⚠️
The reserve/outflow/calendar logic is triplicated on purpose

The same rules live in three places: Go store, Go pipeline, and Python finance_math.py. Any change to the math must land in all three, and both reports must stay byte-identical — verify with the parity command (go run ./cmd/mylana --no-advice against uv run mylana --no-advice, modulo line endings).

Tests follow the toolchain: Go tests live inside each package they cover (pipeline/, store/), while Python tests live in tests/.

Roadmap

Roadmap

What's built and what's planned. Detailed feature designs get their own pages — the first is flexible cadence payments.

1Shipped

Accounts Done

Liquid money split by account, with transfers between them (replaced the single cash number).

Payment control (core) Done

Pay or mark recurring obligations, with paid/overdue status, the home agenda and the open-the-app reminders.

SQLite storage Done

All data in one transactional db/mylana.db; export/import for JSON backup and the Python oracle.

Income & the fixed/variable taxonomy Done

Declared income sources (one-time plus perpetual, fixed or variable); the AMOUNT environment variable was removed.

Multi-currency (Phase 1) Done

Income and expenses record how the money entered versus how it landed — which yields the real exchange rate you got.

Subscription cancellation Done

An ends_on date with the strictly-before charge rule, so a final paid cycle is never invented or dropped.

2Planned

Flexible cadence payments Design ready

Schedule a payment on a weekly / biweekly / monthly / every-N-days rhythm — "fixed but variable", flexible, and counted only when it is actually paid. Read the design →

Monthly close & statements Planned

A frozen month-end "estado de cuenta" as a clear money-flow map (in → out, with percentages), exportable to PDF, with 2–3 months of read-only history.

Essential vs. discretionary Planned

A necessary/unnecessary tag on every expense, feeding a "needed vs. not" split (with %) in the report and the Statistics tab.

Installment purchases (MSI/MCI) Planned

Interest-free (or financed) month plans tied to a card, created straight from the add-expense flow.

Investments & surplus Planned

Net worth, an emergency-fund target, and a surplus waterfall for what's left over.

Managed people (dependents) Planned

Give money to a spouse, parent or kid and track their running balance.

3Design pages

Each planned feature earns a full design page once its shape is settled.

🔁

Flexible cadence payments

Weekly, biweekly, monthly or every-N-days rhythms, counted only when paid.

📄

Monthly statement

The frozen month-end money-flow map, exportable to PDF.

Coming soon
Roadmap · feature design

Flexible cadence payments

Schedule a payment on a rhythm the monthly model can't express — weekly, biweekly, monthly or every N days. "Fixed but variable": the cadence is fixed, the amount changes each time, and the payment may or may not happen.

Planned Design ready Backward compatible

1The problem

Today every recurring item in mylana assumes a monthly cycle keyed to a day‑of‑month (due_date = "01""31") with a month-boundary paid marker (paid_through: "YYYY-MM"). There is no concept of frequency.

But some payments follow another rhythm. The motivating case, in the user's words:

💬
Real use case

"Tengo un pago que hago en efectivo cada semana, puede ser o puede no ser, es flexible. […] Tipo de pago: fijo pero variable."

Three things the monthly model can't represent: a sub-monthly cadence (weekly), an amount that varies each time, and the fact that an occurrence is optional — it may simply not happen.

2Design decisions

Three choices shape the whole feature:

It counts only when paid

A flexible payment is not forecast into "committed this month" — it never inflates what's left. When you log a real payment, that amount lowers the month. Honest with "may or may not happen", and it keeps the math simple.

Soft status, never red

A past occurrence you didn't log shows as a muted pending / optional hint — never a red "overdue" badge, and never in the overdue banner. It respects that the payment is flexible.

Not married to a date

No rigid calendar of fixed dates. The cadence only paces the reminder from your last payment: next occurrence = last paid + interval. You say "done or not done" — the app just nudges at the right rhythm.

3Data model

New optional fields on a recurring item. All are absent in today's data, so nothing changes for existing monthly items — the feature is fully backward compatible.

FieldTypeDefaultMeaning
frequencystringabsent → monthly weekly · biweekly · monthly · custom
interval_daysint7 / 14 / 30 Days between reminders. Explicit for custom; derived otherwise.
flexibleboolfalse Optional-cadence: soft status and excluded from the forecast sum.
sincedatetoday (at creation) Start of the rhythm — the reference for the first reminder before any payment.
paid_untildate Last payment ("paid up to this moment"). Next occurrence = paid_until + interval.
⚠️
paid_until is not paid_through

Monthly items keep paid_through (a YYYY-MM boundary). Cadenced items use paid_until (a date). The two boundary semantics are deliberately not unified.

4How it counts (the exclusion trick)

The monthly reserve math is triplicated across three implementations (Go store, Go pipeline, and the Python oracle finance_math.py) and must stay in sync — see Architecture. Rather than teach all three to multiply a weekly amount by "occurrences in the month", a flexible item is simply excluded from the forecast sum — the same pattern already used for card-domiciled items. Its real payments then count in full through an existing code path.

A normal monthly item
forecast in "committed" · replaced by real payment
A flexible item — forecast
excluded → contributes 0
A flexible item — once paid
real payment counts in full

Concretely, all three recurringExtra implementations compute:

extra = Σ(all recurring payments)
for each matched payable item:
    extra -= min(paid, monthly_amount)   // a real payment REPLACES the forecast

A payment whose name matches no item stays in extra in full. So if a flexible item is (a) kept out of the committed sum and (b) skipped in this matched loop, its payment is never subtracted — it counts exactly like "a payment matching no item." No occurrence multiplication anywhere, and because every guard keys on flexible (absent in all current data) the report stays byte-identical for existing users.

🔎
Honest tradeoff

Because it isn't forecast, the month doesn't pre-reserve anything for a flexible payment — it appears as spending only once logged. That's the correct reading of "may or may not happen". A soft reserve could be added later.

5The flow

Schedule
Create the payment, pick a frequency (weekly / biweekly / monthly / every N days) and mark it flexible.
Remind
The recurring tab and agenda show a soft nudge at the cadence — "toca esta semana" / "in N days" — never a red overdue.
Mark done
You paid it: enter the real (variable) amount and the account it came from. Missed it? Do nothing — it just nudges again next cycle.
Deduct
The existing pay flow logs a recurring-payment entry and ApplyEffects debits the chosen account. paid_until = today resets the rhythm.

6Phases

Phase 1 this design
  • Cadence fields + the flexible exclusion (three synced implementations).
  • Next-reminder resolver: last paid + interval.
  • Pay flow advances paid_until; soft status & agenda.
  • Web forms: frequency selector, interval, "mark paid now"; i18n.
Phase 2 deferred
  • Firm (non-flexible) sub-monthly obligations that are reserved — these need occurrence-multiplication in the report.
  • Flexible occurrences in the report calendar with expected amounts.
  • Multi-occurrence agenda (all weekly occurrences within 14 days).
  • Optional soft reserve (expected amount × remaining occurrences).

7Files touched

The math lives in three places that must stay in sync; the rest is UI and strings.

AreaFileChange
Data layerstore/store.goFields on Obligation; Cadenced/IsPaid/NextReminder/SetRecurringPaidUntil; exclusion guards.
Pipeline (Go)pipeline/math.goGuards in CategoryTotals & recurringExtra; next-reminder mirror.
Oracle (Python)src/mylana/finance_math.pyMirror the guards in category_totals & _recurring_extra.
Web handlersweb/web.goPay/toggle cadenced branches; soft recurringRowStatus; agenda.
Templatesweb/templates/index.htmlFrequency selector, interval, "mark paid now" in the recurring & pay modals.
i18ni18n/i18n.go · src/mylana/i18n.pyCadence labels + soft status strings (en/es/fr).