Jelajahi Sumber

fix(finance): show the currency the install is configured for (issue #3123)

    The Finance page was the only surface in Bambuddy that read its currency
    from a data row rather than the `currency` setting, and it fell back to EUR
    where every other page falls back to USD. One variable drives every amount
    on that page, so the personal balance, the cost-center budgets and the whole
    transaction list were wrong together on any install not set to euros. It now
    takes the configured currency from /settings/ui-flags, which is readable by
    anyone who can see Finance -- /settings needs SETTINGS_READ, which a
    cost_centers:read_own user does not have.

    The backend was the other half. Of the four places that settle on a
    currency, three wrote a hardcoded "EUR": the wallet the API mints on demand,
    the wallet a print charge mints when none exists, and the balance returned
    for a user with no wallet row at all. All four now go through one resolver,
    which lives beside the rest of the balance logic.

    The wallet's currency column is removed outright rather than merely ignored.
    An install has one currency and nothing here converts between them, so a
    per-wallet copy could only ever drift from the setting -- and a column
    nothing reads is a trap for whoever finds it next. A startup migration drops
    it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
    otherwise re-add it on an install whose finance tables predate the ORM.
    SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
    since it has a default and no reader.

    Saving settings now invalidates the ui-flags query too. Nothing did, so a
    changed currency sat behind that query's staleTime before showing up. The
    sponsor prompt's own EUR fallback is now USD, matching AppSettings.
maziggy 4 hari lalu
induk
melakukan
7a20e731b5

+ 16 - 7
backend/app/api/routes/finance.py

@@ -41,6 +41,7 @@ from backend.app.services.finance_balance import (
     calculate_personal_balance,
     is_personal_transaction,
     personal_balance_condition,
+    resolve_configured_currency,
     sync_personal_wallet_balance,
 )
 from backend.app.services.finance_budget import get_cost_center_reserved_map
@@ -251,7 +252,7 @@ async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
     if wallet:
         return wallet
 
-    wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
+    wallet = UserWallet(user_id=user_id, balance=0.0)
     db.add(wallet)
     await db.flush()
     await db.refresh(wallet)
@@ -276,24 +277,31 @@ async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> Cost
     return center
 
 
-def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
+def _to_balance_response(wallet: UserWallet, currency: str) -> WalletBalanceResponse:
+    """Serialize a wallet, reporting the install's configured currency.
+
+    The wallet row holds no currency of its own: an install has exactly one,
+    and an admin who changes it expects every balance to follow, the way the
+    rest of the app does (#3123).
+    """
     return WalletBalanceResponse(
         user_id=wallet.user_id,
         balance=wallet.balance,
-        currency=wallet.currency,
+        currency=currency,
         updated_at=wallet.updated_at,
     )
 
 
 async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
     """Return a balance without creating a wallet row from a GET request."""
+    currency = await resolve_configured_currency(db)
     wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
     if wallet is not None:
-        return _to_balance_response(wallet)
+        return _to_balance_response(wallet, currency)
     return WalletBalanceResponse(
         user_id=user_id,
         balance=await calculate_personal_balance(db, user_id),
-        currency="EUR",
+        currency=currency,
         updated_at=None,
     )
 
@@ -386,15 +394,16 @@ async def _create_wallet_adjustment(
     await db.refresh(tx)
 
     # Return appropriate balance based on transaction type
+    currency = await resolve_configured_currency(db)
     if affects_personal_wallet:
         # Personal transaction: return user wallet balance
-        response_balance = _to_balance_response(wallet)
+        response_balance = _to_balance_response(wallet, currency)
     else:
         # Cost-center transaction: return cost-center balance as if it were a wallet
         response_balance = WalletBalanceResponse(
             user_id=target_user_id,
             balance=balance_after,
-            currency=wallet.currency,
+            currency=currency,
             updated_at=tx.created_at,
         )
 

+ 33 - 2
backend/app/core/database.py

@@ -1297,7 +1297,6 @@ async def _migrate_create_finance_tables(conn) -> None:
                 id INTEGER PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
-                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             """,
@@ -1366,7 +1365,6 @@ async def _migrate_create_finance_tables(conn) -> None:
                 id SERIAL PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
-                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             """,
@@ -1471,6 +1469,35 @@ async def _migrate_finance_money_to_numeric(conn) -> None:
             )
 
 
+async def _migrate_drop_wallet_currency(conn) -> None:
+    """Remove ``user_wallets.currency`` (#3123).
+
+    An install has one currency, held in the ``currency`` app setting. The
+    column stored whatever was configured when a wallet row happened to be
+    created -- and three of its four writers hardcoded "EUR" -- so it could
+    only ever disagree with the setting. Everything reads the setting now, so
+    the column would otherwise sit here unread -- a trap for the next person
+    who finds it and assumes it means something.
+
+    Skipped on SQLite older than 3.35, which has no DROP COLUMN. Leaving the
+    column in place there costs nothing: no code references it and it carries
+    a DEFAULT, so inserts that omit it still succeed.
+    """
+    if is_sqlite():
+        import sqlite3
+
+        if sqlite3.sqlite_version_info < (3, 35, 0):
+            logger.info(
+                "SQLite %s has no ALTER TABLE DROP COLUMN; leaving the unused user_wallets.currency in place",
+                sqlite3.sqlite_version,
+            )
+            return
+        await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN currency")
+        return
+
+    await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency")
+
+
 async def _migrate_add_print_archive_cost_center(conn) -> None:
     """Add the nullable cost-center link missing from pre-billing archives."""
     await _safe_execute(
@@ -1868,6 +1895,10 @@ async def run_migrations(conn):
     await _migrate_finance_money_to_numeric(conn)
     await _migrate_create_finance_indexes(conn)
 
+    # Runs after the CREATE TABLE above, which used to re-add the column on an
+    # install whose finance tables predate the ORM (#3123).
+    await _migrate_drop_wallet_currency(conn)
+
     # Migration: Add missing-spool-assignment print-start notification toggle
     try:
         async with conn.begin_nested():

+ 7 - 1
backend/app/models/finance.py

@@ -38,6 +38,13 @@ class UserWallet(Base):
     """Per-user wallet balance.
 
     Balance updates are driven by wallet transactions.
+
+    No currency column: an install has exactly one currency, held in the
+    ``currency`` app setting, and nothing here converts between currencies. The
+    column that used to sit on this table recorded whatever was configured when
+    the row happened to be created, three of the four writers hardcoded "EUR"
+    into it, and the Finance page rendered what it found -- so an install set
+    to AUD reported euros (#3123).
     """
 
     __tablename__ = "user_wallets"
@@ -45,7 +52,6 @@ class UserWallet(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
     balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
-    currency: Mapped[str] = mapped_column(String(3), default="EUR")
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     user: Mapped[User] = relationship()

+ 18 - 0
backend/app/services/finance_balance.py

@@ -4,6 +4,24 @@ from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+from backend.app.models.settings import Settings as AppSettingModel
+from backend.app.schemas.settings import AppSettings as AppSettingsSchema
+
+
+async def resolve_configured_currency(db: AsyncSession) -> str:
+    """The currency this install reports balances in.
+
+    Every other surface in Bambuddy renders the ``currency`` app setting.
+    Finance used to answer from ``user_wallets.currency``, which three of its
+    four writers filled with a hardcoded "EUR", so an install configured for
+    AUD reported a euro balance (#3123). That column is gone; this is the one
+    place that answers the question.
+    """
+    result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
+    setting = result.scalar_one_or_none()
+    if setting and setting.value:
+        return setting.value
+    return AppSettingsSchema().currency
 
 
 def transaction_affects_personal_balance(

+ 1 - 1
backend/app/services/finance_billing.py

@@ -220,7 +220,7 @@ async def apply_print_charge_for_archive(
 
         wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
         if wallet is None:
-            wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
+            wallet = UserWallet(user_id=actual_user_id, balance=0.0)
             db.add(wallet)
             await db.flush()
             logger.info("Created new wallet for user ID %s.", actual_user_id)

+ 1 - 8
backend/app/services/finance_defaults.py

@@ -2,9 +2,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
-from backend.app.models.settings import Settings as AppSettingModel
 from backend.app.models.user import User
-from backend.app.schemas.settings import AppSettings as AppSettingsSchema
 
 
 async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
@@ -16,12 +14,7 @@ async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
 
     wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
     if wallet is None:
-        # Respect admin-configured currency if present, otherwise fall back to app default
-        default_currency = AppSettingsSchema().currency
-        result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
-        setting = result.scalar_one_or_none()
-        currency = setting.value if setting and setting.value else default_currency
-        db.add(UserWallet(user_id=user.id, balance=0.0, currency=currency))
+        db.add(UserWallet(user_id=user.id, balance=0.0))
         changed = True
 
     private_center = (

+ 77 - 0
backend/tests/integration/test_finance_api.py

@@ -1191,3 +1191,80 @@ class TestFinanceUserDefaults:
 
         wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
         assert wallet is not None
+
+
+class TestFinanceCurrency(TestFinanceAPI):
+    """#3123: every balance is reported in the install's configured currency.
+
+    Wallets used to carry a currency of their own, which three of its four
+    writers filled with a hardcoded "EUR" and the Finance page rendered as it
+    found it -- so an install set to AUD showed a euro balance. The column is
+    gone; these tests pin what replaced it.
+    """
+
+    @pytest.fixture
+    async def aud_install(self, db_session):
+        existing = await db_session.scalar(select(Settings).where(Settings.key == "currency"))
+        if existing is None:
+            db_session.add(Settings(key="currency", value="AUD"))
+        else:
+            existing.value = "AUD"
+        await db_session.commit()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_balance_reports_the_configured_currency_without_a_wallet(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+        admin_user,
+        aud_install,
+    ):
+        """The read-only path used to answer a flat "EUR" when no wallet row existed."""
+        assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == admin_user.id)) is None
+
+        response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
+
+        assert response.status_code == 200
+        assert response.json()["currency"] == "AUD"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_existing_wallet_is_reported_in_the_configured_currency(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+        admin_user,
+        aud_install,
+    ):
+        """The reporter's case: a wallet created back when the install said EUR."""
+        db_session.add(UserWallet(user_id=admin_user.id, balance=12.34))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
+
+        assert response.status_code == 200
+        assert response.json()["balance"] == 12.34
+        assert response.json()["currency"] == "AUD"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_adjustment_answers_in_the_configured_currency(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+        admin_user,
+        aud_install,
+    ):
+        """_get_or_create_wallet is the path that used to write EUR into the database."""
+        response = await async_client.post(
+            f"/api/v1/finance/users/{admin_user.id}/deposit",
+            json={"amount": 5.0, "description": "currency check"},
+            headers=auth_headers,
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["balance"]["currency"] == "AUD"

+ 1 - 1
backend/tests/integration/test_scheduler_budget_reservation.py

@@ -202,7 +202,7 @@ async def test_cost_center_without_budget_is_unlimited_regardless_of_wallet_bala
         center = await db.get(CostCenter, billing_dispatch_case.ids.cost_center_id)
         center.monthly_budget = None
         center.total_budget = None
-        wallet = UserWallet(user_id=user.id, balance=-100.0, currency="EUR")
+        wallet = UserWallet(user_id=user.id, balance=-100.0)
         db.add(wallet)
         await db.commit()
 

+ 39 - 0
backend/tests/unit/services/test_finance_service_balance.py

@@ -0,0 +1,39 @@
+"""Unit tests for how a balance is reported."""
+
+import pytest
+
+from backend.app.models.settings import Settings
+from backend.app.schemas.settings import AppSettings as AppSettingsSchema
+from backend.app.services.finance_balance import resolve_configured_currency
+
+
+class TestConfiguredCurrency:
+    """#3123: finance is not allowed its own idea of the currency.
+
+    Every other surface renders the ``currency`` app setting. Finance answered
+    from a per-wallet column instead, which three of its four writers filled
+    with a hardcoded "EUR", so an install configured for AUD reported euros.
+    The column is gone and this function is what replaced it.
+    """
+
+    @pytest.mark.asyncio
+    async def test_reads_the_configured_currency(self, db_session):
+        db_session.add(Settings(key="currency", value="AUD"))
+        await db_session.commit()
+
+        assert await resolve_configured_currency(db_session) == "AUD"
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_the_app_default_when_unset(self, db_session):
+        # The app default is USD, which is also what every frontend fallback
+        # uses. The old finance fallback said EUR, which is how an install
+        # that never touched the setting still showed euros.
+        assert await resolve_configured_currency(db_session) == AppSettingsSchema().currency
+        assert await resolve_configured_currency(db_session) == "USD"
+
+    @pytest.mark.asyncio
+    async def test_an_empty_setting_row_is_not_a_currency(self, db_session):
+        db_session.add(Settings(key="currency", value=""))
+        await db_session.commit()
+
+        assert await resolve_configured_currency(db_session) == "USD"

+ 0 - 1
backend/tests/unit/services/test_finance_service_defaults.py

@@ -27,7 +27,6 @@ class TestFinanceDefaults:
         wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
         assert wallet is not None
         assert wallet.balance == 0.0
-        assert wallet.currency == "USD"
 
         center = await db_session.scalar(
             select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))

+ 196 - 0
backend/tests/unit/test_wallet_currency_drop_migration.py

@@ -0,0 +1,196 @@
+"""Migration coverage for removing user_wallets.currency (#3123).
+
+An install has one currency, held in the ``currency`` app setting. The column
+recorded whatever was configured when a wallet row happened to be created, and
+three of its four writers hardcoded "EUR", so it could only ever disagree with
+the setting. Dropping it from the model alone would leave every existing
+database carrying a column nothing reads.
+"""
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import Base, run_migrations
+
+
+def _register_all_models():
+    """run_migrations touches many tables; the whole schema has to exist.
+
+    Same list as test_vp_mode_rename_migration.py -- importing only the finance
+    models leaves run_migrations ALTERing tables create_all never built.
+    """
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        finance,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """run_migrations branches on the global dialect, not on the connection.
+
+    settings.database_url may point at Postgres in a dev config, which would
+    run the Postgres branch against the SQLite engine below. Same fixture as
+    test_billing_run_id_migration.py.
+    """
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    # database.py imported is_sqlite at module load time — patch there too.
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+async def _wallet_columns(conn) -> set[str]:
+    return {row[1] for row in (await conn.execute(text("PRAGMA table_info(user_wallets)"))).all()}
+
+
+@pytest.mark.asyncio
+async def test_an_existing_currency_column_is_dropped(tmp_path):
+    """The upgrade path: a database that predates the fix."""
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-currency.db'}")
+    try:
+        async with engine.begin() as conn:
+            _register_all_models()
+            await conn.run_sync(Base.metadata.create_all)
+            # Recreate the pre-#3123 shape, balance and all, then prove the
+            # migration takes the column without taking the row with it.
+            await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
+            await conn.execute(text("INSERT INTO user_wallets (user_id, balance, currency) VALUES (7, 12.34, 'EUR')"))
+            assert "currency" in await _wallet_columns(conn)
+
+            await run_migrations(conn)
+
+            assert "currency" not in await _wallet_columns(conn)
+            row = (await conn.execute(text("SELECT user_id, balance FROM user_wallets"))).all()
+            assert row == [(7, 12.34)]
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_the_legacy_create_table_does_not_declare_it(tmp_path):
+    """_migrate_create_finance_tables carries its own raw CREATE TABLE.
+
+    It exists for installs whose finance tables predate the ORM models, and it
+    declared the column independently of the model. Exercised on its own here,
+    without the drop migration that would otherwise mask it.
+    """
+    from backend.app.core.database import _migrate_create_finance_tables
+
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-legacy.db'}")
+    try:
+        async with engine.begin() as conn:
+            _register_all_models()
+            await conn.run_sync(Base.metadata.create_all)
+            await conn.execute(text("DROP TABLE user_wallets"))
+
+            await _migrate_create_finance_tables(conn)
+
+            assert await _wallet_columns(conn), "the legacy path must still create the table"
+            assert "currency" not in await _wallet_columns(conn)
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_the_migration_is_idempotent(tmp_path):
+    """Startup runs it every time; the second pass must not error."""
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wallet-twice.db'}")
+    try:
+        async with engine.begin() as conn:
+            _register_all_models()
+            await conn.run_sync(Base.metadata.create_all)
+            await conn.execute(text("ALTER TABLE user_wallets ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"))
+            await run_migrations(conn)
+            await run_migrations(conn)
+
+            assert "currency" not in await _wallet_columns(conn)
+    finally:
+        await engine.dispose()
+
+
+class _AsyncCtxStub:
+    """Async context manager that does nothing — for ``begin_nested()``."""
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *_exc):
+        return False
+
+
+async def _capture_drop_sql(is_sqlite_value: bool) -> list[str]:
+    """Every DROP COLUMN statement run_migrations would issue on this dialect.
+
+    The project's suite runs on SQLite, so the PostgreSQL branch is otherwise
+    dead code in CI. Same capture pattern as test_oidc_icon_migration_pg.py.
+    """
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    from backend.app.core import database as db_module
+
+    executed: list[str] = []
+
+    async def fake_safe_execute(_conn, sql: str) -> None:
+        executed.append(sql)
+
+    fake_conn = MagicMock()
+    fake_conn.begin_nested = lambda: _AsyncCtxStub()
+    fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
+
+    with (
+        patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
+        patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
+        patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
+        patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
+    ):
+        await db_module.run_migrations(fake_conn)
+
+    return [sql for sql in executed if "user_wallets" in sql and "DROP COLUMN" in sql]
+
+
+@pytest.mark.asyncio
+async def test_postgres_drops_it_conditionally():
+    """PostgreSQL takes IF EXISTS, which SQLite's DROP COLUMN does not accept."""
+    statements = await _capture_drop_sql(is_sqlite_value=False)
+    assert statements == ["ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency"]
+
+
+@pytest.mark.asyncio
+async def test_sqlite_drops_it_plainly():
+    """Companion to the PostgreSQL case, so the dialect switch cannot invert."""
+    statements = await _capture_drop_sql(is_sqlite_value=True)
+    assert statements == ["ALTER TABLE user_wallets DROP COLUMN currency"]

+ 58 - 0
frontend/src/__tests__/pages/FinancePageCurrency.test.tsx

@@ -0,0 +1,58 @@
+/**
+ * #3123: the Finance page renders the install's currency.
+ *
+ * It was the only surface in the app that took its currency from a data row
+ * (`wallet.currency`, a column since removed) instead of the `currency`
+ * setting, and it fell back to EUR where every other page falls back to USD.
+ * An install configured for AUD showed a euro balance, euro cost-center
+ * budgets and euro transactions -- they all read the same variable. The
+ * balance response still carries a currency; the point here is that the page
+ * does not depend on it, so a failed or ungranted wallet fetch cannot change
+ * the symbol.
+ *
+ * The currency comes from /settings/ui-flags rather than /settings, which
+ * needs SETTINGS_READ that a cost_centers:read_own user does not have (#3023).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FinancePage } from '../../pages/FinancePage';
+import { server } from '../mocks/server';
+
+function mockFinance({ uiFlagCurrency, walletCurrency }: { uiFlagCurrency?: string; walletCurrency: string }) {
+  server.use(
+    http.get('*/api/v1/settings/ui-flags', () =>
+      HttpResponse.json({ billing_enabled: true, ...(uiFlagCurrency ? { currency: uiFlagCurrency } : {}) }),
+    ),
+    http.get('*/api/v1/finance/me/balance', () =>
+      HttpResponse.json({ user_id: 1, balance: 12.34, currency: walletCurrency, updated_at: null }),
+    ),
+    http.get('*/api/v1/finance/me/transactions', () => HttpResponse.json({ items: [], total: 0 })),
+    http.get('*/api/v1/finance/cost-centers/mine', () => HttpResponse.json([])),
+    http.get('*/api/v1/finance/cost-centers', () => HttpResponse.json([])),
+    http.get('*/api/v1/users/slim', () => HttpResponse.json([])),
+  );
+}
+
+describe('FinancePage currency', () => {
+  it('renders the configured currency even when the wallet row says otherwise', async () => {
+    // The reporter's case: currency set to AUD, wallet minted as EUR.
+    mockFinance({ uiFlagCurrency: 'AUD', walletCurrency: 'EUR' });
+
+    render(<FinancePage />);
+
+    await waitFor(() => expect(screen.getByText('$12.34')).toBeInTheDocument());
+    expect(screen.queryByText('€12.34')).not.toBeInTheDocument();
+  });
+
+  it('falls back to USD, not EUR, when the install exposes no currency', async () => {
+    // Matches AppSettings.currency's default and every other page's fallback.
+    mockFinance({ walletCurrency: 'EUR' });
+
+    render(<FinancePage />);
+
+    await waitFor(() => expect(screen.getByText('$12.34')).toBeInTheDocument());
+  });
+});

