mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user