diff --git a/README.md b/README.md index 2d1793c..5a13532 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License -1514 Tests Passing 12 AI Agents +1520 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1514 tests across 214 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1520 tests across 215 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1514 tests across 214 test files) +# All tests (1520 tests across 215 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1514 tests, 214 files) +pnpm test # Run all tests (1520 tests, 215 files) pnpm lint # ESLint ``` diff --git a/apps/dashboard/src/components/front-desk/GuestDetailsModal.tsx b/apps/dashboard/src/components/front-desk/GuestDetailsModal.tsx index 0076110..cee3433 100644 --- a/apps/dashboard/src/components/front-desk/GuestDetailsModal.tsx +++ b/apps/dashboard/src/components/front-desk/GuestDetailsModal.tsx @@ -1,5 +1,4 @@ import { Link } from 'react-router-dom'; -import { formatMoney } from '../../lib/money'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { ArrowRightLeft, StickyNote, LogIn, LogOut } from 'lucide-react'; @@ -7,6 +6,7 @@ import { api } from '../../lib/api'; import Modal from '../ui/Modal'; import StatusBadge from '../ui/StatusBadge'; import ReservationPartyPanel from '../reservations/ReservationPartyPanel'; +import { formatMoney } from '../../lib/money'; export interface GuestDetailsReservation { id: string; @@ -72,6 +72,7 @@ export default function GuestDetailsModal({ open, reservation, propertyId, + currencyCode, doorPin, onClose, onNotes, @@ -82,6 +83,9 @@ export default function GuestDetailsModal({ }: { open: boolean; reservation: GuestDetailsReservation | null; + /** The property's currency. Null renders unsymbolled rather than guessing — + * this modal shows a real folio balance and must not invent a currency. */ + currencyCode: string | null; propertyId: string; doorPin?: string | null; onClose: () => void; @@ -230,7 +234,7 @@ export default function GuestDetailsModal({ {t('frontDesk.accountSummary')}

- {t('frontDesk.balance')}: {formatMoney(balance)} + {t('frontDesk.balance')}: {formatMoney(balance, currencyCode)}