+ 1 - 1
frontend/src/components/Layout.tsx

@@ -146,7 +146,7 @@ export function Layout() {
   });
 
   // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
-  useSponsorPrompt(uiFlags?.currency ?? 'EUR');
+  useSponsorPrompt(uiFlags?.currency ?? 'USD');
 
   // Unknown-spool prompt — surfaces a confirmation modal when the AMS reports a
   // tag with no inventory match (only when `auto_add_unknown_rfid` is off).

+ 10 - 1
frontend/src/pages/FinancePage.tsx

@@ -186,6 +186,15 @@ export function FinancePage() {
     enabled: canReadOwn,
   });
 
+  // Currency comes from the install setting, like every other page. Read from
+  // /settings/ui-flags rather than /settings, which needs SETTINGS_READ that a
+  // cost_centers:read_own user does not have (#3023). The Layout already holds
+  // this query key, so this is served from cache.
+  const { data: uiFlags } = useQuery({
+    queryKey: ['ui-flags'],
+    queryFn: api.getUiFlags,
+  });
+
   const { data: transactionsResponse, isLoading: personalTxLoading } = useQuery({
     queryKey: ['finance', 'me', 'transactions', txLimit, txOffset],
     queryFn: () => api.getMyTransactions(txLimit, txOffset),
@@ -583,7 +592,7 @@ export function FinancePage() {
     removeMemberMutation.mutate({ costCenterId: selectedManageCenterId, userId });
   };
 
-  const currency = wallet?.currency || 'EUR';
+  const currency = uiFlags?.currency || 'USD';
   const currencySymbol = getCurrencySymbol(currency);
 
   const sortedUsers = useMemo(() => {

+ 4 - 0
frontend/src/pages/SettingsPage.tsx

@@ -1081,6 +1081,10 @@ export function SettingsPage() {
       // re-compare the updated `settings` with current `localSettings` and
       // debounce-save any remaining differences.
       queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
+      // /settings/ui-flags serves currency and the sidebar gates to users who
+      // cannot read /settings. Nothing invalidated it, so a currency change
+      // sat behind that query's own staleTime instead of showing up (#3123).
+      queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
       showToast(t('settings.toast.settingsSaved'), 'success');
     },
     onError: (error: Error) => {

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-Z-jaTirY.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DNh2afIf.js"></script>
+    <script type="module" crossorigin src="/assets/index-Z-jaTirY.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini