PRD - rafaelfqueiroz/allmywallet GitHub Wiki
| Status | Draft v1.4 — for review |
| Last updated | 2026-08-11 |
| Owner | Rafael Queiroz |
| Target release | v1.0 (MVP) |
A Brazilian retail investor with a diversified portfolio has their money spread across asset classes that live in different systems and speak different languages. Stocks, FIIs, BDRs and ETFs sit in B3 custody. Tesouro Direto is priced daily by the Tesouro Nacional. CDBs, LCIs and LCAs are bank paper with a contracted rate and no public price at all. Each broker shows a slice of the picture, styled its own way, using its own conventions.
The result is that a simple question — "how is my money actually doing, and is it beating the CDI?" — has no single trustworthy answer. Investors resort to spreadsheets that go stale, or to broker dashboards that only see their own slice and quietly disagree with each other.
A second, subtler problem: money has purposes. The stocks bought for dividend income in retirement and the ones bought for a five-year trade are the same rows in the same brokerage account, but they are answering different questions and deserve to be measured separately. No broker models this.
A single web app where a Brazilian investor consolidates every investment, organizes holdings into purpose-driven wallets, and gets honest performance answers — including the one that matters most, did this beat the CDI? — with every number traceable back to the transaction that produced it.
Brazilian retail investors who:
- hold assets across more than one asset class and often more than one broker
- have a multi-year horizon and care about compounding, not day trading
- currently track things in a spreadsheet and are tired of maintaining it
- want to reason about money by goal, not by account number
- Not a broker or an execution venue. AllMyWallet never places orders and never touches money.
- Not a robo-advisor. It does not recommend what to buy or sell, and gives no personalized investment advice.
- Not a tax filing tool in v1. It informs the IR declaration but does not generate it (see §11).
- Not real-time. Quotes refresh through the trading day on a short delay, not tick-by-tick — see F6.1 for why that is a licensing decision rather than an engineering one. This is a long-horizon tracker, not a trading screen.
- Every number is explainable. Any figure in any report can be traced to the transactions behind it. If the app cannot explain a number, it should not show it.
- Honest over impressive. When data is stale, incomplete, or an estimate, say so on the screen. A confidently wrong number is worse than an acknowledged gap.
- The ledger is sacred. Transactions are the single source of truth. Positions, valuations and reports are all derived and can always be rebuilt from scratch.
- The user's data is the user's. Export it, delete it, on demand, without asking support.
Mid-30s, tech professional, invests monthly. Holds ~25 tickers across stocks and FIIs, a Tesouro IPCA+ position for the long haul, and two CDBs from a digital bank. Keeps a spreadsheet that he updates "when he remembers," which is roughly quarterly.
What he wants: to stop maintaining the spreadsheet, and to finally know whether his stock picking has beaten just leaving the money in CDI.
Journey — first run:
- Signs in with Google.
- Is walked through downloading three
.xlsxfiles from investidor.b3.com.br. - Uploads them; sees a preview of what will be imported before anything is committed.
- Confirms; lands on a dashboard with his real portfolio, valued at the current market — not at the date of the extract he just uploaded.
- The app flags two CDBs whose contracted rate it could not read, and asks him to fill them in.
Early 40s, building a dividend portfolio explicitly meant to supplement her income when she stops working. She also holds unrelated positions she bought opportunistically and doesn't want mixed into that picture.
What she wants: a "Retirement" view containing only the dividend payers, showing what it yields monthly and whether that income is growing.
Journey — recurring:
- Opens the app; sees that her last B3 import is 34 days old and is prompted to refresh it.
- Imports the new extract; four new dividend credits appear.
- Opens her Retirement wallet: monthly income chart, yield-on-cost, and the twelve-month income trend.
- Most of the new shares were allocated automatically; the one asset she keeps split across two wallets is waiting for her, and she assigns it from the reconciliation screen.
| Area | Included |
|---|---|
| Asset classes | Stocks (ações), FIIs, BDRs, ETFs, Tesouro Direto, CDB, LCI, LCA |
| Data entry | B3 .xlsx extract import; manual transaction entry and editing |
| Organization | Wallets with quantity-level allocation |
| Reports | Performance, Portfolio Value, Earnings, Composition — each with scope, grouping and period controls |
| Market data | Automated sync: intraday quotes on a free ~30-min delayed feed, daily Tesouro prices and index series |
| Auth | Google OAuth (sole sign-in method) |
| Compliance | Multi-tenant isolation, full LGPD program |
| Currency | BRL only |
F7–F9 are specified but post-launch. Wallet balancing, buy/sell opportunity rules and wallet goals are fully specified (SPEC-017, SPEC-018, SPEC-019) and scheduled for M7, after the M0–M6 set that constitutes a launchable product. Nothing in M0–M6 depends on them.
| Excluded | Why |
|---|---|
| Investment funds (fundos) | Frequently not in B3 custody — held at the distributor. Needs a separate sourcing strategy. Planned v2. |
| Options, futures, derivatives | Different valuation and margin model; small share of the target user's portfolio. |
| Crypto | No B3 relationship; separate sourcing and volatile intraday valuation. |
| Foreign/international brokerage | Requires FX handling and a different custody chain. Note: BDRs are in scope — they trade in BRL on B3. |
| IR / tax report generation | High regulatory risk and high accuracy bar. The data model is built so this can be added later. |
| Real-time (tick-by-tick) quotes | Requires a licensed B3 distributor agreement with a per-end-user fee. Free ~30-min delayed intraday quotes are in scope and sufficient — see F6.1. |
| Automatic B3 credential-based sync | See §4.1 — not available to individuals, and credential storage is a liability we decline to take on. |
| Mobile native apps | Responsive web only in v1. |
This section exists because the original requirement — "sync investment data from B3 daily or when manually triggered" — cannot be met as literally stated, and the honest reframing shapes much of the product.
B3 does operate APIs that expose exactly the data this product needs — Posição, Movimentação, Negociação de Ativos, Eventos Provisionados — through its Área do Investidor integration program. However, B3's developer portal states the access rule directly:
"Não oferecemos acesso direto às APIs para pessoas físicas."
Access is B2B only, granted to contracted financial and non-financial institutions and fintechs with an investor-authorization flow. An individual developer cannot obtain credentials.
Options considered:
| Option | Automatic? | Cost / barrier | Risk | Decision |
|---|---|---|---|---|
| B3 Área do Investidor API, direct | Yes | Institutional contract required | — | Unavailable to individuals |
| Open Finance aggregator (Pluggy, Belvo) | Yes | ~R$2.5k–6k/month at published market rates; CNPJ contract | Vendor dependency | Deferred to v2 |
| Headless-browser scraping of investidor.b3.com.br | Yes | Free | Requires storing the user's B3 credentials; breaks on 2FA and layout changes; serious LGPD and security exposure | Rejected |
B3 .xlsx extract import + manual entry |
No | Free | Low | Chosen for v1 |
Rather than pretend at automation it doesn't have, v1 splits the requirement in two and is explicit with the user about which is which.
Custody data — user-initiated. The user downloads their extracts from investidor.b3.com.br and uploads them. This is the authoritative record of what they own and every movement in it. It is not automatic, and the UI never implies it is.
Market data — genuinely automatic. Quotes for listed assets refresh through the trading session on a short delay; Tesouro Direto prices and the CDI / IPCA / Selic / IBOV series sync daily, which is as often as those sources publish. This is what keeps valuations, charts and benchmark comparisons current between imports — so the portfolio's value tracks the market today even when the transaction list is a few weeks old. Quote frequency is specified in F6.1.
Sources for the automatic half:
| Data | Source | Notes |
|---|---|---|
| Listed asset quotes | Market data API (e.g. brapi.dev), free tier | ~30-min delayed intraday during the session only, for held assets only (F6.1). Anything else is fetched on demand and persisted (F6.3). Abstracted behind a provider interface |
| Tesouro Direto prices & rates | Tesouro Transparente daily CSV | Official, public, no key required |
| CDI, IPCA, Selic | BCB SGS API (series 12, 433, 11) | Official, public, no key required |
| IBOV | Market data API | Same provider as quotes |
| Official closes and price history of listed assets | B3 historical quotes (COTAHIST) | Official, public, no key required. Daily and annual files since 1986; PREULT is the official close (FR-6.2, FR-8.72; SPEC-008 BR-008-30). B3's consumption policy limits it to informational charts and tables (SPEC-008 BR-008-31) |
| Split, grupamento and bonificação factors | B3 listed-companies public data | Official, public, no key required. Confirms a split ratio derived from the extract; never a source of custody data (SPEC-008 BR-008-29, #113) |
Because custody data can drift, the product treats freshness as a first-class, visible concept.
- FR-4.1 Every screen showing portfolio value displays the as-of date of the valuation and the date of the most recent custody import.
- FR-4.2 When the last import is more than 30 days old, the app shows a non-blocking prompt to refresh, with a direct link and a reminder of the steps.
- FR-4.3 Optional email reminder, monthly, opt-in at signup and toggleable in settings.
- FR-4.4 Where a value is an estimate rather than an observed price (all accrued fixed income — see §6.5), the UI marks it as estimated and explains the basis on hover.
All custody ingestion goes through a single internal ingestion port — an interface accepting normalized transaction and position records regardless of origin. The v1 .xlsx parser is one implementation of that port; manual entry is a second. An Open Finance aggregator adapter becomes a third, with no change to the ledger, the reconciliation engine, or any report.
FR-4.5 No parsing, file-format or provider-specific logic may exist outside an ingestion adapter. Everything downstream consumes only the normalized record.
Google OAuth 2.0 is the sole sign-in method for v1. No passwords are created, stored, or handled by AllMyWallet — which removes an entire category of breach risk and is a deliberate security decision, not just a convenience one.
| ID | Requirement |
|---|---|
| FR-1.1 | A user can sign up and sign in with a Google account. No other method is offered in v1. |
| FR-1.2 | First sign-in creates a tenant account and presents the onboarding flow (F2.1). |
| FR-1.3 | Only email, name, profile picture and the stable Google subject ID are requested. No Google scopes beyond basic profile. |
| FR-1.4 | Sessions expire after 30 days of inactivity; sign-out invalidates the session server-side. |
| FR-1.5 | A user can permanently delete their account and all associated data from settings (see FR-8.12). |
| FR-1.6 | If a user's Google account becomes inaccessible, account recovery is out of scope — the PRD acknowledges this as a known limitation of single-provider auth, revisited if a second provider is added. |
Acceptance: A new user reaches an empty dashboard in under 30 seconds from landing page to signed-in state, with no form fields to fill.
This is the feature the product lives or dies on. It must be forgiving of user error, impossible to corrupt through repetition, and transparent about what it did.
Owned by F10 and SPEC-020 from M8. These three requirements keep their IDs but are no longer SPEC-005's. FR-2.1's screenshots were replaced by project-authored diagrams carrying a verification stamp — see SPEC-020 DL-020-06.
| ID | Requirement |
|---|---|
| FR-2.1 | First-run flow explains, with screenshots, how to export the three files from investidor.b3.com.br: Movimentação, Negociação, and Posição. |
| FR-2.2 | The flow advises selecting the earliest available date so the full history is captured on the first import. |
| FR-2.3 | Files may be uploaded together or one at a time, in any order. |
Each export is a different shape and serves a different role:
| Extract | Key columns | Role in the system |
|---|---|---|
| Movimentação |
Entrada/Saída, Data, Movimentação (type), Produto, Instituição, Quantidade, Preço unitário, Valor da Operação
|
The richest source. Carries dividends, JCP, rendimentos, splits, grupamentos, bonificações, subscriptions, transfers, amortizations. |
| Negociação |
Data do Negócio, Tipo de Movimentação (Compra/Venda), Mercado, Instituição, Código de Negociação, Quantidade, Preço, Valor
|
Authoritative trade record for listed assets, with the ticker in tradeable form. |
| Posição | Multi-tab: variable income, Tesouro Direto, fixed income | Point-in-time snapshot. The only source of contracted rates for CDB/LCI/LCA, and the basis for reconciliation. |
| ID | Requirement |
|---|---|
| FR-2.4 | The parser accepts .xlsx and .xls, up to 10 MB per file. |
| FR-2.5 | The parser identifies which of the three extracts a file is from its structure, not its filename — users rename files. |
| FR-2.6 | Column detection is resilient to leading metadata rows and to minor header variations, and does not depend on column order. |
| FR-2.7 | If a file cannot be identified or parsed, the user gets a specific, actionable error naming what was expected — never a generic failure. |
| FR-2.8 | The Posição fixed-income tab is parsed for contracted rate, indexer, issue date and maturity, creating or updating a FixedIncomeContract. |
Nothing is written to the ledger until the user confirms.
| ID | Requirement |
|---|---|
| FR-2.9 | Uploading stages rows into an ImportBatch with status pending. |
| FR-2.10 | The preview summarizes: rows read, new rows, duplicates skipped, rows needing attention, and the resulting date range. |
| FR-2.11 | The user may drill into any category and inspect the individual rows before committing. |
| FR-2.12 | The user may cancel; a cancelled batch writes nothing to the ledger. |
| FR-2.13 | Committing is atomic — the batch applies fully or not at all. |
Users will re-import overlapping date ranges. This must be a non-event.
| ID | Requirement |
|---|---|
| FR-2.14 | Each row gets a deterministic natural key: date + normalized product + movement type + quantity + value + institution + user. |
| FR-2.15 | A row whose natural key already exists in the ledger is classified as a duplicate and skipped, not inserted. |
| FR-2.16 | Genuine same-day identical trades (a real possibility — two identical buys of the same ticker at the same price) are handled by an occurrence counter within the batch, so the second legitimate occurrence is not mistaken for a duplicate. |
| FR-2.17 | Re-importing an identical file produces zero new ledger rows and reports as such. |
| ID | Requirement |
|---|---|
| FR-2.18 | B3 Movimentação types are mapped to internal transaction types through an explicit, versioned mapping table. |
| FR-2.19 | An unrecognized movement type is never silently dropped. It is imported as unclassified, excluded from calculations, and surfaced in a "Needs attention" queue. |
| FR-2.20 | The user may manually classify an unclassified row, which brings it into calculations. |
| FR-2.21 | Unmapped types are logged (type string only, no personal data) so the mapping table can be extended. |
The check that makes the whole thing trustworthy: does what we computed match what B3 says the user owns?
| ID | Requirement |
|---|---|
| FR-2.22 | After committing a batch containing a Posição file, the system compares its computed positions against the B3 snapshot as of that date. |
| FR-2.23 | The reconciliation report lists, per asset: computed quantity, B3 quantity, and the difference. |
| FR-2.24 | Discrepancies are explained by likely cause where possible (missing history before the import range; unclassified rows affecting that asset; a corporate event not captured). |
| FR-2.25 | The user may accept the B3 figure for an asset, which creates a dated, clearly-labelled adjustment transaction — never a silent overwrite of history. |
| FR-2.26 | Portfolio-level reconciliation status (reconciled / discrepancies found / never reconciled) is visible on the dashboard. |
Acceptance: Importing a full history, then re-importing an overlapping range, yields identical positions and zero duplicate transactions, and the reconciliation report shows zero discrepancies against the same-date B3 snapshot.
| ID | Requirement |
|---|---|
| FR-3.1 | A chronological, paginated transaction list across all assets and institutions. |
| FR-3.2 | Filterable by date range, asset, asset class, transaction type, institution and wallet. |
| FR-3.3 | Full-text search on asset code and name. |
| FR-3.4 | Every transaction shows its provenance: which import batch it came from, or that it was entered manually — visible in the UI, not just the database. |
| FR-3.5 | Exportable to CSV, honouring the active filters. |
Essential, both for assets outside B3 custody and for correcting import gaps.
| ID | Requirement |
|---|---|
| FR-3.6 | Create a transaction manually: type, asset, date, quantity, unit price, fees, institution. |
| FR-3.7 | Supported types: buy, sell, dividend, JCP, rendimento, amortization, split, grupamento, bonificação, subscription, transfer in, transfer out, adjustment. |
| FR-3.8 | Edit any transaction, whether manual or imported. |
| FR-3.9 | Delete any transaction, with confirmation stating what will be recalculated. |
| FR-3.10 | Editing or deleting triggers recalculation of every derived figure from that date forward, and the affected reports refresh. |
| FR-3.11 | Validation prevents impossible states — most importantly, selling more than the quantity held at that date — with a clear explanation rather than a silent rejection. |
| FR-3.12 | An edited imported transaction is flagged as user-modified, so a later re-import does not overwrite the correction. |
| FR-3.13 | Bulk operations: delete multiple, assign multiple to a wallet. |
Acceptance: A user can add a CDB that never appeared in a B3 extract, see it valued in Portfolio Value, and include it in a wallet — end to end, without touching an import file.
Wallets are the product's organizing idea: purpose-based groupings that cut across brokers and asset classes.
Design principle: allocation defaults to the whole position and stays that way by itself — but only where the answer is unambiguous. The common case by far is that an asset belongs entirely to one wallet: the dividend payers are all retirement money. Splitting a position across wallets is the exception. So assignment is a single click that takes the full position, and every subsequent purchase of that asset flows into the same wallet automatically. The user should never have to revisit a wallet after buying more of something already in it.
Where the answer is ambiguous, the system stops rather than guesses. Once an asset is deliberately split across two or more wallets, the system has no basis for knowing which purpose a new purchase serves — buying more ITSA4 could be adding to retirement or to the trading position, and the existing 60/40 split is evidence of nothing. Splitting the purchase proportionally would be inventing an intention the user never expressed, and quietly corrupting the very separation they took the trouble to create. So auto-increment applies only to assets held in exactly one wallet; anything split waits for the user to decide.
| ID | Requirement |
|---|---|
| FR-4.6 | A user can create, rename, describe, colour and delete wallets. |
| FR-4.7 | A wallet may optionally record a stated goal (e.g. "supplement income from 2045") — descriptive in v1, not used in calculations. |
| FR-4.8 | Assigning an asset to a wallet allocates the entire held quantity by default. The user confirms one click, not a number. |
| FR-4.9 | Allocation is nonetheless stored by quantity, so a position can be split when the user chooses to: 100 ITSA4 may be 60 in "Retirement" and 40 in "Trading". Splitting is an explicit action, never the default. |
| FR-4.10 | Total allocated quantity for an asset may never exceed the quantity held. |
| FR-4.11 | Unallocated quantity sits in an implicit Unassigned bucket, always visible so nothing hides. In the default flow Unassigned holds only assets the user has never assigned to any wallet. |
| FR-4.12 | Deleting a wallet returns its allocations to Unassigned. It never deletes transactions. |
| FR-4.13 | Wallets are views over the single ledger. A transaction is never duplicated to belong to a wallet. |
| ID | Requirement |
|---|---|
| FR-4.14 | Assign from the holdings screen, the wallet screen, or the post-import reconciliation screen. |
| FR-4.15 | Buys auto-increment only for single-wallet assets. A purchase of an asset allocated to exactly one wallet adds the full purchased quantity to that wallet, with no user action. |
| FR-4.16 | A purchase of an asset allocated across more than one wallet is never auto-allocated. The purchased quantity goes to Unassigned and is raised for the user to allocate. The system does not distribute it proportionally, and does not infer intent from the existing split. |
| FR-4.17 | Purchases awaiting allocation appear in the same "Needs attention" queue used for unclassified imports (FR-2.19), and are surfaced on the post-import summary and the dashboard until resolved. |
| FR-4.18 | Allocating a pending purchase is one action from that queue: pick the wallet, or split it deliberately. The quantity is pre-filled with the full purchased amount. |
| FR-4.19 | A user may optionally set a standing rule per asset directing all future purchases of it to one named wallet. This is opt-in and off by default; when set, it is the user's own instruction, not an inference — and FR-4.16 no longer applies to that asset. |
| FR-4.20 | Auto-allocation is reported, not silent: the post-import summary lists every allocation the system made and every purchase left pending, and each is reversible or resolvable from that screen. |
| FR-4.21 | An asset entering the portfolio for the first time lands in Unassigned — the system never guesses which wallet a brand-new holding belongs to. |
| FR-4.22 | Sales differ from purchases and reduce allocations proportionally across wallets holding that asset, unless the user specifies which wallet sold. A sale is not symmetric with a purchase: the position has already shrunk, allocations must come down or they would exceed the quantity held (FR-4.10), so the system has no option to defer. Proportional is the least-surprising forced choice; leaving a sale pending would break the invariant. |
| FR-4.23 | Splits, grupamentos and bonificações adjust allocated quantities by the same ratio as the position, preserving each wallet's proportional share. These are mechanical adjustments to an existing holding, not new intent, so no user decision is required. |
| ID | Requirement |
|---|---|
| FR-4.24 | All four reports (F5) run at portfolio scope or at any wallet's scope. |
| FR-4.25 | Wallet is also available as a grouping dimension within portfolio-scoped reports — see F5.0. |
| FR-4.26 | Wallets can be compared side by side on performance and composition. |
| FR-4.27 | Wallet-scoped cash flows for return calculation derive from the allocated quantities and their cost basis at the time of allocation. |
Acceptance: Marina creates "Retirement", assigns six dividend payers in six clicks with no quantities typed, and sees monthly income, yield-on-cost and performance-vs-CDI for that wallet alone. When she next imports an extract containing further purchases of five of those six, they appear inside Retirement automatically and the import summary tells her so. The sixth — ITSA4, which she had deliberately split 60/40 between Retirement and Trading — is not auto-allocated: its new shares wait in "Needs attention" for her to say which purpose they served.
Three controls are common to all four reports and behave identically in each. Scope narrows what is included; grouping decides how the included data is broken apart. They are independent — a user can scope to one wallet and still group its contents by asset class.
| ID | Requirement |
|---|---|
| FR-5.25 | Every report accepts a period: YTD, 12m, 24m, all time, or a custom range. |
| FR-5.26 | Every report accepts a scope: the whole portfolio, or a single wallet. |
| FR-5.27 | Every report accepts a grouping dimension, and must present its figures broken down by the chosen one: asset class (stocks, FIIs, BDRs, ETFs, Tesouro Direto, CDB/LCI/LCA), wallet, individual asset, sector, or institution. |
| FR-5.28 | Asset class is the default grouping at portfolio scope; individual asset is the default within a single-wallet scope. |
| FR-5.29 | Grouping is consistent across all four reports — the same control, the same options, the same behaviour — so a user who learns it once can use it everywhere. |
| FR-5.30 | Groups are expandable: a group row drills down to the individual assets composing it, without leaving the report. |
| FR-5.31 | Every grouped view totals to the same scope-level figure regardless of which grouping is selected. Switching the grouping must never change the total. |
| FR-5.32 | Grouping by wallet at portfolio scope includes the Unassigned bucket as a visible group, so allocated and unallocated holdings always sum to the portfolio. |
| FR-5.33 | Where a grouping does not apply to part of the scope — sector is undefined for fixed income, for instance — those holdings appear in an explicit "Not classified" group rather than being dropped from the total. |
| FR-5.34 | The selected period, scope and grouping are reflected in the URL so a particular view can be bookmarked and returned to. |
| FR-5.35 | Any grouped report view is exportable to CSV with the grouping preserved. |
Note on requirement IDs: IDs are stable identifiers, not a reading order. Requirements added during review keep the next free number rather than triggering a renumber, so references from elsewhere in the document stay valid.
Answers: is this working, and would I have done better in the CDI?
| ID | Requirement |
|---|---|
| FR-5.1 | Show Time-Weighted Return for the selected period and scope (rationale in §6.1). |
| FR-5.2 | Show XIRR alongside, distinctly labelled as the user's personal money-weighted return. |
| FR-5.3 | Offer both with earnings (total return) and without earnings (price return) views. |
| FR-5.4 | Chart cumulative performance against CDI, IPCA and IBOV, each toggleable. |
| FR-5.5 | Express performance versus CDI as a percentage of CDI (e.g. "118% do CDI") — the idiom Brazilian investors actually use. |
| FR-5.6 | Break performance down by the selected grouping (FR-5.27), showing each group's own return and its contribution to the scope's total return — a group can outperform while contributing little, and both facts matter. |
| FR-5.7 | Where TWR and XIRR differ materially, offer an inline explanation of why — this confuses people, and explaining it is part of the product. |
Terminology decision. The English term for this report is "Portfolio Value".
The tempting translation is "Net Worth" — it's what Empower, Kubera and most international personal-finance products call the equivalent screen, and it carries the same weight "patrimônio" does in Portuguese. But net worth has a precise meaning: assets minus liabilities. AllMyWallet tracks investments only — no debts, no property, no current accounts — so a figure labelled "Net Worth" would systematically overstate what it measures for any user with a mortgage. "Portfolio Value" says exactly what the number is.
Neither Equity (means something else in finance) nor Estate (inheritance and property) nor Wealth (vague, and it invites the same overstatement) is better here.
If AllMyWallet later tracks liabilities and non-investment assets, "Net Worth" becomes the correct term and should replace it — that's a rename, not a redesign.
The pt-BR interface uses "Patrimônio", which is the natural word in the market's own vocabulary and carries no such ambiguity.
Answers: how much do I have, and where did the growth come from?
| ID | Requirement |
|---|---|
| FR-5.8 | Chart total portfolio value over time, at daily granularity, aggregated to weekly/monthly for long periods. |
| FR-5.9 | Decompose growth into three distinct drivers: net contributions, price change, and earnings received. This is the report's central purpose — money that grew because you deposited it must never look like money that grew because it performed. |
| FR-5.10 | Show current total value, total invested (net contributions), and absolute and percentage gain. |
| FR-5.11 | Show the value split by the selected grouping (FR-5.27) at any point on the timeline, as a stacked series — so the user can watch, for example, the fixed-income share grow relative to equities, or one wallet grow relative to another. |
| FR-5.12 | Show monthly net contribution as a separate bar series, itself breakable by the selected grouping. |
| FR-5.13 | Mark estimated values (accrued fixed income) distinctly from observed market prices. |
Answers: how much income is this producing, and is it growing?
| ID | Requirement |
|---|---|
| FR-5.14 | Total proventos received in the period, by type (dividend, JCP, rendimento, amortization). |
| FR-5.15 | Monthly income chart with a 12-month moving average to cut the lumpiness of quarterly payers. |
| FR-5.16 | Income broken down by the selected grouping (FR-5.27) — by asset class, by wallet, by individual asset — ranked, with each group's share of total income. Grouping by wallet answers Marina's actual question: how much monthly income is my retirement money producing? |
| FR-5.17 | Yield on cost per asset and for the scope as a whole — income relative to what was actually paid, which is the number that matters to an income investor. |
| FR-5.18 | Current dividend yield per asset, based on trailing 12-month income over current price. |
| FR-5.19 | Announced-but-unpaid earnings (from provisioned corporate events) shown as expected upcoming income, clearly separated from received income. Sourcing caveat: the free quote tier excludes dividend data and B3's Eventos Provisionados API is B2B-only, so this ships degraded in v1 — see Q8. Received earnings are unaffected; they come from the B3 extract. |
| FR-5.20 | Year-over-year income growth for the scope. |
Answers: what am I actually holding, and is it balanced?
| ID | Requirement |
|---|---|
| FR-5.21 | Composition by the selected grouping (FR-5.27): asset class, wallet, individual asset, sector (stocks/FIIs, where available), or institution. |
| FR-5.22 | Both a share-of-total visualization and a sortable table with value, quantity, average price, current price and unrealized gain. |
| FR-5.23 | Composition drift over time — how allocation across the selected grouping shifted during the period. |
| FR-5.24 | Concentration flags when a single asset exceeds a user-configurable share of the scope (default 20%). Informational only; explicitly not advice. |
Acceptance: For a portfolio with a known expected answer, all four reports agree with each other — total value in Composition equals the endpoint of Portfolio Value, and earnings in the Earnings report reconcile to the earnings component of Portfolio Value growth. Switching the grouping from asset class to wallet and back leaves every total unchanged (FR-5.31).
End-of-day is not the only option, and v1 does better than it. But true real-time is a licensing problem, not an engineering one, so it helps to be precise about the three tiers.
| Tier | Delay | Availability | Cost |
|---|---|---|---|
| End-of-day / D-1 | Overnight | Freely redistributable — B3's commercial policy permits distributing end-of-day and D-1 data without prior authorization | Free |
| Delayed intraday | ~30 min (free tier) → ~15 min → ~5 min | Available from commercial API providers; brapi.dev's Startup plan is ~R$100/mo at ~15 min and Pro ~R$117/mo at ~5 min | Low |
| Real-time / tick-by-tick | None | Requires a licensed B3 market data distributor agreement, with a variable fee reported and paid per end user, per segment. Not offered by the low-cost API providers at all. | High, and scales with user count |
Decision: v1 launches on the free tier — the most frequent free option — refreshing quotes through the trading session and taking the official closing price as authoritative after the close.
The free tier updates roughly every 30 minutes. Polling faster buys nothing: the provider's own documentation notes that calling more often does not make the quote fresher. So the refresh cadence is aligned to the delay, not to ambition.
This directly addresses intraday movement — a portfolio checked at 14:00 reflects roughly where the market is, not yesterday's close. What it does not do is claim to be live.
The free tier's real constraint is not the delay — it's the request budget. The free plan allows 15,000 requests/month at one ticker per call. That combination, not the 30-minute lag, is what sets the ceiling. Three rules keep spend proportional to what is actually being looked at — they are specified in F6.3 and are the reason the budget is survivable at all:
- Poll only while the market is open. A closed market produces no new prices, so a request made at 21:00 buys nothing. Outside the session the stored value is the correct answer.
- Poll only assets someone actually holds. The catalog is thousands of instruments; the polling set is the few dozen in real portfolios.
- Fetch anything else on demand and keep it. An asset looked up but not held is fetched once, persisted, and served from the database thereafter.
Budget with those rules, reserving 10% of the quota for on-demand lookups:
| Refresh cadence | Calls per asset/day | Calls per asset/month | Distinct held assets supported |
|---|---|---|---|
| Every 30 min | 14 intraday + 1 close = 15 | 315 | ~42 |
| Every 60 min | 7 + 1 = 8 | 168 | ~80 |
| Every 2 h | 3 + 1 = 4 | 84 | ~160 |
(~7-hour session, 21 trading days, 13,500 calls/month for scheduled polling and 1,500 — about 71/trading day — reserved for on-demand lookups)
The one deliberate call outside session hours is the post-close capture of the official closing price (FR-6.2). It is worth its 21 calls/asset/month: the last 30-minute-delayed intraday quote is not the close, and every historical return computed from it would be permanently and invisibly wrong.
Because quotes are shared across tenants (FR-6.8), this ceiling counts distinct assets held across all users, not assets per user — one user with 25 tickers is fine; forty users holding overlapping blue chips are also fine. But it is genuinely low, and a growing user base will cross it. Hence FR-6.15: cadence is configurable and degrades automatically rather than failing.
Why not real-time: the per-end-user licensing fee scales with the user base, which is a genuinely bad cost structure for a subscription product, and it buys nothing this product needs. AllMyWallet is a long-horizon tracker (§1.4) — no order is ever placed from it, and no decision it supports turns on a five-minute price difference. Paying a per-user fee so a number is 5 minutes fresher would be spending real money on false precision.
One free-tier restriction with a product consequence: the free plan excludes dividend data. Received earnings are unaffected — those come from the B3 Movimentação extract, which is the authoritative source anyway. But announced-but-unpaid earnings (FR-5.19) need a forward-looking dividend calendar the free tier does not supply, and B3's Eventos Provisionados API is B2B-only (§4.1). FR-5.19 therefore ships degraded in v1 unless another source is found — tracked as Q8.
What this obliges the product to do: never imply the number is live. FR-6.12 requires the delay be shown, not buried.
| ID | Requirement |
|---|---|
| FR-6.1 | Only while the B3 regular session is open, a scheduled job refreshes quotes for only those listed assets currently held in at least one user's portfolio, at a cadence matched to the active plan's delay — ~30 min on the launch (free) tier. Polling faster than the provider's own update interval is prohibited: it consumes budget without improving freshness. |
| FR-6.2 | After the close, the job fetches the official closing price, which supersedes the last intraday quote and is the value written to that day's valuation snapshot. Only closing prices are used in historical series and return calculations. |
| FR-6.3 | Intraday quotes update current portfolio value and today's change. They never rewrite a historical data point. |
| FR-6.4 | A scheduled daily job fetches the Tesouro Transparente price/rate file. Tesouro Direto prices are published daily, not intraday — this is a property of the source, and the UI reflects it. |
| FR-6.5 | A scheduled daily job fetches BCB SGS series: CDI (12), IPCA (433), Selic (11). |
| FR-6.6 | Outside the session — before the open, after the close, on weekends and B3 holidays — no quote request is made at all. The stored value is served as-is and is the correct answer: a closed market produces no new prices. The B3 trading calendar, including half-sessions, drives this and is treated as data, not as hardcoded hours. |
| FR-6.7 | Fixed income (CDB/LCI/LCA) accrues once daily. It has no intraday behaviour, and showing one would be fiction. |
| FR-6.8 | Market data is shared across tenants — it is not personal data and is stored once. Polling cost therefore scales with the number of distinct assets held, not with the number of users. |
| FR-6.9 | Failures retry with backoff; persistent failure raises an operational alert and the affected data is marked stale in the UI rather than silently showing an old figure as current. |
| FR-6.10 | Quote providers sit behind a provider interface so the vendor — and therefore the delay tier — can be changed without touching valuation logic. Moving to a licensed real-time feed later must be a provider swap, not a redesign. |
| FR-6.11 | A user can manually trigger a refresh of their own portfolio's valuation, rate-limited to protect the provider quota. Outside session hours this recomputes from stored quotes and makes no provider call — there is nothing newer to fetch, and the UI says so rather than appearing to do nothing. |
| FR-6.12 | Every screen showing a current value displays the quote timestamp and the delay tier in plain language (e.g. "cotações com ~15 min de atraso · 14:32"). The product never implies real-time. |
| FR-6.13 | Provider request budget is tracked continuously against the monthly quota, with the projected month-end burn visible to operators. Crossing 70% raises an alert while there is still time to act. |
| FR-6.14 | Daily valuation snapshots are persisted per user so historical charts do not require recomputation from scratch on every page load. |
| FR-6.15 | The refresh cadence is configuration, not code, and degrades automatically before it breaks. If the distinct-asset universe grows past what the current cadence and quota allow, the system lengthens the interval (30 min → 60 min → 2 h) rather than exhausting the budget and leaving quotes stale mid-month. Operators are alerted when this happens — it is the signal to consider a paid tier (Q7). |
| FR-6.16 | If the quota is nevertheless exhausted, the last known quote is retained and clearly marked stale (FR-6.9). Portfolio value is never shown as current when it is not. |
The database is the system of record for quotes; the provider is only consulted when the database cannot answer. Every read follows the same path — serve from the database, and call the provider only on a miss or a stale-during-session entry, then persist what comes back.
| ID | Requirement |
|---|---|
| FR-6.17 | The scheduled polling set is derived, not configured: it is exactly the distinct assets appearing in at least one non-zero user position. Nothing else is ever polled on a schedule. |
| FR-6.18 | An asset entering any user's portfolio joins the polling set on the next cycle, with no manual step. An asset no longer held by anyone drops out of it — the system stops spending budget on positions that no longer exist. |
| FR-6.19 | On-demand read-through: if a user looks up an asset with no quote in the database, the system fetches it from the provider, persists it, and serves it. The next lookup of that asset — by that user or any other — is answered from the database. |
| FR-6.20 | An on-demand fetch for an asset nobody holds does not add it to the polling set. It is fetched once and kept; it starts being polled only if it later enters a portfolio (FR-6.18). |
| FR-6.21 | On-demand fetches draw from a reserved share of the monthly request budget (10% at launch) so that user-driven lookups can never starve the scheduled polling that keeps real portfolios valued. |
| FR-6.22 | On-demand lookups are rate-limited per user, and an unknown-ticker lookup is validated against the asset catalog before any provider call — so a typo or a scripted sweep cannot burn the reserve. |
| FR-6.23 | A stored quote is re-fetched on read only when the session is open and the entry is older than the cadence interval. Outside the session a stored quote is never considered stale, however old it is. |
| FR-6.24 | If an on-demand fetch fails or the asset does not exist at the provider, the user is told plainly that no quote is available. The system does not retry in a loop or fabricate a value. |
Worked example. A user opens their dashboard at 21:00 holding PETR4: no request is made, the stored close is shown with its timestamp. At 11:30 the next day the same page triggers a refresh, because the session is open and the stored quote is older than 30 minutes. The user then searches WEGE3, which nobody in the system holds: it is fetched once, saved, and shown. It is not added to the polling set. If they buy it and import that trade, it joins the polling set on the next cycle.
Concentration builds silently: winners run, contributions land unevenly, and a position that began as a tenth of a wallet becomes a third without any single decision having been taken. F7 lets a user state the proportions they intended and shows the distance from them.
Every number here originates with the user. The product stores targets that were typed in, compares them to a price it already displays, and never originates a threshold, ranks an asset, or names a trade. That constraint is what makes the feature a calculator rather than an opinion, and it is what closes Q6 without reopening risk R7. Detailed in SPEC-017.
A note on requirement numbering. F7 uses
FR-7.x, but F8 and F9 useFR-9.xandFR-10.x—FR-8.xbelongs to the non-functional requirements in §8 and is fully allocated. The gap is deliberate; renumbering §8 would invalidate citations in four existing specs.
| ID | Requirement |
|---|---|
| FR-7.1 | A wallet may define target allocations. Targets are optional and a wallet without them is unchanged. |
| FR-7.2 | A wallet is in one of two target modes, stored per wallet: equal-weight or manual. |
| FR-7.3 | Targets are a percentage of market value, consistent with how Composition computes shares (§6, FR-5.21). |
| FR-7.4 | Targets must total exactly 100%. Any other total is rejected at write time. |
| FR-7.5 | Equal-weight mode derives each target as 100 / n over the targeted assets rather than storing them. |
| FR-7.6 | In equal-weight mode, an asset joining or leaving recomputes every target automatically — the mode is a standing instruction, in the same class as FR-4.19. |
| FR-7.7 | In manual mode targets never change by themselves. A new asset enters at a 0% target and the wallet is flagged for review until targets total 100% again. |
| FR-7.8 | Mode changes are explicit, and switching to equal-weight confirms before discarding hand-set targets. |
| ID | Requirement |
|---|---|
| FR-7.9 | Fixed income is excluded from targets. CDB, LCI, LCA and Tesouro Direto never carry one and never count toward the 100%. |
| FR-7.10 | The balance view states the share of the wallet its targets cover — otherwise it reports balance over a subset and understates concentration. |
| FR-7.11 | A wallet holding only fixed income offers no target form and explains why. |
| FR-7.12 | Unassigned quantity (FR-4.11) belongs to no wallet and appears in no wallet's targets. |
| ID | Requirement |
|---|---|
| FR-7.13 | An asset's current allocation is its market value as a share of the wallet's targeted market value. |
| FR-7.14 | Drift is current − target, in percentage points, signed. |
| FR-7.15 | An asset is out of tolerance beyond a configurable threshold (§8.7; default 5 percentage points). |
| FR-7.16 | A wallet is out of balance when at least one targeted asset is out of tolerance. |
| FR-7.17 | Out-of-balance wallets surface in the existing "Needs attention" queue (FR-4.21), not a second one. |
| ID | Requirement |
|---|---|
| FR-7.18 | Each targeted asset shows drift in percentage points, in R$, and as an approximate share count — in both directions. |
| FR-7.19 | No wording may recommend an action (FR-5.24 applies unchanged). Figures are labelled as distance from the user's own target. |
| FR-7.20 | Share counts are marked approximate, because the quote carries a delay (F6.1). |
| FR-7.21 | An asset with a stale or missing quote shows drift as unavailable rather than computing it from an old price. |
| ID | Requirement |
|---|---|
| FR-7.22 | Targets and quantity allocations are independent: changing one never changes the other. |
| FR-7.23 | Splits, grupamentos and bonificações leave targets unchanged — they are percentages, and FR-4.23 already adjusts allocations by the same ratio. |
| FR-7.24 | Targets are current-state. Editing one restates no historical figure anywhere in the product. |
Following every price movement across a portfolio is work nobody does reliably. F8 lets a user write down the prices that would matter to them and have the product watch instead — showing each asset's state on screen and emailing when it changes.
Two external constraints shaped it. The quote budget is the first: F6 derives the polling set from actual positions and deliberately keeps casual lookups out of it (FR-6.20), against a ceiling risk R5 already rates as the most likely to bite. So rules exist only on assets the user holds, and evaluation is attached to quote writes rather than to a schedule — the feature adds zero provider requests. The cost is stated rather than hidden: the buy state means add to a position, never open one.
The second is the advice line. The product supplies no thresholds and no view about any asset. Even the meaning of a bound is the user's — "below R$ 30" is a buy for someone averaging down and a sell for someone running a stop-loss. Detailed in SPEC-018.
| ID | Requirement |
|---|---|
| FR-9.1 | A rule exists only on an asset the user currently holds in a non-zero position. |
| FR-9.2 | A rule exists only on an asset with an observable market price — variable income and Tesouro Direto. CDB, LCI and LCA can never carry one. |
| FR-9.3 | Closing a position deactivates and retains its rules; holding the asset again reactivates them unchanged. |
| FR-9.4 | Rules are per user. Two users watching the same asset share nothing but the quote. |
| ID | Requirement |
|---|---|
| FR-9.5 | An asset carries at most one lower and one upper bound, each optional; at least one must be set. |
| FR-9.6 | Each bound carries a user-chosen state — buy, hold or sell. The product never assigns one. |
| FR-9.7 | The user chooses the default state between the bounds; it defaults to hold. |
| FR-9.8 | The lower bound must be strictly below the upper, enforced at write time, so no price can match both. |
| FR-9.9 | Thresholds are absolute BRL amounts. v1 offers no derived thresholds — nothing computed from average cost, historical range or yield. |
| FR-9.10 | Thresholds are money and obey §6.8 — never floating point at any point. |
| ID | Requirement |
|---|---|
| FR-9.11 | Rules are evaluated when a quote is written (FR-6.17), never on their own schedule. The feature adds no requests to the budget. |
| FR-9.12 | The state is the matching bound's state, or the default state between bounds. |
| FR-9.13 | The last observed state is persisted per rule, so a change is distinguishable from a repeat. |
| FR-9.14 | Evaluation uses the same stored quote the product displays, so no two screens can disagree about the price. |
| FR-9.15 | A crossing that reverses inside the polling interval is never observed. This is stated where rules are configured, not buried in help. |
| FR-9.16 | With no usable quote the state reads unknown; a state is never carried forward from a price known to be old. |
| ID | Requirement |
|---|---|
| FR-9.17 | State is shown as a colour and a text label — buy green, hold yellow, sell blue. Colour is never the sole carrier (§8.6). |
| FR-9.18 | Wording states the user's own rule, never the product's judgement (FR-5.24). |
| FR-9.19 | State appears on a watch screen and as a badge in the holdings list, with quote timestamp and delay tier alongside. |
| FR-9.20 | The watch screen shows, per asset: current price, both bounds, current state, and the time of the last email sent. |
| ID | Requirement |
|---|---|
| FR-9.21 | Email is sent when the evaluated state differs from the last observed state. A repeat sends nothing. |
| FR-9.22 | A configurable cooldown (§8.7; default 24h) then suppresses further email for that asset. |
| FR-9.23 | Every send is recorded in a notification log keyed on the triggering observation, so an at-least-once retry cannot resend. |
| FR-9.24 | A genuine second crossing inside the cooldown is suppressed. In-app state stays current, and FR-9.20 makes the suppression visible. |
| FR-9.25 | Email requires explicit opt-in recorded as consent (§8.2); it is off by default, and the in-app feature works fully without it. |
| FR-9.26 | Per-asset mute and a global unsubscribe are available, and unsubscribe is honoured without sign-in. |
| FR-9.27 | Email is sent only during market hours plus a configurable quiet window, is pt-BR, and carries only the asset, its price, the user's threshold and the state — no CPF, no position size, no portfolio value. |
Wallets exist because money has purposes. F4 already lets a wallet record a goal, but as decoration. F9 promotes it: a goal becomes a number the product measures against.
Two kinds, matching the two questions a purpose-driven wallet raises. A growth goal asks how much have I built — as amount invested or as current value. An earnings goal asks how much is it paying me, which is the question Marina's persona (§2.2) exists for: a dividend wallet is not built to be large, it is built to produce income.
A goal carries an amount but no target date. Without a date there is no required-pace line and no projection, so the burn-up chart answers "how far have I come" and cannot answer "am I on track". That is a deliberate simplification, and the chart is honest about it rather than implying a schedule nobody set. Detailed in SPEC-019.
| ID | Requirement |
|---|---|
| FR-10.1 | A goal belongs to exactly one wallet. |
| FR-10.2 | A wallet may hold more than one goal, of either kind, at once. |
| FR-10.3 | A goal is growth or earnings; the kind is fixed at creation. |
| FR-10.4 | A goal has an amount in BRL and no target date. This supersedes F4's descriptive wallet goal, whose text becomes the goal's name. |
| FR-10.5 | A growth goal names its basis — amount invested (contributions at cost) or current value (market value). |
| FR-10.6 | An earnings goal names a period — monthly or yearly — and an amount. |
| FR-10.7 | Goal amounts are money and obey §6.8. |
| FR-10.8 | Deleting a wallet deletes its goals; it never deletes transactions or allocations. |
| ID | Requirement |
|---|---|
| FR-10.9 | Growth progress is cumulative since the wallet's first allocation and never resets. Invested amount and market value are stocks, not annual flows. |
| FR-10.10 | The chart is a burn-up: the real line from daily valuation snapshots plus a horizontal goal line. There is no pace line and no projection. |
| FR-10.11 | On the amount invested basis, progress uses allocated quantities at their cost basis at allocation (FR-4.27). |
| FR-10.12 | On the current value basis, progress uses market value, with accrued fixed income marked estimated (CR-1). |
| FR-10.13 | The growth chart is not year-scoped; FR-10.18 governs the earnings chart only. |
| ID | Requirement |
|---|---|
| FR-10.14 | Monthly income comes from the Earnings report's projection at wallet scope (F5.3). F9 computes no income of its own. |
| FR-10.15 | A monthly goal renders as a horizontal line across the twelve months of the selected year. |
| FR-10.16 | A yearly goal renders against cumulative income within the selected year. |
| FR-10.17 | A year-to-date average line accompanies the bars: the mean of months elapsed in the selected year. It resets each January and reads no data from outside that year — deliberately not the 12-month moving average used in F5.3, and both charts label which line they show. |
| FR-10.18 | The earnings chart shows exactly one calendar year, user-selected, defaulting to the current one. No series, figure or axis may span a year boundary. |
| FR-10.19 | The current month's income is shown as its own figure; on a closed year it is replaced by that year's total. |
| FR-10.20 | A selected year with no income renders an explicit empty state, never a row of zeros. |
| ID | Requirement |
|---|---|
| FR-10.21 | A goal is achieved when progress reaches or exceeds its amount — for an earnings goal, within the period it names. |
| FR-10.22 | Achieved goals are marked with the date achieved and stay visible. Nothing archives or deletes automatically. |
| FR-10.23 | One email is sent on achievement where the user has opted in (FR-9.25's consent model applies unchanged). |
| FR-10.24 | A goal falling back below its amount keeps its achieved marker and date — the marker records an event, not a current status. Goals are current-state: editing an amount moves the goal line and nothing else. |
Everything the product does depends on one step the user performs by hand: exporting three files from B3 and uploading them. R1 rates that friction High/High and names guided onboarding as its mitigation; the success metric is >70% of signups reaching a first successful import. A user who does not clear that step never sees anything this product was built for.
F10 owns the sequence, the gating, the progress model and the empty states. The mechanics of each step stay with the feature that owns them — F2 for parsing and preview, §6.5 for fixed-income contracts, F4 for wallets. Detailed in SPEC-020.
F2.1's requirements (FR-2.1–2.3) are now owned here, and by SPEC-020 rather than SPEC-005. They keep their IDs, because IDs are stable identifiers rather than a reading order.
Two things it deliberately does not do. It stores almost nothing — every step's completion is a query against data that already exists, for the same reason positions derive from the ledger. And it ships no screenshots of investidor.b3.com.br: R2 establishes that B3 changes things, and the redesign that breaks a parser silently invalidates a screenshot, which then walks the user confidently to a control that moved.
| ID | Requirement |
|---|---|
| FR-11.1 | Onboarding covers first sign-in through to a dashboard showing a real portfolio, owning sequence, gating, progress and empty states. |
| FR-11.2 | The guided sequence is: export the three extracts → upload → review the staged preview → commit. |
| FR-11.3 | Onboarding is complete at the first successfully committed import. Nothing else gates completion. |
| FR-11.4 | Creating a first wallet is offered after the import and is never a gate. |
| FR-11.5 | Onboarding collects no personal data. FR-1.5's limit stands: nothing beyond what Google supplies. There is no registration to complete. |
| ID | Requirement |
|---|---|
| FR-11.6 | Step completion is derived by query — committed imports, fixed-income contracts missing a rate, unclassified movement types, wallets. No progress table exists. |
| FR-11.7 | Derived state follows reality and is never latched: deleting the only import returns onboarding to incomplete and the dashboard to its empty state. |
| FR-11.8 | The only persisted onboarding field is a nullable dismissal timestamp on the user — the one fact that is not a query. |
| ID | Requirement |
|---|---|
| FR-11.9 | The guide is dismissible at any step; the user is never trapped in a wizard. |
| FR-11.10 | Dismissal hides the guide and marks no step complete. |
| FR-11.11 | The guide is reopenable at any time from a help entry point, which clears the dismissal. |
| FR-11.12 | A user who dismissed the guide and still has no import continues to see the dashboard empty state. |
| ID | Requirement |
|---|---|
| FR-11.13 | Unmet data-quality gates surface in the existing "Needs attention" queue (FR-4.21). No second queue and no dedicated onboarding panel. |
| FR-11.14 | The gates are: fixed-income contracts whose contracted rate could not be read, unclassified movement types, and purchases awaiting allocation. |
| FR-11.15 | Gates are not part of onboarding completion. They persist while the data is incomplete, which may be indefinitely, and that is not a failed onboarding. |
| FR-11.16 | Each gate states what is wrong, what it prevents, and links to the one screen that resolves it. A missing fixed-income rate names its consequence: that contract cannot be valued, so portfolio value is understated until it is supplied. |
| ID | Requirement |
|---|---|
| FR-11.17 | Guidance uses diagrams authored by this project. Screenshots of investidor.b3.com.br are prohibited — no third-party interface enters the repository. |
| FR-11.18 | Each diagram set carries a "verified against B3 as of <date>" stamp visible to the user, so staleness is visible rather than silent. |
| FR-11.19 | The stamp is re-verified whenever a B3 parser changes — a parser change is evidence B3 has moved. |
| FR-11.20 | The guide states that only the three B3 extracts are accepted and that assets outside B3 custody need manual entry (F3), never implying completeness §4 cannot deliver. |
| ID | Requirement |
|---|---|
| FR-11.21 | Before the first import the dashboard shows an explicit onboarding empty state, not zeros — a portfolio shown as R$ 0,00 is a false claim. |
| FR-11.22 | No screen fabricates sample or demo data. A first-run user never sees numbers that are not theirs. |
M0–M9 built each screen inside the spec that needed it. A shared design system followed (#33), but three things were never decided:
- a brand colour;
- how a destination organises more than one task;
- where each thing lives.
A walkthrough on 2026-10-09 found:
- a monochrome interface;
- long single pages, next to a tabbed one;
- no way to sign out;
- misaligned fields and clipped controls;
- figures summed across incompatible units.
F11 owns the navigation, the page structure every screen follows, the account surfaces, value masking and the visual language. The visual implementation — tokens, components and the DS- rules — stays in docs/guidelines/DESIGN.md. Detailed in SPEC-022.
The navigation follows one distinction: what the user holds now (Portfólio) versus how it performed over a period (Relatórios). Only the second takes a period.
| ID | Requirement |
|---|---|
| FR-12.1 | Primary navigation has five destinations: Dashboard, Portfólio, Relatórios, Transações, Configurações. |
| FR-12.2 | Portfólio shows current state and takes no period. Its tabs are Patrimônio, Composição, Carteiras (spread across wallets, comparison, balancing) and Metas. |
| FR-12.3 | Relatórios shows performance over a period, and every tab takes one. Its tabs are Evolução patrimonial, Rentabilidade and Proventos. |
| FR-12.4 | Transações stays its own destination: it is the source every figure derives from. |
| FR-12.5 | Configurações holds setup and never a portfolio figure. Its sections are Carteiras (create, edit, assign), Importar and Observar preços (rules). |
| FR-12.6 | In pt-BR, the whole portfolio is Portfólio and the purpose groups are Carteiras. |
| FR-12.7 | The Painel summarises and links, and owns no detail. It includes a card listing held assets currently in a buy or sell state. |
| FR-12.8 | Every pre-existing URL keeps working through a permanent redirect. |
| ID | Requirement |
|---|---|
| FR-12.9 | Every authenticated screen has a top bar with an account menu showing the Google name, e-mail and picture. |
| FR-12.10 | The account menu holds Conta, Preferências, Privacidade, the onboarding guide, the theme switch and Sair. |
| FR-12.11 | Sign-out is one menu action away from every authenticated screen, and is server-side (FR-1.4). |
| FR-12.12 | Conta shows the profile read-only and links to Privacidade, without duplicating its controls. |
| FR-12.13 | Preferências holds personal preferences only. Feature parameters render in their feature's Configurações section, and both screens stay generated from the registry (§8.7). |
| ID | Requirement |
|---|---|
| FR-12.14 | Every destination shares one page frame, one left edge and one maximum width. |
| FR-12.15 | A destination with more than one task uses tabs, one task per tab, each tab addressable by URL. Unrelated tasks are never stacked on one page. |
| FR-12.16 | Configurações uses two rows of tabs: sections as main tabs, each section's tasks as pill tabs. Importar is split into Enviar (upload beside the export guide), Histórico and Parâmetros. |
| FR-12.17 | Portfólio and Relatórios share one scope selector (all wallets or one wallet), kept in the URL across tabs. |
| FR-12.18 | Collections render as paginated, sortable tables with text left and figures right, column widths independent of content, and truncated text shown in full on hover. A list never grows a page without bound. |
| FR-12.19 | Desktop is the design target, and FR-8.40's 360px floor still holds. |
| ID | Requirement |
|---|---|
| FR-12.20 | Field instructions sit behind an info icon that opens on hover, focus and tap, and they never change a field's height. Validation errors stay inline. |
| FR-12.21 | No control clips its own text, and no browser chrome appears in English (FR-8.38, FR-8.39). |
| ID | Requirement |
|---|---|
| FR-12.22 | A top-bar eye toggle masks every money amount, and the value labels and tooltips of value charts. Quantities and percentages stay visible. |
| FR-12.23 | The masking state is a user-level preference applied when the server renders, so values never flash, and it follows the user across devices. |
| FR-12.24 | A masked amount is a fixed-width placeholder, whatever its magnitude. Exports are never masked. |
| ID | Requirement |
|---|---|
| FR-12.25 | The brand colour is a navy blue, used for actions, navigation and focus, never for a figure. Gains stay green and losses red, always with a sign or an arrow. |
| FR-12.26 | Light and dark are both first-class and both WCAG AA (FR-8.37). The dark theme uses layered surfaces, not one black plane. |
| FR-12.27 | The theme switch (Claro / Escuro / Sistema) lives in the account menu and persists on the account. |
| FR-12.28 | Status badges use semantic colours. A warning never uses the loss or destructive colour. |
| FR-12.29 | Each concept has one wording across all screens — estimates read “Valor estimado” or “Custo estimado”, each explained — and the active navigation state looks the same everywhere. |
| FR-12.30 | No total is summed across different units. "Precisa da sua atenção" names, for every item, the asset, the movement type and what resolves it. |
| FR-12.31 | Every block of content sits in a panel with a header; corner radius 8 px for panels, 6 px for controls. |
| FR-12.32 | Every create and edit form uses one two-column panel layout, with Cancelar and the primary action at the bottom right. |
| FR-12.33 | Money charts label axes in abbreviated reais and show the exact amount with centavos in the tooltip. |
| FR-12.34 | The cost of current holdings reads Valor aplicado; net money put in reads Aportes líquidos; each is explained in place. |
| FR-12.35 | Transações fits the page width without horizontal scrolling. |
| FR-12.36 | Chart date axes read pt-BR months (“nov/25”, “nov”), never ISO dates. |
These are product decisions, not implementation details. Each one changes what the user sees, and reasonable people choose differently — so each is stated with its rationale.
Decision: Benchmark comparison uses daily-linked Time-Weighted Return (TWR). XIRR is shown alongside as the personal, money-weighted return.
Why: These answer different questions and legitimately give different numbers for the same portfolio.
- TWR neutralizes the effect of deposits and withdrawals. It measures how the investments performed, independent of when money went in. This is the only fair way to answer "did I beat the CDI?" — otherwise a large deposit right before a rally inflates the result and makes a mediocre portfolio look brilliant.
- XIRR does the opposite: it accounts for the timing and size of every cash flow, answering "what return did I actually earn on my money?"
Both are true; neither alone is complete. Showing only one would silently answer a question the user did not ask. FR-5.7 requires explaining the gap when it is material.
Method: Portfolio value is snapshotted daily. Each day's return is computed excluding that day's external cash flows, and daily returns are geometrically linked across the period. Where a daily snapshot is unavailable for a sub-period, Modified Dietz is the documented fallback.
Decision: With earnings = total return, counting proventos as income at their pay date. Without earnings = price return only.
Why: For a dividend-focused portfolio these differ enormously, and both views are legitimate. Total return is the honest measure of whether the investment worked. Price-only return isolates capital appreciation and is what a user comparing to a price index like IBOV expects. Earnings are not assumed to be reinvested — that would invent transactions the user never made. They are counted as received income on the pay date.
Decision: Moving weighted average cost, adjusted for corporate events. Sales do not change the average cost.
Why: This is the Brazilian convention, it is what Receita Federal expects for cost basis, and it is what the user's broker statement shows. Any other choice guarantees the app's numbers disagree with the broker's and destroys trust.
Rules:
-
Buy:
new average = (existing quantity × existing average + purchase quantity × purchase price + fees) ÷ total quantity - Sell: quantity decreases; average cost is unchanged.
- Split / grupamento: quantity adjusts by the ratio; total cost is unchanged; average cost adjusts inversely.
- Bonificação: quantity increases; total cost increases by the value B3 attributes to the bonus shares (or zero if none is attributed); average cost recomputed.
- Subscription: treated as a buy at the subscription price.
- Position fully closed then reopened: average cost resets — a new lot begins.
Decision: Average-cost basis. realized gain = (sale price − average cost at sale date) × quantity sold − fees.
Why: Consistent with §6.3 and with Brazilian tax rules. FIFO is deliberately not used — it would produce figures that contradict what the user must report to Receita Federal.
Different instruments get different treatment, because the available data genuinely differs.
Tesouro Direto — marked to market. Daily buy/sell prices are published openly by Tesouro Transparente, so the position is valued at the observed price. This is a real market value, not an estimate.
CDB / LCI / LCA — accrued from the contracted rate. No public daily price exists for bank paper. Value is accrued daily from the contracted indexer captured from the B3 Posição extract:
| Indexer | Accrual basis |
|---|---|
| % of CDI (e.g. 110% do CDI) | Daily CDI from BCB SGS series 12, scaled by the contracted percentage, compounded on business days |
| Prefixado | Contracted annual rate, compounded pro-rata on business days (252-day convention) |
| IPCA + spread | Monthly IPCA (SGS 433) applied to principal, plus the spread accrued on business days |
CR-1: Accrued fixed-income values are flagged in the UI as estimated, with the basis shown on hover. They approximate carrying value, not what a secondary-market sale would fetch.
Gross of tax. All fixed-income figures in v1 are gross of IR and IOF. Modelling the regressive IR table and IOF tables accurately requires care disproportionate to v1, and a wrong net figure is worse than an explicitly gross one. The UI states this; the data model supports adding it later.
Missing contract data. If the indexer or rate cannot be read from the extract, the position is valued at cost, flagged as needing input, and surfaced in the "Needs attention" queue.
Decision: A benchmark is modelled as a hypothetical portfolio receiving the user's actual cash flows on the same dates, growing at the benchmark's rate.
Why: Comparing a portfolio's TWR to a raw CDI accumulation over the same period is the standard approach and is what §6.1 uses for the headline number. But the "what if I'd just left it in CDI?" chart is far more intuitive when it mirrors the user's real contribution pattern — so both are offered: the pure index line, and the shadow-portfolio line.
- CDI: daily accumulation from SGS series 12.
- IPCA: monthly, interpolated for daily display; also used for real (inflation-adjusted) return.
- IBOV: index level from the quote provider.
Decision: BRL only; no FX layer in v1.
Why: Every in-scope asset settles in BRL — including BDRs, which trade in BRL on B3. An FX layer would add real complexity for zero v1 benefit. Note the consequence, stated in the UI: a BDR's return in BRL blends the underlying asset's move with the USD/BRL move, and v1 does not decompose the two.
| ID | Requirement |
|---|---|
| CR-2 | All monetary values stored as exact decimals (never floating point), 2 decimal places for currency, 8 for quantities (fractional shares, Tesouro fractions). |
| CR-3 | Rounding applied only at display time; intermediate calculations retain full precision. |
| CR-4 | Percentages displayed to 2 decimal places. |
-
Transactionis the single source of truth, append-only in spirit. Positions, valuations and every report figure are derived and can be rebuilt from the ledger alone. -
Every ledger row is traceable to its
ImportBatchor marked as manual entry. Any number in any report can be explained back to source. - Derived data is cached, never authoritative. If a cached snapshot disagrees with a recomputation from the ledger, the ledger wins.
-
Tenant scoping is structural. Every table holding user data carries
user_idand is protected by row-level security.
Identity & tenancy
| Entity | Purpose | Key fields |
|---|---|---|
User |
Tenant root |
id, google_subject_id, email, name, created_at, deleted_at
|
Consent |
LGPD consent record |
user_id, purpose, granted_at, revoked_at, policy_version
|
AuditLog |
Access & change trail |
user_id, actor, action, entity, entity_id, at, ip_hash
|
Reference data (shared, not tenant-scoped)
| Entity | Purpose | Key fields |
|---|---|---|
Institution |
Broker / custodian |
id, name, cnpj_root
|
Asset |
Instrument catalog |
id, code, isin, name, asset_class, sector, issuer
|
PriceQuote |
Daily closing prices — the authoritative history all returns are computed from |
asset_id, date, close, source
|
LatestQuote |
Most recent delayed intraday quote, one row per asset, overwritten on each refresh. Written by both scheduled polling and on-demand read-through (FR-6.19), so a quote fetched for one user's search is immediately available to everyone. Drives current value only; never enters a historical series (FR-6.3) |
asset_id, price, quoted_at, fetched_at, source
|
IndexSeries |
CDI / IPCA / Selic / IBOV |
series, date, value
|
Ledger & holdings (tenant-scoped)
| Entity | Purpose | Key fields |
|---|---|---|
Transaction |
The ledger |
id, user_id, asset_id, institution_id, type, date, quantity, unit_price, fees, total_value, natural_key, import_batch_id, is_manual, is_user_modified, status
|
Position |
Derived holdings |
user_id, asset_id, institution_id, quantity, average_cost, as_of
|
FixedIncomeContract |
CDB/LCI/LCA terms |
id, user_id, asset_id, indexer, rate, issue_date, maturity_date, principal, source
|
CorporateEvent |
Splits, bonuses, provisioned events |
id, asset_id, type, ex_date, pay_date, ratio, value_per_share
|
Earning |
Received proventos | Materialized view over Transaction where type ∈ {dividend, JCP, rendimento, amortization} |
Organization (tenant-scoped)
| Entity | Purpose | Key fields |
|---|---|---|
Wallet |
Purpose grouping |
id, user_id, name, description, goal, color, auto_assign_rule
|
WalletAllocation |
Quantity allocation |
wallet_id, asset_id, quantity, allocated_at, cost_basis_at_allocation
|
Ingestion (tenant-scoped)
| Entity | Purpose | Key fields |
|---|---|---|
ImportBatch |
One upload |
id, user_id, source_type, extract_type, filename, status, uploaded_at, committed_at, row_counts
|
ImportRow |
Staged row + provenance |
id, batch_id, raw_payload, parsed_payload, classification, transaction_id
|
Derived cache (tenant-scoped)
| Entity | Purpose | Key fields |
|---|---|---|
DailyValuationSnapshot |
Per-day portfolio value |
user_id, date, total_value, net_contributions, earnings_to_date, by_asset_class
|
| ID | Constraint |
|---|---|
| DM-1 |
Transaction.natural_key is unique per user — the database-level guarantee behind FR-2.15. |
| DM-2 | Sum of WalletAllocation.quantity per (user, asset) ≤ Position.quantity, enforced at write time. |
| DM-3 | Every tenant-scoped table has user_id NOT NULL with a foreign key to User. |
| DM-4 |
Position and DailyValuationSnapshot are rebuildable from Transaction + PriceQuote + IndexSeries; a rebuild command exists and is part of the test suite. |
| DM-5 |
Transaction.status ∈ {active, unclassified, superseded} — unclassified rows are stored but excluded from all calculations. |
| DM-6 | Deleting a User cascades to every tenant-scoped row (see FR-8.12). |
The requirement "user must see only its own investments" is treated as a security boundary, not a query filter.
| ID | Requirement |
|---|---|
| FR-8.1 | Every tenant-scoped table carries user_id, and PostgreSQL Row-Level Security policies restrict access to the session's tenant. This is the enforcement floor: an application-layer bug that forgets a WHERE user_id = … must not leak data across tenants. |
| FR-8.2 | The application connects with a role subject to RLS. No request-serving code path uses a superuser or BYPASSRLS role. |
| FR-8.3 | Tenant context is set from the authenticated session at the start of each request/transaction, never from a client-supplied parameter. |
| FR-8.4 | Automated tests assert cross-tenant isolation for every tenant-scoped entity, including through report and export endpoints. |
| FR-8.5 | Background jobs operating across tenants (market data sync) touch only shared reference tables. |
Accuracy note: investment holdings are not "dados pessoais sensíveis" under LGPD Art. 5, II — that category covers racial/ethnic origin, religious belief, political opinion, union membership, health, sex life, genetic and biometric data. Financial holdings are ordinary personal data. However, they are high-impact if breached, and this product applies protections equivalent to the sensitive-data tier by choice. Stating this correctly matters: building a compliance program on a mistaken legal premise leads to the wrong controls in the wrong places.
Data minimization
| ID | Requirement |
|---|---|
| FR-8.6 | Personal data collected is limited to: Google subject ID, email, name, profile picture URL, and the investment data the user imports. |
| FR-8.7 |
B3 extracts contain the user's CPF. The CPF is stripped at parse time and never persisted — not in the ledger, not in ImportRow.raw_payload, not in logs. The application has no functional need for it, so it is not collected. |
| FR-8.8 | Uploaded source files are not retained after a batch is committed or cancelled; only parsed, CPF-free rows persist. |
| FR-8.9 | Application logs and error reports must not contain personal data. Import diagnostics log structural facts (unmapped type strings, row counts), never values. |
Legal basis & consent
| ID | Requirement |
|---|---|
| FR-8.10 | Processing basis documented per data category: contract execution (Art. 7, V) for investment data required to deliver the service; consent (Art. 7, I) for optional email reminders and any future analytics. |
| FR-8.11 | Consent is granular, opt-in, separately revocable, and recorded in Consent with the policy version in force at the time. Revoking optional consent never degrades core functionality. |
Data subject rights (Art. 18)
| ID | Requirement |
|---|---|
| FR-8.12 | Deletion: self-service, from settings. Irreversibly removes all tenant-scoped data within 30 days, with immediate access revocation. Confirmed by email. |
| FR-8.13 | Portability & access: self-service export of all personal and investment data in a machine-readable format (JSON + CSV), available on demand without contacting support. |
| FR-8.14 | Correction: covered by manual transaction management (F3.2) and profile editing. |
| FR-8.15 | Information about processing: a plain-language privacy policy listing data collected, purposes, legal bases, retention periods and all subprocessors. |
| FR-8.16 | Rights requests requiring manual handling are answered within 15 days per Art. 19. |
Retention & security
| ID | Requirement |
|---|---|
| FR-8.17 | Active-account data retained while the account lives; deleted per FR-8.12 thereafter. Audit logs retained 12 months. Backups purge deleted-user data within one backup cycle, documented in the privacy policy. |
| FR-8.18 | TLS 1.2+ in transit; encryption at rest for the database and all backups. |
| FR-8.19 | Access to personal data by any operator is logged in AuditLog. |
| FR-8.20 | Subprocessor inventory maintained (hosting, database, email, market data, error tracking), each assessed for LGPD adequacy and listed publicly. |
| FR-8.21 | Documented incident response: containment, ANPD notification and affected-user notification per Art. 48, within the timelines the ANPD requires. |
| FR-8.22 | A named data protection contact is published (Art. 41). |
| ID | Requirement |
|---|---|
| FR-8.23 | No password is ever created, transmitted or stored — Google OAuth only (see F1). |
| FR-8.24 | No B3, broker or bank credential is ever collected or stored. This is a firm product boundary and the reason §4.1's scraping option was rejected. |
| FR-8.25 | All secrets in a managed secret store, never in source control. |
| FR-8.26 | Standard web hardening: CSRF protection, security headers, strict CSP, rate limiting on auth and upload endpoints. |
| FR-8.27 | Uploads validated by content, size-capped, and parsed in a resource-limited context — spreadsheet parsers are a known attack surface. |
| FR-8.28 | Dependency vulnerability scanning in CI. |
| ID | Requirement |
|---|---|
| FR-8.29 | Dashboard loads in under 2s (p95) for a portfolio of 100 assets and 10,000 transactions. |
| FR-8.30 | Any report renders in under 3s (p95) at the same scale, over a 5-year period. |
| FR-8.31 | An import of 10,000 rows completes preview in under 30s and commit in under 60s. |
| FR-8.32 | Reports read from DailyValuationSnapshot rather than recomputing from the ledger on every request. |
| ID | Requirement |
|---|---|
| FR-8.33 | 99% monthly availability target. This is not a trading system; brief downtime has no financial consequence. |
| FR-8.34 | Weekly automated database backups, retained 30 days, with restore tested quarterly. Weekly is a deliberate acceptance of up to 7 days' data loss (see SPEC-016 DL-016-08); revisit as the user base grows. For the personal deployment this is superseded by FR-8.64: before every migration and daily on start, to a local encrypted destination (SPEC-021). |
| FR-8.35 | Market-data job failures alert operators and mark affected data stale in the UI (FR-6.9). |
| FR-8.36 | Structured, personal-data-free application logging. |
| ID | Requirement |
|---|---|
| FR-8.37 | WCAG 2.1 AA for core flows. |
| FR-8.38 | Portuguese (pt-BR) is the primary language; the interface uses the market's own vocabulary — proventos, preço médio, patrimônio, rentabilidade. |
| FR-8.39 | BRL formatting and dd/mm/yyyy dates throughout. |
| FR-8.40 | Responsive from 360px; reports remain legible on mobile. |
Principle: any value an operator might reasonably want to change should be configuration, not a code change and a deploy. Nearly every number in this document is a starting guess made before the system has run — the quote cadence, the staleness thresholds, the budget reserve. Some of them will be wrong, and finding out is the point. A guess that takes a release to correct is a guess that stays wrong for a sprint.
The clearest live example is the quote cadence: FR-6.15 already requires the system to change it at runtime in response to budget pressure. That requirement is unimplementable if the interval is a constant in the polling job — so configurability here is not a preference, it is a precondition for behaviour already specified.
| ID | Requirement |
|---|---|
| FR-8.41 | Operational parameters are read from configuration at runtime, with a documented default. Adding a new tunable value means adding a config key, not a constant. |
| FR-8.42 | Configuration resolves in a defined order: built-in default → deployment/environment config → per-tenant override → per-user preference, with the most specific winning. Not every key is exposed at every level; each key declares which levels may set it. |
| FR-8.43 | Every configuration key is validated at startup against its type and permitted range. An invalid value fails the boot loudly rather than silently falling back to a default — a quote cadence of 0 must never become "poll continuously". |
| FR-8.44 | Values the system adjusts itself (FR-6.15's cadence degradation) are stored as runtime state distinct from operator-set configuration, so an automatic adjustment is never mistaken for an intentional setting and is not silently reverted by the next deploy. |
| FR-8.45 | Configuration changes affecting behaviour are recorded in AuditLog with the actor, the previous value and the new one. |
| FR-8.46 | Secrets are never configuration in this sense — they live in the managed secret store (FR-8.25) and are excluded from any configuration listing or export. |
| FR-8.47 | The effective configuration — resolved values and where each came from — is inspectable by an operator without reading source code. |
The registry. Every value in this document that is a judgement call rather than a fact is a config key. This is the initial set, with the defaults specified elsewhere in the PRD:
| Key | Default | Level | Source requirement |
|---|---|---|---|
| Quote refresh cadence | ~30 min | Deployment | FR-6.1, FR-6.15 |
| Quote cadence degradation ladder | 30 → 60 → 120 min | Deployment | FR-6.15 |
| On-demand budget reserve | 10% of quota | Deployment | FR-6.21 |
| Provider monthly quota | 15,000 | Deployment | F6.1 |
| Budget alert threshold | 70% | Deployment | FR-6.13 |
| Market data provider | brapi.dev free | Deployment | FR-6.10 |
| Trading calendar & session hours | B3 calendar | Deployment (as data) | FR-6.6 |
| Import staleness prompt | 30 days | Deployment / per-user | FR-4.2 |
| Import reminder email | opt-in, monthly | Per-user | FR-4.3 |
| Max upload size | 10 MB | Deployment | FR-2.4 |
| Concentration flag threshold | 20% | Per-user | FR-5.24 |
| Default report grouping | asset class / individual asset | Per-user | FR-5.28 |
| Benchmark series shown | CDI, IPCA, IBOV | Per-user | FR-5.4 |
| Session inactivity expiry | 30 days | Deployment | FR-1.4 |
| Audit log retention | 12 months | Deployment | FR-8.17 |
| Data deletion window | 30 days | Deployment | FR-8.12 |
| Per-user on-demand rate limit | — | Deployment | FR-6.22 |
| v2 ingestion adapters | off | Deployment (feature flag) | FR-4.5 |
Where configurability stops. "As configurable as possible" has a real boundary, and drawing it is part of the requirement:
| ID | Requirement |
|---|---|
| FR-8.48 | Calculation conventions (§6) are not configurable. Average-cost basis, TWR for benchmarking, the fixed-income accrual model — these are product decisions with documented rationale, and a runtime toggle would make two users' reports incomparable and a single user's history unreproducible. Changing one is a versioned product decision with a migration, not a setting. |
| FR-8.49 | Security and compliance controls are not configurable off. Tenant isolation (§8.1), CPF stripping (FR-8.7), audit logging and encryption have no "disable" key. Where a compliance value is genuinely a parameter — a retention period — it is configurable within a validated range that cannot be set to something unlawful. |
| FR-8.50 | Monetary precision and rounding (CR-2–CR-4) are fixed. Configurable precision produces figures that silently disagree between environments. |
The product is not released. It runs on its owner's laptop against their own real assets — avoiding hosting cost and validating every feature on real data. That makes the instance personal production, not staging: it holds the only copy of everything B3 does not keep. Detailed, with the full resource inventory, in SPEC-021.
| ID | Requirement |
|---|---|
| FR-8.51 | The personal instance is personal production — one tenant, real data, never disposable, rebuildable or safe to reset. |
| FR-8.52 | It is the only environment holding real data; development and tests use separate disposable databases. |
| FR-8.53 | It replaces hosted production. No VPS is provisioned. |
| FR-8.54 | Resources are split into mandatory (Postgres, web, worker, Google OAuth, quote provider, BCB and Tesouro, an email provider, a local backup destination, full-disk encryption, the GHCR image) and skipped (VPS, Caddy, TLS, SSH deploy, R2, Sentry, Uptime Kuma, metrics stack), per SPEC-021. |
| FR-8.55 | The instance serves exactly one person's data; admitting anyone else's requires §8.2's obligations in full first. |
| FR-8.56 | The personal database runs as its own Compose project, on a non-default port, with its own volume. |
| FR-8.57 | It carries a database-level marker no TRUNCATE can remove, and every destructive development or test tool refuses to run against a database bearing it. |
| FR-8.58 | Personal secrets live outside the repository working tree, never in the development .env. |
| FR-8.59 | Every published port binds to loopback only. |
| FR-8.60 | Caddy is not run; AUTH_URL is pinned to localhost and no domain is required. The personal definition overrides the hosted Compose file rather than forking it. |
| FR-8.61 | The runtime image carries its own migrator. |
| FR-8.62 | The image — publicly pullable, and irreversibly so — contains no secret and no personal data, ever. |
| FR-8.63 | The image is published on every merge to main as both :<sha> and :latest. |
| FR-8.64 | A backup runs before every migration and once per day on start. |
| FR-8.65 | Backups are age-encrypted pg_dumps, written to a destination the instance refuses if it shares the Postgres data volume. |
| FR-8.66 | A configured number of dumps is retained; older ones are pruned only after a new backup succeeds. |
| FR-8.67 | A failed backup is never silent — logged, reported by health, shown in the app until one succeeds. |
| FR-8.68 | Restore is drilled quarterly into a disposable instance. For this deployment, local dumps supersede the R2 target. |
| FR-8.69 | On start, the instance upgrades automatically when :latest differs from the running image. |
| FR-8.70 | The upgrade sequence is fixed — backup, pull, gated migration, start, health check — and no migration runs without a backup from the same upgrade. |
| FR-8.71 | A failed migration keeps the current image; a failed health check restarts the last-known-good image; start never blocks on the registry. |
| FR-8.72 | On worker start, missed business days are caught up before schedules resume — closes backfilled from B3's historical quotes (COTAHIST), snapshots and accrual recomputed in date order — within the request budget and a configured day limit; unrecoverable days are marked as gaps, never interpolated. |
| FR-8.73 | Recovered days never trigger opportunity email. A real transactional email provider replaces the log sender, behind the existing port. |
| FR-8.74 | Full-disk encryption is a precondition the setup enforces, and raw B3 extracts are kept outside the repository. |
| Milestone | Delivers | Exit criteria |
|---|---|---|
| M0 — Foundations | Next.js + TypeScript app, Google OAuth, tenant model, RLS baseline, CI, configuration layer (§8.7) | A user can sign in; isolation tests pass against an empty schema; config resolves through its levels and rejects invalid values at boot |
| M1 — Import pipeline | Parsers for the three extracts, staging & preview, dedupe, reconciliation | A real full-history import produces correct positions; re-import is a no-op |
| M2 — Ledger | Transaction history, filters, manual CRUD, derived positions, average cost | Positions and average cost match broker statements for a real portfolio |
| M3 — Wallets | Wallets with quantity allocation, Unassigned bucket, allocation workflow | Allocations survive buys and sells and never exceed holdings |
| M4 — Market data & valuation | Intraday quote refresh + official close capture, Tesouro/BCB jobs, fixed-income accrual, valuation snapshots | Portfolio value is correct, moves during the session, and needs no user action |
| M5 — Reports | Performance, Portfolio Value, Earnings, Composition, with the shared scope/grouping/period controls (F5.0) | All four agree with each other; TWR/XIRR validated against worked examples |
| M6 — LGPD hardening | Export, deletion, consent records, audit log, retention, privacy policy | Every Art. 18 right exercisable self-service; deletion verified complete |
| M7 — Balancing, opportunity & goals | Wallet target allocations and drift (F7), buy/sell opportunity rules with email notification (F8), wallet growth and earnings goals (F9) | Targets total 100% and drift reconciles; rules evaluate with zero added provider requests; goals reconcile to Portfolio Value and to Earnings |
| M8 — Onboarding | Guided first run, derived progress, required gates in the existing "Needs attention" queue, empty states, project-authored export diagrams (F10) | More than 70% of signups reach a first successful import; onboarding state reproducible entirely by query, with one persisted column |
| M9 — Personal deployment | Loopback-only personal production on the owner's laptop: isolated database with a refusal guard, migrator in the image, backups before every migration and daily, automatic upgrade on start with rollback, catch-up of missed business days, a real email provider (§8.8) | The real portfolio survives a forced failed migration, a failed backup and a truncating test run against it; three days offline recover with gaps marked; no service is reachable from the network |
| M10 — Interface redesign | Owner-approved visual prototype; navy brand in first-class light and dark themes; five-destination navigation (Portfólio for current state, Relatórios over time, Configurações for setup); account menu with sign-out; value masking; tabbed page structure and paginated tables applied to every screen (F11) | Every screen follows the one page frame; sign-out and masking work from every authenticated screen; both themes pass AA contrast; every pre-M10 URL redirects |
M0–M2 constitute the smallest genuinely useful product: consolidated, trustworthy holdings. M4–M5 are what make it worth returning to. M7 is the first post-launch increment — nothing before it depends on it, and it is the milestone that answers Q6.
| Metric | Target | Why it matters |
|---|---|---|
| Onboarding completion (signup → first successful import) | > 70% | The import is the product's riskiest step; failure here means nothing else gets used |
| Import success rate (uploads parsed without error) | > 95% | Directly measures parser robustness |
| Reconciliation clean rate (imports with zero discrepancies) | > 90% | Measures whether the ledger can be trusted |
| Users creating ≥ 1 wallet | > 50% | Validates the product's central differentiating idea |
| 30-day return rate | > 40% | A tracker only works if people come back |
| Median time from import to a rendered report | < 2 min | Measures whether value is felt immediately |
| # | Question | Impact | Owner |
|---|---|---|---|
| Q1 | Pursue the Open Finance aggregator (Pluggy/Belvo) path for true auto-sync? Requires a CNPJ and meaningful recurring cost — likely only justified with paying users. | Would resolve the product's biggest UX weakness | Product |
| Q2 | Add investment funds (fundos) in v2? Needs a sourcing strategy for holdings that sit with the distributor rather than B3. | Expands addressable portfolio coverage | Product |
| Q3 | Add IR/tax reporting (posição em 31/12, realized gains, DARF support)? High value to Brazilian users, high accuracy bar, real liability. | Potentially the strongest retention feature | Product |
| Q4 | Model IR/IOF on fixed income to show net values (§6.5)? | Improves accuracy of Portfolio Value for fixed-income-heavy users | Product |
| Q5 | Do sector classifications need a paid data source, or is free coverage sufficient for FR-5.21? | Affects Composition completeness | Engineering |
| Q6 | Decided: yes, as F7 (M7). Wallets support target allocations and drift against them. The advice concern that kept this open is answered by requiring every threshold to originate with the user — the product supplies arithmetic against a target that was typed in, never a threshold, a ranking or a recommendation. SPEC-015’s exclusion is superseded; FR-5.24 stands unchanged. | Feature scope + regulatory posture | Product |
| Q7 | Decided: launch on the free tier (~30 min). The open part is when to upgrade. FR-6.15 degrades the cadence automatically as the asset universe grows, so the trigger is a business call: pay ~R$100/mo (~15 min, 150k requests, 10 tickers/call) once degradation is hurting the experience, or once dividend data is needed (Q8). Architecture makes it a config change (FR-6.10). | Running cost vs. freshness and asset-count headroom | Product |
| Q8 | Where do announced-but-unpaid earnings come from? The free quote tier excludes dividend data and B3's Eventos Provisionados API is B2B-only, so FR-5.19 ships degraded. Options: upgrade the provider plan, scrape RI announcements, or drop the forward-looking view from v1. | FR-5.19 completeness — the "expected income" view Marina wants | Product |
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | Manual import friction kills retention. The core UX weakness of the v1 sourcing decision. | High | High | Guided onboarding specified as F10 / SPEC-020 and scheduled M8 ahead of further feature work, with the >70% metric as its exit criterion; reminders, staleness indicators, automatic market-data refresh so value stays fresh between imports; Q1 as the structural fix |
| R2 | B3 changes its export format, breaking parsers. | Medium | High | Structure-based detection (FR-2.5/2.6) rather than fixed positions; unmapped data surfaced not dropped (FR-2.19); fast parser-fix path |
| R3 | Calculation disputes. User's broker shows a different number and trust collapses. | Medium | High | Conventions documented in-product (§6); every figure traceable to transactions (FR-3.4); explain TWR vs XIRR inline (FR-5.7) |
| R4 | Fixed-income accrual diverges from the issuer's actual figure. | Medium | Medium | Values explicitly labelled estimated (CR-1); gross-of-tax stated plainly; reconciliation against the B3 Posição snapshot |
| R5 | Free quote tier is outgrown or withdrawn. The 15k requests/month, 1-ticker-per-call budget caps the distinct-asset universe at roughly 51 at a 30-minute cadence — low enough that growth will reach it. | High | Medium | Quotes shared across tenants so the ceiling counts distinct assets, not users (FR-6.8); automatic cadence degradation before quota exhaustion (FR-6.15); stale marking rather than silent staleness (FR-6.16); provider interface makes a paid upgrade a config change (FR-6.10) |
| R6 | Cross-tenant data leak — the most serious failure mode this product has. | Low | Severe | RLS as enforcement floor (FR-8.1), non-bypassing role (FR-8.2), isolation tests on every entity (FR-8.4) |
| R7 | Perceived as investment advice, attracting regulatory attention. | Low | High | No recommendations; concentration flags explicitly informational (FR-5.24); clear disclaimers |
Every requirement from the original brief, and where it is met.
| Original requirement | Where addressed | Notes |
|---|---|---|
| Sync investment data from B3 daily or when manually triggered | §4, F2, F6 |
Modified. B3's APIs are B2B-only (§4.1). Delivered as user-initiated .xlsx import for custody data + automatic market-data sync (delayed intraday quotes, daily indexes). Aggregator path designed for (FR-4.5), open as Q1. |
| Transactions history | F3.1 (FR-3.1–3.5) | Full history with filters, search, provenance, export |
| Manage transactions | F3.2 (FR-3.6–3.13) | Create, edit, delete, bulk ops, with recalculation |
| Create wallets grouping investments by purpose | F4 (FR-4.6–4.25) | Assignment defaults to the whole position and auto-increments on buys; quantity splits available but never the default; every report runs at wallet scope |
| Report: Performance, with/without earnings, vs indexes | F5.1, §6.1, §6.2, §6.6 | TWR + XIRR; vs CDI, IPCA, IBOV; "% do CDI" idiom |
| Report: Patrimônio | F5.2 | Named Portfolio Value in English (rationale in F5.2); "Patrimônio" in the pt-BR UI. Value over time, decomposed into contributions / price / earnings |
| Report: Earnings | F5.3 | By month, asset, type; yield on cost; upcoming income |
| Report: Composition | F5.4 | By class, asset, sector, institution, wallet; drift over time |
| Balance a wallet across its assets by proportion | F7 (FR-7.1–7.24) | Equal-weight or manual targets as a share of market value, forced to total 100%; drift shown in percentage points, R$ and share count in both directions. Fixed income excluded and the targeted share stated on screen (FR-7.10). No wording recommends an action (FR-7.19) |
| Watch assets for buy/sell opportunities | F8 (FR-9.1–9.27) | User-set lower and upper bounds, each carrying a user-chosen state, so the same threshold expresses averaging down or a stop-loss. Green/yellow/blue with text labels; email on state change with a configurable cooldown. Held assets only, so the quote budget is untouched (FR-9.11) |
| Goals for a wallet — growth and earnings | F9 (FR-10.1–10.24) | Growth burn-up against a flat goal line, cumulative and undated; earnings goal charted one calendar year at a time with a year-to-date average. Promotes F4's descriptive wallet goal to a measured one (FR-10.4) |
| Run the application locally for personal use instead of releasing it | §8.8 (FR-8.51–8.74) | Modified framing. Requested as "local as staging"; specified as personal production, because the instance holds the only copy of manual entries, wallets and allocations. Mandatory versus skipped infrastructure enumerated in SPEC-021; no hosting cost; backups local and encrypted |
| Organize report data by group — asset type or wallet | F5.0 (FR-5.25–5.35) | Scope and grouping are independent controls shared by all four reports. Grouping options: asset class, wallet, individual asset, sector, institution. Totals are invariant across groupings (FR-5.31) |
| Wallet allocation defaults to the full position and auto-increments on purchase | F4.1–F4.2 (FR-4.8, FR-4.15–4.20) | One-click assignment takes the whole position; later buys flow into the same wallet with no user action, reported in the import summary. Auto-increment applies only to assets held in a single wallet — a purchase of a split asset is never distributed by inference (FR-4.16) and waits in "Needs attention" |
| Prices should reflect intraday movement, not only end-of-day | F6.1 (FR-6.1–6.3, FR-6.12, FR-6.15) | Launches on the most frequent free option: ~30-min delayed intraday refresh during the session; official close is authoritative for history. Real-time declined — it needs a licensed B3 distributor agreement with a per-end-user fee |
| Fetch quotes only while the market is open; only for held assets; fetch-and-store on demand otherwise | F6.3 (FR-6.17–6.24), FR-6.1, FR-6.6 | Database is the system of record; the provider is consulted only on a miss or a stale-during-session entry. Polling set derived from live positions and self-maintaining. On-demand lookups persisted and drawn from a reserved budget share |
| Login with Google | F1 | Sole auth method — also a deliberate security decision |
| User must see only its own investments | §8.1 (FR-8.1–8.5) | PostgreSQL RLS as enforcement floor, not just query filters |
| User PII should follow security rules defined at LGPD | §8.2 (FR-8.6–8.22) | Minimization (notably CPF never stored), legal bases, Art. 18 rights, retention, incident response |
| System should be configurable as much as possible | §8.7 (FR-8.41–8.50) | Operational parameters are runtime config with defined resolution order, startup validation and audit. Registry of 18 initial keys. Explicit boundary: calculation conventions, security controls and monetary precision stay fixed — see FR-8.48–8.50 |
| Redesign the interface: usability, functionality, a design system and a colour palette, light and dark modes, and one standard way to organise pages | F11 (FR-12.1–12.36) | Owner-approved prototype first (SPEC-022 DL-022-01). Five destinations split current state (Portfólio) from performance over time (Relatórios), modelled on the owner's reference tool. Setup moves to Configurações and personal settings to an account menu with sign-out. A navy brand in first-class light and dark themes. Tabs and paginated tables replace long single pages. Money and chart values can be masked. Visual implementation is amended into docs/guidelines/DESIGN.md
|
| Term | Meaning |
|---|---|
| B3 | Brasil, Bolsa, Balcão — the Brazilian stock exchange and central depository |
| Área do Investidor | B3's investor portal (investidor.b3.com.br) where custody data is viewed and exported |
| Patrimônio | Total value of accumulated investments. Rendered in English as Portfolio Value — not "Net Worth", which means assets minus liabilities (see F5.2) |
| Proventos | Earnings distributed by an asset — dividends, JCP, rendimentos |
| JCP | Juros sobre Capital Próprio — interest on equity, a Brazilian distribution form taxed differently from dividends |
| Rendimento | Monthly income distribution, typically from FIIs |
| Preço médio | Average acquisition cost per unit |
| Rentabilidade | Return / performance |
| CDI | Certificado de Depósito Interbancário — the de facto risk-free benchmark in Brazil |
| Tesouro Direto | The government programme for retail purchase of federal public bonds |
| CDB / LCI / LCA | Bank-issued fixed income instruments |
| FII | Fundo de Investimento Imobiliário — listed real estate fund |
| BDR | Brazilian Depositary Receipt — a foreign asset traded in BRL on B3 |
| Bonificação | Bonus share issuance |
| Grupamento | Reverse split |
| LGPD | Lei Geral de Proteção de Dados — Brazil's data protection law |
| ANPD | Autoridade Nacional de Proteção de Dados — the Brazilian data protection authority |
| TWR | Time-Weighted Return |
| XIRR | Extended Internal Rate of Return — money-weighted |
- B3 — Integrações da Área do Investidor (APIs)
- B3 Developers — APIs Área do Investidor — source of the individual-access restriction
- B3 — Área do Investidor — where users export their extracts
- Tesouro Transparente — Taxas dos Títulos Ofertados pelo Tesouro Direto
- Banco Central — Portal de Dados Abertos (SGS)
- brapi.dev — API documentation