fix(costs): keep display-currency payments settled, count them as typed in the plan and the PDF, and file every booking type under its Costs key

A display-currency payment whose halves pointed opposite ways no longer
stayed settled (#1445), the plan sidebar and the PDF total still went
through the trip currency, and a zero-decimal display currency was not
rounded. Every booking type now maps to one Costs key on every path; a
cost the old map filed under 'other' still moves with a type change.
This commit is contained in:
Maurice
2026-11-05 13:02:28 +01:00
parent c0f7f81e23
commit 3a8191fc07
15 changed files with 227 additions and 58 deletions
+1 -1
View File
@@ -12,8 +12,8 @@ import {
budgetItemsPageSchema,
BUDGET_ITEMS_PAGE_DEFAULT,
COST_CATEGORIES,
typeToCostCategory,
} from './budget.schema';
import { typeToCostCategory } from './cost-category';
import { describe, it, expect } from 'vitest';
-30
View File
@@ -54,36 +54,6 @@ export const COST_CATEGORIES = [
] as const;
export type CostCategory = (typeof COST_CATEGORIES)[number];
/**
* Maps a reservation `type` (flight, train, hotel, …) to one of the fixed Costs
* categories, so an expense created from a booking lands in the right bucket
* instead of a free-text/localized label. Unknown types fall back to `other`.
*/
const RESERVATION_TYPE_TO_COST_CATEGORY: Record<string, CostCategory> = {
flight: 'flights',
plane: 'flights',
train: 'transport',
bus: 'transport',
car: 'transport',
'car-rental': 'transport',
ferry: 'transport',
boat: 'transport',
taxi: 'transport',
transfer: 'transport',
transport: 'transport',
hotel: 'accommodation',
accommodation: 'accommodation',
lodging: 'accommodation',
parking: 'parking',
restaurant: 'food',
activity: 'activities',
};
export function typeToCostCategory(type: string | null | undefined): CostCategory {
if (!type) return 'other';
return RESERVATION_TYPE_TO_COST_CATEGORY[type.trim().toLowerCase()] || 'other';
}
/**
* One payer of an expense — a row of budget_item_payers. `amount` is in the
* expense's own currency (budget_items.currency). Several payers can split who
+23 -2
View File
@@ -1,5 +1,7 @@
// COST-CATEGORY-001 to COST-CATEGORY-005: a typed category read as one of the fixed keys.
import { COST_CATEGORY_HINT, costCategoryOf, storedCostCategory } from './cost-category';
// COST-CATEGORY-001 to COST-CATEGORY-007: a typed category read as one of the fixed keys.
import { COST_CATEGORIES } from './budget.schema';
import { COST_CATEGORY_HINT, costCategoryOf, storedCostCategory, typeToCostCategory } from './cost-category';
import { RESERVATION_TYPES } from '../reservation/reservation-types';
import { describe, expect, it } from 'vitest';
@@ -37,4 +39,23 @@ describe('costCategoryOf', () => {
for (const key of ['accommodation', 'food', 'transport', 'flights', 'other'])
expect(COST_CATEGORY_HINT).toContain(key);
});
it('COST-CATEGORY-006: every booking type is filed under a key, the same one on every path', () => {
for (const { type } of RESERVATION_TYPES) {
expect(COST_CATEGORIES).toContain(typeToCostCategory(type));
// The MCP stores the raw type through storedCostCategory; it must land where the form puts it.
expect(storedCostCategory(type)).toBe(typeToCostCategory(type));
}
expect(typeToCostCategory('event')).toBe('activities');
expect(typeToCostCategory('cable_car')).toBe('transport');
expect(typeToCostCategory('car-rental')).toBe('transport');
expect(typeToCostCategory('car rental')).toBe('transport');
});
it('COST-CATEGORY-007: a type no key stands for is filed under other', () => {
expect(typeToCostCategory('spaceship')).toBe('other');
expect(typeToCostCategory('')).toBe('other');
expect(typeToCostCategory(null)).toBe('other');
expect(typeToCostCategory(undefined)).toBe('other');
});
});
+18
View File
@@ -4,6 +4,8 @@ import { COST_CATEGORIES, type CostCategory } from './budget.schema';
* Free-text and legacy categories (and booking type labels) mapped to the fixed
* keys. Bookings used to store labels like "Flight"/"Train"/"Other", and an
* assistant writing through the MCP says "Food" where the key is "food".
* Every reservation type is in here too, so a booking's cost lands on the same
* key whether the web form, the import or the MCP wrote it.
*/
const CATEGORY_ALIASES: Record<string, CostCategory> = {
flight: 'flights',
@@ -14,11 +16,16 @@ const CATEGORY_ALIASES: Record<string, CostCategory> = {
bus: 'transport',
car: 'transport',
'car rental': 'transport',
'car-rental': 'transport',
bicycle: 'transport',
cruise: 'transport',
ferry: 'transport',
boat: 'transport',
taxi: 'transport',
transfer: 'transport',
cable_car: 'transport',
transit: 'transport',
transport_other: 'transport',
transport: 'transport',
transportation: 'transport',
hotel: 'accommodation',
@@ -34,6 +41,8 @@ const CATEGORY_ALIASES: Record<string, CostCategory> = {
groceries: 'groceries',
activity: 'activities',
activities: 'activities',
event: 'activities',
tour: 'activities',
sightseeing: 'sightseeing',
sights: 'sightseeing',
shop: 'shopping',
@@ -66,6 +75,15 @@ export function costCategoryOf(category: string | null | undefined): CostCategor
return CATEGORY_ALIASES[category.trim().toLowerCase()] ?? null;
}
/**
* The Costs category a booking of this type is filed under, `other` for a type
* no key stands for. Reads the same table as costCategoryOf, so a cost created
* with a booking and the type-change resync never disagree about it.
*/
export function typeToCostCategory(type: string | null | undefined): CostCategory {
return costCategoryOf(type) ?? 'other';
}
/**
* The category an expense is stored under: the fixed key when the text names one,
* so "Food" and "food" are one category, and the text itself otherwise.