diff --git a/static/js/index.js b/static/js/index.js index 2b4c750..6f451f7 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -596,14 +596,15 @@ window.app = Vue.createApp({ return } // Each segment under the root must be a valid Beancount account - // component: start with an uppercase letter, then letters/digits/hyphens. + // component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase + // letter or digit, then letters/digits/hyphens (Unicode letters allowed). const badSegment = name.split(':').slice(1).find( - seg => !/^[A-Z][A-Za-z0-9-]*$/.test(seg) + seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg) ) if (badSegment !== undefined) { this.$q.notify({ type: 'warning', - message: `Invalid segment "${badSegment}" — each part must start with a capital letter (letters, digits, hyphens only)` + message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit` }) return } diff --git a/views_api.py b/views_api.py index d88b292..3e8647a 100644 --- a/views_api.py +++ b/views_api.py @@ -3661,6 +3661,52 @@ async def api_get_account_hierarchy( _VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:") +def _is_valid_account_component(component: str, *, is_root: bool) -> bool: + """Validate one ':'-separated account component against Beancount's grammar. + + Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*`` + (must start with an uppercase letter); a sub component matches + ``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body + chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware + str methods (libra's runtime has no beancount — Fava is a separate service), + so non-ASCII letters are accepted exactly as Beancount accepts them. + """ + if not component: + return False + first, rest = component[0], component[1:] + first_ok = (first.isalpha() and first.isupper()) or ( + not is_root and first.isdecimal() + ) + if not first_ok: + return False + return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest) + + +def _validate_account_name(name: str) -> None: + """Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account. + + The UI guards this client-side, but the endpoint is reachable directly via + API, so this is the load-bearing check before the name is written into the + ledger source. Requires a root plus at least one sub-component. + """ + parts = name.split(":") + valid = ( + len(parts) >= 2 + and _is_valid_account_component(parts[0], is_root=True) + and all(_is_valid_account_component(p, is_root=False) for p in parts[1:]) + ) + if not valid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Invalid account name {name!r}: each ':'-separated part must be " + "letters/digits/hyphens, the root starting with an uppercase " + "letter (sub-accounts may start with a digit), with at least one " + "sub-account (e.g. Expenses:Food)." + ), + ) + + @libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED) async def api_admin_add_chart_account( payload: CreateChartAccount, @@ -3685,6 +3731,8 @@ async def api_admin_add_chart_account( ), ) + _validate_account_name(payload.name) + logger.info( f"Admin {auth.user_id[:8]} adding chart account {payload.name} " f"with currencies {payload.currencies}"