Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1514%20passing-brightgreen" alt="1514 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1520%20passing-brightgreen" alt="1520 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
12 changes: 8 additions & 4 deletions apps/dashboard/src/components/front-desk/GuestDetailsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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';
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;
Expand Down Expand Up @@ -72,6 +72,7 @@ export default function GuestDetailsModal({
open,
reservation,
propertyId,
currencyCode,
doorPin,
onClose,
onNotes,
Expand All @@ -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;
Expand Down Expand Up @@ -230,7 +234,7 @@ export default function GuestDetailsModal({
{t('frontDesk.accountSummary')}
</p>
<p className="text-sm text-telivity-navy font-semibold">
{t('frontDesk.balance')}: {formatMoney(balance)}
{t('frontDesk.balance')}: {formatMoney(balance, currencyCode)}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
Expand All @@ -248,7 +252,7 @@ export default function GuestDetailsModal({
<li key={c.id} className="flex justify-between gap-2 text-sm">
<span className="text-telivity-slate truncate">{c.description || '—'}</span>
<span className="font-medium text-telivity-navy shrink-0">
{formatMoney(c.amount)}
{formatMoney(c.amount, currencyCode)}
</span>
</li>
))}
Expand All @@ -269,7 +273,7 @@ export default function GuestDetailsModal({
<li key={p.id} className="flex justify-between gap-2 text-sm">
<span className="text-telivity-slate truncate">{p.method || '—'}</span>
<span className="font-medium text-telivity-navy shrink-0">
{formatMoney(p.amount)}
{formatMoney(p.amount, currencyCode)}
</span>
</li>
))}
Expand Down
21 changes: 9 additions & 12 deletions apps/dashboard/src/context/PropertyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[];
Expand All @@ -23,7 +23,7 @@ interface PropertyContextValue {

const PropertyContext = createContext<PropertyContextValue>({
propertyId: null,
currencyCode: DEFAULT_CURRENCY,
currencyCode: null,
setPropertyId: () => {},
isPortfolioMode: false,
properties: [],
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions apps/dashboard/src/lib/api-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '—';
Expand Down
54 changes: 54 additions & 0 deletions apps/dashboard/src/lib/money.spec.ts
Original file line number Diff line number Diff line change
@@ -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('—');
});
});
39 changes: 19 additions & 20 deletions apps/dashboard/src/lib/money.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
Expand All @@ -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',
Expand Down
18 changes: 10 additions & 8 deletions apps/dashboard/src/pages/Accounting.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -147,6 +147,7 @@ function AccountingHome() {
const recordDeposit = useMutation({
mutationFn: () => {
requirePropertyId(propertyId);
requireCurrency(currencyCode);
return api.post('/v1/deposits', {
propertyId,
amount: moneyString(depositAmount),
Expand Down Expand Up @@ -212,6 +213,7 @@ function AccountingHome() {
const createLedger = useMutation({
mutationFn: () => {
requirePropertyId(propertyId);
requireCurrency(currencyCode);
return api.post('/v1/ar/ledgers', {
propertyId,
name: ledgerName,
Expand Down Expand Up @@ -341,12 +343,12 @@ function AccountingHome() {
{(Object.keys(AGING_LABELS) as (keyof AgingBuckets)[]).map((key) => (
<div key={key} className="flex justify-between border-b border-gray-50 py-1">
<span>{AGING_LABELS[key]}</span>
<span className="font-medium">{formatMoney(report.buckets[key] ?? 0)}</span>
<span className="font-medium">{formatMoney(report.buckets[key] ?? 0, currencyCode)}</span>
</div>
))}
<div className="flex justify-between pt-2 font-semibold">
<span>{t('accounting.total')}</span>
<span>{formatMoney(report.total ?? 0)}</span>
<span>{formatMoney(report.total ?? 0, currencyCode)}</span>
</div>
</div>
) : (
Expand Down Expand Up @@ -397,7 +399,7 @@ function AccountingHome() {
<ul className="space-y-2 text-sm">
{deposits.slice(0, 8).map((d) => (
<li key={d.id} className="flex justify-between items-center border-b border-gray-50 py-1">
<span>{formatMoney(d.amount)}</span>
<span>{formatMoney(d.amount, currencyCode)}</span>
<span className="text-telivity-mid-grey">{d.status}</span>
{d.status === 'held' && (
<button
Expand Down Expand Up @@ -493,7 +495,7 @@ function AccountingHome() {
<span className="ml-2 text-xs text-telivity-mid-grey">({t('accounting.closed')})</span>
)}
</span>
<span className="font-medium shrink-0">{formatMoney(l.balance ?? 0)}</span>
<span className="font-medium shrink-0">{formatMoney(l.balance ?? 0, currencyCode)}</span>
<div className="flex flex-wrap gap-2 justify-end">
<button onClick={() => { setSelectedLedger(l); setArActionOpen('payment'); }} className="text-xs text-telivity-teal hover:underline">{t('accounting.payment')}</button>
<button onClick={async () => { setSelectedLedger(l); setArActionOpen('aging'); await refetchAging(); }} className="text-xs text-telivity-teal hover:underline">{t('accounting.aging')}</button>
Expand Down Expand Up @@ -555,7 +557,7 @@ function AccountingHome() {
</div>
</Modal>

<Modal open={depositActionOpen} onClose={() => setDepositActionOpen(false)} title={t('accounting.depositActions', { amount: formatMoney(selectedDeposit?.amount ?? 0) })}>
<Modal open={depositActionOpen} onClose={() => setDepositActionOpen(false)} title={t('accounting.depositActions', { amount: Number(selectedDeposit?.amount ?? 0).toFixed(2) })}>
<div className="space-y-4">
<input type="text" value={applyFolioId} onChange={(e) => setApplyFolioId(e.target.value)} placeholder={t('accounting.folioIdPlaceholder')} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono text-xs" />
<button onClick={() => applyDeposit.mutate()} disabled={applyDeposit.isPending} className="w-full bg-telivity-teal text-white rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50">{t('accounting.applyToFolio')}</button>
Expand Down Expand Up @@ -597,7 +599,7 @@ function AccountingHome() {
<option value="">{t('accounting.selectTransaction')}</option>
{reversible.map((tx) => (
<option key={tx.id} value={tx.id}>
{formatMoney(tx.amount)} · {tx.createdAt?.split('T')[0] ?? tx.id.slice(0, 8)}
{formatMoney(tx.amount, currencyCode)} · {tx.createdAt?.split('T')[0] ?? tx.id.slice(0, 8)}
</option>
))}
</select>
Expand Down
Loading