@@ -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')}
))}
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) => (