Переглянути джерело

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 5 днів тому
батько
коміт
4a85e033c0

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **The Finance page showed euros whatever currency the install was set to (#3123)** — it was the only surface in Bambuddy that read its currency from a data row (`user_wallets.currency`) 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 all wrong together. The backend was the other half of it: 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. The page now takes the configured currency from `/settings/ui-flags`, which is readable by anyone who can see Finance, and the API reports the setting. The `user_wallets.currency` column is gone altogether — an install has one currency and nothing here converts between them, so a per-wallet copy could only ever drift from the setting; a startup migration drops it from existing databases on both SQLite and PostgreSQL. Saving settings now also refreshes those flags, which nothing invalidated before.
 - **On Windows and macOS the Virtual Printer's bind dropdown offered one entry per network adapter, not one per IP address (#3121, reported by @SJB-OLVG)** — each enabled virtual printer needs its own address, and the documented way to get several is to add secondary IPs to the adapter you already have. Linux reads those back through `ip -j addr show`, which reports every address; Windows and macOS have no `ip` command and fell through to an enumeration that stopped at the first IPv4 of each adapter. A Windows host with three addresses on one NIC therefore offered exactly one bind target, and creating a second virtual printer could only fail with "Bind IP ... is already in use". Every address is now listed on those platforms too, with the ones after an adapter's first marked as aliases exactly as on Linux. A Linux host without `iproute2` installed gains the same thing, having previously been limited to one address per interface for the same reason. The wiki gained the Windows instructions for adding those addresses, which it had for every other platform but that one, and the creation steps it describes now match the dialog — bind interface, model and access code are set on the printer's card after **Create**, not in the dialog itself.
 - **A macOS native install could silently lose all access to the printer after a system or Homebrew update (#3114, reported by @TheeBobbyDonuts)** — macOS grants Local Network permission to a code signature, and judges a launchd-spawned process on its own rather than letting it inherit the grant of the Terminal that started it. Homebrew ships Python unsigned on Intel, so there was no identity for that grant to attach to: every connection to the printer was dropped with no error Bambuddy could log and no permission prompt to answer. The web interface worked, the printer showed as disconnected, all three port checks reported unreachable while the subnet check passed, and the log recorded no MQTT attempt at all — not a failure, just silence. Toggling the Python entry in Privacy & Security did nothing, because the entry referred to an identity that no longer resolved. `install.sh` now ad-hoc signs the interpreter during a macOS install, and `install/update_macos.sh` re-checks it on every update, since `brew upgrade python` lays down a fresh unsigned binary under a new versioned path. Both sign only what is currently unsigned: on Apple Silicon the linker already ad-hoc signs every binary, and re-signing one would rotate the hash its identity is built from and revoke a working grant on each update. The connection diagnostic also gained a **macOS Local Network permission** check, which reports the missing signature where there is one, and otherwise points at System Settings — where an Apple Silicon machine, whose identity changes with every Python upgrade, can re-grant it.
 - **The Library bulk add-to-queue answered 200 for a call that queued nothing, and queued items nothing could dispatch (#3112, reported by @toxxicpickles)** — `POST /api/v1/library/files/add-to-queue` reported every per-file rejection in an `errors` array and returned `200` regardless, so a caller that checked the status code saw a successful request, no visible failure, and no queue item. That is now a `400` when nothing at all was added, with the same per-file reasons in the body; a call that created some items still succeeds, because it did. The items it created were also aimed at nothing: the route always wrote them with no printer and no target model, and the scheduler dispatches on one or the other, so those rows could never be picked up by anything and sat in Unassigned until someone opened each one and assigned it by hand. The request now takes an optional `printer_id` or `target_model` for the batch, and with neither it aims each file at the model its own G-code declares — when a printer of that model is active, since aiming a job at hardware nobody owns would only look like progress. Three gates `POST /api/v1/queue/` has applied for a while now apply here too, because an item reaching the scheduler through this route has to be as printable as one reaching it through that one: the cross-model check that stops a file sliced for one printer being dispatched to another, the filename check that would otherwise surface as a failed upload hours later, and the filament requirements the scheduler matches before handing a model-based item to hardware. This endpoint has no caller inside Bambuddy — the Library's own Print action goes through the queue API with a printer already chosen — which is how it came to drift this far from it.

+ 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) => {

Різницю між файлами не показано, бо вона завелика
+ 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>

Деякі файли не було показано, через те що забагато файлів було змінено