@@ -248,7 +252,7 @@ export default function GuestDetailsModal({
  • {c.description || '—'} - {formatMoney(c.amount)} + {formatMoney(c.amount, currencyCode)}
  • ))} @@ -269,7 +273,7 @@ export default function GuestDetailsModal({
  • {p.method || '—'} - {formatMoney(p.amount)} + {formatMoney(p.amount, currencyCode)}
  • ))} diff --git a/apps/dashboard/src/context/PropertyContext.tsx b/apps/dashboard/src/context/PropertyContext.tsx index 78fda6f..fb6ff35 100644 --- a/apps/dashboard/src/context/PropertyContext.tsx +++ b/apps/dashboard/src/context/PropertyContext.tsx @@ -2,7 +2,6 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from ' import { useSearchParams } from 'react-router-dom'; import { api, setPropertyId as setApiPropertyId } from '../lib/api'; import { joinPropertyRoom, leavePropertyRoom } from '../lib/socket'; -import { DEFAULT_CURRENCY, setActiveCurrency } from '../lib/money'; import { PORTFOLIO_MODE_ID, type PropertySummary, @@ -11,8 +10,9 @@ import { interface PropertyContextValue { propertyId: string | null; - /** Active property's ISO 4217 code; falls back to USD before properties load. */ - currencyCode: string; + /** null when unknown — portfolio mode, or a property with no code. Never a + * substituted default: see lib/money.ts. */ + currencyCode: string | null; setPropertyId: (id: string) => void; isPortfolioMode: boolean; properties: PropertySummary[]; @@ -23,7 +23,7 @@ interface PropertyContextValue { const PropertyContext = createContext({ propertyId: null, - currencyCode: DEFAULT_CURRENCY, + currencyCode: null, setPropertyId: () => {}, isPortfolioMode: false, properties: [], @@ -44,11 +44,14 @@ export function PropertyProvider({ children }: { children: ReactNode }) { const isPortfolioMode = propertyId === PORTFOLIO_MODE_ID; // Portfolio mode spans properties that may not share a currency, so it has no - // single answer — fall back rather than assert one property's code over others. + // single answer — and NULL is that answer. Substituting one property's code, + // or a house default, prints a currency nobody chose next to real money. + // formatMoney renders an unsymbolled number when the code is null, which is + // the honest rendering of "we do not know". const currencyCode = (!isPortfolioMode && properties.find((p) => p.id === propertyId)?.currencyCode) || - DEFAULT_CURRENCY; + null; function setPropertyId(id: string) { setPropertyIdState(id); @@ -85,12 +88,6 @@ export function PropertyProvider({ children }: { children: ReactNode }) { // Bootstrap once; propertyId auto-select handled inside the effect. }, []); - // Keep the money formatter's default in step with the active property, the - // same way setApiPropertyId keeps the API client in step above. - useEffect(() => { - setActiveCurrency(currencyCode); - }, [currencyCode]); - useEffect(() => { if (isPortfolioMode) { setApiPropertyId(null); diff --git a/apps/dashboard/src/lib/api-helpers.ts b/apps/dashboard/src/lib/api-helpers.ts index 780b6b5..7c8e3f0 100644 --- a/apps/dashboard/src/lib/api-helpers.ts +++ b/apps/dashboard/src/lib/api-helpers.ts @@ -15,6 +15,22 @@ export function requirePropertyId(propertyId: string | null): asserts propertyId } } +/** + * Assert a currency before WRITING money. + * + * Same idiom as requirePropertyId, and for the same reason. The dashboard used + * to substitute a house default when the active property's code was unknown, + * which meant a deposit or an AR ledger could be created in USD against a + * property trading in yen — a wrong record, silently, with no error anywhere. + * Refusing is the correct outcome: an unknown currency is a reason not to + * write, never a reason to pick one. + */ +export function requireCurrency(currencyCode: string | null): asserts currencyCode is string { + if (!currencyCode) { + throw new Error('No currency for this property — select a single property first'); + } +} + /** Format occupancy rate (0–1 decimal from API) as a percentage string. */ export function formatOccupancyPercent(rate: number | null | undefined): string { if (rate == null) return '—'; diff --git a/apps/dashboard/src/lib/money.spec.ts b/apps/dashboard/src/lib/money.spec.ts new file mode 100644 index 0000000..b552d17 --- /dev/null +++ b/apps/dashboard/src/lib/money.spec.ts @@ -0,0 +1,54 @@ +/** + * Money formatting, tested on the case that put a wrong number on a live folio. + * + * The formatter used to fall back to a hardcoded USD when a record carried no + * currency code, so a real ¥151,110 balance on a JPY property rendered as a + * dollar figure with two decimal places — authoritative-looking and materially + * wrong. The fix is that there is no fallback at all. + * + * These assert BEHAVIOUR rather than exact glyphs: ICU renders JPY as "¥" in + * some versions and "JP¥" in others, and a test that pins the symbol would fail + * on a runner upgrade while telling us nothing about the defect. + */ +import { describe, expect, it } from 'vitest'; + +import { formatMoney } from './money'; + +describe('formatMoney', () => { + it('renders JPY with no minor units', () => { + const out = formatMoney('151110', 'JPY', 'en-US'); + expect(out).toContain('151,110'); + expect(out).not.toContain('.'); // zero-decimal currency + }); + + it('never invents a currency when the code is missing', () => { + // The whole defect: these used to come back as dollar amounts. + for (const missing of [undefined, null, '', ' ']) { + const out = formatMoney('151110', missing, 'en-US'); + expect(out).toBe('151,110'); // grouped, unsymbolled, honest + expect(out).not.toContain('$'); + expect(out).not.toContain('USD'); + } + }); + + it('still renders minor units where the currency has them', () => { + expect(formatMoney('1234.5', 'USD', 'en-US')).toContain('1,234.50'); + }); + + it('distinguishes an absent amount from a zero balance', () => { + expect(formatMoney(null, 'JPY', 'en-US')).toBe('—'); + expect(formatMoney(undefined, 'JPY', 'en-US')).toBe('—'); + expect(formatMoney('', 'JPY', 'en-US')).toBe('—'); + expect(formatMoney('0', 'JPY', 'en-US')).toContain('0'); + expect(formatMoney('0', 'JPY', 'en-US')).not.toBe('—'); + }); + + it('degrades to a plain number for an unknown code rather than throwing', () => { + // A bad code must not take down a page that was only showing a total. + expect(formatMoney('1000', 'NOTACODE', 'en-US')).toContain('1,000'); + }); + + it('returns an em dash for a non-numeric amount', () => { + expect(formatMoney('not money', 'JPY', 'en-US')).toBe('—'); + }); +}); diff --git a/apps/dashboard/src/lib/money.ts b/apps/dashboard/src/lib/money.ts index 26cae3c..dcd2e54 100644 --- a/apps/dashboard/src/lib/money.ts +++ b/apps/dashboard/src/lib/money.ts @@ -12,27 +12,21 @@ * number of fraction digits for every ISO 4217 code, so it does the work here. */ -/** Fallback when a record carries no currency and no property is selected. */ -export const DEFAULT_CURRENCY = 'USD'; - /** - * The active property's currency, pushed here by PropertyContext. + * THERE IS NO DEFAULT CURRENCY, deliberately. + * + * This file used to export DEFAULT_CURRENCY = 'USD' and fall back to it + * whenever a record carried no code — which reintroduced, one line below the + * comment explaining why it is wrong, exactly the bug it was written to fix. + * A property trading in JPY renders a real ¥151,110 balance as a dollar figure + * with two decimal places when the code is missing: a materially wrong number, + * on a live ledger, in whichever party's disfavour the reader happens to guess. * - * Same pattern the API client already uses for propertyId (`setPropertyId` in - * lib/api.ts): a module-level value the context keeps current. It means a money - * render does not need the currency threaded into every component that happens - * to display an amount — dozens of call sites across the dashboard, many inside - * helpers that cannot call a hook at all. + * A symbol we invented is worse than no symbol at all, because it looks + * authoritative. So an absent currency now renders the number PLAINLY — grouped + * but unsymbolled — which is honest about what we know and visibly odd enough + * that someone asks, rather than quietly wrong. */ -let activeCurrency = DEFAULT_CURRENCY; - -export function setActiveCurrency(code?: string | null) { - activeCurrency = (code || DEFAULT_CURRENCY).toUpperCase(); -} - -export function getActiveCurrency() { - return activeCurrency; -} /** * Format a money value for display. @@ -50,7 +44,11 @@ export function formatMoney( const value = typeof amount === 'string' ? Number(amount) : amount; if (!Number.isFinite(value)) return '—'; - const code = (currencyCode || activeCurrency).toUpperCase(); + const code = (currencyCode || '').trim().toUpperCase(); + if (!code) { + // No code, no symbol. Never guess one. + return value.toLocaleString(locale); + } try { return new Intl.NumberFormat(locale, { style: 'currency', @@ -75,7 +73,8 @@ export function formatMoneyPlain( const value = typeof amount === 'string' ? Number(amount) : amount; if (!Number.isFinite(value)) return '—'; - const code = (currencyCode || activeCurrency).toUpperCase(); + const code = (currencyCode || '').trim().toUpperCase(); + if (!code) return value.toLocaleString(locale); try { const digits = new Intl.NumberFormat(locale, { style: 'currency', diff --git a/apps/dashboard/src/pages/Accounting.tsx b/apps/dashboard/src/pages/Accounting.tsx index 3dd158f..69c5e70 100644 --- a/apps/dashboard/src/pages/Accounting.tsx +++ b/apps/dashboard/src/pages/Accounting.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; -import { formatMoney } from '../lib/money'; import { Routes, Route, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Calculator, Plus, Download, BarChart3, Pencil, Archive } from 'lucide-react'; import { format } from 'date-fns'; import { api } from '../lib/api'; -import { moneyString, requirePropertyId } from '../lib/api-helpers'; +import { moneyString, requirePropertyId, requireCurrency } from '../lib/api-helpers'; import { useProperty } from '../context/PropertyContext'; import Modal from '../components/ui/Modal'; +import { formatMoney } from '../lib/money'; import { useTranslation } from 'react-i18next'; interface Deposit { @@ -147,6 +147,7 @@ function AccountingHome() { const recordDeposit = useMutation({ mutationFn: () => { requirePropertyId(propertyId); + requireCurrency(currencyCode); return api.post('/v1/deposits', { propertyId, amount: moneyString(depositAmount), @@ -212,6 +213,7 @@ function AccountingHome() { const createLedger = useMutation({ mutationFn: () => { requirePropertyId(propertyId); + requireCurrency(currencyCode); return api.post('/v1/ar/ledgers', { propertyId, name: ledgerName, @@ -341,12 +343,12 @@ function AccountingHome() { {(Object.keys(AGING_LABELS) as (keyof AgingBuckets)[]).map((key) => (
    {AGING_LABELS[key]} - {formatMoney(report.buckets[key] ?? 0)} + {formatMoney(report.buckets[key] ?? 0, currencyCode)}
    ))}
    {t('accounting.total')} - {formatMoney(report.total ?? 0)} + {formatMoney(report.total ?? 0, currencyCode)}
    ) : ( @@ -397,7 +399,7 @@ function AccountingHome() {