Ctrl K
ComunidadGuías…

app/money.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Rendering an amount of money so it says which money it is, and reading one
back in: `parse` turns typed or imported text into a figure that fits its column.

`REPORTING` is a constant today and becomes `portfolio.reporting_currency`
later, so nothing outside this module hard-codes "AUD".

Where the symbol goes: headline numbers carry it, table columns carry it in the
HEADER, and a column that can hold more than one currency names the currency
per row (see `columns.py`). Why: decisions.md #80.
"""

from __future__ import annotations

from decimal import ROUND_HALF_UP, Decimal, InvalidOperation

# The currency every total is computed in. A constant until T28b makes it a
# property of the portfolio — see the module docstring.
REPORTING = "AUD"

# Only the symbol. Deliberately not a full locale table: the app formats
# thousands and decimals one way (1,234.56) and inventing per-locale grouping
# here would be a half-implementation of something T28b has to do properly.
SYMBOLS = {
    "AUD": "$", "USD": "$", "NZD": "$", "CAD": "$", "SGD": "$", "HKD": "$",
    "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", "CHF": "CHF", "INR": "₹",
}

# Currencies whose symbol is shared with another. When two amounts in different
# currencies sit together, these need their code as well or the pair is a lie:
# "$1,000.20 ($1,425.40)" says nothing about which is which.
_SHARED = {code for code, symbol in SYMBOLS.items()
           if sum(1 for s in SYMBOLS.values() if s == symbol) > 1}


def symbol(currency: str | None = None) -> str:
    """The symbol for a currency, falling back to the code itself.

    An unknown code renders as the code — `KRW 1,000.00` — which is honest.
    Guessing a symbol for a currency nobody listed would be worse than plain.
    """
    code = (currency or REPORTING).upper()
    return SYMBOLS.get(code, code)


def is_shared(currency: str | None = None) -> bool:
    """Whether this currency's symbol is used by another currency too."""
    return (currency or REPORTING).upper() in _SHARED


def plain(value) -> str:
    """The digits alone: grouped thousands, two decimals, an em dash for None.

    Used inside HTML attributes (placeholders, titles) where markup cannot go.
    """
    return f"{value:,.2f}" if value is not None else "—"


def text(value, currency: str | None = None) -> str:
    """An amount with its symbol, as plain text.

    The sign comes FIRST — `-$4.00`, not `$-4.00`. That is the whole reason a
    literal `$` in a template could not be right.
    """
    if value is None:
        return "—"
    number = Decimal(value)
    sign = "-" if number < 0 else ""
    return f"{sign}{symbol(currency)}{abs(number):,.2f}"


def limit(column) -> Decimal:
    """The first magnitude a NUMERIC column cannot hold: 10 ** (precision - scale).

    Read from the column so it cannot drift from the schema. `Numeric(20, 8)`
    holds twelve digits before the point, so 10**12 and up does not fit.
    """
    kind = column.type
    return Decimal(10) ** (kind.precision - kind.scale)


class FigureError(ValueError):
    """A figure that is not a number, or does not fit where it is going.

    A `ValueError`, so a caller that already catches that keeps working; its own
    type so a form can show this message, which says what is wrong with the
    figure, instead of the generic one for a bad date.
    """


# How much of what was typed a message repeats. Forms carry the message in a
# redirect's query string, and a pasted page of text would make that URL too
# long for the proxy, which shows a bare error instead of the message.
_SHOWN = 20


def parse(text, column, name: str | None = None) -> Decimal:
    """Text a person typed or a file carried, as a number that fits `column`.

    A bare `Decimal(text)` accepts `NaN` (whose comparisons then raise),
    `Infinity` (which saves), and `1E+999999` (finite, but SQLite stores a float
    and reads it back as Infinity while Postgres refuses it with an overflow).
    This refuses all three with a message naming the field, `name`, and the
    value. Zero, sign and meaning stay with the caller: it only says whether the
    figure is a figure and fits.

    "Fits" is judged after rounding to the column's scale, because the database
    rounds on the way in: 999999999999.999999999 is under 10**12 as typed and is
    10**12 once Postgres has stored it in a `Numeric(20, 8)`.
    """
    raw = str(text).strip()
    label = f"{name}: " if name else ""
    shown = repr(raw if len(raw) <= _SHOWN else raw[:_SHOWN] + "…")
    try:
        value = Decimal(raw)
    except InvalidOperation:
        raise FigureError(f"{label}{shown} is not a number") from None
    if not value.is_finite():
        raise FigureError(f"{label}{shown} is not a number")
    ceiling = limit(column)
    # Compared before rounding too: `quantize` itself raises on a value as
    # large as 1E+999999, so the size check has to come first.
    if abs(value) >= ceiling or abs(
            value.quantize(Decimal(1).scaleb(-column.type.scale),
                           rounding=ROUND_HALF_UP)) >= ceiling:
        raise FigureError(
            f"{label}{shown} is too large: it has to be under {ceiling:,}")
    return value